@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,68 @@
1
+ // API-level identity enforcement helpers for v2 endpoints.
2
+ //
3
+ // Per the v2 design, every orch action carries a `from:` / `alias` field
4
+ // that names the user it's being performed AS. The authenticated user
5
+ // (resolved by middleware.ts) MUST match this field — Alice can't post
6
+ // as Bob, even if she has a valid token.
7
+ //
8
+ // Admins do NOT bypass this — admin status lets you create/delete users
9
+ // and mint tokens for them, but it does NOT let you pose as them on the
10
+ // orch channel. That's a deliberate design choice (anti-impersonation).
11
+ //
12
+ // Trust context: SERVICE — called per request after authenticate().
13
+ //
14
+ // References:
15
+ // V2-SYSTEM-AUTH-DESIGN.md § "Identity-bound tokens" — no impersonation
16
+ // V2-SYSTEM-AUTH-DESIGN.md § "Build plan" — Phase 7
17
+
18
+ import type { User } from './users.js';
19
+ import type { HttpResult, ErrorBody } from './handlers.js';
20
+
21
+ export type EnforceOk = { ok: true };
22
+ export type EnforceFail = HttpResult<ErrorBody> & { ok: false };
23
+ export type EnforceResult = EnforceOk | EnforceFail;
24
+
25
+ const FORBIDDEN = (msg: string): EnforceFail => ({
26
+ ok: false,
27
+ status: 403,
28
+ body: { error: msg },
29
+ });
30
+
31
+ /**
32
+ * Per v2 design, the user's `username` IS their orch alias. Requests
33
+ * that target a specific alias (orch send `from:`, claim release `by:`,
34
+ * etc.) MUST match the authenticated user. Admin status confers NO
35
+ * bypass — admins cannot pose as other users on orch.
36
+ *
37
+ * Returns `{ ok: true }` if the alias matches. Otherwise an EnforceFail
38
+ * the HTTP layer can pass through directly.
39
+ *
40
+ * Pass `requestedAlias = undefined` if the endpoint doesn't take an
41
+ * explicit alias — this returns ok and the handler should use
42
+ * `user.username` as the implicit alias.
43
+ */
44
+ export function requireOwnAlias(user: User, requestedAlias: string | undefined): EnforceResult {
45
+ if (requestedAlias === undefined || requestedAlias === user.username) {
46
+ return { ok: true };
47
+ }
48
+ return FORBIDDEN(`alias '${requestedAlias}' does not belong to authenticated user '${user.username}'`);
49
+ }
50
+
51
+ /**
52
+ * Admin-gated endpoints (create/delete users, mint tokens for others,
53
+ * reset passphrases). Use this AFTER authenticate() succeeds.
54
+ */
55
+ export function requireAdmin(user: User): EnforceResult {
56
+ if (user.admin) return { ok: true };
57
+ return FORBIDDEN('admin only');
58
+ }
59
+
60
+ /**
61
+ * Endpoints that allow either the user themselves OR an admin (e.g.,
62
+ * viewing tokens, rotating own passphrase). The targetUsername is the
63
+ * resource owner the request is acting on.
64
+ */
65
+ export function requireSelfOrAdmin(user: User, targetUsername: string): EnforceResult {
66
+ if (user.username === targetUsername || user.admin) return { ok: true };
67
+ return FORBIDDEN(`access denied: not '${targetUsername}' and not admin`);
68
+ }
@@ -0,0 +1,266 @@
1
+ // /api/auth/login + /api/auth/logout endpoint logic.
2
+ //
3
+ // Framework-agnostic — handlers take parsed input + stores, return
4
+ // { status, body } that the HTTP layer maps to its response shape. The
5
+ // HTTP layer is responsible for: extracting the request body, calling
6
+ // the handler, writing the status + JSON response.
7
+ //
8
+ // Trust context: SERVICE.
9
+ //
10
+ // References:
11
+ // V2-SYSTEM-AUTH-DESIGN.md § "Auth flow (v2 client side)" — server side
12
+ // V2-SYSTEM-AUTH-DESIGN.md § "Build plan" — Phase 7
13
+
14
+ import type { UserStore, User, CreateUserInput } from './users.js';
15
+ import { UserValidationError } from './users.js';
16
+ import type { TokenStore, IdentityToken } from './tokens.js';
17
+
18
+ export interface HttpResult<TBody = unknown> {
19
+ status: number;
20
+ body: TBody;
21
+ }
22
+
23
+ export interface LoginRequest {
24
+ username: unknown;
25
+ passphrase: unknown;
26
+ deviceName?: unknown;
27
+ }
28
+
29
+ export interface LoginResponseOk {
30
+ token: string; // wire format: sas_<id>.<secret>
31
+ tokenName: string;
32
+ username: string;
33
+ }
34
+
35
+ export interface ErrorBody { error: string; }
36
+
37
+ /**
38
+ * POST /api/auth/login
39
+ *
40
+ * Validates username + passphrase against the user store, mints a fresh
41
+ * identity-bound token, returns the wire-form plaintext (ONCE).
42
+ *
43
+ * Status codes:
44
+ * 400 — request body missing required fields or wrong types
45
+ * 401 — username unknown OR passphrase wrong (returned same way to
46
+ * prevent username enumeration)
47
+ * 200 — { token, tokenName, username }
48
+ *
49
+ * Side effects: tokens.json gets one new row on success.
50
+ */
51
+ export async function handleLogin(
52
+ input: LoginRequest,
53
+ userStore: UserStore,
54
+ tokenStore: TokenStore,
55
+ ): Promise<HttpResult<LoginResponseOk | ErrorBody>> {
56
+ if (typeof input.username !== 'string' || typeof input.passphrase !== 'string') {
57
+ return { status: 400, body: { error: 'username and passphrase required' } };
58
+ }
59
+ const username = input.username;
60
+ const passphrase = input.passphrase;
61
+ const deviceName = typeof input.deviceName === 'string' && input.deviceName.trim().length > 0
62
+ ? input.deviceName.trim()
63
+ : `${username} device`;
64
+
65
+ // Constant-time-ish: verifyPassphrase returns false for unknown users
66
+ // (no separate exists check that would leak existence via response time).
67
+ const ok = await userStore.verifyPassphrase(username, passphrase);
68
+ if (!ok) {
69
+ return { status: 401, body: { error: 'invalid credentials' } };
70
+ }
71
+
72
+ const minted = await tokenStore.mint({ username, name: deviceName });
73
+ return {
74
+ status: 200,
75
+ body: {
76
+ token: minted.plaintextSecret,
77
+ tokenName: minted.token.name,
78
+ username,
79
+ },
80
+ };
81
+ }
82
+
83
+ /**
84
+ * POST /api/auth/logout
85
+ *
86
+ * Revokes the bearer token the caller authenticated with. Must be called
87
+ * AFTER the auth middleware so we already have the validated token row.
88
+ * Idempotent — re-logout on an already-revoked token still returns 200
89
+ * (the token can't be reused anyway).
90
+ *
91
+ * Note: revoke is per-token, not per-user. To log out everywhere, the
92
+ * caller iterates list({ username }) + revoke individually, or calls the
93
+ * admin revokeAllForUser path.
94
+ */
95
+ export async function handleLogout(
96
+ callerToken: IdentityToken,
97
+ tokenStore: TokenStore,
98
+ ): Promise<HttpResult<{ ok: true } | ErrorBody>> {
99
+ await tokenStore.revoke(callerToken.tokenId);
100
+ return { status: 200, body: { ok: true } };
101
+ }
102
+
103
+ // ── Account self-service handlers ──────────────────────────────────────────
104
+
105
+ export interface ProfileUpdateRequest { name: unknown; }
106
+
107
+ export async function handleProfileUpdate(
108
+ input: ProfileUpdateRequest,
109
+ caller: User,
110
+ userStore: UserStore,
111
+ ): Promise<HttpResult<{ ok: true; user: User } | ErrorBody>> {
112
+ if (typeof input.name !== 'string' || input.name.trim().length === 0) {
113
+ return { status: 400, body: { error: 'name required' } };
114
+ }
115
+ try {
116
+ await userStore.updateName(caller.username, input.name);
117
+ } catch (err) {
118
+ return wrapValidation(err);
119
+ }
120
+ const updated = await userStore.getUser(caller.username);
121
+ return { status: 200, body: { ok: true, user: updated! } };
122
+ }
123
+
124
+ export interface PassphraseChangeRequest {
125
+ oldPassphrase: unknown;
126
+ newPassphrase: unknown;
127
+ }
128
+
129
+ /**
130
+ * Change own passphrase. Verifies the old one (no admin bypass) — even
131
+ * the user themselves must prove the current one. Revokes ALL of the
132
+ * caller's tokens on success so any other devices have to re-login,
133
+ * which is the expected behavior after a passphrase rotation.
134
+ */
135
+ export async function handlePassphraseChange(
136
+ input: PassphraseChangeRequest,
137
+ caller: User,
138
+ userStore: UserStore,
139
+ tokenStore: TokenStore,
140
+ ): Promise<HttpResult<{ ok: true; revokedTokens: number } | ErrorBody>> {
141
+ if (typeof input.oldPassphrase !== 'string' || typeof input.newPassphrase !== 'string') {
142
+ return { status: 400, body: { error: 'oldPassphrase and newPassphrase required' } };
143
+ }
144
+ const ok = await userStore.verifyPassphrase(caller.username, input.oldPassphrase);
145
+ if (!ok) return { status: 401, body: { error: 'current passphrase incorrect' } };
146
+ try {
147
+ await userStore.setPassphrase(caller.username, input.newPassphrase);
148
+ } catch (err) {
149
+ return wrapValidation(err);
150
+ }
151
+ const revoked = await tokenStore.revokeAllForUser(caller.username);
152
+ return { status: 200, body: { ok: true, revokedTokens: revoked } };
153
+ }
154
+
155
+ /**
156
+ * Revoke a token by id. Caller must own the token OR be admin. Returns
157
+ * 404 if no such token; 403 if owner mismatch + caller not admin.
158
+ */
159
+ export async function handleRevokeToken(
160
+ tokenId: string,
161
+ caller: User,
162
+ tokenStore: TokenStore,
163
+ ): Promise<HttpResult<{ ok: true } | ErrorBody>> {
164
+ const row = await tokenStore.get(tokenId);
165
+ if (!row) return { status: 404, body: { error: 'token not found' } };
166
+ if (row.username !== caller.username && !caller.admin) {
167
+ return { status: 403, body: { error: 'cannot revoke tokens owned by another user' } };
168
+ }
169
+ await tokenStore.revoke(tokenId);
170
+ return { status: 200, body: { ok: true } };
171
+ }
172
+
173
+ // ── Admin user CRUD ────────────────────────────────────────────────────────
174
+
175
+ export interface AdminCreateUserRequest {
176
+ username: unknown;
177
+ name: unknown;
178
+ passphrase: unknown;
179
+ admin?: unknown;
180
+ }
181
+
182
+ export async function handleAdminCreateUser(
183
+ input: AdminCreateUserRequest,
184
+ userStore: UserStore,
185
+ ): Promise<HttpResult<{ ok: true; user: User } | ErrorBody>> {
186
+ if (typeof input.username !== 'string' || typeof input.name !== 'string' || typeof input.passphrase !== 'string') {
187
+ return { status: 400, body: { error: 'username, name, passphrase required' } };
188
+ }
189
+ const payload: CreateUserInput = {
190
+ username: input.username,
191
+ name: input.name,
192
+ passphrase: input.passphrase,
193
+ admin: input.admin === true,
194
+ };
195
+ try {
196
+ const user = await userStore.createUser(payload);
197
+ return { status: 200, body: { ok: true, user } };
198
+ } catch (err) {
199
+ return wrapValidation(err);
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Delete a user. Refuses to delete the caller (no foot-shooting) and the
205
+ * last admin (no lockout). Cascades to token revocation.
206
+ */
207
+ export async function handleAdminDeleteUser(
208
+ targetUsername: string,
209
+ caller: User,
210
+ userStore: UserStore,
211
+ tokenStore: TokenStore,
212
+ ): Promise<HttpResult<{ ok: true; revokedTokens: number } | ErrorBody>> {
213
+ if (targetUsername === caller.username) {
214
+ return { status: 400, body: { error: 'cannot delete your own account' } };
215
+ }
216
+ const target = await userStore.getUser(targetUsername);
217
+ if (!target) return { status: 404, body: { error: 'user not found' } };
218
+ if (target.admin) {
219
+ const all = await userStore.listUsers();
220
+ const adminCount = all.filter(u => u.admin).length;
221
+ if (adminCount <= 1) {
222
+ return { status: 400, body: { error: 'cannot delete the last admin' } };
223
+ }
224
+ }
225
+ try {
226
+ await userStore.deleteUser(targetUsername);
227
+ } catch (err) {
228
+ return wrapValidation(err);
229
+ }
230
+ const revoked = await tokenStore.revokeAllForUser(targetUsername);
231
+ return { status: 200, body: { ok: true, revokedTokens: revoked } };
232
+ }
233
+
234
+ export interface AdminResetPassphraseRequest { newPassphrase: unknown; }
235
+
236
+ /**
237
+ * Admin override — set another user's passphrase WITHOUT proving the old
238
+ * one. Revokes all of the target's tokens so they have to re-login with
239
+ * the new passphrase.
240
+ */
241
+ export async function handleAdminResetPassphrase(
242
+ targetUsername: string,
243
+ input: AdminResetPassphraseRequest,
244
+ userStore: UserStore,
245
+ tokenStore: TokenStore,
246
+ ): Promise<HttpResult<{ ok: true; revokedTokens: number } | ErrorBody>> {
247
+ if (typeof input.newPassphrase !== 'string') {
248
+ return { status: 400, body: { error: 'newPassphrase required' } };
249
+ }
250
+ const target = await userStore.getUser(targetUsername);
251
+ if (!target) return { status: 404, body: { error: 'user not found' } };
252
+ try {
253
+ await userStore.setPassphrase(targetUsername, input.newPassphrase);
254
+ } catch (err) {
255
+ return wrapValidation(err);
256
+ }
257
+ const revoked = await tokenStore.revokeAllForUser(targetUsername);
258
+ return { status: 200, body: { ok: true, revokedTokens: revoked } };
259
+ }
260
+
261
+ function wrapValidation(err: unknown): HttpResult<ErrorBody> {
262
+ if (err instanceof UserValidationError) {
263
+ return { status: 400, body: { error: err.message } };
264
+ }
265
+ return { status: 500, body: { error: `internal: ${(err as Error).message}` } };
266
+ }
@@ -0,0 +1,132 @@
1
+ // HTTP-framework-agnostic auth middleware for v2.
2
+ //
3
+ // Inputs are parsed primitives (bearer token string) so the same logic
4
+ // works behind raw http, fastify, hono, koa, etc. Outputs are structured
5
+ // results the HTTP layer maps to its response shape.
6
+ //
7
+ // Trust context: SERVICE.
8
+ //
9
+ // References:
10
+ // V2-SYSTEM-AUTH-DESIGN.md § "Per-request auth gate"
11
+ // V2-SYSTEM-AUTH-DESIGN.md § "Build plan" — Phase 7
12
+
13
+ import type { TokenStore, IdentityToken } from './tokens.js';
14
+ import type { UserStore, User } from './users.js';
15
+
16
+ export interface AuthContext {
17
+ tokenStore: TokenStore;
18
+ userStore: UserStore;
19
+ }
20
+
21
+ export interface AuthSuccess {
22
+ ok: true;
23
+ user: User;
24
+ token: IdentityToken;
25
+ }
26
+
27
+ export interface AuthFailure {
28
+ ok: false;
29
+ status: number;
30
+ body: { error: string };
31
+ }
32
+
33
+ export type AuthResult = AuthSuccess | AuthFailure;
34
+
35
+ const UNAUTHORIZED = (msg: string): AuthFailure => ({
36
+ ok: false,
37
+ status: 401,
38
+ body: { error: msg },
39
+ });
40
+
41
+ /**
42
+ * Extract `Bearer <token>` from an Authorization header value. Returns
43
+ * undefined if the header is missing or not in bearer form. Tolerant of
44
+ * case ('bearer', 'Bearer', 'BEARER') and extra whitespace.
45
+ */
46
+ export function extractBearer(authorizationHeader: string | undefined): string | undefined {
47
+ if (!authorizationHeader || typeof authorizationHeader !== 'string') return undefined;
48
+ const match = authorizationHeader.match(/^\s*bearer\s+(.+?)\s*$/i);
49
+ return match ? match[1] : undefined;
50
+ }
51
+
52
+ /**
53
+ * Authenticate a request given its bearer token. Validates the token,
54
+ * resolves the owning user, returns both. Failure modes:
55
+ * - no bearer → 401 missing
56
+ * - malformed wire / bad signature / not found → 401 invalid
57
+ * - expired → 401 expired
58
+ * - user behind token has been deleted → 401 stale + best-effort revoke
59
+ *
60
+ * Stale-token cleanup: if a token validates but its owning user is gone,
61
+ * the token row is orphaned (deleteUser doesn't reach into tokens to
62
+ * preserve store decoupling). We revoke it inline so the orphan doesn't
63
+ * linger; treat as 401.
64
+ */
65
+ export async function authenticate(
66
+ bearerToken: string | undefined,
67
+ ctx: AuthContext,
68
+ ): Promise<AuthResult> {
69
+ if (!bearerToken) return UNAUTHORIZED('missing bearer token');
70
+
71
+ const validation = await ctx.tokenStore.validate(bearerToken);
72
+ if (!validation.ok || !validation.token) {
73
+ if (validation.reason === 'expired') return UNAUTHORIZED('token expired');
74
+ return UNAUTHORIZED('invalid token');
75
+ }
76
+
77
+ const user = await ctx.userStore.getUser(validation.token.username);
78
+ if (!user) {
79
+ void ctx.tokenStore.revoke(validation.token.tokenId);
80
+ return UNAUTHORIZED('token owner no longer exists');
81
+ }
82
+
83
+ return { ok: true, user, token: validation.token };
84
+ }
85
+
86
+ /**
87
+ * Convenience: pull the Authorization header off a Node http IncomingMessage
88
+ * (or any object with a headers map) and authenticate. Saves callers from
89
+ * doing the header-name dance themselves. Also falls back to the
90
+ * `crosstalk_session` cookie so browser sessions work without JS having to
91
+ * juggle Authorization on every request.
92
+ */
93
+ export async function authenticateRequest(
94
+ req: { headers: Record<string, string | string[] | undefined> },
95
+ ctx: AuthContext,
96
+ ): Promise<AuthResult> {
97
+ const raw = req.headers['authorization'] ?? req.headers['Authorization'];
98
+ const header = Array.isArray(raw) ? raw[0] : raw;
99
+ const fromHeader = extractBearer(header);
100
+ if (fromHeader) return authenticate(fromHeader, ctx);
101
+
102
+ const cookieRaw = req.headers['cookie'];
103
+ const cookieHeader = Array.isArray(cookieRaw) ? cookieRaw.join('; ') : cookieRaw;
104
+ const fromCookie = extractSessionCookie(cookieHeader);
105
+ return authenticate(fromCookie, ctx);
106
+ }
107
+
108
+ /** Parse the `crosstalk_session` value out of a Cookie header. */
109
+ export function extractSessionCookie(cookieHeader: string | undefined): string | undefined {
110
+ if (!cookieHeader || typeof cookieHeader !== 'string') return undefined;
111
+ for (const part of cookieHeader.split(';')) {
112
+ const eq = part.indexOf('=');
113
+ if (eq < 0) continue;
114
+ const name = part.slice(0, eq).trim();
115
+ if (name === 'crosstalk_session') return decodeURIComponent(part.slice(eq + 1).trim());
116
+ }
117
+ return undefined;
118
+ }
119
+
120
+ /** Set-Cookie value for a freshly issued session token. HttpOnly + SameSite=Lax. */
121
+ export function sessionCookieFor(wireToken: string, opts?: { secure?: boolean }): string {
122
+ const flags = ['Path=/', 'HttpOnly', 'SameSite=Lax'];
123
+ if (opts?.secure) flags.push('Secure');
124
+ return `crosstalk_session=${encodeURIComponent(wireToken)}; ${flags.join('; ')}`;
125
+ }
126
+
127
+ /** Set-Cookie value that clears the session. */
128
+ export function clearSessionCookie(opts?: { secure?: boolean }): string {
129
+ const flags = ['Path=/', 'HttpOnly', 'SameSite=Lax', 'Max-Age=0'];
130
+ if (opts?.secure) flags.push('Secure');
131
+ return `crosstalk_session=; ${flags.join('; ')}`;
132
+ }