@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 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-ai-dev/supercode-teams`, `machine.mjs`).
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-ai-dev/supercode-teams).
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
- chmodSync(this.socketPath, 0o600);
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
- if (!existsSync(path)) return;
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.5",
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.3.1",
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
- const rows=await api.store.query('SELECT t.id,t.name 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]);
158
- let teams=rows;
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;