@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/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@volter/teams",
3
- "version": "0.1.89",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Volter Teams, the neutral team plane: machine identity and daemon core, the local door and server link, admission from machine grants, product doors, and the Teams server's neutral routes (company RFC 0008).",
6
6
  "license": "MIT",
7
7
  "exports": {
8
+ "./agent-profile": {"types":"./agent-profile.d.ts","default":"./agent-profile.mjs"},
8
9
  ".": "./index.mjs",
9
10
  "./channel": "./channel.mjs",
10
11
  "./browser-channel": {
@@ -21,7 +22,6 @@
21
22
  "./machine-core": "./machine-core.mjs",
22
23
  "./home": "./home.mjs",
23
24
  "./server/core": "./server/core.mjs",
24
- "./server/identity-orgs": "./server/identity-orgs.mjs",
25
25
  "./server/http": "./server/http.mjs",
26
26
  "./server/apps": "./server/apps.mjs",
27
27
  "./server/auth": "./server/auth.mjs",
@@ -32,9 +32,19 @@
32
32
  "./server/wire": "./server/wire.mjs",
33
33
  "./server/migrations": "./server/migrations/index.mjs",
34
34
  "./client": "./client.mjs",
35
+ "./lines": "./lines.mjs",
35
36
  "./stream-rpc": "./stream-rpc.mjs",
36
37
  "./server/delegated": "./server/delegated.mjs",
37
- "./server/viewer": "./server/viewer.mjs"
38
+ "./server/viewer": "./server/viewer.mjs",
39
+ "./http-client": {
40
+ "types": "./http-client.d.ts",
41
+ "default": "./http-client.mjs"
42
+ },
43
+ "./context": "./context.mjs",
44
+ "./event-client": "./event-client.mjs",
45
+ "./machine-door": "./machine-door.mjs",
46
+ "./sender-identity": "./sender-identity.mjs",
47
+ "./server/agent-authority": "./server/agent-authority.mjs"
38
48
  },
39
49
  "files": [
40
50
  "LICENSE-MIT",
@@ -43,7 +53,9 @@
43
53
  "session-follow.d.ts",
44
54
  "server",
45
55
  "viewer",
46
- "README.md"
56
+ "README.md",
57
+ "http-client.d.ts",
58
+ "identity-record.d.ts"
47
59
  ],
48
60
  "engines": {
49
61
  "node": ">=22"
@@ -0,0 +1,55 @@
1
+ // Public client: durable observation before attribution. Names never create proof.
2
+ import { mkdirSync, writeFileSync, renameSync, readdirSync, readFileSync, unlinkSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { randomUUID, createHash } from 'node:crypto';
5
+ import { eventClient } from './event-client.mjs';
6
+ import { teamsHome } from './home.mjs';
7
+
8
+ export class SenderIdentityRecord {
9
+ constructor({home=teamsHome(),supercodeHome=dirname(home),context,fetch}={}) {
10
+ this.home=home;this.select={supercodeHome,context,fetch};
11
+ }
12
+ selected() { try{return eventClient(this.select);}catch{return null;} }
13
+ async observe({observation,location,at=Date.now(),name=null,evidence={source:'sender'}}) {
14
+ const selected=this.selected(),org=selected?.context.org??null,server=selected?.context.server??null;
15
+ const audience=createHash('sha256').update(JSON.stringify([org,server])).digest('hex');
16
+ const directory=join(this.home,'identity','observations',audience);
17
+ mkdirSync(directory,{recursive:true,mode:0o700});
18
+ if(typeof observation!=='string'||!observation.includes(':'))throw new TypeError('a sender observation must be namespaced');
19
+ name=typeof name==='string'?name.replace(/[\x00-\x1f]/g,'').slice(0,256)||null:null;
20
+ const id=randomUUID(),path=join(directory,`${id}.json`),body={observation,location,at,name,evidence};
21
+ writeFileSync(path,JSON.stringify({org,server,body})+'\n',{mode:0o600,flag:'wx'});
22
+ let resolution={observation,org,principal:null,classification:'unresolved',label:'unresolved',links:[]};
23
+ if(!selected)return {...resolution,pending:true};
24
+ // Only this audience's pending sightings replay; an unknown local audience stays local.
25
+ // Arrival drives retry. No identity inference or background scan runs.
26
+ for(const file of readdirSync(directory).filter(f=>f.endsWith('.json')).slice(0,1)) {
27
+ const pending=join(directory,file);let saved;try{saved=JSON.parse(readFileSync(pending,'utf8'));}catch(error){if(error.code==='ENOENT')continue;throw error;}
28
+ const key=`identity-sighting-${file.slice(0,-5)}`;
29
+ try {await selected.team.identity.observe(saved.body,{idempotencyKey:key,signal:AbortSignal.timeout(1000)});try{unlinkSync(pending);}catch(error){if(error.code!=='ENOENT')throw error;}}
30
+ catch {break;}
31
+ }
32
+ try {
33
+ const record=(await selected.team.identity.resolve(observation,{signal:AbortSignal.timeout(1000)})).data;
34
+ resolution={...record,org};
35
+ const cache=join(this.home,'identity',audience+'.json');
36
+ // One record per observation; atomic files avoid competing gateway/mail processes losing entries.
37
+ const cacheDirectory=cache.slice(0,-5);mkdirSync(cacheDirectory,{recursive:true,mode:0o700});
38
+ const target=join(cacheDirectory,createHash('sha256').update(observation).digest('hex')+'.json'),temp=target+'.'+randomUUID();
39
+ writeFileSync(temp,JSON.stringify(resolution)+'\n',{mode:0o600,flag:'wx'});renameSync(temp,target);
40
+ }catch {
41
+ // Current authority is unknown. A cached former proof does not become current attribution.
42
+ resolution={...resolution,unavailable:true};
43
+ }
44
+ return {...resolution,pending:readdirSync(directory).includes(id+'.json')};
45
+ }
46
+ async approvedPairing({platform,user_id,user_name,code,principal_id=null,location='pairing'}) {
47
+ const selected=this.selected();if(!selected)throw new Error('recording pairing proof needs a selected Teams context');
48
+ // Called only after Hermes returns its successful approval receipt; the code itself is never retained.
49
+ if(!principal_id){const existing=(await selected.team.identity.resolve(`${platform}:${user_id}`)).data;if(existing.classification==='proven')return existing.links.find(link=>link.classification==='proven');}
50
+ principal_id??=(await selected.team.identity.declarePerson({name:user_name||`${platform}:${user_id}`})).data.id;
51
+ const result=await selected.team.identity.link({observation:`${platform}:${user_id}`,principal_id,classification:'proven',
52
+ proof:{kind:'pairing',evidence:{location,approved_by:selected.context.principal,code_digest:createHash('sha256').update(code).digest('hex')}}});
53
+ return result.data;
54
+ }
55
+ }
@@ -0,0 +1,21 @@
1
+ // Live agency and delegation are evaluated from the store; identity labels and environment hints grant nothing.
2
+ export const AGENT_ACTIVE = `WITH RECURSIVE scope(team,id) AS (VALUES(?,?)), chain(id) AS (
3
+ SELECT a.id FROM agents a,scope s WHERE a.team_id=s.team AND a.id=s.id
4
+ UNION SELECT a.parent_id FROM agents a JOIN chain c ON a.id=c.id,scope s WHERE a.team_id=s.team AND a.parent_id IS NOT NULL
5
+ UNION SELECT json_extract(CASE WHEN j.type='object' THEN j.value ELSE '{}' END,'$.id') FROM agents a JOIN chain c ON a.id=c.id,scope s,json_tree(a.lineage_json,'$.starter') j WHERE a.team_id=s.team AND j.type='object' AND json_extract(CASE WHEN j.type='object' THEN j.value ELSE '{}' END,'$.kind')='agent'
6
+ ), ancestry(seed,id) AS (
7
+ SELECT id,id FROM chain
8
+ UNION SELECT g.seed,a.parent_id FROM ancestry g JOIN agents a ON a.id=g.id,scope s WHERE a.team_id=s.team AND a.parent_id IS NOT NULL
9
+ UNION SELECT g.seed,json_extract(CASE WHEN j.type='object' THEN j.value ELSE '{}' END,'$.id') FROM ancestry g JOIN agents a ON a.id=g.id,scope s,json_tree(a.lineage_json,'$.starter') j WHERE a.team_id=s.team AND j.type='object' AND json_extract(CASE WHEN j.type='object' THEN j.value ELSE '{}' END,'$.kind')='agent'
10
+ ), people(seed,id) AS (
11
+ SELECT g.seed,a.sponsor_id FROM agents a JOIN ancestry g ON a.id=g.id,scope s WHERE a.team_id=s.team
12
+ UNION SELECT g.seed,json_extract(CASE WHEN j.type='object' THEN j.value ELSE '{}' END,'$.id') FROM agents a JOIN ancestry g ON a.id=g.id,scope s,json_tree(a.lineage_json,'$.starter') j WHERE a.team_id=s.team AND j.type='object' AND json_extract(CASE WHEN j.type='object' THEN j.value ELSE '{}' END,'$.kind')='person'
13
+ UNION SELECT g.seed,ps.sponsor_id FROM agents a JOIN ancestry g ON a.id=g.id JOIN json_tree(a.lineage_json,'$.starter') j ON j.type='object' JOIN principal_sponsors ps ON ps.team_id=a.team_id AND ps.principal_id=json_extract(CASE WHEN j.type='object' THEN j.value ELSE '{}' END,'$.id'),scope s WHERE a.team_id=s.team
14
+ UNION SELECT g.seed,i.installer_id FROM agents a JOIN ancestry g ON a.id=g.id JOIN json_tree(a.lineage_json,'$.starter') j ON j.type='object' JOIN app_installations i ON i.team_id=a.team_id AND i.principal_id=json_extract(CASE WHEN j.type='object' THEN j.value ELSE '{}' END,'$.id') AND i.state='active' JOIN apps app ON app.id=i.app_id AND app.state='active',scope s WHERE a.team_id=s.team
15
+ ) SELECT 1 FROM agents root,scope s WHERE root.id=s.id AND root.team_id=s.team
16
+ AND NOT EXISTS(SELECT 1 FROM chain c LEFT JOIN agents a ON a.id=c.id AND a.team_id=s.team WHERE a.id IS NULL OR a.state!='active' OR NOT EXISTS(
17
+ SELECT 1 FROM people x JOIN principals p ON p.id=x.id WHERE x.seed=a.id AND p.kind='user' AND
18
+ (EXISTS(SELECT 1 FROM memberships m WHERE m.team_id=s.team AND m.principal_id=p.id AND m.active=1)
19
+ OR EXISTS(SELECT 1 FROM identity_principals ip WHERE ip.team_id=s.team AND ip.principal_id=p.id)
20
+ AND NOT EXISTS(SELECT 1 FROM memberships m WHERE m.team_id=s.team AND m.principal_id=p.id AND m.active=0))))`;
21
+ export const AGENT_DESCENDANTS = `WITH RECURSIVE tree(id) AS (SELECT id FROM agents WHERE team_id=? AND id=? UNION SELECT a.id FROM agents a JOIN tree t ON a.parent_id=t.id) SELECT id FROM tree`;
@@ -0,0 +1,226 @@
1
+ // Session ownership is stable identity; resolved configuration is reported by its enrolled machine.
2
+ import { agentIdentity, agentSponsor, manageableAgent, managementGuards, publication } from './agents.mjs';
3
+ import { exact } from './request-json.mjs';
4
+ import { AGENT_ACTIVE } from './agent-authority.mjs';
5
+ import { APP_LINEAGE_REVOKED } from './auth.mjs';
6
+ import { deliverNativeMail } from './machine-link.mjs';
7
+ import { createHash } from 'node:crypto';
8
+ import { nativeSessionSnapshot } from './session-snapshot.mjs';
9
+ const fail=(status,code,message)=>{throw Object.assign(new Error(message),{status,code});};
10
+ const stmt=(sql,...params)=>({sql,params});
11
+ const guard=(sql,params,message='session ownership changed')=>({sql,params,status:409,code:'conflict',message});
12
+ const memberGuards=(auth,now)=>[
13
+ guard('SELECT 1 FROM credentials WHERE id=? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at>?)',[auth.credential_id,now],'credential changed'),
14
+ guard('SELECT 1 FROM memberships WHERE team_id=? AND principal_id=? AND active=1',[auth.team_id,auth.principal_id],'membership changed'),
15
+ ];
16
+ const ownerGuard=(auth,session)=>guard('SELECT 1 FROM sessions WHERE team_id=? AND id=? AND custodian_id=? AND deleted_at IS NULL',[auth.team_id,session.id,auth.principal_id],'only the current custodian declares session ownership');
17
+ const revisionGuard=(team,session,record)=>record
18
+ ?guard('SELECT 1 FROM agent_sessions WHERE team_id=? AND session_id=? AND revision=?',[team,session,record.revision])
19
+ :guard('SELECT 1 WHERE NOT EXISTS(SELECT 1 FROM agent_sessions WHERE team_id=? AND session_id=?)',[team,session]);
20
+ // Identity updates preserve the native session snapshot consumed by event-driven supervision.
21
+ const publish=(api,auth,session,data,now)=>stmt(`INSERT INTO org_events(id,team_id,kind,resource_kind,resource_id,actor_json,data_json,created_at)
22
+ SELECT ?,?,?,'session',?,?,json_set(${nativeSessionSnapshot('s.team_id','s.id')},'$.agent_identity',json(?)),?
23
+ FROM sessions s WHERE s.team_id=? AND s.id=?`,
24
+ api.ids.next('event'),auth.team_id,'session.changed',session,JSON.stringify({principal:auth.principal_id,session:null,lineage:auth.act??[]}),JSON.stringify(data),now,auth.team_id,session);
25
+
26
+ export async function sessionIdentity(api,team,session){
27
+ const record=await api.store.one('SELECT * FROM agent_sessions WHERE team_id=? AND session_id=?',[team,session]);
28
+ // A session nobody recorded lineage for is plain: it says so, and names the person whose machine runs it, as a fact,
29
+ // never as its starter (`agent record` records that).
30
+ if(!record){const held=await api.store.one('SELECT custodian_id FROM sessions WHERE team_id=? AND id=?',[team,session]);
31
+ return {session,agent:null,state:'unrecorded',plain:true,custodian:held?.custodian_id??null,starter:null,resolved:null,main:false};}
32
+ const agent=record.agent_id?await api.store.one('SELECT * FROM agents WHERE team_id=? AND id=?',[team,record.agent_id]):null;
33
+ return {session,agent:agent?await agentIdentity(api,team,agent):null,starter:JSON.parse(record.starter_json),
34
+ resolved:JSON.parse(record.profile_json),resolved_at:JSON.parse(record.profile_json)===null?null:record.resolved_at,
35
+ main:Boolean(record.main),ended_at:record.ended_at,end_reason:record.end_reason,revision:record.revision};
36
+ }
37
+ export async function authorizeAgentMail(api,auth,ref){
38
+ if(!auth.agent_credential||auth.principal_kind!=='agent')fail(403,'forbidden','an agent session credential is required');
39
+ const session=await api.store.authorizedSession({teamId:auth.team_id,principalId:auth.principal_id,credentialId:auth.credential_id,ref,now:api.clock.nowMs()});
40
+ const owner=await api.store.one('SELECT * FROM agent_sessions WHERE team_id=? AND session_id=? AND agent_id=? AND ended_at IS NULL',[auth.team_id,session.id,auth.principal_id]);
41
+ if(!owner)fail(404,'not_found','live own session not found');
42
+ return {session,owner};
43
+ }
44
+ async function sendAgentMail(api,auth,ref,text,idempotencyKey){
45
+ const standing=await api.store.one('SELECT auth_epoch FROM teams WHERE id=?',[auth.team_id]);
46
+ const {session,owner}=await authorizeAgentMail(api,auth,ref),now=api.clock.nowMs();
47
+ const target=await api.store.one(`WITH RECURSIVE family(id) AS (SELECT ? UNION SELECT s.id FROM sessions s JOIN family f ON s.canonical_id=f.id WHERE s.team_id=? AND s.deleted_at IS NULL)
48
+ SELECT o.source_id,o.session_id,o.harness,o.native_id,o.sequence,src.machine_id,m.name FROM origins o
49
+ 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
50
+ JOIN machines m ON m.team_id=src.team_id AND m.id=src.machine_id
51
+ WHERE o.team_id=? AND o.deleted_at IS NULL AND src.state='active' AND m.revoked_at IS NULL ORDER BY o.sequence DESC,o.source_id,o.native_id LIMIT 1`,[session.id,auth.team_id,auth.team_id]);
52
+ if(!target)fail(404,'not_found','live session origin not found');
53
+ const machineName=String(target.name).split('.')[0].toLowerCase().replace(/[^a-z0-9_-]/g,'-');
54
+ const to=`sc:${machineName}:${target.harness}:${target.native_id}`,grant=api.ids.next('grant');
55
+ const boundary=at=>[
56
+ {sql:'SELECT 1 FROM teams WHERE id=? AND auth_epoch=?',params:[auth.team_id,standing.auth_epoch],status:409,code:'conflict',message:'session authorization changed'},
57
+ {sql:'SELECT 1 FROM credentials WHERE id=? AND principal_id=? AND revoked_at IS NULL AND expires_at>?',params:[auth.credential_id,auth.principal_id,at],status:401,code:'authentication_required',message:'agent credential changed'},
58
+ {sql:AGENT_ACTIVE,params:[auth.team_id,auth.principal_id],status:401,code:'authentication_required',message:'agent standing changed'},
59
+ {sql:`SELECT 1 WHERE NOT EXISTS(${APP_LINEAGE_REVOKED})`,params:[auth.credential_id,at],status:401,code:'authentication_required',message:'delegation changed'},
60
+ {sql:'SELECT 1 FROM agent_sessions WHERE team_id=? AND session_id=? AND agent_id=? AND revision=? AND ended_at IS NULL',params:[auth.team_id,session.id,auth.principal_id,owner.revision],status:409,code:'conflict',message:'session ownership changed'},
61
+ {sql:'WITH RECURSIVE family(id) AS (SELECT id FROM sessions WHERE team_id=? AND id=? AND canonical_id IS NULL AND deleted_at IS NULL UNION SELECT s.id FROM sessions s JOIN family f ON s.canonical_id=f.id WHERE s.team_id=? AND s.deleted_at IS NULL) SELECT 1 FROM family WHERE id=?',params:[auth.team_id,session.id,auth.team_id,target.session_id],status:409,code:'conflict',message:'native session family changed'},
62
+ {sql:'SELECT 1 FROM origins o JOIN sources src ON src.team_id=o.team_id AND src.id=o.source_id JOIN machines m ON m.team_id=src.team_id AND m.id=src.machine_id WHERE o.team_id=? AND o.source_id=? AND o.session_id=? AND o.harness=? AND o.native_id=? AND o.sequence=? AND o.deleted_at IS NULL AND src.state=\'active\' AND m.id=? AND m.revoked_at IS NULL',params:[auth.team_id,target.source_id,target.session_id,target.harness,target.native_id,target.sequence,target.machine_id],status:409,code:'conflict',message:'native session origin changed'},
63
+ ];
64
+ await api.store.guardedTransaction(boundary(now),[stmt('INSERT INTO machine_grants(team_id,id,machine_id,subject_id,caps_json,targets_json,issuer_id,created_at,expires_at) VALUES (?,?,?,?,?,?,?,?,?)',auth.team_id,grant,target.machine_id,auth.principal_id,JSON.stringify(['mail']),JSON.stringify([`mail:${to}`]),auth.principal_id,now,Math.min(now+30000,auth.expires_at))]);
65
+ try{
66
+ const agent=await api.store.one('SELECT name FROM agents WHERE team_id=? AND id=?',[auth.team_id,auth.principal_id]);
67
+ const digest=createHash('sha256').update(JSON.stringify([auth.team_id,auth.principal_id,session.id,idempotencyKey])).digest('hex');
68
+ return await deliverNativeMail(api,auth.team_id,target.machine_id,{principal:auth.principal_id,groups:[],grants:[grant],revisions:{[grant]:1}},
69
+ {op:'send',from:`sc:${machineName}:operator:${auth.principal_id}`,from_name:agent.name,to,body:text,id:`agent-mail:${digest}`,queue:false},
70
+ async()=>{await authorizeAgentMail(api,auth,session.id);await api.store.guardedTransaction(boundary(api.clock.nowMs()),[]);});
71
+ }finally{await api.store.transaction([stmt('DELETE FROM machine_grants WHERE team_id=? AND id=?',auth.team_id,grant)]);}
72
+ }
73
+ async function starterFor(api,auth,ref,now){
74
+ const principal=await api.store.one('SELECT kind FROM principals WHERE id=?',[auth.principal_id]);
75
+ const starter={kind:({user:'person',agent:'agent',service:'automation'})[principal.kind],id:auth.principal_id};
76
+ if(!ref)return starter;
77
+ const session=await api.store.authorizedSession({teamId:auth.team_id,principalId:auth.principal_id,credentialId:auth.credential_id,ref,now});
78
+ if(session.custodian_id!==auth.principal_id)fail(403,'forbidden','starter session must be your own');
79
+ const parent=await api.store.one('SELECT agent_id,starter_json FROM agent_sessions WHERE team_id=? AND session_id=?',[auth.team_id,session.id]);
80
+ return parent?.agent_id?{kind:'agent',id:parent.agent_id,session:session.id}:{kind:'session',id:session.id,delegator:parent?JSON.parse(parent.starter_json):starter};
81
+ }
82
+ /** Resolve only origins admitted to this machine; retain every canonical edge as a write guard. */
83
+ export async function machineProfileSession(api,auth,ref){
84
+ const native=/^([a-z][a-z0-9-]*):(.+)$/.exec(ref??'');
85
+ const rows=await api.store.query(`SELECT DISTINCT s.* FROM sessions s JOIN origins o ON o.team_id=s.team_id AND o.session_id=s.id JOIN sources src ON src.team_id=o.team_id AND src.id=o.source_id JOIN machines m ON m.team_id=src.team_id AND m.id=src.machine_id WHERE s.team_id=? AND s.deleted_at IS NULL AND o.deleted_at IS NULL AND o.source_id=? AND src.machine_id=? AND src.state='active' AND m.revoked_at IS NULL AND ${native?'o.harness=? AND o.native_id=?':'s.id=?'}`,[auth.team_id,auth.source_id,auth.machine_id,...(native?[native[1],native[2]]:[ref])]);
86
+ if(rows.length!==1)fail(404,'not_found','session is not uniquely captured by this machine');
87
+ const origin=rows[0],edges=[],seen=new Set();let session=origin;
88
+ while(session){
89
+ if(seen.has(session.id))fail(409,'conflict','session correlation cycle');seen.add(session.id);
90
+ edges.push(guard('SELECT 1 FROM sessions WHERE team_id=? AND id=? AND canonical_id IS ? AND deleted_at IS NULL',[auth.team_id,session.id,session.canonical_id]));
91
+ if(!session.canonical_id)break;
92
+ session=await api.store.one('SELECT * FROM sessions WHERE team_id=? AND id=? AND deleted_at IS NULL',[auth.team_id,session.canonical_id]);
93
+ }
94
+ if(!session)fail(404,'not_found','canonical session not found');
95
+ edges.push(guard("SELECT 1 FROM machines m JOIN sources src ON src.team_id=m.team_id AND src.machine_id=m.id JOIN origins o ON o.team_id=src.team_id AND o.source_id=src.id WHERE m.team_id=? AND m.id=? AND m.revoked_at IS NULL AND src.id=? AND src.state='active' AND o.session_id=? AND o.deleted_at IS NULL",[auth.team_id,auth.machine_id,auth.source_id,origin.id],'machine source changed'));
96
+ return {session,guards:edges};
97
+ }
98
+ export async function agentSessionRoute(ctx){
99
+ const {api,req,res,auth,sub,url,requestId,parseJson,send,result,guardedMutation}=ctx;
100
+ const now=api.clock.nowMs();let match;
101
+ if((match=/^\/machines\/([^/]+)\/agent-closures(?:\/([^/]+)(?:\/(claim|result))?)?$/.exec(sub))){
102
+ if(auth.kind!=='machine'||auth.machine_id!==match[1])fail(403,'forbidden','the owning machine consumes session closures');
103
+ if(req.method==='GET'&&!match[2]){
104
+ const rows=await api.store.query("SELECT * FROM agent_session_closures WHERE team_id=? AND machine_id=? AND state IN ('pending','claimed') ORDER BY created_at,id LIMIT 100",[auth.team_id,auth.machine_id]);
105
+ send(res,200,result(api,auth,{items:rows}),requestId);return true;
106
+ }
107
+ if(req.method==='GET'&&match[2]&&!match[3]){
108
+ const row=await api.store.one('SELECT c.* FROM agent_session_closures c JOIN agents a ON a.team_id=c.team_id AND a.id=c.agent_id WHERE c.team_id=? AND c.machine_id=? AND c.id=? AND a.state=\'ended\'',[auth.team_id,auth.machine_id,match[2]]);
109
+ if(!row)fail(404,'not_found','closure not found');
110
+ send(res,200,result(api,auth,row),requestId);return true;
111
+ }
112
+ if(req.method!=='POST'||!match[2])fail(404,'not_found','closure operation not found');
113
+ const row=await api.store.one('SELECT * FROM agent_session_closures WHERE team_id=? AND machine_id=? AND id=?',[auth.team_id,auth.machine_id,match[2]]);
114
+ if(!row)fail(404,'not_found','closure not found');
115
+ const body=await parseJson(req);
116
+ const guards=[...memberGuards(auth,now),guard("SELECT 1 FROM agents WHERE team_id=? AND id=? AND state='ended'",[auth.team_id,row.agent_id],'agent end changed'),guard('SELECT 1 FROM machines WHERE team_id=? AND id=? AND revoked_at IS NULL',[auth.team_id,auth.machine_id],'machine changed')];
117
+ if(match[3]==='claim'){
118
+ exact(body,[]);
119
+ if(row.state!=='pending'){send(res,200,result(api,auth,{execute:false,state:row.state}),requestId);return true;}
120
+ const response=result(api,auth,{execute:true,state:'claimed'});
121
+ guards.push(guard("SELECT 1 FROM agent_session_closures WHERE team_id=? AND id=? AND state='pending'",[auth.team_id,row.id],'closure already claimed'));
122
+ await guardedMutation(api,res,auth,guards,[stmt("UPDATE agent_session_closures SET state='claimed',claimed_at=? WHERE team_id=? AND id=?",now,auth.team_id,row.id)],{status:200,body:response,targetId:row.id});
123
+ send(res,200,response,requestId);return true;
124
+ }
125
+ exact(body,['state','reason']);
126
+ if(!['closed','failed','unknown'].includes(body.state)||typeof body.reason!=='string'||body.reason.length>1024)fail(400,'invalid_request','invalid closure result');
127
+ if(row.state!=='claimed'){
128
+ if(row.result_json!==JSON.stringify(body))fail(409,'conflict','closure result changed');
129
+ send(res,200,result(api,auth,body),requestId);return true;
130
+ }
131
+ guards.push(guard("SELECT 1 FROM agent_session_closures WHERE team_id=? AND id=? AND state='claimed'",[auth.team_id,row.id]));
132
+ const response=result(api,auth,body);
133
+ await guardedMutation(api,res,auth,guards,[stmt('UPDATE agent_session_closures SET state=?,result_json=?,finished_at=? WHERE team_id=? AND id=?',body.state,JSON.stringify(body),now,auth.team_id,row.id)],{status:200,body:response,targetId:row.id});
134
+ send(res,200,response,requestId);return true;
135
+ }
136
+ if(req.method==='POST'&&(match=/^\/sessions\/([^/]+)\/mail$/.exec(sub))){
137
+ const body=await parseJson(req);exact(body,['text']);
138
+ if(typeof body.text!=='string'||!body.text.trim()||body.text.length>65536)fail(400,'invalid_request','text must contain 1..65536 characters');
139
+ const receipt=await sendAgentMail(api,auth,decodeURIComponent(match[1]),body.text,req.headers['idempotency-key']);
140
+ await send(res,200,result(api,auth,receipt),requestId);return true;
141
+ }
142
+ if((match=/^\/machines\/([^/]+)\/session-profiles$/.exec(sub))&&req.method==='POST'){
143
+ if(auth.kind!=='machine'||auth.machine_id!==match[1])fail(403,'forbidden','the owning machine reports resolved profiles');
144
+ const body=await parseJson(req);exact(body,['session','profile','observed_at','observation_id']);
145
+ if(typeof body.session!=='string'||!body.profile||typeof body.profile!=='object'||Array.isArray(body.profile))fail(400,'invalid_request','session and resolved profile object are required');
146
+ const observedAt=body.observed_at??now;
147
+ if(!Number.isSafeInteger(observedAt)||observedAt<0||body.observation_id!==undefined&&!/^profile_[0-9a-f]{32}$/.test(body.observation_id))fail(400,'invalid_request','observed_at and observation_id are invalid');
148
+ const {session,guards:machineGuards}=await machineProfileSession(api,auth,body.session);
149
+ const record=await api.store.one('SELECT * FROM agent_sessions WHERE team_id=? AND session_id=?',[auth.team_id,session.id]);
150
+ if(!record)fail(409,'conflict','declare the session lineage before reporting its profile');
151
+ const value=JSON.stringify(body.profile),revision=record.revision+1,response=result(api,auth,{session:session.id,resolved:body.profile,resolved_at:observedAt,revision});
152
+ await guardedMutation(api,res,auth,[...memberGuards(auth,now),revisionGuard(auth.team_id,session.id,record),
153
+ ...machineGuards],
154
+ [stmt('UPDATE agent_sessions SET profile_json=?,resolved_at=?,revision=? WHERE team_id=? AND session_id=?',value,observedAt,revision,auth.team_id,session.id),
155
+ stmt('INSERT INTO session_profile_history(team_id,session_id,revision,profile_json,recorded_at) VALUES (?,?,?,?,?)',auth.team_id,session.id,revision,value,observedAt),publish(api,auth,session.id,response.data,now)],{status:200,body:response,targetId:session.id});
156
+ send(res,200,response,requestId);return true;
157
+ }
158
+ if((match=/^\/sessions\/([^/]+)\/agent$/.exec(sub))){
159
+ const session=await api.store.authorizedSession({teamId:auth.team_id,principalId:auth.principal_id,credentialId:auth.credential_id,ref:decodeURIComponent(match[1]),now});
160
+ if(req.method==='GET'){send(res,200,result(api,auth,await sessionIdentity(api,auth.team_id,session.id)),requestId);return true;}
161
+ if(req.method!=='POST')fail(404,'not_found','session identity operation not found');
162
+ const body=await parseJson(req);exact(body,['starter_session']);
163
+ if(session.custodian_id!==auth.principal_id||auth.app_grants!=null||auth.installation_id)fail(403,'forbidden','only native custody records a plain session');
164
+ const old=await api.store.one('SELECT * FROM agent_sessions WHERE team_id=? AND session_id=?',[auth.team_id,session.id]);
165
+ if(old){send(res,200,result(api,auth,await sessionIdentity(api,auth.team_id,session.id)),requestId);return true;}
166
+ const starter=await starterFor(api,auth,body.starter_session,now);
167
+ if(starter.kind==='agent'){
168
+ // Rule 2: a session an agent's session starts is that agent's, a concurrent session of it (a helper,
169
+ // a parallel thread). Recording it needs the same management standing as any ownership declaration.
170
+ const agent=await api.store.one('SELECT * FROM agents WHERE team_id=? AND id=?',[auth.team_id,starter.id]);
171
+ if(!agent||!await manageableAgent(api,auth,agent))fail(403,'forbidden','only the agent\'s custodian, parent, sponsor or an Org admin records its sessions');
172
+ const standing=[];
173
+ if(agent.state!=='active'||!await agentSponsor(api,auth.team_id,agent,new Set(),standing))fail(409,'conflict','a suspended or ended agent cannot start sessions');
174
+ const data={session:session.id,agent:await agentIdentity(api,auth.team_id,agent),starter,resolved:null,main:false,revision:1};
175
+ await guardedMutation(api,res,auth,[...memberGuards(auth,now),ownerGuard(auth,session),...standing,...await managementGuards(api,auth,agent),revisionGuard(auth.team_id,session.id,null)],
176
+ [stmt("INSERT INTO agent_sessions(team_id,session_id,agent_id,starter_json,profile_json,resolved_at,main) VALUES (?,?,?,?,'null',?,0)",auth.team_id,session.id,agent.id,JSON.stringify(starter),now),
177
+ stmt('UPDATE teams SET auth_epoch=auth_epoch+1 WHERE id=?',auth.team_id),publish(api,auth,session.id,data,now)],{status:201,body:result(api,auth,data),targetId:session.id});
178
+ send(res,201,result(api,auth,data),requestId);return true;
179
+ }
180
+ const data={session:session.id,agent:null,starter,resolved:null,main:false,revision:1};
181
+ await guardedMutation(api,res,auth,[...memberGuards(auth,now),ownerGuard(auth,session),revisionGuard(auth.team_id,session.id,null)],
182
+ [stmt("INSERT INTO agent_sessions(team_id,session_id,agent_id,starter_json,profile_json,resolved_at) VALUES (?,?,NULL,?,'null',?)",auth.team_id,session.id,JSON.stringify(starter),now),publish(api,auth,session.id,data,now)],{status:201,body:result(api,auth,data),targetId:session.id});
183
+ send(res,201,result(api,auth,data),requestId);return true;
184
+ }
185
+ if(!(match=/^\/agents\/([^/]+)\/sessions$/.exec(sub)))return false;
186
+ const agent=await api.store.one('SELECT * FROM agents WHERE team_id=? AND id=?',[auth.team_id,decodeURIComponent(match[1])]);
187
+ if(!agent)fail(404,'not_found','agent not found');
188
+ if(req.method==='GET'){
189
+ const limit=Number(url.searchParams.get('limit')??100),after=url.searchParams.get('after')??'',descendants=url.searchParams.get('descendants')==='true';
190
+ if(!Number.isSafeInteger(limit)||limit<1||limit>200)fail(400,'invalid_request','limit must be 1..200');
191
+ const rows=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=? AND ?=1)
192
+ SELECT s.session_id FROM agent_sessions s JOIN tree t ON s.agent_id=t.id WHERE s.team_id=? AND s.session_id>? ORDER BY s.session_id LIMIT ?`,[agent.id,auth.team_id,descendants?1:0,auth.team_id,after,limit+1]);
193
+ const items=[];
194
+ for(const row of rows.slice(0,limit)){
195
+ try{await api.store.authorizedSession({teamId:auth.team_id,principalId:auth.principal_id,credentialId:auth.credential_id,ref:row.session_id,now:api.clock.nowMs()});}
196
+ catch(error){if([401,403,404,410].includes(error.status))continue;throw error;}
197
+ items.push(await sessionIdentity(api,auth.team_id,row.session_id));
198
+ }
199
+ send(res,200,{version:1,items,next_after:rows.length>limit?rows[limit-1].session_id:null},requestId);return true;
200
+ }
201
+ if(req.method!=='POST')fail(404,'not_found','agent session operation not found');
202
+ if(auth.app_grants!=null||auth.installation_id||!await manageableAgent(api,auth,agent))fail(403,'forbidden','only native agent management declares session ownership');
203
+ const body=await parseJson(req);exact(body,['session','main','starter_session']);
204
+ if(typeof body.session!=='string'||body.main!==undefined&&typeof body.main!=='boolean')fail(400,'invalid_request','session reference and optional main boolean are required');
205
+ const session=await api.store.authorizedSession({teamId:auth.team_id,principalId:auth.principal_id,credentialId:auth.credential_id,ref:body.session,now});
206
+ if(session.custodian_id!==auth.principal_id)fail(403,'forbidden','only your own session can be declared');
207
+ const old=await api.store.one('SELECT * FROM agent_sessions WHERE team_id=? AND session_id=?',[auth.team_id,session.id]);
208
+ if(old?.agent_id&&old.agent_id!==agent.id)fail(409,'conflict','a session already owned by an agent cannot change identity');
209
+ if(old?.ended_at!=null)fail(409,'conflict','an ended session cannot become live again');
210
+ const standing=[];
211
+ if(agent.state!=='active'||!await agentSponsor(api,auth.team_id,agent,new Set(),standing))fail(409,'conflict','a suspended or ended agent cannot launch');
212
+ const starter=old?JSON.parse(old.starter_json):await starterFor(api,auth,body.starter_session,now),main=body.main??!agent.main_session_id,revision=(old?.revision??0)+1;
213
+ if(body.main===false&&agent.main_session_id===session.id)fail(409,'conflict','select another main session before demoting the current one');
214
+ const data={session:session.id,agent:{...await agentIdentity(api,auth.team_id,agent),main_session:main?session.id:agent.main_session_id},starter,resolved:old?JSON.parse(old.profile_json):null,main,revision};
215
+ const statements=[];
216
+ if(main)statements.push(stmt('UPDATE agent_sessions SET main=0,revision=revision+1 WHERE team_id=? AND agent_id=? AND main=1 AND session_id!=?',auth.team_id,agent.id,session.id));
217
+ statements.push(old?stmt('UPDATE agent_sessions SET agent_id=?,main=?,revision=? WHERE team_id=? AND session_id=?',agent.id,main?1:0,revision,auth.team_id,session.id)
218
+ :stmt("INSERT INTO agent_sessions(team_id,session_id,agent_id,starter_json,profile_json,resolved_at,main) VALUES (?,?,?,?,'null',?,?)",auth.team_id,session.id,agent.id,JSON.stringify(starter),now,main?1:0));
219
+ if(main){data.agent.revision=agent.revision+1;}
220
+ if(main)statements.push(stmt('UPDATE agents SET main_session_id=?,revision=revision+1 WHERE team_id=? AND id=?',session.id,auth.team_id,agent.id));
221
+ // The main session is an Agent field (ontology Agent.main_session): its change is the agent's change too.
222
+ if(main&&agent.main_session_id!==session.id)statements.push(publication(api,auth,'agent.changed',agent.id,{...data.agent,revision:agent.revision+1},now));
223
+ statements.push(stmt('UPDATE teams SET auth_epoch=auth_epoch+1 WHERE id=?',auth.team_id),publish(api,auth,session.id,data,now));
224
+ await guardedMutation(api,res,auth,[...memberGuards(auth,now),ownerGuard(auth,session),...standing,...await managementGuards(api,auth,agent),revisionGuard(auth.team_id,session.id,old)],statements,{status:old?200:201,body:result(api,auth,data),targetId:session.id});
225
+ send(res,old?200:201,result(api,auth,data),requestId);return true;
226
+ }