@volter/supercode-teams 0.2.163 → 0.2.165

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(['get','rotate-secret','deregister'].includes(sub)){requireArgs(rest,1,1,'teams apps get|rotate-secret|deregister APP');return output(await team.apps[sub==='rotate-secret'?'rotateSecret':sub](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|deregister; 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.163",
3
+ "version": "0.2.165",
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",
@@ -1,3 +1,5 @@
1
+ import { appGrantAllows } from '@volter/teams/server/auth';
2
+ import { appAuthentication } from '@volter/teams/server/apps';
1
3
  import { createHash } from 'node:crypto';
2
4
  import { validateListQuery } from '../contracts/index.mjs';
3
5
  import { cursorHash, cursorToken } from './catalog.mjs';
@@ -82,6 +84,7 @@ export async function sessionNames(api, teamId, descriptor) {
82
84
  }
83
85
 
84
86
  async function readForEvent(api, auth, ref, tail) {
87
+ if(!auth.bound&&!await appAuthentication(api,auth))throw Object.assign(new Error('app authorization changed'),{status:401});
85
88
  const nextCursorToken = cursorToken();
86
89
  const snapshot = await api.store.readSessionSnapshot({
87
90
  ...sessionArgs(api, auth, ref), tail,
@@ -121,6 +124,7 @@ export async function handleSessionEvents({ api, req, res, auth, id, url, reques
121
124
  const stream = openStream(api, req, res, auth, requestId, fail);
122
125
  if (after === null) stream.write({ type: 'snapshot', cursor: initial.event_cursor, revision: initial.revision, data: initial });
123
126
  const enqueue = streamQueue(stream, async () => {
127
+ if(!auth.bound&&!await appAuthentication(api,auth))fail(401,'authentication_required','app authorization changed');
124
128
  // This worker operation reads current credential, membership, groups and every constituent ACL together.
125
129
  await api.store.authorizedSession(sessionArgs(api, auth, sessionId));
126
130
  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 +157,7 @@ async function catalogWindow(api, auth, query) {
153
157
  { 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
158
  { kind: 'query', sql: 'SELECT * FROM session_grants WHERE team_id=?', params: [auth.team_id] },
155
159
  ]);
160
+ if(!auth.bound&&!await appAuthentication(api,auth))throw Object.assign(new Error('app authorization changed'),{status:401});
156
161
  const now = api.clock.nowMs();
157
162
  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
163
  const byId = new Map(rows.map(row => [row.id, row]));
@@ -168,7 +173,7 @@ async function catalogWindow(api, auth, query) {
168
173
  list.push(grant); grantsBySession.set(grant.session_id, list);
169
174
  }
170
175
  const readable = (row, seen = new Set()) => {
171
- if (!row || seen.has(row.id) || row.deleted_at != null || !row.descriptor_json) return false;
176
+ if (!row || seen.has(row.id) || row.deleted_at != null || !row.descriptor_json || !appGrantAllows(auth,'session',row.id,'sessions.read')) return false;
172
177
  seen.add(row.id);
173
178
  const own = row.visibility === 'team' || row.custodian_id === auth.principal_id || (grantsBySession.get(row.id) ?? []).some(grant =>
174
179
  (grant.expires_at == null || grant.expires_at > now) &&
@@ -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
@@ -52,7 +53,7 @@ async function preAuth({ api, req, res, path, requestId }) {
52
53
  const access=await delegatedSessionOf(api,bearer(req.headers.authorization));
53
54
  const ref=decodeURIComponent(early[2]);
54
55
  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');}
55
- 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}};
56
+ 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}};
56
57
  const url=new URL(req.url,'http://local');
57
58
  if(!early[3])await sessionDescriptor(api,res,auth,ref,requestId);
58
59
  else if(early[3]==='/messages')await readSession(api,res,auth,ref,url,requestId);
@@ -424,7 +425,7 @@ async function viewBindingRoute(api,req,res,auth,ref,requestId){
424
425
  if(session.custodian_id!==auth.principal_id)fail(403,'forbidden',"only the session's owner binds it to a view");
425
426
  const body=await parseJson(req);exact(body,['issuer','room','conversation']);
426
427
  if(typeof body.room!=='string'||!body.room||typeof body.conversation!=='string'||!body.conversation)fail(400,'invalid_request','room and conversation are required');
427
- 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');
428
429
  const key=[auth.team_id,session.id,body.issuer,body.room,body.conversation];
429
430
  await api.store.transaction(req.method==='POST'
430
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{
@@ -7,6 +7,7 @@ import { ingestOtlpBatch, correlateSessions } from './arc2-transactions.mjs';
7
7
  import { claimEffect, getEffect, listEffects, reportEffect } from './effects-transactions.mjs';
8
8
  import { applyCaptureMessages } from './capture-transactions.mjs';
9
9
  import { MIGRATIONS, MESSAGE_AT } from '@volter/teams/server/migrations';
10
+ import { APP_LINEAGE_REVOKED, appGrantAllows } from '@volter/teams/server/auth';
10
11
 
11
12
  /** Bring a database to the newest schema. `version()`/`setVersion(n)` name where the host records it. */
12
13
  export function migrate({ exec, tx, version, setVersion }) {
@@ -26,12 +27,23 @@ export function createStoreHandlers({ all, get, run, tx }) {
26
27
  const {teamId,principalId,credentialId,ref,now,bound}=args;
27
28
  // `bound`: a workplace's session-view token ({issuer, room, conversation, expiresAt}), which reads only a
28
29
  // session its owner bound to that Room and Conversation; it holds no credential and is no member.
29
- let membership=null;
30
+ let membership=null,appFence=null,grantSource=null;
31
+ if((args.delegationDepth??0)>16)domainError(401,'authentication_required','delegation lineage is too deep');
30
32
  if(bound){
33
+ 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.id=? AND i.created_at=? 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,bound.installationId,bound.installedAt]))domainError(401,'authentication_required','integration was uninstalled');
31
34
  if(!(bound.expiresAt>now))domainError(401,'authentication_required','authentication required');
32
35
  }else{
33
36
  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]);
34
37
  membership=get('SELECT role FROM memberships WHERE team_id=? AND principal_id=? AND active=1',[teamId,principalId]);
38
+ 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]);
39
+ if(identity&&(identity.state!=='active'||identity.app_state!=='active'||!identity.active||!['owner','admin'].includes(identity.role)))domainError(401,'authentication_required','identity integration revoked');
40
+ 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]);
41
+ if(app){
42
+ 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');
43
+ if(get(APP_LINEAGE_REVOKED,[credentialId,now]))domainError(401,'authentication_required','delegation authority revoked');
44
+ appFence=JSON.parse(app.grants_json);
45
+ if(appFence!==null)grantSource=get('SELECT id,principal_id FROM credentials WHERE id=?',[app.subject_credential_id??app.parent_id]);
46
+ }
35
47
  if(!credential||!membership)domainError(401,'authentication_required','authentication required');
36
48
  }
37
49
  if(typeof ref!=='string'||ref.length<8)domainError(404,'not_found','not found');
@@ -52,6 +64,11 @@ export function createStoreHandlers({ all, get, run, tx }) {
52
64
  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]);
53
65
  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=>{
54
66
  if(session.deleted_at!=null)return false;
67
+ if(!appGrantAllows({app_grants:appFence},'session',session.id,'sessions.read'))return false;
68
+ if(appFence!==null){
69
+ if(!grantSource)return false;
70
+ try{authorizedSession({teamId,principalId:grantSource.principal_id,credentialId:grantSource.id,ref:session.id,now,delegationDepth:(args.delegationDepth??0)+1,projectionDepth:1});}catch{return false;}
71
+ }
55
72
  if(session.visibility==='team'||session.custodian_id===principalId)return true;
56
73
  const grants=all('SELECT subject_kind,subject_id,expires_at FROM session_grants WHERE team_id=? AND session_id=?',[teamId,session.id]);
57
74
  return grants.some(grant=>(grant.subject_kind==='principal'&&grant.subject_id===principalId||grant.subject_kind==='group'&&groups.includes(grant.subject_id))&&(grant.expires_at==null||grant.expires_at>now));
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,26 @@ 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 AppResourceGrant { kind:'session'; id:string; actions:('sessions.read'|'sessions.write'|'sessions.access')[] }
53
+ export interface AppDelegation { actions:string[]; actor:string; expires_in_ms?:number; principal_id?:string; grants?:AppResourceGrant[]; subject_token?:string }
54
+ export interface AppToken { grants?:AppResourceGrant[]|null; token:string; kind:'service'; expires_at:number; actions:string[]; subject:string; act:string[] }
55
+ export interface AppCodeExchange { version:1; installation:AppInstallation; credential:AppToken; webhook_secret:string }
41
56
  export interface TeamClient {
57
+ apps:{deregister(id:string,options?:RequestOptions):Promise<null>;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}>>};
58
+ 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:AppDelegation,options?:RequestOptions):Promise<Result<AppToken|null>>};
42
59
  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
60
  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
61
  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,19 @@ 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
+ deregister: (id, o) => remove(resource('/apps', id), o),
37
+ rotateSecret: (id, o) => post(resource('/apps', id) + '/secret', {}, o),
38
+ install: (id, body, o) => post(resource('/apps', id) + '/installations', body, o),
39
+ };
40
+ this.appInstallations = {
41
+ list: o => get('/app-installations', o), get: (id, o) => get(resource('/app-installations', id), o),
42
+ uninstall: (id, o) => remove(resource('/app-installations', id), o),
43
+ delegate: (id, body, o) => post(resource('/app-installations', id) + '/delegations', body, o),
44
+ };
31
45
  this.sessions = {
32
46
  list: (q = {}, o) => get('/sessions' + query(q), o),
33
47
  search: (q, o) => get('/sessions/search' + query(q), o),