@volter/teams 0.1.5 → 0.1.7
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/home.mjs +8 -2
- package/index.mjs +1 -1
- package/machine-core.mjs +8 -6
- package/package.json +2 -2
- package/server/http.mjs +24 -7
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/home.mjs
CHANGED
|
@@ -31,14 +31,19 @@ export function teamsHome(env = process.env) {
|
|
|
31
31
|
return dir;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** A Windows named pipe (`\\.\pipe\…`): no file to make, chmod or remove; it vanishes with its last handle. */
|
|
35
|
+
export const isNamedPipe = (path) => /^\\\\[.?]\\pipe\\/i.test(path);
|
|
36
|
+
|
|
34
37
|
/**
|
|
35
38
|
* A Unix socket path for `name` in `dir`: the path itself when it fits a socket
|
|
36
39
|
* address (104 bytes on macOS, 108 on Linux); otherwise, as tmux does, a
|
|
37
40
|
* per-user directory under /tmp that only this user may enter, with the socket
|
|
38
|
-
* named by a hash of the path it stands for.
|
|
41
|
+
* named by a hash of the path it stands for. Node on Windows listens only on
|
|
42
|
+
* named pipes, so there it is always a pipe named by that same hash.
|
|
39
43
|
*/
|
|
40
|
-
export function shortSocketPath(dir, name) {
|
|
44
|
+
export function shortSocketPath(dir, name, platform = process.platform) {
|
|
41
45
|
const inside = join(dir, name);
|
|
46
|
+
if (platform === 'win32') return `\\\\.\\pipe\\supercode-${createHash('sha256').update(inside).digest('hex').slice(0, 24)}`;
|
|
42
47
|
if (Buffer.byteLength(inside) <= 100) return inside;
|
|
43
48
|
const uid = typeof process.getuid === 'function' ? process.getuid() : 'user';
|
|
44
49
|
return join('/tmp', `supercode-machine-${uid}`, `${createHash('sha256').update(inside).digest('hex').slice(0, 24)}.sock`);
|
|
@@ -49,6 +54,7 @@ export const machineSocketPath = (home) => shortSocketPath(home, 'machine.sock')
|
|
|
49
54
|
|
|
50
55
|
/** The socket's directory exists; the shared /tmp fallback must be ours and admit only us, and anything else there is refused, never adopted. */
|
|
51
56
|
export function ensureSocketDirectory(socketPath) {
|
|
57
|
+
if (isNamedPipe(socketPath)) return;
|
|
52
58
|
const dir = dirname(socketPath);
|
|
53
59
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
54
60
|
if (!basename(dir).startsWith('supercode-machine-') || dirname(dir) !== '/tmp') return;
|
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/machine-core.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import { Channel, NODE_PROTOCOL } from './channel.mjs';
|
|
|
15
15
|
import { holds, admitFromGrants, LOCAL_OPERATOR } from './admission.mjs';
|
|
16
16
|
import { findProduct, DAEMON_KINDS, PRODUCT_MAX_MESSAGE, productProof, sameSecret } from './products.mjs';
|
|
17
17
|
import { loadOrCreateKeyPair, publicKeyId } from './identity.mjs';
|
|
18
|
-
import { teamsHome, paths, writeJson, readLimits, AccountLog, ensureSocketDirectory } from './home.mjs';
|
|
18
|
+
import { teamsHome, paths, writeJson, readLimits, AccountLog, ensureSocketDirectory, isNamedPipe } from './home.mjs';
|
|
19
19
|
|
|
20
20
|
export const MACHINE_VERSION = 2;
|
|
21
21
|
const LINK_BACKOFF_MIN_MS = 1_000;
|
|
@@ -93,8 +93,9 @@ export class MachineCore extends EventEmitter {
|
|
|
93
93
|
this.server.once('error', reject);
|
|
94
94
|
this.server.listen(this.socketPath, () => { this.server.off('error', reject); resolveListen(); });
|
|
95
95
|
});
|
|
96
|
-
// Owner-only: the socket is the local door, and whoever opens it is this OS user.
|
|
97
|
-
|
|
96
|
+
// Owner-only: the socket is the local door, and whoever opens it is this OS user. A named pipe's default
|
|
97
|
+
// access already admits only its owner (and SYSTEM and Administrators) to connect.
|
|
98
|
+
if (!isNamedPipe(this.socketPath)) chmodSync(this.socketPath, 0o600);
|
|
98
99
|
this.address = this.socketPath;
|
|
99
100
|
writeJson(this.p.machine, { pid: process.pid, id: this.id, socket: this.socketPath, startedAt: Date.now(), version: MACHINE_VERSION, protocol: NODE_PROTOCOL, floor: this.floor, server: this.link?.server ?? null });
|
|
100
101
|
this.emit('listening', this.socketPath);
|
|
@@ -115,7 +116,7 @@ export class MachineCore extends EventEmitter {
|
|
|
115
116
|
for (const { channel } of this.connections.values()) channel.close();
|
|
116
117
|
this.server?.close();
|
|
117
118
|
await this.afterStop();
|
|
118
|
-
rmSync(this.socketPath, { force: true });
|
|
119
|
+
if (!isNamedPipe(this.socketPath)) rmSync(this.socketPath, { force: true });
|
|
119
120
|
if (existsSync(this.p.machine)) rmSync(this.p.machine);
|
|
120
121
|
}
|
|
121
122
|
|
|
@@ -385,13 +386,14 @@ export class MachineCore extends EventEmitter {
|
|
|
385
386
|
}
|
|
386
387
|
|
|
387
388
|
async function clearStaleSocket(path) {
|
|
388
|
-
|
|
389
|
+
// A named pipe has no file to leave behind: only a live daemon holds its name.
|
|
390
|
+
if (!isNamedPipe(path) && !existsSync(path)) return;
|
|
389
391
|
const live = await new Promise((done) => {
|
|
390
392
|
const probe = netConnect(path);
|
|
391
393
|
probe.once('connect', () => { probe.destroy(); done(true); });
|
|
392
394
|
probe.once('error', () => done(false));
|
|
393
395
|
});
|
|
394
396
|
if (live) throw Object.assign(new Error(`a machine daemon is already serving ${path}`), { code: 'EADDRINUSE' });
|
|
395
|
-
rmSync(path, { force: true });
|
|
397
|
+
if (!isNamedPipe(path)) rmSync(path, { force: true });
|
|
396
398
|
}
|
|
397
399
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/teams",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
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;
|