@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 +4 -4
- package/bin/workspace/args.mjs +2 -2
- package/bin/workspace.mjs +23 -1
- package/package.json +1 -1
- package/server/event-routes.mjs +6 -1
- package/server/harness-routes.mjs +3 -2
- package/server/index.mjs +3 -2
- package/server/store-core.mjs +18 -1
- package/worker/index.mjs +3 -4
- package/worker/wrangler.toml +2 -12
- package/workspace-client.d.ts +17 -0
- package/workspace-client.mjs +14 -0
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
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
package/bin/workspace/args.mjs
CHANGED
|
@@ -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', '
|
|
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,
|
|
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.
|
|
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",
|
package/server/event-routes.mjs
CHANGED
|
@@ -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.
|
|
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,
|
|
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,
|
|
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{
|
package/server/store-core.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
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),
|
|
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;
|
package/worker/wrangler.toml
CHANGED
|
@@ -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
|
-
#
|
|
8
|
-
#
|
|
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
|
package/workspace-client.d.ts
CHANGED
|
@@ -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>};
|
package/workspace-client.mjs
CHANGED
|
@@ -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),
|