@volter/supercode-teams 0.2.25 → 0.2.27

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.
@@ -11,6 +11,7 @@ const VALUES = new Set([
11
11
  'out', 'ttl', 'type', 'name', 'visibility', 'backfill', 'format', 'to',
12
12
  'grant', 'binding', 'caps', 'fidelity', 'cwd', 'root', 'workspace-from', 'since', 'until', 'session',
13
13
  'on', 'timeout', 'plan', 'tmux-socket', 'volter-issuer', 'delegated-issuer', 'workplace', 'organization', 'key', 'room',
14
+ 'conversation', 'rh2',
14
15
  ]);
15
16
 
16
17
  export function parseArgs(argv) {
package/bin/workspace.mjs CHANGED
@@ -299,6 +299,15 @@ async function sessionsCommand(sub, command) {
299
299
  if (sub === 'export') return exportSession(session, command);
300
300
  if (sub === 'continue') return continueSession(session, command);
301
301
  if (sub === 'url') { only(flags, ['json', 'help', 'context']); requireArgs(rest, 1, 1, 'sessions url REF'); const descriptor = await session.descriptor(); return output({ version: 1, context: scope(context), data: { url: `${context.origin}/teams/${context.team_id}/sessions/${descriptor.data.id}` } }); }
302
+ // A session view (RH2 ADR 0008): its owner lets a workplace Room's Conversation read this session with the tokens that
303
+ // workplace signs. `bind` records it and prints what the workplace's own bind door takes; `unbind` removes it.
304
+ if (sub === 'bind' || sub === 'unbind') {
305
+ only(flags, ['json', 'help', 'context', 'room', 'conversation', 'rh2']); requireArgs(rest, 1, 1, `sessions ${sub} REF --room ROOM --conversation CONVERSATION --rh2 ORIGIN`);
306
+ if (!flags.room || !flags.conversation || !flags.rh2) usage(`sessions ${sub} needs --room, --conversation and --rh2 (the workplace's origin)`);
307
+ const body = { issuer: new URL(String(flags.rh2)).origin, room: String(flags.room), conversation: String(flags.conversation) };
308
+ const bound = await command.client.request(sub === 'bind' ? 'POST' : 'DELETE', `${session.path}/view-bindings`, { body });
309
+ return output({ version: 1, context: scope(context), data: { teamsUrl: new URL(context.origin).origin, team: context.team_id, sessionId: bound.data.session_id, harness: bound.data.harness, principalId: context.principal_id, bindings: bound.data.bindings } });
310
+ }
302
311
  if (sub === 'rename') { only(flags, ['json', 'help', 'context']); requireArgs(rest, 2, 2, 'sessions rename REF TITLE'); const descriptor = await session.descriptor(); return output(await command.client.request('PATCH', session.path, { body: { title: rest[1] }, ifMatch: descriptor.data.revision })); }
303
312
  if (sub === 'delete') { only(flags, ['json', 'help', 'context']); requireArgs(rest, 1, 1, 'sessions delete REF'); const descriptor = await session.descriptor(); await command.client.request('DELETE', session.path, { ifMatch: descriptor.data.revision }); return output({ version: 1, context: scope(context), data: null }); }
304
313
  unsupported(`sessions ${sub} is reserved for a later arc`);
@@ -860,7 +869,7 @@ function printHelp() {
860
869
  sources create|list|status|pause|revoke|credentials|config
861
870
  machines list|roots|access|authorize-continuation
862
871
  sync add|list|show|pause|resume|remove
863
- sessions list|search|show|follow|access|rename|delete|url|attach|export|continue
872
+ sessions list|search|show|follow|access|rename|delete|url|attach|export|continue|bind|unbind
864
873
  Use --help with a command for its required arguments and options.
865
874
  `);
866
875
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.2.25",
3
+ "version": "0.2.27",
4
4
  "type": "module",
5
5
  "description": "A machine daemon serving this machine's doors, and a local or remote Teams server for shared session discovery, OpenTelemetry ingestion and access to enrolled machines.",
6
6
  "license": "MIT",
@@ -63,7 +63,7 @@ function streamQueue(stream, work) {
63
63
 
64
64
  const sessionArgs = (api, auth, ref) => ({
65
65
  teamId: auth.team_id, principalId: auth.principal_id,
66
- credentialId: auth.credential_id, ref, now: api.clock.nowMs(),
66
+ credentialId: auth.credential_id, ref, now: api.clock.nowMs(), bound: auth.bound,
67
67
  });
68
68
 
69
69
  async function readForEvent(api, auth, ref, tail) {
@@ -12,6 +12,7 @@ import { normalizeSearch, textFromContent, cursorHash, cursorToken } from './cat
12
12
  import { captureDigest, captureMessages, handleOtlpRoute, sha256, validateArtifactName } from './ingest.mjs';
13
13
  import { handleCatalogEvents, handleSessionEvents } from './event-routes.mjs';
14
14
  import { exact } from '@volter/teams/server/request-json';
15
+ import { delegatedSessionOf, looksDelegated } from '@volter/teams/server/delegated';
15
16
  import { renderSourceCollectorConfig } from './otlp/collector-config.mjs';
16
17
  import { attachmentActionRoute, createAttachment, liveGrantRoute, machineEffectRoute } from './effects.mjs';
17
18
  import { exportSessionRoute, operationRoute } from './exports.mjs';
@@ -21,6 +22,20 @@ import { fail, parseJson, result, send, guardedMutation, requireRevision, checkR
21
22
 
22
23
  async function preAuth({ api, req, res, path, requestId }) {
23
24
  let early;
25
+ // A session view (RH2 ADR 0008): a workplace page reads one session with a token its workplace signed for a Room and
26
+ // Conversation, which reaches only a session its owner bound to them (session_view_bindings). A member's own
27
+ // credential takes the ordinary routes below.
28
+ if(req.method==='GET'&&(early=/^\/api\/v1\/teams\/([^/]+)\/sessions\/([^/]+)(\/messages|\/events)?$/.exec(path))&&looksDelegated(bearer(req.headers.authorization))){
29
+ const access=await delegatedSessionOf(api,bearer(req.headers.authorization));
30
+ const ref=decodeURIComponent(early[2]);
31
+ if(!access||access.teamId!==early[1]||access.sessionRef!==ref)fail(401,'authentication_required','authentication required');
32
+ const auth={kind:'session_view',team_id:early[1],principal_id:null,credential_id:null,bound:{issuer:access.issuer,room:access.room,conversation:access.conversation,expiresAt:access.expiresAt}};
33
+ const url=new URL(req.url,'http://local');
34
+ if(!early[3])await sessionDescriptor(api,res,auth,ref,requestId);
35
+ else if(early[3]==='/messages')await readSession(api,res,auth,ref,url,requestId);
36
+ else await handleSessionEvents({api,req,res,auth,id:ref,url,requestId,fail});
37
+ return true;
38
+ }
24
39
  if(req.method==='GET'&&(early=/^\/api\/v1\/teams\/([^/]+)\/attachments\/([^/]+)\/stream$/.exec(path))){const token=bearer(req.headers.authorization);checkRate(api,`attachment:${hashToken(token??'')}`,120,30);await attachmentStreamRoute({api,req,res,teamId:early[1],attachmentId:early[2],token,fail});return true;}
25
40
  return false;
26
41
  }
@@ -52,6 +67,7 @@ async function team({ api, req, res, auth, url, path, sub, requestId }) {
52
67
  if(req.method==='POST'&&(m=/^\/sessions\/([^/]+)\/exports$/.exec(sub))){await exportSessionRoute({api,req,res,auth,sessionId:m[1],requestId,parseJson,exact,fail,send,result,guardedMutation});return true;}
53
68
  if(req.method==='POST'&&(m=/^\/sessions\/([^/]+)\/continuations$/.exec(sub))){await continuationRoute({api,req,res,auth,sessionId:m[1],requestId,parseJson,exact,fail,send,result,guardedMutation});return true;}
54
69
  if(req.method==='GET'&&(m=/^\/sessions\/([^/]+)\/events$/.exec(sub))){await handleSessionEvents({api,req,res,auth,id:m[1],url,requestId,resolve,canReadMerged,readerProjection,fail});return true;}
70
+ if(['GET','POST','DELETE'].includes(req.method)&&(m=/^\/sessions\/([^/]+)\/view-bindings$/.exec(sub))){await viewBindingRoute(api,req,res,auth,m[1],requestId);return true;}
55
71
  if((m=/^\/sessions\/([^/]+)\/access(?:\/([^/]+))?$/.exec(sub))){await accessRoute(api,req,res,auth,m[1],m[2],requestId);return true;}
56
72
  if((m=/^\/machines\/([^/]+)\/grants(?:\/([^/]+))?$/.exec(sub))){await liveGrantRoute({api,req,res,auth,machineId:m[1],grantId:m[2],url,requestId,parseJson,exact,fail,send,result,guardedMutation,requireRevision});return true;}
57
73
  if((m=/^\/machines\/([^/]+)\/effects(?:\/([^/]+)(?:\/(claim|result))?)?$/.exec(sub))){await machineEffectRoute({api,req,res,auth,machineId:m[1],effectId:m[2],action:m[3],url,requestId,parseJson,exact,fail,send,result,guardedMutation});return true;}
@@ -146,7 +162,7 @@ async function listSessions(api,res,auth,url,requestId,search){
146
162
  send(res,200,body,requestId);
147
163
  }
148
164
  async function sessionDescriptor(api,res,auth,id,requestId){
149
- const row=await api.store.authorizedSession({teamId:auth.team_id,principalId:auth.principal_id,credentialId:auth.credential_id,ref:id,now:api.clock.nowMs()});
165
+ const row=await api.store.authorizedSession({teamId:auth.team_id,principalId:auth.principal_id,credentialId:auth.credential_id,ref:id,now:api.clock.nowMs(),bound:auth.bound});
150
166
  send(res,200,result(api,auth,JSON.parse(row.descriptor_json)),requestId,{ETag:`"${row.revision}"`});
151
167
  }
152
168
  async function correlateRoute(api,req,res,auth,path,requestId){
@@ -168,7 +184,7 @@ async function readSession(api,res,auth,id,url,requestId){
168
184
  const tail=query.tail??100;
169
185
  if(query.before&&query.revision!==undefined)fail(400,'invalid_request','revision cannot be combined with before');
170
186
  const nextCursor=cursorToken();
171
- const snapshot=await api.store.readSessionSnapshot({teamId:auth.team_id,principalId:auth.principal_id,credentialId:auth.credential_id,ref:id,now:api.clock.nowMs(),beforeHash:query.before?cursorHash(query.before):null,requestedRevision:query.revision,tail,nextCursorHash:cursorHash(nextCursor),nextCursorToken:nextCursor});
187
+ const snapshot=await api.store.readSessionSnapshot({teamId:auth.team_id,principalId:auth.principal_id,credentialId:auth.credential_id,ref:id,now:api.clock.nowMs(),bound:auth.bound,beforeHash:query.before?cursorHash(query.before):null,requestedRevision:query.revision,tail,nextCursorHash:cursorHash(nextCursor),nextCursorToken:nextCursor});
172
188
  const {rows,beforeCursor,revision}=snapshot;
173
189
  const messages=rows.map(row=>({id:row.id,source_id:row.source_id,native_key:row.native_key,recorded_at:row.recorded_at,value:JSON.parse(row.value_json)}));
174
190
  send(res,200,{version:1,context:{server_id:api.serverId,team_id:auth.team_id,principal_id:auth.principal_id},descriptor:JSON.parse(snapshot.descriptor_json),messages,before_cursor:beforeCursor,event_cursor:snapshot.eventCursor,revision,display:{returned:messages.length,total:snapshot.total,truncated_fields:0}},requestId);
@@ -246,3 +262,23 @@ async function captureCommit(api,req,res,auth,sourceId,captureId,requestId){cons
246
262
  statements.push({sql:'INSERT INTO session_versions(team_id,session_id,revision,descriptor_json,source_projection_json,search_title,sort_at,created_at) VALUES (?,?,?,?,?,?,?,?)',params:[auth.team_id,sid,revisionParam,{$journalJsonValue:descriptor},JSON.stringify({source_id:sourceId}),normalizeSearch(commit.title??''),sortAt,boundaryNow]});let ordinal=0;for(const m of messages){let mid=(await api.store.one('SELECT id FROM message_ids WHERE team_id=? AND source_id=? AND harness=? AND native_id=? AND native_key=?',[auth.team_id,sourceId,cap.harness,cap.native_id,m.native_key]))?.id;if(!mid){mid=api.ids.next('message');statements.push({sql:'INSERT INTO message_ids(team_id,source_id,harness,native_id,native_key,id) VALUES (?,?,?,?,?,?)',params:[auth.team_id,sourceId,cap.harness,cap.native_id,m.native_key,mid]});}statements.push({sql:'INSERT INTO messages(team_id,session_id,revision,ordinal,id,source_id,native_key,recorded_at,value_json,search_text) VALUES (?,?,?,?,?,?,?,?,?,?)',params:[auth.team_id,sid,revisionParam,ordinal++,mid,sourceId,m.native_key,m.recorded_at,JSON.stringify(m.value),normalizeSearch(textFromContent(m.value.content))]});}
247
263
  const bundle=commit.native_bundle??null,legacyBundle=commit.artifacts.length?captureId:null;for(let index=0;index<commit.artifacts.length;index++){const a=commit.artifacts[index],file=bundle?.files.find(x=>x.upload_id===a.upload_id);statements.push({sql:'UPDATE uploads SET manifest_json=?,committed=1 WHERE team_id=? AND id=?',params:[JSON.stringify(a),auth.team_id,a.upload_id]},{sql:'INSERT INTO session_artifacts(team_id,session_id,revision,bundle_id,upload_id,format,provenance,file_role,fidelity,residue_json) VALUES (?,?,?,?,?,?,?,?,?,?)',params:[auth.team_id,sid,revisionParam,bundle?.id??legacyBundle,a.upload_id,bundle?.harness??cap.harness,'capture',file?.role??(index===0?'primary':'bundle'),bundle?.fidelity??'semantic',JSON.stringify(bundle?.residue??['native_bundle_metadata_unavailable'])]});}const storedReceipt={source_id:sourceId,capture_id:captureId,session_id:sid,generation:cap.generation,sequence:cap.sequence,digest:commit.digest,revision:'__JOURNAL_REVISION__',duplicate:false};statements.push({sql:"UPDATE captures SET state='committed',commit_json=?,receipt_json=? WHERE team_id=? AND id=?",params:[JSON.stringify(commit),{$journalJsonValue:storedReceipt},auth.team_id,captureId]},{sql:'UPDATE sources SET last_received_at=? WHERE team_id=? AND id=?',params:[boundaryNow,auth.team_id,sourceId]},{sql:'UPDATE teams SET journal_revision=? WHERE id=?',params:[revisionParam,auth.team_id]},{sql:'INSERT INTO journal(team_id,revision,session_id,kind,created_at) VALUES (?,?,?,?,?)',params:[auth.team_id,revisionParam,sid,'capture',boundaryNow]});const guards=[{sql:'SELECT 1 ok FROM credentials WHERE id=? AND source_id=? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at>?)',params:[auth.credential_id,sourceId,boundaryNow],code:'authentication_required',status:401,message:'source credential expired or revoked'},...lineageGuards,{sql:"SELECT 1 ok FROM captures c JOIN sources s ON s.team_id=c.team_id AND s.id=c.source_id LEFT JOIN machines m ON m.team_id=s.team_id AND m.id=s.machine_id LEFT JOIN sync_rules r ON r.team_id=c.team_id AND r.id=json_extract(c.begin_json,'$.rule_id') WHERE c.team_id=? AND c.id=? AND c.source_id=? AND c.state='staging' AND s.state='active' AND (s.kind!='connector' OR (m.revoked_at IS NULL AND r.machine_id=m.id AND r.source_id=s.id AND r.state='active' AND r.revision=json_extract(c.begin_json,'$.rule_revision') AND r.visibility=json_extract(c.begin_json,'$.visibility')))",params:[auth.team_id,captureId,sourceId],code:'conflict',status:409,message:'capture authorization changed'}],storedResponse=result(api,auth,storedReceipt),{revision}=await guardedMutation(api,res,auth,guards,statements,{status:200,body:storedResponse,targetId:captureId,journal:true}),receipt={...storedReceipt,revision};api.events.publish(auth.team_id,{type:'session_changed',cursor:String(revision),revision,session_id:sid,reason:'capture',read_required:true});send(res,200,result(api,auth,receipt),requestId);
248
264
  }
265
+
266
+ /**
267
+ * A session's views (RH2 ADR 0008): its owner lets one workplace Room's Conversation read it, and that workplace's
268
+ * signed tokens then reach this session and nothing else (preAuth). GET lists them; POST adds one; DELETE removes one.
269
+ */
270
+ async function viewBindingRoute(api,req,res,auth,ref,requestId){
271
+ if(!['user','service'].includes(auth.kind))fail(403,'forbidden','credential cannot bind sessions');
272
+ const session=await api.store.authorizedSession({teamId:auth.team_id,principalId:auth.principal_id,credentialId:auth.credential_id,ref,now:api.clock.nowMs()});
273
+ const list=async()=>api.store.query('SELECT issuer,room,conversation,bound_by,created_at FROM session_view_bindings WHERE team_id=? AND session_id=? ORDER BY created_at',[auth.team_id,session.id]);
274
+ if(req.method==='GET'){send(res,200,result(api,auth,{session_id:session.id,harness:session.harness,bindings:await list()}),requestId);return;}
275
+ if(session.custodian_id!==auth.principal_id)fail(403,'forbidden',"only the session's owner binds it to a view");
276
+ const body=await parseJson(req);exact(body,['issuer','room','conversation']);
277
+ if(typeof body.room!=='string'||!body.room||typeof body.conversation!=='string'||!body.conversation)fail(400,'invalid_request','room and conversation are required');
278
+ if(!api.delegatedIssuer||body.issuer!==api.delegatedIssuer)fail(400,'invalid_request',`this server accepts views signed by ${api.delegatedIssuer??'no workplace'}`);
279
+ const key=[auth.team_id,session.id,body.issuer,body.room,body.conversation];
280
+ await api.store.transaction(req.method==='POST'
281
+ ?[{sql:'INSERT OR IGNORE INTO session_view_bindings(team_id,session_id,issuer,room,conversation,bound_by,created_at) VALUES (?,?,?,?,?,?,?)',params:[...key,auth.principal_id,api.clock.nowMs()]}]
282
+ :[{sql:'DELETE FROM session_view_bindings WHERE team_id=? AND session_id=? AND issuer=? AND room=? AND conversation=?',params:key}]);
283
+ send(res,200,result(api,auth,{session_id:session.id,harness:session.harness,bindings:await list()}),requestId);
284
+ }
@@ -22,20 +22,30 @@ export function createStoreHandlers({ all, get, run, tx }) {
22
22
  const journalParam=(value,revision)=>value&&typeof value==='object'&&value.$journalRevision===true?revision:value&&typeof value==='object'&&Object.hasOwn(value,'$journalJson')?JSON.stringify({...value.$journalJson,revision}):value&&typeof value==='object'&&Object.hasOwn(value,'$journalJsonValue')?JSON.stringify(journalValue(value.$journalJsonValue,revision)):value;
23
23
  const domainError=(status,code,message)=>{const error=new Error(message);error.status=status;error.code=code;throw error;};
24
24
  const authorizedSession=(args)=>{
25
- const {teamId,principalId,credentialId,ref,now}=args;
26
- const credential=get("SELECT id FROM credentials WHERE id=? AND principal_id=? AND kind IN ('user','service') AND (team_id IS NULL OR team_id=?) AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at>?)",[credentialId,principalId,teamId,now]);
27
- const membership=get('SELECT role FROM memberships WHERE team_id=? AND principal_id=? AND active=1',[teamId,principalId]);
28
- if(!credential||!membership)domainError(401,'authentication_required','authentication required');
25
+ const {teamId,principalId,credentialId,ref,now,bound}=args;
26
+ // `bound`: a workplace's session-view token ({issuer, room, conversation, expiresAt}), which reads only a
27
+ // session its owner bound to that Room and Conversation; it holds no credential and is no member.
28
+ let membership=null;
29
+ if(bound){
30
+ if(!(bound.expiresAt>now))domainError(401,'authentication_required','authentication required');
31
+ }else{
32
+ const credential=get("SELECT id FROM credentials WHERE id=? AND principal_id=? AND kind IN ('user','service') AND (team_id IS NULL OR team_id=?) AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at>?)",[credentialId,principalId,teamId,now]);
33
+ membership=get('SELECT role FROM memberships WHERE team_id=? AND principal_id=? AND active=1',[teamId,principalId]);
34
+ if(!credential||!membership)domainError(401,'authentication_required','authentication required');
35
+ }
29
36
  if(typeof ref!=='string'||ref.length<8)domainError(404,'not_found','not found');
30
37
  const groups=all('SELECT group_id FROM group_members WHERE team_id=? AND principal_id=?',[teamId,principalId]).map(row=>row.group_id);
31
- const candidates=all('SELECT * FROM sessions WHERE team_id=? AND id LIKE ? AND deleted_at IS NULL',[teamId,`${ref}%`]);
38
+ // A session is named by its id (or a prefix of it) or by the harness's own id for it (a native
39
+ // session id such as a Claude Code session's uuid), which its origin records.
40
+ let candidates=all('SELECT * FROM sessions WHERE team_id=? AND id LIKE ? AND deleted_at IS NULL',[teamId,`${ref}%`]);
41
+ if(!candidates.length)candidates=all('SELECT DISTINCT s.* FROM sessions s JOIN origins o ON o.team_id=s.team_id AND o.session_id=s.id WHERE s.team_id=? AND o.native_id=? AND o.deleted_at IS NULL AND s.deleted_at IS NULL',[teamId,ref]);
32
42
  const visible=new Map();
33
43
  for(const candidate of candidates){
34
44
  let root=candidate;const seen=new Set();
35
45
  while(root.canonical_id){if(seen.has(root.id))domainError(409,'conflict','correlation cycle');seen.add(root.id);root=get('SELECT * FROM sessions WHERE team_id=? AND id=? AND deleted_at IS NULL',[teamId,root.canonical_id]);if(!root)break;}
36
46
  if(!root)continue;
37
47
  const family=all('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=?) SELECT s.* FROM sessions s JOIN family f ON f.id=s.id WHERE s.team_id=?',[root.id,teamId,teamId]);
38
- const readable=family.every(session=>{
48
+ const readable=bound?Boolean(get('SELECT 1 ok FROM session_view_bindings WHERE team_id=? AND session_id=? AND issuer=? AND room=? AND conversation=?',[teamId,root.id,bound.issuer,bound.room,bound.conversation]))&&family.every(session=>session.deleted_at==null):family.every(session=>{
39
49
  if(session.deleted_at!=null)return false;
40
50
  if(session.visibility==='team'||session.custodian_id===principalId)return true;
41
51
  const grants=all('SELECT subject_kind,subject_id,expires_at FROM session_grants WHERE team_id=? AND session_id=?',[teamId,session.id]);
@@ -57,8 +67,10 @@ export function createStoreHandlers({ all, get, run, tx }) {
57
67
  return {session:{...session,descriptor_json:JSON.stringify(projection)},membership};
58
68
  };
59
69
  const readSessionSnapshot=args=>tx(()=>{
60
- const {teamId,principalId,credentialId,ref,now,beforeHash,requestedRevision,tail,nextCursorHash,nextCursorToken}=args;
61
- const {session}=authorizedSession(args);const team=get('SELECT auth_epoch,journal_revision FROM teams WHERE id=?',[teamId]);
70
+ const {teamId,ref,now,beforeHash,requestedRevision,tail,nextCursorHash,nextCursorToken}=args;
71
+ const {session}=authorizedSession(args);
72
+ // A bound view's history cursors are kept under the session's owner, the member it reads for.
73
+ const principalId=args.bound?session.custodian_id:args.principalId;const team=get('SELECT auth_epoch,journal_revision FROM teams WHERE id=?',[teamId]);
62
74
  let revision,ordinals;
63
75
  if(beforeHash){
64
76
  const cursor=get("SELECT * FROM cursors WHERE id=? AND team_id=? AND principal_id=? AND kind='history'",[beforeHash,teamId,principalId]);