@volter/teams 0.1.0

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/home.mjs ADDED
@@ -0,0 +1,220 @@
1
+ // The files. Everything lives under <SUPERCODE_HOME>/teams — the
2
+ // same home supercode's own receipts use (agent.rs global_instructions_dir):
3
+ // SUPERCODE_HOME, else XDG_CONFIG_HOME/supercode, else ~/.config/supercode.
4
+ //
5
+ // machine.key / machine.pub the host key; it signs `describe`, nothing else
6
+ // machine.sock the local door: owner-only, the OS user is the caller
7
+ // machine.json the running daemon: pid, key id, socket, linked server
8
+ // launches.jsonl launch key → pane contextKey
9
+ // log.jsonl hash-chained accountability, no bodies
10
+ // log.<ts>.jsonl its rotations, oldest pruned past `logKeep`
11
+ // limits.json the daemon's bounds
12
+ // attention.json requested-attention records; rank table
13
+ // titles.json pane → display title
14
+ // suspended.json panes whose remote input is refused
15
+ // products/ receipts of products served behind the daemon (products.mjs)
16
+ // workspaces/ Teams contexts, credentials and connector enrollments
17
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, statSync, renameSync, readdirSync, rmSync } from 'node:fs';
18
+ import { join, dirname, basename } from 'node:path';
19
+ import { homedir } from 'node:os';
20
+ import { createHash } from 'node:crypto';
21
+
22
+ export function supercodeHome(env = process.env) {
23
+ if (env.SUPERCODE_HOME) return env.SUPERCODE_HOME;
24
+ if (env.XDG_CONFIG_HOME) return join(env.XDG_CONFIG_HOME, 'supercode');
25
+ return join(homedir(), '.config', 'supercode');
26
+ }
27
+
28
+ export function teamsHome(env = process.env) {
29
+ const dir = env.SUPERCODE_TEAMS_HOME ?? join(supercodeHome(env), 'teams');
30
+ mkdirSync(dir, { recursive: true });
31
+ return dir;
32
+ }
33
+
34
+ /**
35
+ * A Unix socket path for `name` in `dir`: the path itself when it fits a socket
36
+ * address (104 bytes on macOS, 108 on Linux); otherwise, as tmux does, a
37
+ * 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.
39
+ */
40
+ export function shortSocketPath(dir, name) {
41
+ const inside = join(dir, name);
42
+ if (Buffer.byteLength(inside) <= 100) return inside;
43
+ const uid = typeof process.getuid === 'function' ? process.getuid() : 'user';
44
+ return join('/tmp', `supercode-machine-${uid}`, `${createHash('sha256').update(inside).digest('hex').slice(0, 24)}.sock`);
45
+ }
46
+
47
+ /** Where the daemon for `home` serves its local door. */
48
+ export const machineSocketPath = (home) => shortSocketPath(home, 'machine.sock');
49
+
50
+ /** 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
+ export function ensureSocketDirectory(socketPath) {
52
+ const dir = dirname(socketPath);
53
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
54
+ if (!basename(dir).startsWith('supercode-machine-') || dirname(dir) !== '/tmp') return;
55
+ const stat = statSync(dir);
56
+ const uid = typeof process.getuid === 'function' ? process.getuid() : stat.uid;
57
+ if (stat.uid !== uid || (stat.mode & 0o077) !== 0) throw new Error(`${dir} must be owned by this user with mode 0700`);
58
+ }
59
+
60
+ export const paths = (home) => ({
61
+ machineKey: join(home, 'machine'),
62
+ machine: join(home, 'machine.json'),
63
+ socket: machineSocketPath(home),
64
+ launches: join(home, 'launches.jsonl'),
65
+ log: join(home, 'log.jsonl'),
66
+ attention: join(home, 'attention.json'),
67
+ titles: join(home, 'titles.json'),
68
+ suspended: join(home, 'suspended.json'),
69
+ limits: join(home, 'limits.json'),
70
+ });
71
+
72
+ /**
73
+ * The daemon's bounds, read from limits.json when it starts:
74
+ *
75
+ * maxAttachmentsPerPrincipal live pane attachments one caller may hold at once
76
+ * protocolFloor the lowest caller node protocol this machine speaks to
77
+ * logRotateBytes / logKeep when log.jsonl rotates, and how many rotations survive
78
+ */
79
+ export const DEFAULT_LIMITS = Object.freeze({
80
+ maxAttachmentsPerPrincipal: 16,
81
+ protocolFloor: 2,
82
+ logRotateBytes: 16 * 1024 * 1024,
83
+ logKeep: 8,
84
+ });
85
+
86
+ export function readLimits(path) {
87
+ return { ...DEFAULT_LIMITS, ...readJson(path, {}) };
88
+ }
89
+
90
+ export function readJson(path, fallback) {
91
+ if (!existsSync(path)) return fallback;
92
+ return JSON.parse(readFileSync(path, 'utf8'));
93
+ }
94
+
95
+ export function writeJson(path, value, mode = 0o600) {
96
+ writeFileSync(path, JSON.stringify(value, null, 2) + '\n', { mode });
97
+ }
98
+
99
+ /** `log.jsonl` → `log.<stamp>.jsonl`, the name a rotation takes. */
100
+ function rotatedPath(path, stamp) {
101
+ const name = basename(path);
102
+ const dot = name.indexOf('.');
103
+ const base = dot < 0 ? name : name.slice(0, dot);
104
+ const ext = dot < 0 ? '' : name.slice(dot);
105
+ return join(dirname(path), `${base}.${stamp}${ext}`);
106
+ }
107
+
108
+ /**
109
+ * The accountability log: one JSON line per act, each carrying the sha256
110
+ * of the previous line so tampering breaks the chain. Never a body.
111
+ *
112
+ * Retention: past `rotateBytes` the file
113
+ * is renamed `log.<ts>.jsonl` and a fresh one starts, keeping `logKeep`
114
+ * rotations. The chain does NOT restart — the first line of the new file names
115
+ * the last line of the old one — so the whole set verifies as one sequence and
116
+ * a deleted rotation is visible as a break, not a silent gap.
117
+ */
118
+ export class AccountLog {
119
+ constructor(path, { rotateBytes = DEFAULT_LIMITS.logRotateBytes, keep = DEFAULT_LIMITS.logKeep } = {}) {
120
+ this.path = path;
121
+ this.rotateBytes = rotateBytes;
122
+ this.keep = keep;
123
+ this.last = null;
124
+ if (existsSync(path)) {
125
+ const lines = readFileSync(path, 'utf8').trimEnd().split('\n').filter(Boolean);
126
+ this.last = lines.length ? createHash('sha256').update(lines[lines.length - 1]).digest('hex') : null;
127
+ }
128
+ }
129
+ append({ principal, cap, target, outcome, detail = null }) {
130
+ const entry = { t: Date.now(), principal, cap, target, outcome, detail, prev: this.last };
131
+ const line = JSON.stringify(entry);
132
+ appendFileSync(this.path, line + '\n', { mode: 0o600 });
133
+ this.last = createHash('sha256').update(line).digest('hex');
134
+ this.rotate();
135
+ return entry;
136
+ }
137
+ /** Rename the live file aside once it is over its bound, then prune old rotations. */
138
+ rotate() {
139
+ if (!Number.isFinite(this.rotateBytes) || this.rotateBytes <= 0) return null;
140
+ let size = 0;
141
+ try { size = statSync(this.path).size; } catch { return null; }
142
+ if (size <= this.rotateBytes) return null;
143
+ let stamp = Date.now();
144
+ while (existsSync(rotatedPath(this.path, stamp))) stamp += 1;
145
+ const rotated = rotatedPath(this.path, stamp);
146
+ renameSync(this.path, rotated);
147
+ const rotations = AccountLog.rotations(this.path);
148
+ for (const old of rotations.slice(0, Math.max(0, rotations.length - this.keep))) rmSync(old, { force: true });
149
+ return rotated;
150
+ }
151
+ /** Every rotation of `path`, oldest first. */
152
+ static rotations(path) {
153
+ const dir = dirname(path);
154
+ if (!existsSync(dir)) return [];
155
+ const name = basename(path);
156
+ const dot = name.indexOf('.');
157
+ const base = dot < 0 ? name : name.slice(0, dot);
158
+ const ext = dot < 0 ? '' : name.slice(dot);
159
+ const pattern = new RegExp(`^${base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\.(\\d+)${ext.replace(/\./g, '\\.')}$`);
160
+ return readdirSync(dir)
161
+ .map((entry) => [entry, pattern.exec(entry)])
162
+ .filter(([, match]) => match)
163
+ .sort((a, b) => Number(a[1][1]) - Number(b[1][1]))
164
+ .map(([entry]) => join(dir, entry));
165
+ }
166
+ /** The whole set in chain order: rotations oldest first, then the live file. */
167
+ static files(path) {
168
+ return [...AccountLog.rotations(path), ...(existsSync(path) ? [path] : [])];
169
+ }
170
+ /** Verify the chain across the rotated set; returns { ok, files, entries, brokenAt }. */
171
+ static verify(path) {
172
+ const files = AccountLog.files(path);
173
+ let prev = null;
174
+ let entries = 0;
175
+ for (const file of files) {
176
+ const lines = readFileSync(file, 'utf8').trimEnd().split('\n').filter(Boolean);
177
+ for (let i = 0; i < lines.length; i += 1) {
178
+ const entry = JSON.parse(lines[i]);
179
+ entries += 1;
180
+ if (entry.prev !== prev) return { ok: false, files, entries, brokenAt: { file, line: i } };
181
+ prev = createHash('sha256').update(lines[i]).digest('hex');
182
+ }
183
+ }
184
+ return { ok: true, files, entries, brokenAt: null };
185
+ }
186
+ }
187
+
188
+ /** launches.jsonl: appended before a launch is issued; the last record per key wins. */
189
+ export class Launches {
190
+ constructor(path) { this.path = path; }
191
+ record(entry) { appendFileSync(this.path, JSON.stringify({ t: Date.now(), ...entry }) + '\n', { mode: 0o600 }); }
192
+ find(key) {
193
+ if (!existsSync(this.path)) return null;
194
+ const lines = readFileSync(this.path, 'utf8').trimEnd().split('\n').filter(Boolean);
195
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
196
+ const entry = JSON.parse(lines[i]);
197
+ if (entry.key === key) return entry;
198
+ }
199
+ return null;
200
+ }
201
+ /** Every key's last record, newest first: what this node has launched for its callers. */
202
+ all() {
203
+ if (!existsSync(this.path)) return [];
204
+ const lines = readFileSync(this.path, 'utf8').trimEnd().split('\n').filter(Boolean);
205
+ const last = new Map();
206
+ for (const line of lines) { const entry = JSON.parse(line); last.set(entry.key, entry); }
207
+ return [...last.values()].sort((a, b) => b.t - a.t);
208
+ }
209
+ }
210
+
211
+ /** `host:port` or `[v6]:port` → { host, port }. */
212
+ export function parseHostPort(text, fallback = { host: '127.0.0.1', port: 0 }) {
213
+ const value = String(text ?? '').trim();
214
+ if (!value) return fallback;
215
+ const bracketed = /^\[([^\]]+)\]:(\d+)$/.exec(value);
216
+ if (bracketed) return { host: bracketed[1], port: Number(bracketed[2]) };
217
+ const colon = value.lastIndexOf(':');
218
+ if (colon < 0) return { host: value, port: fallback.port };
219
+ return { host: value.slice(0, colon) || fallback.host, port: Number(value.slice(colon + 1)) };
220
+ }
package/identity.mjs ADDED
@@ -0,0 +1,85 @@
1
+ // The machine's host key: an Ed25519 keypair on disk that signs the daemon's
2
+ // `describe` statement, and nothing else. node:crypto and plain JSON only.
3
+ import { createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify, createHash } from 'node:crypto';
4
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
5
+ import { dirname } from 'node:path';
6
+
7
+ /** Generate an Ed25519 keypair. Returns PEM strings. */
8
+ export function generateKeyPair() {
9
+ const { privateKey, publicKey } = generateKeyPairSync('ed25519');
10
+ return {
11
+ privateKeyPem: privateKey.export({ type: 'pkcs8', format: 'pem' }),
12
+ publicKeyPem: publicKey.export({ type: 'spki', format: 'pem' }),
13
+ };
14
+ }
15
+
16
+ /** Raw 32-byte public key from a PEM, as base64url. This is the principal's id. */
17
+ export function publicKeyId(publicKeyPem) {
18
+ const key = createPublicKey(publicKeyPem);
19
+ const der = key.export({ type: 'spki', format: 'der' });
20
+ // SPKI for Ed25519 is a fixed 12-byte prefix followed by the 32-byte key.
21
+ return der.subarray(der.length - 32).toString('base64url');
22
+ }
23
+
24
+ export function publicKeyPemFromId(id) {
25
+ const raw = Buffer.from(id, 'base64url');
26
+ if (raw.length !== 32) throw new Error('principal id is not a 32-byte Ed25519 public key');
27
+ const prefix = Buffer.from('302a300506032b6570032100', 'hex');
28
+ return createPublicKey({ key: Buffer.concat([prefix, raw]), type: 'spki', format: 'der' })
29
+ .export({ type: 'spki', format: 'pem' });
30
+ }
31
+
32
+ /** A short fingerprint for humans: sha256 of the raw key, first 16 hex chars. */
33
+ export function fingerprint(publicKeyPemOrId) {
34
+ const id = publicKeyPemOrId.includes('-----') ? publicKeyId(publicKeyPemOrId) : publicKeyPemOrId;
35
+ return createHash('sha256').update(Buffer.from(id, 'base64url')).digest('hex').slice(0, 16);
36
+ }
37
+
38
+ export function signBytes(privateKeyPem, bytes) {
39
+ return sign(null, bytes, createPrivateKey(privateKeyPem)).toString('base64url');
40
+ }
41
+
42
+ export function verifyBytes(publicKeyPemOrId, bytes, signatureB64) {
43
+ const pem = publicKeyPemOrId.includes('-----') ? publicKeyPemOrId : publicKeyPemFromId(publicKeyPemOrId);
44
+ try {
45
+ return verify(null, bytes, createPublicKey(pem), Buffer.from(signatureB64, 'base64url'));
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ /** Canonical JSON: sorted keys, no whitespace. Signatures are over this form. */
52
+ export function canonical(value) {
53
+ return JSON.stringify(sortKeys(value));
54
+ }
55
+ function sortKeys(value) {
56
+ if (Array.isArray(value)) return value.map(sortKeys);
57
+ if (value && typeof value === 'object') {
58
+ return Object.fromEntries(Object.keys(value).sort().map((k) => [k, sortKeys(value[k])]));
59
+ }
60
+ return value;
61
+ }
62
+
63
+ /** Load or create a keypair on disk. Files: <base>.key (pkcs8 pem, 0600), <base>.pub (spki pem). */
64
+ export function loadOrCreateKeyPair(basePath) {
65
+ const keyPath = `${basePath}.key`;
66
+ const pubPath = `${basePath}.pub`;
67
+ if (existsSync(keyPath)) {
68
+ const privateKeyPem = readFileSync(keyPath, 'utf8');
69
+ const publicKeyPem = existsSync(pubPath)
70
+ ? readFileSync(pubPath, 'utf8')
71
+ : createPublicKey(createPrivateKey(privateKeyPem)).export({ type: 'spki', format: 'pem' });
72
+ return { privateKeyPem, publicKeyPem, created: false };
73
+ }
74
+ mkdirSync(dirname(keyPath), { recursive: true });
75
+ const pair = generateKeyPair();
76
+ writeFileSync(keyPath, pair.privateKeyPem, { mode: 0o600 });
77
+ writeFileSync(pubPath, pair.publicKeyPem, { mode: 0o644 });
78
+ return { ...pair, created: true };
79
+ }
80
+
81
+ export function readPublicKeyPem(path) {
82
+ const text = readFileSync(path, 'utf8');
83
+ if (text.includes('-----')) return text;
84
+ return publicKeyPemFromId(text.trim());
85
+ }
package/index.mjs ADDED
@@ -0,0 +1,8 @@
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).
3
+ export { MachineCore, MACHINE_VERSION } from './machine-core.mjs';
4
+ export { Channel, NODE_PROTOCOL } from './channel.mjs';
5
+ export { holds, targetsCover, admitFromGrants, observesPanes, LOCAL_OPERATOR } from './admission.mjs';
6
+ export { serveProduct, findProduct, productProof, DAEMON_KINDS } from './products.mjs';
7
+ export { loadOrCreateKeyPair, publicKeyId, fingerprint } from './identity.mjs';
8
+ export { teamsHome, paths } from './home.mjs';