@volter/supercode-teams 0.2.165 → 0.2.167

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.
@@ -1,6 +1,6 @@
1
1
  const BOOLEANS = new Set([
2
2
  'json', 'help', 'live', 'saved', 'watch', 'paginate', 'token-stdin',
3
- 'invite-stdin', 'include-children', 'rotate', 'install', 'force',
3
+ 'invite-stdin', 'enrollment-stdin', 'include-children', 'rotate', 'install', 'force',
4
4
  'take-control', 'dry-run', 'volter', 'foreground', 'no-connect', 'record-only', 'workplace-only',
5
5
  ]);
6
6
 
@@ -11,7 +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', 'workplace', 'organization', 'key', 'room',
14
- 'conversation', 'rh2', 'scope', 'agent', 'body-file',
14
+ 'conversation', 'rh2', 'scope', 'agent', 'body-file', 'enrollment',
15
15
  ]);
16
16
 
17
17
  export function parseArgs(argv) {
package/bin/workspace.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  import { hostname } from 'node:os';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { existsSync, readFileSync, realpathSync, rmSync } from 'node:fs';
5
- import { basename, join, resolve } from 'node:path';
5
+ import { basename, isAbsolute, join, resolve } from 'node:path';
6
6
  import { execFileSync, spawn } from 'node:child_process';
7
7
  import { createHash } from 'node:crypto';
8
8
  import { SupercodeHarnessClient } from '@volter/supercode-harness-sdk';
@@ -47,6 +47,7 @@ export async function workspaceMain(argv = process.argv.slice(2), env = process.
47
47
  if (verb === 'server') return serverCommand({ flags, sub, rest, home, output });
48
48
  if (verb === 'login') return loginCommand({ flags, origin: sub, rest, home, output });
49
49
  if (verb === 'auth') return authCommand({ flags, sub, rest, home, contexts, output, env, dependencies });
50
+ if (verb === 'connect' && flags.enrollment_stdin) adoptEnrollment(contexts, home, flags);
50
51
  const explicitContext = flags.context ?? env.SUPERCODE_CONTEXT;
51
52
  let selected = explicitContext ?? contexts.read().current;
52
53
  const urlReference = verb === 'sessions' ? rest.find(value => /^https?:\/\//.test(value)) : null;
@@ -55,6 +56,8 @@ export async function workspaceMain(argv = process.argv.slice(2), env = process.
55
56
  if (selected === 'local') return localCommand({ verb, sub, rest, flags, output });
56
57
  const { client, context } = fromContext({ supercodeHome: home, name: selected, fetch: globalThis.fetch });
57
58
  const team = client.team(context.team_id);
59
+ // A machine-only context (a box enrolled by its custodian) holds its machine credential and nothing a person has.
60
+ if (context.machine_only && !['connect', 'disconnect', 'status'].includes(verb)) usage(`context ${selected} is machine ${context.machine_name}'s: run teams ${verb} where a person is signed in`);
58
61
  const command = { flags, rest, output, client, context, team, explicitContext, home, contextName: selected, env, dependencies };
59
62
  if (teamsPrefix && ['list', 'create'].includes(verb)) return teamsCommand(verb, sub, command);
60
63
  if (verb === 'apps' || verb === 'app-installations') return appsCommand(verb,sub,{...command,output:value=>output(flags.json?value:redactAppSecrets(value))});
@@ -588,6 +591,26 @@ async function sourcesCommand(sub, command) {
588
591
  }
589
592
 
590
593
  async function machinesCommand(sub, { flags, rest, output, client, context, team }) {
594
+ if (sub === 'remove') {
595
+ // The custodian revokes a machine: its credentials, links and grants end, and every later door is refused.
596
+ requireArgs(rest, 1, 1, 'teams machines remove MACHINE_ID'); const current = await team.machines.get(rest[0]);
597
+ await client.request('DELETE', teamPath(context, `/machines/${rest[0]}`), { ifMatch: current.data.revision });
598
+ return output({ version: 1, context: scope(context), data: { removed: rest[0] } });
599
+ }
600
+ if (sub === 'enroll') {
601
+ // The signed-in person enrolls another machine as its custodian. The file carries only that machine's own
602
+ // credential; the machine connects from it (teams connect --enrollment-stdin) and never holds a person's.
603
+ requireArgs(rest, 0, 0, 'teams machines enroll --name NAME --out PATH'); if (!flags.name) usage('--name is required');
604
+ if (!flags.out) usage('--out is required to deliver the machine credential'); const destination = reserveProtected(flags.out, { force: flags.force });
605
+ try {
606
+ const enrollment_key = crypto.randomUUID();
607
+ const response = await team.machines.create({ name: flags.name, enrollment_key });
608
+ let credential = response.data.credential;
609
+ if (!credential) credential = (await client.request('POST', teamPath(context, `/sources/${response.data.source.id}/credentials`), { body: { rotate: true } })).data;
610
+ destination.write({ version: 1, kind: 'machine-enrollment', origin: context.origin, server_id: context.server_id, team_id: context.team_id, team_name: context.team_name ?? null, enrollment_key, machine: response.data.machine, source: response.data.source, credential });
611
+ return output({ version: 1, context: scope(context), data: { machine: response.data.machine, source: response.data.source, written_to: resolve(flags.out) } });
612
+ } catch (error) { destination.abort(); throw error; }
613
+ }
591
614
  if (['subscribe','unsubscribe','events'].includes(sub)) {
592
615
  requireArgs(rest,sub==='events'?0:1,sub==='events'?0:1,`teams machines ${sub} ${sub==='events'?'':'ADDRESS'}`);
593
616
  const channel=await openLocalMachine(paths(teamsHome()).socket);
@@ -647,11 +670,24 @@ async function machinesCommand(sub, { flags, rest, output, client, context, team
647
670
  return output(await client.request('POST', machineBase, { body: { subject_id: subject, caps, targets, expires_at: Date.now() + ttl } }));
648
671
  }
649
672
  }
650
- usage('teams machines list|roots|access|authorize-continuation|subscribe|unsubscribe|events');
673
+ usage('teams machines list|roots|access|enroll|remove|authorize-continuation|subscribe|unsubscribe|events');
651
674
  }
652
675
 
653
676
  async function syncCommand(sub, { flags, rest, output, client, context, team, home }) {
654
677
  if (sub === 'list') return output(await team.syncRules.list(optional(flags, ['machine'])));
678
+ if (sub === 'add' && flags.enrollment) {
679
+ // A custodian's rule for a machine it enrolled: the path is the machine's own, and its root id is the one the
680
+ // machine's connector registers for it (sha256 of machine id and path). The harness is named, as this computer
681
+ // cannot ask that machine's native core.
682
+ requireArgs(rest, 0, 0, 'teams sync add --enrollment FILE --repo PATH --harness ID'); if (!flags.repo) usage('--repo is required'); if (!flags.harness) usage('--harness is required with --enrollment');
683
+ const enrolled = JSON.parse(readFileSync(resolve(flags.enrollment), 'utf8'));
684
+ if (enrolled.kind !== 'machine-enrollment' || enrolled.team_id !== context.team_id) usage('--enrollment names no machine of this team');
685
+ // connect registers the folder's real path; name it the same way (absolute, normalized, no symlink).
686
+ if (!isAbsolute(flags.repo)) usage('--repo with --enrollment is the machine\'s absolute path');
687
+ const repo = resolve(flags.repo);
688
+ const root_id = `root_${createHash('sha256').update(`${enrolled.machine.id}\0${repo}`).digest('hex').slice(0, 32)}`;
689
+ return output(await team.syncRules.create({ machine_id: enrolled.machine.id, source_id: enrolled.source.id, selector: { root_id, harnesses: [flags.harness] }, visibility: flags.visibility ?? 'restricted', backfill: backfill(flags.backfill ?? 'none') }));
690
+ }
655
691
  if (sub === 'add') {
656
692
  requireArgs(rest, 0, 0, 'teams sync add --repo PATH'); if (!flags.repo) usage('--repo is required');
657
693
  const enrollment = new WorkspaceContexts(home).connector(context.server_id, context.team_id).read();
@@ -683,16 +719,38 @@ async function stopRunningConnector(saved) {
683
719
  for (let waited = 0; running.pid > 0 && alive() && waited < 15_000; waited += 250) await new Promise(resolve => setTimeout(resolve, 250));
684
720
  }
685
721
 
722
+ /**
723
+ * The machine side of `teams machines enroll`: the enrollment on stdin becomes this home's machine-only context (its
724
+ * machine credential, never a person's) and its connector state, and the connect that follows starts from them.
725
+ */
726
+ function adoptEnrollment(contexts, home, flags) {
727
+ let enrolled;
728
+ try { enrolled = JSON.parse(readFileSync(0, 'utf8')); } catch { usage('stdin is not JSON: pipe the file teams machines enroll --out wrote'); }
729
+ if (enrolled?.version !== 1 || enrolled.kind !== 'machine-enrollment' || !enrolled.credential?.token || !enrolled.machine?.id || !enrolled.source?.id || !enrolled.enrollment_key) usage('stdin is not a machine enrollment (teams machines enroll --out)');
730
+ const connector = contexts.connector(enrolled.server_id, enrolled.team_id), saved = connector.read();
731
+ if (saved?.machine && saved.machine.id !== enrolled.machine.id) usage(`this home is already connected as ${saved.machine.name ?? saved.machine.id}; disconnect it first`);
732
+ const name = flags.context ?? 'machine', existing = contexts.read().contexts[name];
733
+ if (existing && !(existing.machine_only && existing.server_id === enrolled.server_id && existing.team_id === enrolled.team_id)) usage(`context ${name} is not this machine's; name another with --context`);
734
+ const credentialId = `cred_${crypto.randomUUID().replaceAll('-', '')}`;
735
+ contexts.writeCredential(credentialId, { token: enrolled.credential.token, kind: 'machine', expires_at: enrolled.credential.expires_at ?? null, server_id: enrolled.server_id, origin: enrolled.origin });
736
+ contexts.upsert(name, { origin: enrolled.origin, server_id: enrolled.server_id, team_id: enrolled.team_id, team_name: enrolled.team_name ?? null, principal_id: null, credential_ref: credentialId, label: null, machine_only: true, machine_name: enrolled.machine.name });
737
+ if (!saved?.machine) connector.write({ version: 1, enrollment_key: enrolled.enrollment_key, phase: 'enrolled', machine: enrolled.machine, source: enrolled.source, credential: enrolled.credential });
738
+ if (!contexts.read().current) contexts.use(name);
739
+ flags.context = name;
740
+ }
741
+
686
742
  async function connectorCommand(verb, { flags, rest, output, client, context, team, contextName }, home) {
687
743
  requireArgs(rest, 0, 0, `teams ${verb}`); const state = new WorkspaceContexts(home).connector(context.server_id, context.team_id);
688
744
  if (verb === 'status') {
689
- let reachable = true; let account = null; try { account = await client.me(); } catch { reachable = false; }
745
+ // A machine has no account to name: its own door answering its credential is the answer.
746
+ let reachable = true; let account = null;
747
+ try { if (context.machine_only) { const enrolled = state.read(); await client.request('GET', teamPath(context, `/machines/${enrolled?.machine?.id}/configuration`)); } else account = await client.me(); } catch { reachable = false; }
690
748
  const saved = state.read(); const running = saved?.process?.socket ? await activeSocket(saved.process.socket) : false;
691
749
  // A connector deep in a large capture can miss the probe's second while its process lives on: it is busy, not stopped.
692
750
  const busy = !running && Boolean(saved?.process) && lifecycleHeld(state.directory);
693
751
  // What the connector could not sync, as it last wrote it to its state (it is written while it runs).
694
752
  const sync = saved ? readSyncStatus(state.directory) ?? { not_syncing: [], last_error: null, queued_captures: 0 } : null;
695
- const value = { version: 1, context: scope(context), data: { reachable, account, connector: saved ? { enrolled: true, running: running || busy, ...(busy ? { busy: true } : {}), machine: saved.machine, source: saved.source, sync } : { enrolled: false, running: false }, sources: reachable ? (await team.sources.list()).items : [] } };
753
+ const value = { version: 1, context: scope(context), data: { reachable, account, connector: saved ? { enrolled: true, running: running || busy, ...(busy ? { busy: true } : {}), machine: saved.machine, source: saved.source, sync } : { enrolled: false, running: false }, sources: reachable && !context.machine_only ? (await team.sources.list()).items : [] } };
696
754
  if (flags.json) return output(value);
697
755
  const lines = [`Team ${context.team_name ?? context.team_id} · ${new URL(context.origin).host}${reachable ? '' : ' · unreachable'}`];
698
756
  lines.push(saved?.machine ? `Connector ${saved.machine.name ?? saved.machine.id} · ${running ? 'running' : busy ? 'running (busy: its control socket did not answer within 1 s)' : 'not running'}${saved.service?.kind ? ` (${saved.service.kind})` : ''}` : 'Connector not connected (supercode teams connect)');
@@ -846,6 +904,9 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
846
904
  }
847
905
  // Uninstalling a Windows task already stopped its connector, so only one still answering is asked to stop.
848
906
  if (enrollment.process?.socket && enrollment.process?.token && await activeSocket(enrollment.process.socket)) await requestStop(enrollment.process.socket, enrollment.process.token);
907
+ // A machine its custodian enrolled stops here; revoking it is the custodian's (teams machines remove).
908
+ // It keeps its enrollment, so connect resumes it; the custodian's removal is what ends it.
909
+ if (context.machine_only) { state.update((current) => ({ ...current, process: null, service: null })); return output({ version: 1, context: scope(context), data: { disconnected: true, machine_id: enrollment.machine.id, revoked: false } }); }
849
910
  const machine = await team.machines.get(enrollment.machine.id);
850
911
  await client.request('DELETE', teamPath(context, `/machines/${enrollment.machine.id}`), { ifMatch: machine.data.revision });
851
912
  state.remove();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.2.165",
3
+ "version": "0.2.167",
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",
@@ -28,11 +28,17 @@ import { fail, parseJson, result, send, guardedMutation, requireRevision, checkR
28
28
  /** The `sub` a token says it is for, unverified: for a log line only, never for a decision. */
29
29
  function unverifiedSubject(token){try{return String(JSON.parse(Buffer.from(String(token).split('.')[1]??'','base64url').toString('utf8')).sub??'').slice(0,80)||'-';}catch{return '-';}}
30
30
 
31
+ // URL encoding belongs to the HTTP boundary; native references retain their literal bytes in the store.
32
+ function sessionRef(value) {
33
+ try { return decodeURIComponent(value); }
34
+ catch { fail(400, 'invalid_request', 'session reference has invalid URL encoding'); }
35
+ }
36
+
31
37
  async function preAuth({ api, req, res, path, requestId }) {
32
38
  let early;
33
39
  if(req.method==='POST'&&(early=/^\/api\/v1\/teams\/([^/]+)\/sessions\/([^/]+)\/room-notices$/.exec(path))){
34
40
  const access=await delegatedRoomNoticeOf(api,bearer(req.headers.authorization));
35
- if(!access || access.teamId!==early[1] || access.sessionRef!==decodeURIComponent(early[2])) fail(401,'authentication_required','authentication required');
41
+ if(!access || access.teamId!==early[1] || access.sessionRef!==sessionRef(early[2])) fail(401,'authentication_required','authentication required');
36
42
  checkRate(api,`room-notices:${access.issuer}:${access.teamId}`,120,10);
37
43
  const receipt=await deliverSessionRoomNotice(api,access);
38
44
  send(res,200,{data:receipt},requestId);return true;
@@ -46,12 +52,12 @@ async function preAuth({ api, req, res, path, requestId }) {
46
52
  if(req.method==='POST'&&(early=/^\/api\/v1\/teams\/([^/]+)\/sessions\/([^/]+)\/viewer-errors$/.exec(path))){
47
53
  const presented=bearer(req.headers.authorization)??'';checkRate(api,`viewer-errors:${hashToken(presented)}`,30,10);
48
54
  const access=looksDelegated(presented)?await delegatedSessionOf(api,presented):null,body=await parseJson(req).catch(()=>({})),text=(value,limit)=>typeof value==='string'?value.replace(/\s+/g,' ').slice(0,limit):null;
49
- console.log(`viewer-error team=${early[1]} session=${decodeURIComponent(early[2]).slice(0,120)} subject=${access?access.subject:`unverified:${text(unverifiedSubject(presented),80)}`} scope=${access?.scope??'-'} room=${access?.room??'-'} stage=${text(body?.stage,40)} status=${Number.isSafeInteger(body?.status)?body.status:'-'} message=${JSON.stringify(text(body?.message,400))}`);
55
+ console.log(`viewer-error team=${early[1]} session=${sessionRef(early[2]).slice(0,120)} subject=${access?access.subject:`unverified:${text(unverifiedSubject(presented),80)}`} scope=${access?.scope??'-'} room=${access?.room??'-'} stage=${text(body?.stage,40)} status=${Number.isSafeInteger(body?.status)?body.status:'-'} message=${JSON.stringify(text(body?.message,400))}`);
50
56
  send(res,204,null,requestId);return true;
51
57
  }
52
58
  if(req.method==='GET'&&(early=/^\/api\/v1\/teams\/([^/]+)\/sessions\/([^/]+)(\/messages|\/events)?$/.exec(path))&&looksDelegated(bearer(req.headers.authorization))){
53
59
  const access=await delegatedSessionOf(api,bearer(req.headers.authorization));
54
- const ref=decodeURIComponent(early[2]);
60
+ const ref=sessionRef(early[2]);
55
61
  if(!access||access.teamId!==early[1]||access.sessionRef!==ref){console.log(`session-view refused team=${early[1]} session=${ref.slice(0,120)} subject=${access?access.subject:`unverified:${unverifiedSubject(bearer(req.headers.authorization))}`} reason=${!access?'token not admitted':'token names another session'}`);fail(401,'authentication_required','authentication required');}
56
62
  const auth={kind:'session_view',team_id:early[1],principal_id:null,credential_id:null,bound:{installationId:access.installationId,installedAt:access.installedAt,issuer:access.issuer,room:access.room,conversation:access.conversation,expiresAt:access.expiresAt}};
57
63
  const url=new URL(req.url,'http://local');
@@ -85,18 +91,18 @@ async function team({ api, req, res, auth, url, path, sub, requestId }) {
85
91
  if(req.method==='GET'&&sub==='/sessions/search'){await listSessions(api,res,auth,url,requestId,true);return true;}
86
92
  if(req.method==='GET'&&sub==='/sessions/events'){await handleCatalogEvents({api,req,res,auth,url,requestId,qobj,canReadMerged,readerProjection,fail});return true;}
87
93
  if(req.method==='POST'&&sub==='/correlations'){await correlateRoute(api,req,res,auth,path,requestId);return true;}
88
- if(req.method==='GET'&&(m=/^\/sessions\/([^/]+)$/.exec(sub))){await sessionDescriptor(api,res,auth,m[1],requestId);return true;}
89
- if((req.method==='PATCH'||req.method==='DELETE')&&(m=/^\/sessions\/([^/]+)$/.exec(sub))){await mutateSession(api,req,res,auth,m[1],requestId);return true;}
90
- if(req.method==='GET'&&(m=/^\/sessions\/([^/]+)\/messages$/.exec(sub))){await readSession(api,res,auth,m[1],url,requestId);return true;}
91
- if(req.method==='GET'&&(m=/^\/sessions\/([^/]+)\/artifacts\/([^/]+)$/.exec(sub))){await readArtifact(api,req,res,auth,m[1],m[2],requestId);return true;}
92
- 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;}
93
- 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;}
94
- 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;}
95
- if(['GET','POST','DELETE'].includes(req.method)&&(m=/^\/sessions\/([^/]+)\/view-bindings$/.exec(sub))){await viewBindingRoute(api,req,res,auth,m[1],requestId);return true;}
96
- if((m=/^\/sessions\/([^/]+)\/access(?:\/([^/]+))?$/.exec(sub))){await accessRoute(api,req,res,auth,m[1],m[2],requestId);return true;}
94
+ if(req.method==='GET'&&(m=/^\/sessions\/([^/]+)$/.exec(sub))){await sessionDescriptor(api,res,auth,sessionRef(m[1]),requestId);return true;}
95
+ if((req.method==='PATCH'||req.method==='DELETE')&&(m=/^\/sessions\/([^/]+)$/.exec(sub))){await mutateSession(api,req,res,auth,sessionRef(m[1]),requestId);return true;}
96
+ if(req.method==='GET'&&(m=/^\/sessions\/([^/]+)\/messages$/.exec(sub))){await readSession(api,res,auth,sessionRef(m[1]),url,requestId);return true;}
97
+ if(req.method==='GET'&&(m=/^\/sessions\/([^/]+)\/artifacts\/([^/]+)$/.exec(sub))){await readArtifact(api,req,res,auth,sessionRef(m[1]),m[2],requestId);return true;}
98
+ if(req.method==='POST'&&(m=/^\/sessions\/([^/]+)\/exports$/.exec(sub))){await exportSessionRoute({api,req,res,auth,sessionId:sessionRef(m[1]),requestId,parseJson,exact,fail,send,result,guardedMutation});return true;}
99
+ if(req.method==='POST'&&(m=/^\/sessions\/([^/]+)\/continuations$/.exec(sub))){await continuationRoute({api,req,res,auth,sessionId:sessionRef(m[1]),requestId,parseJson,exact,fail,send,result,guardedMutation});return true;}
100
+ if(req.method==='GET'&&(m=/^\/sessions\/([^/]+)\/events$/.exec(sub))){await handleSessionEvents({api,req,res,auth,id:sessionRef(m[1]),url,requestId,resolve,canReadMerged,readerProjection,fail});return true;}
101
+ if(['GET','POST','DELETE'].includes(req.method)&&(m=/^\/sessions\/([^/]+)\/view-bindings$/.exec(sub))){await viewBindingRoute(api,req,res,auth,sessionRef(m[1]),requestId);return true;}
102
+ if((m=/^\/sessions\/([^/]+)\/access(?:\/([^/]+))?$/.exec(sub))){await accessRoute(api,req,res,auth,sessionRef(m[1]),m[2],requestId);return true;}
97
103
  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;}
98
104
  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;}
99
- if(req.method==='POST'&&(m=/^\/sessions\/([^/]+)\/attachments$/.exec(sub))){await createAttachment({api,req,res,auth,sessionId:m[1],requestId,parseJson,exact,fail,send,result,guardedMutation,resolve});return true;}
105
+ if(req.method==='POST'&&(m=/^\/sessions\/([^/]+)\/attachments$/.exec(sub))){await createAttachment({api,req,res,auth,sessionId:sessionRef(m[1]),requestId,parseJson,exact,fail,send,result,guardedMutation,resolve});return true;}
100
106
  if(req.method==='POST'&&(m=/^\/attachments\/([^/]+)\/actions$/.exec(sub))){await attachmentActionRoute({api,req,res,auth,attachmentId:m[1],requestId,parseJson,exact,fail,send,result,guardedMutation,resolve});return true;}
101
107
  if(req.method==='DELETE'&&(m=/^\/attachments\/([^/]+)$/.exec(sub))){await detachAttachmentRoute({api,res,auth,attachmentId:m[1],requestId,fail,send,guardedMutation});return true;}
102
108
  if(req.method==='GET'&&(m=/^\/operations\/([^/]+)$/.exec(sub))){await operationRoute({api,res,auth,operationId:m[1],requestId,fail,send,result,guardedMutation});return true;}
@@ -159,12 +165,8 @@ async function listSessions(api,res,auth,url,requestId,search){
159
165
  {kind:'query',sql:'SELECT * FROM session_grants WHERE team_id=?',params:[auth.team_id]}
160
166
  );
161
167
  if(cursorKey)statements.push({kind:'query',sql:`SELECT s.*,v.revision version_revision,v.descriptor_json,v.sort_at FROM ${cursorVersions} JOIN session_versions v ON v.team_id=? AND v.session_id=json_extract(j.value,'$[0]') AND v.revision=json_extract(j.value,'$[1]') JOIN sessions s ON s.team_id=v.team_id AND s.id=v.session_id WHERE s.deleted_at IS NULL AND v.revision<>s.revision`,params:[...cursorKey,auth.team_id]});
162
- if(search){
163
- statements.push({kind:'query',sql:'SELECT m.id,m.session_id,s.revision revision,m.recorded_at,m.search_text FROM sessions s JOIN messages m ON m.team_id=s.team_id AND m.session_id=s.id AND m.revision<=s.revision AND s.revision<COALESCE(m.until_revision,m.revision+1) WHERE s.team_id=? AND s.deleted_at IS NULL',params:[auth.team_id]});
164
- if(cursorKey)statements.push({kind:'query',sql:`SELECT m.id,m.session_id,json_extract(j.value,'$[1]') revision,m.recorded_at,m.search_text FROM ${cursorVersions} JOIN messages m ON m.team_id=? AND m.session_id=json_extract(j.value,'$[0]') AND m.revision<=json_extract(j.value,'$[1]') AND json_extract(j.value,'$[1]')<COALESCE(m.until_revision,m.revision+1) JOIN sessions s ON s.team_id=m.team_id AND s.id=m.session_id WHERE s.deleted_at IS NULL AND json_extract(j.value,'$[1]')<>s.revision`,params:[...cursorKey,auth.team_id]});
165
- }
166
168
  const values=await api.store.snapshot(statements),offset=cursorKey?1:0,cursor=cursorKey?values[0]:null,team=values[offset],credential=values[offset+1],membership=values[offset+2],currentRows=values[offset+3],allGrants=values[offset+4];
167
- let at=offset+5;const olderRows=cursorKey?values[at++]:[],allMessages=search?[...values[at++],...(cursorKey?values[at++]:[])]:[];
169
+ const olderRows=cursorKey?values[offset+5]:[];
168
170
  const rows=[...currentRows,...olderRows],currentById=new Map(currentRows.map(row=>[row.id,row]));
169
171
  const boundaryNow=api.clock.nowMs();
170
172
  if(!membership?.active||credential?.revoked_at!=null||(credential?.expires_at!=null&&credential.expires_at<=boundaryNow))fail(401,'authentication_required','authentication required');
@@ -173,9 +175,8 @@ async function listSessions(api,res,auth,url,requestId,search){
173
175
  if(cursor.kind!==(search?'search':'list')||cursor.expires_at<=boundaryNow||cursor.auth_epoch!==team.auth_epoch||canonicalJson(JSON.parse(cursor.query_json))!==canonicalJson(normalizedQuery))fail(409,'resync_required','cursor expired, mismatched, or authorization changed');
174
176
  tuples=JSON.parse(cursor.snapshot_json);
175
177
  }else tuples=rows.filter(row=>row.revision===row.version_revision).map(row=>[row.id,row.version_revision]);
176
- const rowByVersion=new Map(rows.map(row=>[`${row.id}:${row.version_revision}`,row])),grantsBySession=new Map(),messagesByVersion=new Map();
178
+ const rowByVersion=new Map(rows.map(row=>[`${row.id}:${row.version_revision}`,row])),grantsBySession=new Map();
177
179
  for(const grant of allGrants){const list=grantsBySession.get(grant.session_id)??[];list.push(grant);grantsBySession.set(grant.session_id,list);}
178
- for(const message of allMessages){const key=`${message.session_id}:${message.revision}`,list=messagesByVersion.get(key)??[];list.push(message);messagesByVersion.set(key,list);}
179
180
  const hits=[],hitTuples=[];
180
181
  for(const [id,revision]of tuples){
181
182
  const row=rowByVersion.get(`${id}:${revision}`),current=currentById.get(id);
@@ -190,7 +191,20 @@ async function listSessions(api,res,auth,url,requestId,search){
190
191
  if(query.until!==undefined&&row.sort_at>=query.until)continue;
191
192
  if(query.include_children===false&&view.parent_id!==null)continue;
192
193
  let hit=view;
193
- if(search){const needle=normalizeSearch(query.query.trim()),messages=messagesByVersion.get(`${id}:${revision}`)??[],matches=[],title=normalizeSearch(view.title??'');if(title.includes(needle))matches.push({message_id:null,field:'title',excerpt:title,offset:title.indexOf(needle)});for(const message of messages){const text=normalizeSearch(message.search_text),at=text.indexOf(needle);if(at>=0&&matches.length<3)matches.push({message_id:message.id,field:'content',excerpt:[...text].slice(Math.max(0,at-120),at+needle.length+120).join(''),offset:at});}if(!matches.length)continue;hit={session:view,matches,match_at:Math.max(...messages.map(message=>message.recorded_at??0),row.sort_at),indexed_revision:revision};}
194
+ if(search){
195
+ const needle=normalizeSearch(query.query.trim()),matches=[],title=normalizeSearch(view.title??'');
196
+ if(title.includes(needle))matches.push({message_id:null,field:'title',excerpt:title,offset:[...title.slice(0,title.indexOf(needle))].length});
197
+ // search_text is normalized on ingestion. SQLite matches the pinned version and returns only three bounded
198
+ // excerpts; neither nonmatching/private transcripts nor the org's retained text enter the Worker's heap.
199
+ const found=await api.store.query(`SELECT m.id,instr(m.search_text,?)-1 offset,
200
+ substr(m.search_text,max(1,instr(m.search_text,?)-120),length(?)+240) excerpt
201
+ FROM messages m WHERE m.team_id=? AND m.session_id=? AND ${MESSAGE_AT('m')}
202
+ AND instr(m.search_text,?)>0 ORDER BY m.ordinal,m.id LIMIT ?`,[needle,needle,needle,auth.team_id,id,revision,revision,needle,3-matches.length]);
203
+ matches.push(...found.map(message=>({message_id:message.id,field:'content',excerpt:message.excerpt,offset:message.offset})));
204
+ if(!matches.length)continue;
205
+ const latest=await api.store.one(`SELECT max(recorded_at) at FROM messages m WHERE m.team_id=? AND m.session_id=? AND ${MESSAGE_AT('m')}`,[auth.team_id,id,revision,revision]);
206
+ hit={session:view,matches,match_at:Math.max(latest?.at??0,row.sort_at),indexed_revision:revision};
207
+ }
194
208
  hits.push(hit);hitTuples.push([id,revision]);
195
209
  }
196
210
  const limit=query.limit??50,page=hits.slice(0,limit);let next=null;