@cordfuse/crosstalk 7.0.0-alpha.9 → 7.0.0-beta.1

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.
Files changed (72) hide show
  1. package/GUIDE-CLI.md +322 -0
  2. package/GUIDE-PROMPTS.md +118 -0
  3. package/LICENSE +21 -0
  4. package/README.md +402 -61
  5. package/bin/crosstalk.js +88 -54
  6. package/commands/agent.js +69 -0
  7. package/commands/auth.js +273 -0
  8. package/commands/channel.js +54 -44
  9. package/commands/chat.js +107 -71
  10. package/commands/daemon.js +120 -0
  11. package/commands/logs.js +108 -19
  12. package/commands/message.js +125 -0
  13. package/commands/server.js +153 -0
  14. package/commands/settings.js +49 -0
  15. package/commands/status.js +37 -13
  16. package/commands/token.js +136 -0
  17. package/commands/transport.js +270 -0
  18. package/commands/version.js +3 -3
  19. package/commands/workflow.js +234 -0
  20. package/deploy/crosstalk@.service +62 -0
  21. package/deploy/install.sh +82 -0
  22. package/lib/api-client.js +77 -22
  23. package/lib/credentials.js +207 -0
  24. package/lib/nativeServer.js +173 -0
  25. package/lib/resolve.js +101 -34
  26. package/package.json +27 -4
  27. package/src/activation.ts +104 -0
  28. package/src/api.ts +1716 -0
  29. package/src/auth/enforce.ts +68 -0
  30. package/src/auth/handlers.ts +266 -0
  31. package/src/auth/middleware.ts +132 -0
  32. package/src/auth/setup.ts +263 -0
  33. package/src/auth/tokens.ts +285 -0
  34. package/src/auth/users.ts +267 -0
  35. package/src/dispatch.ts +492 -0
  36. package/src/dispatchers.ts +91 -0
  37. package/src/filenames.ts +28 -0
  38. package/src/frontmatter.ts +26 -0
  39. package/src/init.ts +116 -0
  40. package/src/invoke.ts +201 -0
  41. package/src/log-buffer.ts +67 -0
  42. package/src/models.ts +283 -0
  43. package/src/resolve.ts +100 -0
  44. package/src/state.ts +190 -0
  45. package/src/stop.ts +37 -0
  46. package/src/transport.ts +243 -0
  47. package/src/web/auth-pages.ts +160 -0
  48. package/src/web/channels.ts +395 -0
  49. package/src/web/chat-page.ts +636 -0
  50. package/src/web/chat-pty.ts +254 -0
  51. package/src/web/dashboard.ts +129 -0
  52. package/src/web/layout.ts +237 -0
  53. package/src/web/stubs.ts +510 -0
  54. package/src/web/workflows.ts +490 -0
  55. package/src/workflow.ts +470 -0
  56. package/template/CLAUDE.md +10 -0
  57. package/template/CROSSTALK-VERSION +1 -0
  58. package/template/CROSSTALK.md +262 -0
  59. package/template/PROTOCOL.md +70 -0
  60. package/template/README.md +64 -0
  61. package/template/auth/.gitkeep +0 -0
  62. package/template/auth/README.md +224 -0
  63. package/template/data/crosstalk.yaml +196 -0
  64. package/template/gitignore +4 -0
  65. package/commands/down.js +0 -40
  66. package/commands/init.js +0 -243
  67. package/commands/pull.js +0 -22
  68. package/commands/replies.js +0 -40
  69. package/commands/restart.js +0 -29
  70. package/commands/rm.js +0 -109
  71. package/commands/run.js +0 -115
  72. package/commands/up.js +0 -135
