@volter/teams 0.1.4 → 0.1.6
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 +1 -1
- package/index.mjs +1 -1
- package/package.json +2 -2
- package/server/http.mjs +27 -9
- package/server/machine-link.mjs +14 -3
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ reached by a team, with no harness in it.
|
|
|
7
7
|
hash-chained account log, the owner-only local door, one outbound link to a Teams
|
|
8
8
|
server with every stream admitted from the machine's own synced grants, and product
|
|
9
9
|
doors. A product builds on `MachineCore` and registers its own doors by stream name;
|
|
10
|
-
the harness does (`@volter
|
|
10
|
+
the harness does (`@volter/supercode-teams`, `machine.mjs`).
|
|
11
11
|
- **Products behind the daemon** (`products.mjs`): a product serving its own kind of
|
|
12
12
|
target writes a receipt; the daemon admits the caller, both sides prove the receipt's
|
|
13
13
|
token without sending it, and messages are relayed.
|
package/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Volter Teams, the neutral team plane (company RFC 0008). A product builds on MachineCore
|
|
2
|
-
// and registers its own doors; the harness is one such product (@volter
|
|
2
|
+
// and registers its own doors; the harness is one such product (@volter/supercode-teams).
|
|
3
3
|
export { MachineCore, MACHINE_VERSION } from './machine-core.mjs';
|
|
4
4
|
export { Channel, NODE_PROTOCOL } from './channel.mjs';
|
|
5
5
|
export { holds, targetsCover, admitFromGrants, observesPanes, LOCAL_OPERATOR } from './admission.mjs';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/teams",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Volter Teams, the neutral team plane: machine identity and daemon core, the local door and server link, admission from machine grants, product doors, and the Teams server's neutral routes (company RFC 0008).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"node": ">=22"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@volter/identity": "^0.
|
|
41
|
+
"@volter/identity": "^0.4.0",
|
|
42
42
|
"ws": "^8.21.0"
|
|
43
43
|
}
|
|
44
44
|
}
|
package/server/http.mjs
CHANGED
|
@@ -33,6 +33,7 @@ export function createHttpHandler(api){return async(req,res)=>{const requestId=a
|
|
|
33
33
|
if(api.harnessRoutes&&await api.harnessRoutes.authed({api,req,res,auth,path,requestId}))return;
|
|
34
34
|
if(req.method==='POST'&&path==='/api/v1/invitations/accept'){checkRate(api,`login:${req.socket.remoteAddress}`,10,5);const body=await parseJson(req);return await acceptInvite(api,res,body,requestId);}
|
|
35
35
|
if(req.method==='POST'&&path==='/api/v1/login/volter'){checkRate(api,`login:${req.socket.remoteAddress}`,10,5);const body=await parseJson(req);return await identityLogin(api,res,body,requestId);}
|
|
36
|
+
if(req.method==='POST'&&path==='/api/v1/login/volter/teams'){checkRate(api,`login-teams:${req.socket.remoteAddress}`,10,5);const body=await parseJson(req);return await identityTeams(api,res,body,requestId);}
|
|
36
37
|
if(!auth)fail(401,'authentication_required','authentication required');auth.now=api.clock.nowMs();auth.clock=api.clock;
|
|
37
38
|
const bulk=/\/captures\/[^/]+\/parts\/\d+$/.test(path)||/\/uploads\/[^/]+\/chunks\/\d+$/.test(path);checkRate(api,`credential:${auth.credential_id}:${bulk?'bulk':'ordinary'}`,bulk?600:120,bulk?60:30);
|
|
38
39
|
if(req.method==='GET'&&path==='/api/v1/me'){if(!['user','service'].includes(auth.kind))fail(403,'forbidden','credential kind cannot use this operation');const teams=await api.store.query('SELECT t.id,t.name,t.revision,m.role FROM memberships m JOIN teams t ON t.id=m.team_id WHERE m.principal_id=? AND m.active=1 AND (? IS NULL OR m.team_id=?)',[auth.principal_id,auth.team_id,auth.team_id]);return send(res,200,{server_id:api.serverId,principal:{id:auth.principal_id,kind:auth.principal_kind,display_name:auth.display_name},teams:teams.map(x=>({team:{id:x.id,name:x.name,revision:x.revision},role:x.role}))},requestId);}
|
|
@@ -154,17 +155,33 @@ async function identityLogin(api,res,body,requestId){
|
|
|
154
155
|
}
|
|
155
156
|
/** The one team a signed-in person belongs to, as a member or through a linked organization. */
|
|
156
157
|
async function teamOfPerson(api,person){
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if(!teams.length){
|
|
160
|
-
const linked=[];
|
|
161
|
-
for(const team of await api.store.query('SELECT id,name FROM teams ORDER BY name',[])){const slug=linkedOrganization(api,team);if(slug&&await inOrganization(api,person.subject,slug))linked.push(team);}
|
|
162
|
-
teams=linked;
|
|
163
|
-
}
|
|
158
|
+
// A member signs into their own team without the organizations door being asked (or being up).
|
|
159
|
+
const member=await teamsOfMember(api,person),teams=member.length?member:(await teamsOfPerson(api,person)).linked;
|
|
164
160
|
if(teams.length===1)return teams[0].id;
|
|
165
161
|
if(!teams.length)fail(401,'authentication_required','you are not a member of a team on this server; ask a teammate for an invitation');
|
|
166
162
|
fail(409,'conflict',`you belong to several teams (${teams.map(t=>t.name).join(', ')}); name one with --team`);
|
|
167
163
|
}
|
|
164
|
+
/** The teams a signed-in person may sign in to: those they are a member of, and those linked to an organization they
|
|
165
|
+
* belong to that they have never left (a member removed from a team stays removed; joinThroughOrganization). */
|
|
166
|
+
async function teamsOfPerson(api,person){
|
|
167
|
+
const member=await teamsOfMember(api,person);
|
|
168
|
+
const ever=new Set((await api.store.query('SELECT m.team_id FROM principal_identities i JOIN memberships m ON m.principal_id=i.principal_id WHERE i.issuer=? AND i.subject=?',[person.issuer,person.subject])).map(row=>row.team_id));
|
|
169
|
+
const linked=[];
|
|
170
|
+
// An organization the door cannot answer for offers no team: the teams the person is a member of still list.
|
|
171
|
+
for(const team of await api.store.query('SELECT id,name FROM teams ORDER BY name',[])){if(ever.has(team.id))continue;const slug=linkedOrganization(api,team);if(slug&&await inOrganization(api,person.subject,slug).catch(()=>false))linked.push(team);}
|
|
172
|
+
return {member,linked};
|
|
173
|
+
}
|
|
174
|
+
/** The teams a signed-in person is an active member of, with their role. */
|
|
175
|
+
function teamsOfMember(api,person){
|
|
176
|
+
return api.store.query('SELECT t.id,t.name,m.role FROM principal_identities i JOIN memberships m ON m.principal_id=i.principal_id AND m.active=1 JOIN teams t ON t.id=m.team_id WHERE i.issuer=? AND i.subject=? ORDER BY t.name',[person.issuer,person.subject]);
|
|
177
|
+
}
|
|
178
|
+
/** Which teams a verified identity token may sign in to, so a person on several chooses rather than being refused. */
|
|
179
|
+
async function identityTeams(api,res,body,requestId){
|
|
180
|
+
exact(body,['access_token']);
|
|
181
|
+
const person=await identityOf(api,body.access_token);if(!person)fail(401,'authentication_required','the identity token did not verify');
|
|
182
|
+
const {member,linked}=await teamsOfPerson(api,person);
|
|
183
|
+
send(res,200,{teams:[...member.map(t=>({id:t.id,name:t.name,role:t.role,joined:true})),...linked.map(t=>({id:t.id,name:t.name,role:null,joined:false}))]},requestId);
|
|
184
|
+
}
|
|
168
185
|
/** Make `person` a member of team `team` when the team is linked to an organization they belong to; true if joined. */
|
|
169
186
|
async function joinThroughOrganization(api,person,team){
|
|
170
187
|
const found=await api.store.one('SELECT id,name FROM teams WHERE id=? OR name=?',[team,team]);if(!found)return false;
|
|
@@ -226,8 +243,9 @@ async function enroll(api,req,res,auth,requestId){if(auth.role==='viewer')fail(4
|
|
|
226
243
|
if(machine){return send(res,200,result(api,auth,{machine:machineJson(machine,api.clock.nowMs()),source:sourceJson({id:machine.source_id,name:machine.source_name,kind:machine.source_kind,state:machine.source_state,machine_id:machine.id,custodian_id:machine.custodian_id,visibility:machine.visibility,revision:machine.source_revision,last_received_at:machine.last_received_at,decoder:machine.decoder,counters_json:machine.counters_json,last_error:machine.last_error}),credential:null}),requestId);}const mid=api.ids.next('machine'),sid=api.ids.next('source'),cid=api.ids.next('credential'),token=makeToken(cid),now=api.clock.nowMs(),data={machine:{id:mid,name:b.name,custodian_id:auth.principal_id,enrollment:'active',connection:'offline',last_seen_at:null,incarnation_id:null,revision:1},source:{id:sid,name:`${b.name} connector`,kind:'connector',state:'active',machine_id:mid,custodian_id:auth.principal_id,default_visibility:'restricted',revision:1,last_received_at:null,decoder:null},credential:{token,kind:'machine',expires_at:now+2592000000}},response=result(api,auth,data);await guardedMutation(api,res,auth,[...authGuards(auth,now),{sql:'SELECT 1 ok WHERE NOT EXISTS (SELECT 1 FROM sources WHERE team_id=? AND name=?)',params:[auth.team_id,`${b.name} connector`],code:'conflict',status:409,message:`a machine named ${b.name} is already enrolled in this team; enroll this one under another --name`}],[{sql:'INSERT INTO machines(team_id,id,name,custodian_id,enrollment_key) VALUES (?,?,?,?,?)',params:[auth.team_id,mid,b.name,auth.principal_id,b.enrollment_key]},{sql:"INSERT INTO sources(team_id,id,name,kind,machine_id,custodian_id,visibility,state) VALUES (?,?,?,?,?,?,?,'active')",params:[auth.team_id,sid,`${b.name} connector`,'connector',mid,auth.principal_id,'restricted']},{sql:'INSERT INTO credentials(id,token_hash,principal_id,kind,team_id,source_id,machine_id,expires_at,created_at) VALUES (?,?,?,?,?,?,?,?,?)',params:[cid,hashToken(token),auth.principal_id,'machine',auth.team_id,sid,mid,now+2592000000,now]}],{status:201,body:response,targetId:mid});send(res,201,response,requestId);}
|
|
227
244
|
const machineJson=(m,now)=>({id:m.id,name:m.name,custodian_id:m.custodian_id,enrollment:m.revoked_at==null?'active':'revoked',connection:m.last_seen_at!=null&&m.last_seen_at+45000>now?'online':'offline',last_seen_at:m.last_seen_at,incarnation_id:m.incarnation_id,host_key_id:m.host_key_id??null,revision:m.revision});
|
|
228
245
|
async function machineAdminRoute(api,req,res,auth,machineId,roots,requestId){
|
|
229
|
-
// Custodians and admins see their machines
|
|
230
|
-
|
|
246
|
+
// Custodians and admins see their machines, revoked ones included; every member sees the team's active machines,
|
|
247
|
+
// each of which they can message (the team's standing mail grant, machine-link.mjs). Roots stay custodial.
|
|
248
|
+
const custody=roots?'(m.custodian_id=? OR ?=1)':'(m.custodian_id=? OR ?=1 OR m.revoked_at IS NULL)',params=[auth.team_id,auth.principal_id,isAdmin(auth)?1:0];
|
|
231
249
|
if(machineId){const machine=await api.store.one(`SELECT m.* FROM machines m WHERE m.team_id=? AND ${custody} AND m.id=?`,[...params,machineId]);if(!machine)fail(404,'not_found','not found');if(roots){const rows=await api.store.query('SELECT value_json FROM machine_roots WHERE team_id=? AND machine_id=? ORDER BY id',[auth.team_id,machineId]);return send(res,200,{version:1,context:{server_id:api.serverId,team_id:auth.team_id,principal_id:auth.principal_id},items:rows.map(row=>JSON.parse(row.value_json)),next_cursor:null},requestId);}return send(res,200,result(api,auth,machineJson(machine,api.clock.nowMs())),requestId,{ETag:`"${machine.revision}"`});}
|
|
232
250
|
const rows=await api.store.query(`SELECT m.* FROM machines m WHERE m.team_id=? AND ${custody} ORDER BY m.name,m.id`,params);send(res,200,{version:1,context:{server_id:api.serverId,team_id:auth.team_id,principal_id:auth.principal_id},items:rows.map(row=>machineJson(row,api.clock.nowMs())),next_cursor:null},requestId);
|
|
233
251
|
}
|
package/server/machine-link.mjs
CHANGED
|
@@ -33,10 +33,20 @@ const channels = (api) => api.machineChannels ??= new Set();
|
|
|
33
33
|
const key = (teamId, machineId) => `${teamId}\0${machineId}`;
|
|
34
34
|
const grantJson = (row) => ({ id: row.id, machine_id: row.machine_id, subject_id: row.subject_id, caps: JSON.parse(row.caps_json), targets: JSON.parse(row.targets_json), issuer_id: row.issuer_id, created_at: row.created_at, expires_at: row.expires_at, revision: row.revision });
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Members of a team message each other's sessions without a grant each way, as Claude's own sessions reach one
|
|
38
|
+
* another: every machine carries the team's standing grant of `mail` on every session there, whose subject is the
|
|
39
|
+
* team itself, and every active member's claim names it. It grants nothing else, and the machine checks it from its
|
|
40
|
+
* own synced rows like any grant.
|
|
41
|
+
*/
|
|
42
|
+
export const TEAM_MAIL_GRANT = 'team-mail';
|
|
43
|
+
const teamSubject = (teamId) => `team:${teamId}`;
|
|
44
|
+
const teamMailGrant = (teamId, machineId) => ({ id: TEAM_MAIL_GRANT, machine_id: machineId, subject_id: teamSubject(teamId), caps: ['mail'], targets: ['mail:*'], issuer_id: null, created_at: 0, expires_at: Number.MAX_SAFE_INTEGER, revision: 1 });
|
|
45
|
+
|
|
36
46
|
/** The unexpired grants of one machine, as its configuration carries them to the machine. */
|
|
37
47
|
export async function machineGrantRows(api, teamId, machineId) {
|
|
38
48
|
const rows = await api.store.query('SELECT * FROM machine_grants WHERE team_id=? AND machine_id=? AND expires_at>? ORDER BY id', [teamId, machineId, api.clock.nowMs()]);
|
|
39
|
-
return rows.map(grantJson);
|
|
49
|
+
return [...rows.map(grantJson), teamMailGrant(teamId, machineId)];
|
|
40
50
|
}
|
|
41
51
|
|
|
42
52
|
/** What the caller may claim on the machine right now, or null (indistinguishable from no machine). */
|
|
@@ -53,9 +63,10 @@ async function claimFor(api, auth, machineId) {
|
|
|
53
63
|
if (machine.custodian_id === auth.principal_id) return { principal: auth.principal_id, groups, custodian: true };
|
|
54
64
|
const subjects = [auth.principal_id, ...groups];
|
|
55
65
|
const rows = await api.store.query(`SELECT id, revision FROM machine_grants WHERE team_id=? AND machine_id=? AND expires_at>? AND subject_id IN (${subjects.map(() => '?').join(',')}) ORDER BY id`, [auth.team_id, machineId, api.clock.nowMs(), ...subjects]);
|
|
56
|
-
|
|
66
|
+
// Every member holds the team's standing mail grant (TEAM_MAIL_GRANT) besides their own.
|
|
67
|
+
const held = [...rows, { id: TEAM_MAIL_GRANT, revision: 1 }];
|
|
57
68
|
// Revisions let the machine notice a grant it holds has changed (rewritten, narrowed) and resync.
|
|
58
|
-
return { principal: auth.principal_id, groups, grants:
|
|
69
|
+
return { principal: auth.principal_id, groups: [...groups, teamSubject(auth.team_id)], grants: held.map((row) => row.id), revisions: Object.fromEntries(held.map((row) => [row.id, row.revision])) };
|
|
59
70
|
}
|
|
60
71
|
|
|
61
72
|
/** Close every channel a member leaving the team, or a group losing a member or being deleted, leaves without its claimed authority. */
|