@volter/supercode-teams 0.2.162 → 0.2.164

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 CHANGED
@@ -46,10 +46,10 @@ a kind another product serves on the same machine. `title.set` needs `control` o
46
46
  `observe` on a `root:/path` reads it (stat, list, read, search); `files` also changes it.
47
47
 
48
48
  A workplace can open one pane or one folder for someone who is not a member of the team. A
49
- server started with `--delegated-issuer ORIGIN` (the Worker's `DELEGATED_ISSUER`) trusts short-lived
50
- tokens that issuer signs. `--delegated-issuers ORIGIN,ORIGIN` (the Worker's
51
- `DELEGATED_ISSUERS`) adds an explicit list of trusted origins; the legacy singular setting
52
- remains supported. Keys are cached separately per origin, and each token must match a
49
+ server trusts only admin-consented [app installations](../../docs/guides/teams-apps.md) with
50
+ `delegation.surfaces` or `delegation.sessions` for that team. Register the exact issuer origin
51
+ on the app; install the needed module separately. Operator issuer allowlists grant no access.
52
+ Keys are cached separately per origin, and each token must match a
53
53
  publication or session binding for that exact issuer. Staging never opens a production
54
54
  binding or publication. Tokens use Ed25519 (keys at `ORIGIN/.well-known/jwks.json`, audience this server's
55
55
  public origin), each naming a pane (`pane:<key>`) or a folder (`root:/path`) on one machine of one
@@ -10,8 +10,8 @@ const VALUES = new Set([
10
10
  'source', 'cursor', 'limit', 'tail', 'before', 'after', 'revision', 'role',
11
11
  'out', 'ttl', 'type', 'name', 'visibility', 'backfill', 'format', 'to',
12
12
  'grant', 'binding', 'caps', 'fidelity', 'cwd', 'root', 'workspace-from', 'since', 'until', 'session',
13
- 'on', 'timeout', 'plan', 'tmux-socket', 'volter-issuer', 'delegated-issuer', 'delegated-issuers', 'workplace', 'organization', 'key', 'room',
14
- 'conversation', 'rh2', 'scope', 'agent',
13
+ 'on', 'timeout', 'plan', 'tmux-socket', 'volter-issuer', 'workplace', 'organization', 'key', 'room',
14
+ 'conversation', 'rh2', 'scope', 'agent', 'body-file',
15
15
  ]);
16
16
 
17
17
  export function parseArgs(argv) {
package/bin/workspace.mjs CHANGED
@@ -57,6 +57,7 @@ export async function workspaceMain(argv = process.argv.slice(2), env = process.
57
57
  const team = client.team(context.team_id);
58
58
  const command = { flags, rest, output, client, context, team, explicitContext, home, contextName: selected, env, dependencies };
59
59
  if (teamsPrefix && ['list', 'create'].includes(verb)) return teamsCommand(verb, sub, command);
60
+ if (verb === 'apps' || verb === 'app-installations') return appsCommand(verb,sub,{...command,output:value=>output(flags.json?value:redactAppSecrets(value))});
60
61
  if (verb === 'sessions') return sessionsCommand(sub, command);
61
62
  if (verb === 'members') return membersCommand(sub, command);
62
63
  if (verb === 'groups') return groupsCommand(sub, command);
@@ -69,6 +70,27 @@ export async function workspaceMain(argv = process.argv.slice(2), env = process.
69
70
  usage(`unsupported workspace command ${verb}`);
70
71
  }
71
72
 
73
+ function redactAppSecrets(value) {
74
+ if(!value||typeof value!=='object')return value;
75
+ if(Array.isArray(value))return value.map(redactAppSecrets);
76
+ return Object.fromEntries(Object.entries(value).filter(([key])=>!['secret','webhook_secret','code','token'].includes(key)).map(([key,item])=>[key,redactAppSecrets(item)]));
77
+ }
78
+
79
+ async function appsCommand(verb,sub,{flags,rest,output,team}) {
80
+ only(flags,['json','help','context','body_file']);
81
+ const body=()=>{if(!flags.body_file)usage('--body-file is required (use - for stdin)');return JSON.parse(readFileSync(flags.body_file==='-'?0:flags.body_file,'utf8'));};
82
+ if(verb==='apps') {
83
+ if(sub==='catalogue'||sub==='list'){requireArgs(rest,0,0,'teams apps catalogue|list');return output(await team.apps[sub]());}
84
+ if(sub==='get'||sub==='rotate-secret'){requireArgs(rest,1,1,'teams apps get|rotate-secret APP');return output(await team.apps[sub==='get'?'get':'rotateSecret'](rest[0]));}
85
+ if(sub==='register'){requireArgs(rest,0,0,'teams apps register --body-file FILE');return output(await team.apps.register(body()));}
86
+ if(sub==='install'){requireArgs(rest,1,1,'teams apps install APP --body-file FILE');return output(await team.apps.install(rest[0],body()));}
87
+ } else {
88
+ if(sub==='list'){requireArgs(rest,0,0,'teams app-installations list');return output(await team.appInstallations.list());}
89
+ if(['get','uninstall','delegate'].includes(sub)){requireArgs(rest,1,1,'teams app-installations get|uninstall|delegate INSTALLATION');return output(await team.appInstallations[sub](rest[0],...(sub==='delegate'?[body()]:[])));}
90
+ }
91
+ usage('teams apps catalogue|list|get|register|install|rotate-secret; app-installations list|get|uninstall|delegate');
92
+ }
93
+
72
94
  async function teamsCommand(verb, sub, { flags, rest, output, client }) {
73
95
  if (verb === 'list') { only(flags, ['json', 'help', 'context', 'cursor', 'limit']); requireArgs(rest, 0, 0, 'teams list'); return output(await client.teams({ cursor: flags.cursor, limit: positiveInteger(flags.limit, 50, 'limit') })); }
74
96
  const names = [sub, ...rest].filter(value => value !== undefined);
@@ -120,7 +142,7 @@ async function serverCommand({ flags, sub, rest, home, output }) {
120
142
  // The Node host's store is node:sqlite (Node 22.5+); only this verb loads it, so every client
121
143
  // verb runs on the Node 20 the package declares.
122
144
  const { createTeamsServer } = await import('../server/index.mjs');
123
- const server = createTeamsServer({ dataDir: flags.data_dir ?? resolve(home, 'teams', 'server'), listen: hostPort(flags.listen ?? '127.0.0.1:7331'), publicOrigin: flags.public_origin ?? null, identityIssuer: flags.volter_issuer ?? null, delegatedIssuer: flags.delegated_issuer ?? null, delegatedIssuers: flags.delegated_issuers?.split(',').map(origin => origin.trim()) ?? [], ...(flags.init_team ? { initTeam: flags.init_team } : {}) });
145
+ const server = createTeamsServer({ dataDir: flags.data_dir ?? resolve(home, 'teams', 'server'), listen: hostPort(flags.listen ?? '127.0.0.1:7331'), publicOrigin: flags.public_origin ?? null, ...(flags.init_team ? { initTeam: flags.init_team } : {}) });
124
146
  output(await server.start());
125
147
  await new Promise(resolveStop => { const stop = async () => { await server.close(); resolveStop(); }; process.once('SIGINT', stop); process.once('SIGTERM', stop); });
126
148
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.2.162",
3
+ "version": "0.2.164",
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",
@@ -0,0 +1,31 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { MESSAGE_AT, OPEN_REVISION } from '@volter/teams/server/migrations';
3
+ import { normalizeSearch, textFromContent } from './catalog.mjs';
4
+
5
+ export function applyCaptureMessages({ get, run }, capture, revision) {
6
+ const { teamId, sessionId, sourceId, captureId, harness, nativeId, currentRevision, carriedCount, parts, messageNamespace } = capture;
7
+ let ordinal = carriedCount;
8
+ for (let index = 0; index < parts; index++) {
9
+ const heldPart = get('SELECT payload_json FROM capture_parts WHERE team_id=? AND capture_id=? AND part_index=?', [teamId, captureId, index]);
10
+ if (!heldPart) throw Object.assign(new Error('capture parts incomplete'), { status: 409, code: 'batch_gap' });
11
+ const part = JSON.parse(heldPart.payload_json);
12
+ for (const message of part.messages) {
13
+ const value = JSON.stringify(message.value);
14
+ const old = currentRevision == null ? null : get(`SELECT revision,source_id,native_key,recorded_at,value_json,until_revision FROM messages m WHERE m.team_id=? AND m.session_id=? AND m.ordinal=? AND ${MESSAGE_AT()} LIMIT 1`, [teamId, sessionId, ordinal, currentRevision, currentRevision]);
15
+ if (old && old.source_id === sourceId && old.native_key === message.native_key && old.recorded_at === message.recorded_at && old.value_json === value) {
16
+ if (old.until_revision !== OPEN_REVISION) run('UPDATE messages SET until_revision=? WHERE team_id=? AND session_id=? AND revision=? AND ordinal=?', [OPEN_REVISION, teamId, sessionId, old.revision, ordinal]);
17
+ ordinal++;
18
+ continue;
19
+ }
20
+ if (old?.until_revision === OPEN_REVISION) run('UPDATE messages SET until_revision=? WHERE team_id=? AND session_id=? AND revision=? AND ordinal=?', [revision, teamId, sessionId, old.revision, ordinal]);
21
+ let messageId = get('SELECT id FROM message_ids WHERE team_id=? AND source_id=? AND harness=? AND native_id=? AND native_key=?', [teamId, sourceId, harness, nativeId, message.native_key])?.id;
22
+ if (!messageId) {
23
+ messageId = `msg_${createHash('sha256').update(`${messageNamespace}\0${message.native_key}`).digest('hex').slice(0, 32)}`;
24
+ run('INSERT INTO message_ids(team_id,source_id,harness,native_id,native_key,id) VALUES (?,?,?,?,?,?)', [teamId, sourceId, harness, nativeId, message.native_key, messageId]);
25
+ }
26
+ run('INSERT INTO messages(team_id,session_id,revision,ordinal,id,source_id,native_key,recorded_at,value_json,search_text,until_revision) VALUES (?,?,?,?,?,?,?,?,?,?,?)', [teamId, sessionId, revision, ordinal++, messageId, sourceId, message.native_key, message.recorded_at, value, normalizeSearch(textFromContent(message.value.content)), OPEN_REVISION]);
27
+ }
28
+ }
29
+ if (currentRevision != null) run('UPDATE messages SET until_revision=? WHERE team_id=? AND session_id=? AND ordinal>=? AND revision<=? AND until_revision=?', [revision, teamId, sessionId, ordinal, currentRevision, OPEN_REVISION]);
30
+ return { messages: ordinal - carriedCount };
31
+ }
@@ -1,3 +1,4 @@
1
+ import { appAuthentication } from '@volter/teams/server/apps';
1
2
  import { createHash } from 'node:crypto';
2
3
  import { validateListQuery } from '../contracts/index.mjs';
3
4
  import { cursorHash, cursorToken } from './catalog.mjs';
@@ -82,6 +83,7 @@ export async function sessionNames(api, teamId, descriptor) {
82
83
  }
83
84
 
84
85
  async function readForEvent(api, auth, ref, tail) {
86
+ if(!auth.bound&&!await appAuthentication(api,auth))throw Object.assign(new Error('app authorization changed'),{status:401});
85
87
  const nextCursorToken = cursorToken();
86
88
  const snapshot = await api.store.readSessionSnapshot({
87
89
  ...sessionArgs(api, auth, ref), tail,
@@ -121,6 +123,7 @@ export async function handleSessionEvents({ api, req, res, auth, id, url, reques
121
123
  const stream = openStream(api, req, res, auth, requestId, fail);
122
124
  if (after === null) stream.write({ type: 'snapshot', cursor: initial.event_cursor, revision: initial.revision, data: initial });
123
125
  const enqueue = streamQueue(stream, async () => {
126
+ if(!auth.bound&&!await appAuthentication(api,auth))fail(401,'authentication_required','app authorization changed');
124
127
  // This worker operation reads current credential, membership, groups and every constituent ACL together.
125
128
  await api.store.authorizedSession(sessionArgs(api, auth, sessionId));
126
129
  const replay = await api.store.query('SELECT revision,kind FROM journal WHERE team_id=? AND session_id=? AND revision>? ORDER BY revision', [auth.team_id, sessionId, delivered]);
@@ -153,6 +156,7 @@ async function catalogWindow(api, auth, query) {
153
156
  { kind: 'query', sql: 'SELECT s.*,v.descriptor_json,v.sort_at FROM sessions s LEFT JOIN session_versions v ON v.team_id=s.team_id AND v.session_id=s.id AND v.revision=s.revision WHERE s.team_id=? ORDER BY v.sort_at DESC,s.id ASC', params: [auth.team_id] },
154
157
  { kind: 'query', sql: 'SELECT * FROM session_grants WHERE team_id=?', params: [auth.team_id] },
155
158
  ]);
159
+ if(!auth.bound&&!await appAuthentication(api,auth))throw Object.assign(new Error('app authorization changed'),{status:401});
156
160
  const now = api.clock.nowMs();
157
161
  if (!membership?.active || !credential || credential.revoked_at != null || credential.expires_at != null && credential.expires_at <= now) throw Object.assign(new Error('authorization changed'), { status: 401 });
158
162
  const byId = new Map(rows.map(row => [row.id, row]));
@@ -1,3 +1,4 @@
1
+ import { installedIssuer } from '@volter/teams/server/apps';
1
2
  // The harness's routes on a Teams server (company RFC 0008 §6): the session catalog,
2
3
  // search, reads, access and correlations; exports, continuations, effects and the
3
4
  // attachment relay; sync rules; OTLP ingest, captures and uploads. The neutral handler
@@ -10,8 +11,8 @@ import { createHash } from 'node:crypto';
10
11
  import { bearer, hashToken, canRead, canManage, isAdmin } from '@volter/teams/server/auth';
11
12
  import { storageReport } from './storage-report.mjs';
12
13
  import { validateListQuery, validateSearchQuery, validateReadQuery, validateCaptureBegin, validateCapturePart, canonicalJson } from '../contracts/index.mjs';
13
- import { normalizeSearch, textFromContent, cursorHash, cursorToken } from './catalog.mjs';
14
- import { captureDigest, captureMessages, handleOtlpRoute, sha256, validateArtifactName } from './ingest.mjs';
14
+ import { normalizeSearch, cursorHash, cursorToken } from './catalog.mjs';
15
+ import { handleOtlpRoute, sha256, validateArtifactName } from './ingest.mjs';
15
16
  import { handleCatalogEvents, handleSessionEvents, sessionNames } from './event-routes.mjs';
16
17
  import { exact } from '@volter/teams/server/request-json';
17
18
  import { MESSAGE_AT, OPEN_REVISION } from '@volter/teams/server/migrations';
@@ -328,7 +329,26 @@ async function artifactChunk(api,req,res,auth,sourceId,uploadId,index,requestId)
328
329
  ],{status:200,body:response,targetId:uploadId});
329
330
  send(res,200,response,requestId);
330
331
  }
331
- async function captureCommit(api,req,res,auth,sourceId,captureId,requestId){const source=await sourceAuth(api,auth,sourceId);const commit=await parseJson(req);exact(commit,['capture_id','parts','digest','title','repository','reported_user','recorded_activity_at','parent_native_id','coverage','artifacts','native_bundle']);if(commit.capture_id!==captureId||!Number.isSafeInteger(commit.parts)||commit.parts<1)fail(400,'invalid_request','invalid commit');const cap=await api.store.one('SELECT * FROM captures WHERE team_id=? AND id=? AND source_id=?',[auth.team_id,captureId,sourceId]);if(await expireStaging(api,auth.team_id,sourceId,captureId,'captures',cap))fail(409,'resync_required','capture reservation expired');if(!cap)fail(404,'not_found','capture not found');if(cap.state==='committed'){const receipt=JSON.parse(cap.receipt_json);if(receipt.digest!==commit.digest)fail(409,'capture_conflict','commit replay differs');receipt.duplicate=true;return send(res,200,result(api,auth,receipt),requestId);}const rows=await api.store.query('SELECT * FROM capture_parts WHERE team_id=? AND capture_id=? ORDER BY part_index',[auth.team_id,captureId]);if(rows.length!==commit.parts)fail(409,'batch_gap','capture parts incomplete');const begin=JSON.parse(cap.begin_json);if(source.kind==='connector'){const rule=await api.store.one('SELECT * FROM sync_rules WHERE team_id=? AND id=? AND source_id=? AND machine_id=?',[auth.team_id,begin.rule_id,sourceId,auth.machine_id]);if(!rule||rule.state!=='active'||rule.revision!==begin.rule_revision||rule.visibility!==begin.visibility)fail(rule?.state==='paused'?409:403,rule?.state==='paused'?'source_paused':'forbidden','sync rule changed before commit');}const parts=rows.map(x=>JSON.parse(x.payload_json));if(captureDigest(begin,parts,commit)!==commit.digest)fail(409,'capture_conflict','capture digest differs');let messages=captureMessages(parts);if(messages.length>100000)fail(413,'request_too_large','capture contains too many messages');
332
+ async function captureCommit(api,req,res,auth,sourceId,captureId,requestId){const source=await sourceAuth(api,auth,sourceId);const commit=await parseJson(req);exact(commit,['capture_id','parts','digest','title','repository','reported_user','recorded_activity_at','parent_native_id','coverage','artifacts','native_bundle']);if(commit.capture_id!==captureId||!Number.isSafeInteger(commit.parts)||commit.parts<1)fail(400,'invalid_request','invalid commit');const cap=await api.store.one('SELECT * FROM captures WHERE team_id=? AND id=? AND source_id=?',[auth.team_id,captureId,sourceId]);if(await expireStaging(api,auth.team_id,sourceId,captureId,'captures',cap))fail(409,'resync_required','capture reservation expired');if(!cap)fail(404,'not_found','capture not found');if(cap.state==='committed'){const receipt=JSON.parse(cap.receipt_json);if(receipt.digest!==commit.digest)fail(409,'capture_conflict','commit replay differs');receipt.duplicate=true;return send(res,200,result(api,auth,receipt),requestId);}const begin=JSON.parse(cap.begin_json);if(source.kind==='connector'){const rule=await api.store.one('SELECT * FROM sync_rules WHERE team_id=? AND id=? AND source_id=? AND machine_id=?',[auth.team_id,begin.rule_id,sourceId,auth.machine_id]);if(!rule||rule.state!=='active'||rule.revision!==begin.rule_revision||rule.visibility!==begin.visibility)fail(rule?.state==='paused'?409:403,rule?.state==='paused'?'source_paused':'forbidden','sync rule changed before commit');}
333
+ const heldParts = await api.store.one('SELECT COUNT(*) count,MIN(part_index) first,MAX(part_index) last FROM capture_parts WHERE team_id=? AND capture_id=?', [auth.team_id, captureId]);
334
+ if (heldParts.count !== commit.parts || heldParts.first !== 0 || heldParts.last !== commit.parts - 1) fail(409, 'batch_gap', 'capture parts incomplete');
335
+ const unsignedCommit = { ...commit };
336
+ delete unsignedCommit.digest;
337
+ const captureHash = createHash('sha256').update(`{"begin":${canonicalJson(begin)},"commit":${canonicalJson(unsignedCommit)},"parts":[`);
338
+ const sentKeys = new Set();
339
+ let messageCount = 0;
340
+ for (let index = 0; index < commit.parts; index++) {
341
+ const part = await api.store.one('SELECT payload_json FROM capture_parts WHERE team_id=? AND capture_id=? AND part_index=?', [auth.team_id, captureId, index]);
342
+ if (!part) fail(409, 'batch_gap', 'capture parts incomplete');
343
+ if (index) captureHash.update(',');
344
+ captureHash.update(part.payload_json);
345
+ for (const message of JSON.parse(part.payload_json).messages) {
346
+ if (sentKeys.has(message.native_key)) fail(409, 'capture_conflict', 'duplicate native message key');
347
+ sentKeys.add(message.native_key);
348
+ if (++messageCount > 100000) fail(413, 'request_too_large', 'capture contains too many messages');
349
+ }
350
+ }
351
+ if (captureHash.update(']}').digest('hex') !== commit.digest) fail(409, 'capture_conflict', 'capture digest differs');
332
352
  // Native transcripts can be larger than the Worker's entire heap. Validate
333
353
  // the retained bytes one chunk at a time; never materialize a whole artifact.
334
354
  for (const manifest of commit.artifacts) {
@@ -358,9 +378,9 @@ async function captureCommit(api,req,res,auth,sourceId,captureId,requestId){cons
358
378
  if (begin.carry) {
359
379
  const { count, digest } = begin.carry;
360
380
  const session = origin ? await api.store.one('SELECT revision FROM sessions WHERE team_id=? AND id=?', [auth.team_id, origin.session_id]) : null;
361
- if (!session || count + messages.length > 100000) fail(409, 'carry_mismatch', 'the carried prefix is unavailable or too large');
381
+ if (!session || count + messageCount > 100000) fail(409, 'carry_mismatch', 'the carried prefix is unavailable or too large');
362
382
  carriedRevision = session.revision;
363
- const hash = createHash('sha256').update('['), sent = new Set(messages.map(m => m.native_key));
383
+ const hash = createHash('sha256').update('[');
364
384
  while (carriedCount < count) {
365
385
  const page = await api.store.query(`SELECT ordinal,source_id,native_key,recorded_at,value_json FROM messages m
366
386
  WHERE m.team_id=? AND m.session_id=? AND m.ordinal>=? AND m.ordinal<? AND ${MESSAGE_AT()} ORDER BY m.ordinal LIMIT 16`,
@@ -368,7 +388,7 @@ async function captureCommit(api,req,res,auth,sourceId,captureId,requestId){cons
368
388
  if (!page.length) fail(409, 'carry_mismatch', 'the carried messages are not what this session holds; send the capture whole');
369
389
  for (const row of page) {
370
390
  if (row.ordinal !== carriedCount || row.source_id !== sourceId) fail(409, 'carry_mismatch', 'the carried messages are not what this session holds; send the capture whole');
371
- if (sent.has(row.native_key)) fail(409, 'capture_conflict', 'duplicate native message key');
391
+ if (sentKeys.has(row.native_key)) fail(409, 'capture_conflict', 'duplicate native message key');
372
392
  if (carriedCount) hash.update(',');
373
393
  hash.update(canonicalJson({native_key:row.native_key, recorded_at:row.recorded_at ?? null, value:JSON.parse(row.value_json)}));
374
394
  carriedCount++;
@@ -385,15 +405,11 @@ async function captureCommit(api,req,res,auth,sourceId,captureId,requestId){cons
385
405
  // session that gained one message writes one row, not the whole session again.
386
406
  const carryGuards=[],currentRevision=origin?(await api.store.one('SELECT revision FROM sessions WHERE team_id=? AND id=?',[auth.team_id,sid]))?.revision??null:null;
387
407
  if (carriedRevision != null && currentRevision !== carriedRevision) fail(409, 'revision_mismatch', 'session changed during prefix verification; retry');
388
- const held=new Map();if(currentRevision!=null)for(const row of await api.store.query(`SELECT revision,ordinal,id,source_id,native_key,recorded_at,value_json,until_revision FROM messages m WHERE m.team_id=? AND m.session_id=? AND ${MESSAGE_AT()} AND ordinal>=?`,[auth.team_id,sid,currentRevision,currentRevision,carriedCount]))held.set(row.ordinal,row);
408
+ sentKeys.clear();
409
+ carryGuards.push({ sql: 'SELECT 1 ok WHERE (SELECT COUNT(*) FROM capture_parts WHERE team_id=? AND capture_id=?)=?', params: [auth.team_id, captureId, commit.parts], status: 409, code: 'batch_gap', message: 'capture parts changed before commit' });
389
410
  // Another commit to this session between this read and the write would make the carried rows wrong: refused, retried.
390
411
  if(currentRevision!=null)carryGuards.push({sql:'SELECT 1 ok FROM sessions WHERE team_id=? AND id=? AND revision=?',params:[auth.team_id,sid,currentRevision],code:'revision_mismatch',status:409,message:'session changed during capture; retry'});
391
- const close=row=>{if(row.until_revision===OPEN_REVISION)statements.push({sql:'UPDATE messages SET until_revision=? WHERE team_id=? AND session_id=? AND revision=? AND ordinal=?',params:[revisionParam,auth.team_id,sid,row.revision,row.ordinal]});};
392
- let ordinal=carriedCount;for(const m of messages){const value=JSON.stringify(m.value),old=held.get(ordinal);held.delete(ordinal);
393
- if(old&&old.source_id===sourceId&&old.native_key===m.native_key&&old.recorded_at===m.recorded_at&&old.value_json===value){if(old.until_revision!==OPEN_REVISION)statements.push({sql:'UPDATE messages SET until_revision=? WHERE team_id=? AND session_id=? AND revision=? AND ordinal=?',params:[OPEN_REVISION,auth.team_id,sid,old.revision,old.ordinal]});ordinal++;continue;}
394
- if(old)close(old);
395
- 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,until_revision) VALUES (?,?,?,?,?,?,?,?,?,?,?)',params:[auth.team_id,sid,revisionParam,ordinal++,mid,sourceId,m.native_key,m.recorded_at,value,normalizeSearch(textFromContent(m.value.content)),OPEN_REVISION]});}
396
- for(const row of held.values())close(row);
412
+ statements.push({ kind: 'capture_messages', capture: { teamId: auth.team_id, sessionId: sid, sourceId, captureId, harness: cap.harness, nativeId: cap.native_id, currentRevision, carriedCount, parts: commit.parts, messageNamespace: api.ids.next('message') } });
397
413
  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:'DELETE FROM capture_parts WHERE team_id=? AND capture_id=?',params:[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,...carryGuards,{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);
398
414
  }
399
415
 
@@ -409,7 +425,7 @@ async function viewBindingRoute(api,req,res,auth,ref,requestId){
409
425
  if(session.custodian_id!==auth.principal_id)fail(403,'forbidden',"only the session's owner binds it to a view");
410
426
  const body=await parseJson(req);exact(body,['issuer','room','conversation']);
411
427
  if(typeof body.room!=='string'||!body.room||typeof body.conversation!=='string'||!body.conversation)fail(400,'invalid_request','room and conversation are required');
412
- if(!api.delegatedIssuers.includes(body.issuer))fail(400,'invalid_request',`this server accepts views signed by ${api.delegatedIssuers.join(', ')||'no workplace'}`);
428
+ if(!await installedIssuer(api,body.issuer,'delegation.sessions',auth.team_id))fail(400,'invalid_request','this issuer is not an installed session integration');
413
429
  const key=[auth.team_id,session.id,body.issuer,body.room,body.conversation];
414
430
  await api.store.transaction(req.method==='POST'
415
431
  ?[{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()]}]
package/server/index.mjs CHANGED
@@ -16,12 +16,12 @@ import { createTeamsApi, bootstrapServer, maintain, normalizePublicOrigin, produ
16
16
 
17
17
  function atomicJson(path,value){const tmp=`${path}.${process.pid}.${randomBytes(12).toString('hex')}.tmp`;let fd;try{fd=openSync(tmp,'wx',0o600);writeFileSync(fd,JSON.stringify(value,null,2)+'\n');closeSync(fd);fd=undefined;renameSync(tmp,path);chmodSync(path,0o600);}catch(error){if(fd!==undefined)closeSync(fd);try{unlinkSync(tmp);}catch{}throw error;}}
18
18
 
19
- export function createTeamsServer({dataDir,listen={host:'127.0.0.1',port:7331},publicOrigin=null,identityIssuer=null,delegatedIssuer=null,delegatedIssuers=[],clock=productionClock,ids=productionIds,initTeam,uploadLimitBytes=256*1024*1024}={}) {
19
+ export function createTeamsServer({dataDir,listen={host:'127.0.0.1',port:7331},publicOrigin=null,clock=productionClock,ids=productionIds,initTeam,uploadLimitBytes=256*1024*1024}={}) {
20
20
  if(!dataDir) throw new TypeError('dataDir is required');
21
21
  const loopback=['127.0.0.1','::1','localhost'].includes(listen.host??'127.0.0.1');
22
22
  publicOrigin=normalizePublicOrigin(publicOrigin);
23
23
  if(!loopback&&!publicOrigin?.startsWith('https:'))throw new TypeError('non-loopback Teams servers require an HTTPS publicOrigin');
24
- const api=createTeamsApi({clock,ids,publicOrigin,identityIssuer,identityOrganizations:identityIssuer?identityOrganizationsFromEnv(process.env):null,delegatedIssuer,delegatedIssuers,uploadLimitBytes});
24
+ const api=createTeamsApi({clock,ids,publicOrigin,identityOrganizations:identityOrganizationsFromEnv(process.env),uploadLimitBytes});
25
25
  // The harness's own routes on this server (harness-routes.mjs); the handler owns the neutral ones.
26
26
  api.attach({harnessRoutes});
27
27
  const events=api.events;
@@ -43,6 +43,7 @@ export function createTeamsServer({dataDir,listen={host:'127.0.0.1',port:7331},p
43
43
  if(!existsSync(credentialPath)&&existsSync(pendingPath)){const pending=JSON.parse(readFileSync(pendingPath,'utf8'));if(pending.server_id!==serverId)throw new Error('bootstrap credential recovery does not match server identity');renameSync(pendingPath,credentialPath);chmodSync(credentialPath,0o600);}
44
44
  }
45
45
  api.attach({serverId});
46
+ if(existsSync(credentialPath))api.appRootSecret=JSON.parse(readFileSync(credentialPath,'utf8')).token;
46
47
  const maintenance=async()=>{
47
48
  let next=60000;
48
49
  try{
@@ -5,6 +5,7 @@
5
5
  // them on the object's own SQLite.
6
6
  import { ingestOtlpBatch, correlateSessions } from './arc2-transactions.mjs';
7
7
  import { claimEffect, getEffect, listEffects, reportEffect } from './effects-transactions.mjs';
8
+ import { applyCaptureMessages } from './capture-transactions.mjs';
8
9
  import { MIGRATIONS, MESSAGE_AT } from '@volter/teams/server/migrations';
9
10
 
10
11
  /** Bring a database to the newest schema. `version()`/`setVersion(n)` name where the host records it. */
@@ -27,10 +28,15 @@ export function createStoreHandlers({ all, get, run, tx }) {
27
28
  // session its owner bound to that Room and Conversation; it holds no credential and is no member.
28
29
  let membership=null;
29
30
  if(bound){
31
+ if(!get(`SELECT 1 FROM app_installations i 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 i.team_id=? AND a.issuer=? AND i.state='active' AND a.state='active' AND m.active=1 AND installer.active=1 AND installer.role IN ('owner','admin') AND EXISTS (SELECT 1 FROM json_each(i.actions_json) WHERE value='delegation.sessions')`,[teamId,bound.issuer]))domainError(401,'authentication_required','integration was uninstalled');
30
32
  if(!(bound.expiresAt>now))domainError(401,'authentication_required','authentication required');
31
33
  }else{
32
34
  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
35
  membership=get('SELECT role FROM memberships WHERE team_id=? AND principal_id=? AND active=1',[teamId,principalId]);
36
+ const identity=get(`SELECT i.state,a.state app_state,m.active,m.role FROM identity_app_credentials c JOIN app_installations i ON i.id=c.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.installer_id WHERE c.credential_id=?`,[credentialId]);
37
+ if(identity&&(identity.state!=='active'||identity.app_state!=='active'||!identity.active||!['owner','admin'].includes(identity.role)))domainError(401,'authentication_required','identity integration revoked');
38
+ const app=get('SELECT ac.*,i.state,a.state app_state,installer.active,installer.role FROM app_credentials ac JOIN app_installations i ON i.id=ac.installation_id JOIN apps a ON a.id=i.app_id JOIN memberships installer ON installer.team_id=i.team_id AND installer.principal_id=i.installer_id WHERE ac.credential_id=?',[credentialId]);
39
+ if(app){if(app.state!=='active'||app.app_state!=='active'||!app.active||!['owner','admin'].includes(app.role)||!JSON.parse(app.actions_json).includes('sessions.read'))domainError(403,'forbidden','app session read authorization changed');const revoked=get(`WITH RECURSIVE lineage(id,parent_id) AS (SELECT credential_id,parent_id FROM app_credentials WHERE credential_id=? UNION ALL SELECT ac.credential_id,ac.parent_id FROM app_credentials ac JOIN lineage l ON ac.credential_id=l.parent_id) SELECT 1 FROM lineage l JOIN credentials c ON c.id=l.id WHERE c.revoked_at IS NOT NULL OR c.expires_at<=? LIMIT 1`,[credentialId,now]);if(revoked)domainError(401,'authentication_required','parent credential revoked');}
34
40
  if(!credential||!membership)domainError(401,'authentication_required','authentication required');
35
41
  }
36
42
  if(typeof ref!=='string'||ref.length<8)domainError(404,'not_found','not found');
@@ -119,7 +125,7 @@ export function createStoreHandlers({ all, get, run, tx }) {
119
125
  transaction: ({statements}) => tx(() => statements.map(s => s.kind === 'query' ? all(s.sql,s.params) : s.kind === 'one' ? get(s.sql,s.params) : run(s.sql,s.params))),
120
126
  snapshot: ({statements}) => tx(() => statements.map(s => s.kind === 'one' ? get(s.sql,s.params) : all(s.sql,s.params))),
121
127
  guardedTransaction: ({guards=[],statements}) => tx(() => { for(const guard of guards){const row=get(guard.sql,guard.params);if(!row){const error=new Error(guard.message??'transaction predicate changed');error.code=guard.code??'conflict';error.status=guard.status??409;throw error;}}return statements.map(s=>s.kind==='query'?all(s.sql,s.params):s.kind==='one'?get(s.sql,s.params):run(s.sql,s.params));}),
122
- journalTransaction: ({teamId,guards=[],statements}) => tx(() => { for(const guard of guards){const row=get(guard.sql,guard.params);if(!row){const error=new Error(guard.message??'transaction predicate changed');error.code=guard.code??'conflict';error.status=guard.status??409;throw error;}}const team=get('SELECT journal_revision FROM teams WHERE id=?',[teamId]);if(!team)throw new Error('team not found');const revision=team.journal_revision+1;const results=statements.map(s=>{const params=(s.params??[]).map(value=>journalParam(value,revision));return s.kind==='query'?all(s.sql,params):s.kind==='one'?get(s.sql,params):run(s.sql,params);});return{revision,results};}),
128
+ journalTransaction: ({teamId,guards=[],statements}) => tx(() => { for(const guard of guards){const row=get(guard.sql,guard.params);if(!row){const error=new Error(guard.message??'transaction predicate changed');error.code=guard.code??'conflict';error.status=guard.status??409;throw error;}}const team=get('SELECT journal_revision FROM teams WHERE id=?',[teamId]);if(!team)throw new Error('team not found');const revision=team.journal_revision+1;const results=statements.map(s=>{if(s.kind==='capture_messages')return applyCaptureMessages({get,run},s.capture,revision);const params=(s.params??[]).map(value=>journalParam(value,revision));return s.kind==='query'?all(s.sql,params):s.kind==='one'?get(s.sql,params):run(s.sql,params);});return{revision,results};}),
123
129
  ingestOtlpBatch: args => tx(() => ingestOtlpBatch({get,all,run},args)),
124
130
  correlateSessions: args => tx(() => correlateSessions({get,all,run},args)),
125
131
  listEffects: args => tx(() => listEffects({get,all,run},args)),
package/worker/index.mjs CHANGED
@@ -5,11 +5,10 @@
5
5
  // Configuration (wrangler.toml [vars] and secrets):
6
6
  // PUBLIC_ORIGIN the https origin the Worker is served at
7
7
  // INIT_TEAM the first team's name (used once, at bootstrap)
8
- // VOLTER_ISSUER optional: an identity service members may sign in with (https://id.volter.ai)
8
+ // Identity and delegation issuers are registered apps installed by team admins through the public doors.
9
9
  // VOLTER_ORG_CLIENT_ID, VOLTER_ORG_CLIENT_SECRET, VOLTER_TEAM_ORGS
10
- // optional: this server's product credential at VOLTER_ISSUER, and the teams whose
10
+ // optional: this server's product credential at the organization's identity issuer, and the teams whose
11
11
  // organization there admits its members with no invitation (`team=slug,…`)
12
- // DELEGATED_ISSUER optional: a workplace (RH2) whose delegated tokens open one pane or folder at /view
13
12
  // SUPERCODE_TEAMS_BOOTSTRAP_TOKEN secret: the owner's first credential, minted with
14
13
  // `supercode teams server token`
15
14
  import { DurableObject } from 'cloudflare:workers';
@@ -81,7 +80,7 @@ export class TeamsServer extends DurableObject {
81
80
  console.log(`storage: database_bytes=${ctx.storage.sql.databaseSize}`);
82
81
  const db = durableSql(ctx.storage);
83
82
  migrate({ exec: db.exec, tx: db.tx, version: () => ctx.storage.kv.get('schema_version') ?? 0, setVersion: (n) => ctx.storage.kv.put('schema_version', n) });
84
- this.api = createTeamsApi({ publicOrigin: normalizePublicOrigin(env.PUBLIC_ORIGIN), identityIssuer: env.VOLTER_ISSUER || null, identityOrganizations: identityOrganizationsFromEnv(env), delegatedIssuer: env.DELEGATED_ISSUER || null, delegatedIssuers: env.DELEGATED_ISSUERS ? env.DELEGATED_ISSUERS.split(',').map(origin => origin.trim()) : [] });
83
+ this.api = createTeamsApi({ publicOrigin: normalizePublicOrigin(env.PUBLIC_ORIGIN), appRootSecret: env.SUPERCODE_TEAMS_BOOTSTRAP_TOKEN || null, identityOrganizations: identityOrganizationsFromEnv(env) });
85
84
  this.api.storageUsage = () => {
86
85
  const sql = ctx.storage.sql;
87
86
  let freeBytes = null;
@@ -4,24 +4,14 @@
4
4
  # --var PUBLIC_ORIGIN:https://<worker>.<account>.workers.dev --var INIT_TEAM:<first team>
5
5
  # The token printed above is the owner's first credential (`supercode teams login <origin> --token-stdin`).
6
6
  #
7
- # Origin and identity variables are retained with keep_vars. The explicit delegated
8
- # allowlist below is versioned and installed by deploy-teams CI.
9
- # PUBLIC_ORIGIN the https origin the Worker is served at (required)
10
- # INIT_TEAM the first team's name, used once at bootstrap
11
- # VOLTER_ISSUER optional: members sign in with Volter (supercode teams login --volter); the issuer
12
- # must have this origin registered as a resource
13
- # DELEGATED_ISSUER optional: a workplace (RH2) whose delegated tokens open published panes and folders and
14
- # bound sessions; its keys are at <issuer>/.well-known/jwks.json
15
- # DELEGATED_ISSUERS comma-separated exact origins, additive with legacy DELEGATED_ISSUER
7
+ # Identity and delegated access are admin-consented app installations, never operator allowlists.
8
+ # PUBLIC_ORIGIN and INIT_TEAM supply the listener and bootstrap team.
16
9
  name = "supercode-teams"
17
10
  main = "index.mjs"
18
11
  compatibility_date = "2026-09-01"
19
12
  compatibility_flags = ["nodejs_compat"]
20
13
  keep_vars = true
21
14
 
22
- [vars]
23
- DELEGATED_ISSUERS = "https://pilot.runhuman.com,https://rh2-staging.voltertest.xyz"
24
-
25
15
  # Workers Logs: the Worker's log lines (viewer-error, session-view refused, maintain) kept and searchable after the fact.
26
16
  [observability]
27
17
  enabled = true
@@ -36,9 +36,24 @@ export declare class TeamsClient {
36
36
  capabilities(options?:RequestOptions):Promise<Capabilities>;
37
37
  teams(query?:{limit?:number;cursor?:string},options?:RequestOptions):Promise<ServerPage<Team>>;
38
38
  createTeam(body:{name:string},options?:RequestOptions):Promise<Result<Team>>;
39
+ redeemAppCode(body:{app_id:string;app_secret:string;code:string;redirect_url:string},options?:RequestOptions):Promise<AppCodeExchange>;
39
40
  team(id:string):TeamClient;
40
41
  }
42
+ export interface TeamsApp {
43
+ id:string; team_id:string; principal_id:string; name:string; description:string;
44
+ actions:string[]; event_kinds:string[]; redirect_urls:string[]; webhook_url:string|null;
45
+ issuer:string|null; state:'active'|'retired'; created_at:number;
46
+ }
47
+ export type RegisterTeamsApp=Pick<TeamsApp,'name'|'actions'|'redirect_urls'> & Partial<Pick<TeamsApp,'description'|'event_kinds'|'webhook_url'|'issuer'>>;
48
+ export interface AppInstallation {
49
+ id:string; app_id:string; team_id:string; principal_id:string; installer_id:string;
50
+ actions:string[]; state:'active'|'uninstalled'; created_at:number; uninstalled_at:number|null;
51
+ }
52
+ export interface AppToken { token:string; kind:'service'; expires_at:number; actions:string[]; subject:string; act:string[] }
53
+ export interface AppCodeExchange { version:1; installation:AppInstallation; credential:AppToken; webhook_secret:string }
41
54
  export interface TeamClient {
55
+ apps:{catalogue(options?:RequestOptions):Promise<Result<{actions:string[];event_kinds:string[]}>>;list(options?:RequestOptions):Promise<Page<TeamsApp>>;get(id:string,options?:RequestOptions):Promise<Result<TeamsApp>>;register(body:RegisterTeamsApp,options?:RequestOptions):Promise<Result<TeamsApp & {secret?:string}>>;rotateSecret(id:string,options?:RequestOptions):Promise<Result<{id:string;secret?:string}>>;install(id:string,body:{redirect_url:string;state:string;actions?:string[]},options?:RequestOptions):Promise<Result<{installation:AppInstallation;code?:string;state:string;redirect_url:string;expires_at:number}>>};
56
+ appInstallations:{list(options?:RequestOptions):Promise<Page<AppInstallation>>;get(id:string,options?:RequestOptions):Promise<Result<AppInstallation>>;uninstall(id:string,options?:RequestOptions):Promise<null>;delegate(id:string,body:{actions:string[];actor:string;expires_in_ms?:number},options?:RequestOptions):Promise<Result<AppToken|null>>};
42
57
  sessions:{list(query?:ListQuery,options?:RequestOptions):Promise<Page<Descriptor>>;search(query:SearchQuery,options?:RequestOptions):Promise<SearchPage>;watch(query?:ListQuery,options?:StreamOptions):AsyncIterable<Event>;get(id:string):SessionClient};
43
58
  members:{list(query?:Record<string,unknown>,options?:RequestOptions):Promise<Page<Membership>>;update(id:string,body:{role:Membership['role']},options?:RequestOptions):Promise<Result<Membership>>;remove(id:string,options?:RequestOptions):Promise<null>};
44
59
  groups:{list(query?:Record<string,unknown>,options?:RequestOptions):Promise<Page<Group>>;create(body:{name:string},options?:RequestOptions):Promise<Result<Group>>;remove(id:string,options?:RequestOptions):Promise<null>;addMember(groupId:string,memberId:string,options?:RequestOptions):Promise<null>;removeMember(groupId:string,memberId:string,options?:RequestOptions):Promise<null>};
@@ -12,6 +12,7 @@ export class TeamsClient {
12
12
  throw last;
13
13
  }
14
14
  me(options){return this.request('GET','/api/v1/me',options);} capabilities(options){return this.request('GET','/api/v1/capabilities',options);} team(id){return new TeamClient(this,id);}
15
+ redeemAppCode(body,options){return this.request('POST','/api/v1/apps/token',{...options,body});}
15
16
  teams(queryValue={},options){return this.request('GET','/api/v1/teams'+query(queryValue),options);}
16
17
  createTeam(body,options){return this.request('POST','/api/v1/teams',{...options,body});}
17
18
  }
@@ -28,6 +29,18 @@ class TeamClient {
28
29
  const patch = (path, body, options) => root.request('PATCH', base + path, { ...options, body });
29
30
  const remove = (path, options) => root.request('DELETE', base + path, options);
30
31
  const resource = (path, id) => `${path}/${segment(id)}`;
32
+ this.apps = {
33
+ catalogue: o => get('/apps/catalogue', o),
34
+ list: o => get('/apps', o), get: (id, o) => get(resource('/apps', id), o),
35
+ register: (body, o) => post('/apps', body, o),
36
+ rotateSecret: (id, o) => post(resource('/apps', id) + '/secret', {}, o),
37
+ install: (id, body, o) => post(resource('/apps', id) + '/installations', body, o),
38
+ };
39
+ this.appInstallations = {
40
+ list: o => get('/app-installations', o), get: (id, o) => get(resource('/app-installations', id), o),
41
+ uninstall: (id, o) => remove(resource('/app-installations', id), o),
42
+ delegate: (id, body, o) => post(resource('/app-installations', id) + '/delegations', body, o),
43
+ };
31
44
  this.sessions = {
32
45
  list: (q = {}, o) => get('/sessions' + query(q), o),
33
46
  search: (q, o) => get('/sessions/search' + query(q), o),