@@ -0,0 +1,267 @@
1
+ // User store — username, name, scrypt-hashed passphrase, admin flag.
2
+ //
3
+ // Trust context: SERVICE. The service-user daemon owns /var/lib/llmux/users.json
4
+ // and is the only thing that reads/writes it. Workers receive their
5
+ // identity via env vars + token validation, not by reading the user store
6
+ // directly.
7
+ //
8
+ // References:
9
+ // V2-SYSTEM-AUTH-DESIGN.md § "User model (v2)"
10
+ // V2-SYSTEM-AUTH-DESIGN.md § "Data plane (v2)" — users.json row
11
+ // V2-SYSTEM-AUTH-DESIGN.md § "Build plan" — Phase 3
12
+
13
+ import { existsSync, readFileSync, writeFileSync, renameSync, chmodSync, mkdirSync } from 'node:fs';
14
+ import { dirname } from 'node:path';
15
+ import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
16
+
17
+ export interface User {
18
+ /** Stable identifier. Application-layer only — does NOT need to match any OS user on the host.
19
+ * Also the user's orch alias. Format: [a-z0-9_-]+ */
20
+ username: string;
21
+ /** Display name. */
22
+ name: string;
23
+ /** scrypt-hashed passphrase + salt + params. Plaintext never written. */
24
+ passphraseHash: string;
25
+ /** Admin can create/delete other users + mint tokens for them. */
26
+ admin: boolean;
27
+ /** ISO-8601. */
28
+ createdAt: string;
29
+ }
30
+
31
+ export interface CreateUserInput {
32
+ username: string;
33
+ name: string;
34
+ /** Plaintext — hashed by createUser before persistence. NEVER persisted. */
35
+ passphrase: string;
36
+ /** Defaults false. Only admins can set this. */
37
+ admin?: boolean;
38
+ }
39
+
40
+ export interface UserStore {
41
+ /** Create a new user. Validates username format + uniqueness + passphrase strength. */
42
+ createUser(input: CreateUserInput): Promise<User>;
43
+ /** Look up by username. Returns undefined if not present. */
44
+ getUser(username: string): Promise<User | undefined>;
45
+ /** List all users. Admin-callable; non-admin must filter to self. */
46
+ listUsers(): Promise<User[]>;
47
+ /** Update name. Username + admin status + passphrase have their own methods. */
48
+ updateName(username: string, newName: string): Promise<void>;
49
+ /** Replace the passphrase hash. Caller verifies the old one separately. */
50
+ setPassphrase(username: string, newPassphrase: string): Promise<void>;
51
+ /** Delete user record. Caller responsible for revoking tokens + reaping orch claims. */
52
+ deleteUser(username: string): Promise<void>;
53
+ /** Toggle admin flag. Caller enforces "can't demote yourself" / "last admin" rules. */
54
+ setAdmin(username: string, admin: boolean): Promise<void>;
55
+ /** Verify a candidate passphrase against the stored hash. */
56
+ verifyPassphrase(username: string, candidate: string): Promise<boolean>;
57
+ /** Whether ANY users exist. Used by the setup wizard to detect first-run. */
58
+ isEmpty(): Promise<boolean>;
59
+ }
60
+
61
+ // ── scrypt format ─────────────────────────────────────────────────────────
62
+ // Self-describing string so we can tune N over time without breaking old
63
+ // hashes. Format: `scrypt$<N>$<saltBase64>$<hashBase64>`.
64
+ //
65
+ // N=2^17 (131072) is the modern OWASP recommendation for interactive auth
66
+ // (~100ms on commodity hardware). r=8, p=1 are scrypt's standard tuning.
67
+ const SCRYPT_N = 1 << 17;
68
+ const SCRYPT_R = 8;
69
+ const SCRYPT_P = 1;
70
+ const SCRYPT_KEYLEN = 64;
71
+ const SALT_BYTES = 16;
72
+ // Memory required by scrypt is ≈ 128 * N * r bytes; for N=2^17 r=8 that's
73
+ // ~128 MB. Node's default maxmem is 32 MB, so we raise it explicitly.
74
+ const SCRYPT_MAXMEM = 256 * 1024 * 1024;
75
+
76
+ function hashPassphrase(plain: string): string {
77
+ const salt = randomBytes(SALT_BYTES);
78
+ const hash = scryptSync(plain, salt, SCRYPT_KEYLEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P, maxmem: SCRYPT_MAXMEM });
79
+ return `scrypt$${SCRYPT_N}$${salt.toString('base64')}$${hash.toString('base64')}`;
80
+ }
81
+
82
+ function verifyHash(plain: string, stored: string): boolean {
83
+ const parts = stored.split('$');
84
+ if (parts.length !== 4 || parts[0] !== 'scrypt') return false;
85
+ const n = Number(parts[1]);
86
+ const salt = Buffer.from(parts[2]!, 'base64');
87
+ const expected = Buffer.from(parts[3]!, 'base64');
88
+ if (!Number.isInteger(n) || n <= 0 || salt.length === 0 || expected.length === 0) return false;
89
+ let candidate: Buffer;
90
+ try {
91
+ candidate = scryptSync(plain, salt, expected.length, { N: n, r: SCRYPT_R, p: SCRYPT_P, maxmem: SCRYPT_MAXMEM });
92
+ } catch {
93
+ return false;
94
+ }
95
+ if (candidate.length !== expected.length) return false;
96
+ return timingSafeEqual(candidate, expected);
97
+ }
98
+
99
+ // ── validation ────────────────────────────────────────────────────────────
100
+ const USERNAME_RE = /^[a-z0-9_-]+$/;
101
+ const USERNAME_MAX = 32;
102
+ const PASSPHRASE_MIN = 8;
103
+
104
+ export class UserValidationError extends Error {
105
+ constructor(message: string) { super(message); this.name = 'UserValidationError'; }
106
+ }
107
+
108
+ function validateUsername(username: string): void {
109
+ if (typeof username !== 'string' || username.length === 0) {
110
+ throw new UserValidationError('username required');
111
+ }
112
+ if (username.length > USERNAME_MAX) {
113
+ throw new UserValidationError(`username too long (max ${USERNAME_MAX})`);
114
+ }
115
+ if (!USERNAME_RE.test(username)) {
116
+ throw new UserValidationError('username must match [a-z0-9_-]+ (lowercase, no spaces)');
117
+ }
118
+ }
119
+
120
+ function validatePassphrase(passphrase: string): void {
121
+ if (typeof passphrase !== 'string' || passphrase.length < PASSPHRASE_MIN) {
122
+ throw new UserValidationError(`passphrase must be at least ${PASSPHRASE_MIN} characters`);
123
+ }
124
+ }
125
+
126
+ /**
127
+ * File-backed user store. Reads/writes /var/lib/llmux/users.json (or
128
+ * config.dataDir/users.json).
129
+ *
130
+ * Concurrency model: in-process mutex on writes (the daemon is single-process).
131
+ * Reads load + cache; writes invalidate the cache.
132
+ */
133
+ export class FileUserStore implements UserStore {
134
+ private cache: User[] | undefined;
135
+ private writeQueue: Promise<void> = Promise.resolve();
136
+
137
+ constructor(private storePath: string) {}
138
+
139
+ async createUser(input: CreateUserInput): Promise<User> {
140
+ validateUsername(input.username);
141
+ validatePassphrase(input.passphrase);
142
+ if (typeof input.name !== 'string' || input.name.trim().length === 0) {
143
+ throw new UserValidationError('name required');
144
+ }
145
+ return this.withLock(async () => {
146
+ const users = this.load();
147
+ if (users.some(u => u.username === input.username)) {
148
+ throw new UserValidationError(`username '${input.username}' already exists`);
149
+ }
150
+ const user: User = {
151
+ username: input.username,
152
+ name: input.name.trim(),
153
+ passphraseHash: hashPassphrase(input.passphrase),
154
+ admin: input.admin === true,
155
+ createdAt: new Date().toISOString(),
156
+ };
157
+ users.push(user);
158
+ this.save(users);
159
+ return user;
160
+ });
161
+ }
162
+
163
+ async getUser(username: string): Promise<User | undefined> {
164
+ return this.load().find(u => u.username === username);
165
+ }
166
+
167
+ async listUsers(): Promise<User[]> {
168
+ return [...this.load()];
169
+ }
170
+
171
+ async updateName(username: string, newName: string): Promise<void> {
172
+ if (typeof newName !== 'string' || newName.trim().length === 0) {
173
+ throw new UserValidationError('name required');
174
+ }
175
+ return this.withLock(async () => {
176
+ const users = this.load();
177
+ const u = users.find(x => x.username === username);
178
+ if (!u) throw new UserValidationError(`user '${username}' not found`);
179
+ u.name = newName.trim();
180
+ this.save(users);
181
+ });
182
+ }
183
+
184
+ async setPassphrase(username: string, newPassphrase: string): Promise<void> {
185
+ validatePassphrase(newPassphrase);
186
+ return this.withLock(async () => {
187
+ const users = this.load();
188
+ const u = users.find(x => x.username === username);
189
+ if (!u) throw new UserValidationError(`user '${username}' not found`);
190
+ u.passphraseHash = hashPassphrase(newPassphrase);
191
+ this.save(users);
192
+ });
193
+ }
194
+
195
+ async deleteUser(username: string): Promise<void> {
196
+ return this.withLock(async () => {
197
+ const users = this.load();
198
+ const idx = users.findIndex(u => u.username === username);
199
+ if (idx < 0) throw new UserValidationError(`user '${username}' not found`);
200
+ users.splice(idx, 1);
201
+ this.save(users);
202
+ });
203
+ }
204
+
205
+ async setAdmin(username: string, admin: boolean): Promise<void> {
206
+ return this.withLock(async () => {
207
+ const users = this.load();
208
+ const u = users.find(x => x.username === username);
209
+ if (!u) throw new UserValidationError(`user '${username}' not found`);
210
+ u.admin = admin === true;
211
+ this.save(users);
212
+ });
213
+ }
214
+
215
+ async verifyPassphrase(username: string, candidate: string): Promise<boolean> {
216
+ const u = this.load().find(x => x.username === username);
217
+ if (!u) return false;
218
+ return verifyHash(candidate, u.passphraseHash);
219
+ }
220
+
221
+ async isEmpty(): Promise<boolean> {
222
+ return this.load().length === 0;
223
+ }
224
+
225
+ // ── private ─────────────────────────────────────────────────────────────
226
+
227
+ private load(): User[] {
228
+ if (this.cache) return this.cache;
229
+ if (!existsSync(this.storePath)) {
230
+ this.cache = [];
231
+ return this.cache;
232
+ }
233
+ const raw = readFileSync(this.storePath, 'utf-8');
234
+ let parsed: unknown;
235
+ try {
236
+ parsed = JSON.parse(raw);
237
+ } catch (err) {
238
+ throw new Error(`users.json: invalid JSON at ${this.storePath}: ${(err as Error).message}`);
239
+ }
240
+ if (!Array.isArray(parsed)) {
241
+ throw new Error(`users.json: expected array at ${this.storePath}`);
242
+ }
243
+ this.cache = parsed as User[];
244
+ return this.cache;
245
+ }
246
+
247
+ private save(users: User[]): void {
248
+ mkdirSync(dirname(this.storePath), { recursive: true });
249
+ const tmp = this.storePath + '.tmp';
250
+ writeFileSync(tmp, JSON.stringify(users, null, 2) + '\n', { mode: 0o600 });
251
+ renameSync(tmp, this.storePath);
252
+ try { chmodSync(this.storePath, 0o600); } catch { /* best-effort */ }
253
+ this.cache = users;
254
+ }
255
+
256
+ /**
257
+ * Serialize writes through a single promise chain. The daemon is
258
+ * single-process so we don't need cross-process locking, but two
259
+ * concurrent createUser calls in the same process would otherwise
260
+ * race on load/save.
261
+ */
262
+ private withLock<T>(fn: () => Promise<T>): Promise<T> {
263
+ const run = this.writeQueue.then(fn);
264
+ this.writeQueue = run.then(() => undefined, () => undefined);
265
+ return run;
266
+ }
267
+ }