@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.
- package/GUIDE-CLI.md +322 -0
- package/GUIDE-PROMPTS.md +118 -0
- package/LICENSE +21 -0
- package/README.md +402 -61
- package/bin/crosstalk.js +88 -54
- package/commands/agent.js +69 -0
- package/commands/auth.js +273 -0
- package/commands/channel.js +54 -44
- package/commands/chat.js +107 -71
- package/commands/daemon.js +120 -0
- package/commands/logs.js +108 -19
- package/commands/message.js +125 -0
- package/commands/server.js +153 -0
- package/commands/settings.js +49 -0
- package/commands/status.js +37 -13
- package/commands/token.js +136 -0
- package/commands/transport.js +270 -0
- package/commands/version.js +3 -3
- package/commands/workflow.js +234 -0
- package/deploy/crosstalk@.service +62 -0
- package/deploy/install.sh +82 -0
- package/lib/api-client.js +77 -22
- package/lib/credentials.js +207 -0
- package/lib/nativeServer.js +173 -0
- package/lib/resolve.js +101 -34
- package/package.json +27 -4
- package/src/activation.ts +104 -0
- package/src/api.ts +1716 -0
- package/src/auth/enforce.ts +68 -0
- package/src/auth/handlers.ts +266 -0
- package/src/auth/middleware.ts +132 -0
- package/src/auth/setup.ts +263 -0
- package/src/auth/tokens.ts +285 -0
- package/src/auth/users.ts +267 -0
- package/src/dispatch.ts +492 -0
- package/src/dispatchers.ts +91 -0
- package/src/filenames.ts +28 -0
- package/src/frontmatter.ts +26 -0
- package/src/init.ts +116 -0
- package/src/invoke.ts +201 -0
- package/src/log-buffer.ts +67 -0
- package/src/models.ts +283 -0
- package/src/resolve.ts +100 -0
- package/src/state.ts +190 -0
- package/src/stop.ts +37 -0
- package/src/transport.ts +243 -0
- package/src/web/auth-pages.ts +160 -0
- package/src/web/channels.ts +395 -0
- package/src/web/chat-page.ts +636 -0
- package/src/web/chat-pty.ts +254 -0
- package/src/web/dashboard.ts +129 -0
- package/src/web/layout.ts +237 -0
- package/src/web/stubs.ts +510 -0
- package/src/web/workflows.ts +490 -0
- package/src/workflow.ts +470 -0
- package/template/CLAUDE.md +10 -0
- package/template/CROSSTALK-VERSION +1 -0
- package/template/CROSSTALK.md +262 -0
- package/template/PROTOCOL.md +70 -0
- package/template/README.md +64 -0
- package/template/auth/.gitkeep +0 -0
- package/template/auth/README.md +224 -0
- package/template/data/crosstalk.yaml +196 -0
- package/template/gitignore +4 -0
- package/commands/down.js +0 -40
- package/commands/init.js +0 -243
- package/commands/pull.js +0 -22
- package/commands/replies.js +0 -40
- package/commands/restart.js +0 -29
- package/commands/rm.js +0 -109
- package/commands/run.js +0 -115
- package/commands/up.js +0 -135
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// First-run setup wizard.
|
|
2
|
+
//
|
|
3
|
+
// Trust context: SERVICE. The setup wizard runs in the daemon process
|
|
4
|
+
// (service user), gated by a one-time setup token printed to the daemon's
|
|
5
|
+
// terminal/journal at boot time when the user store is empty.
|
|
6
|
+
//
|
|
7
|
+
// Flow:
|
|
8
|
+
// 1. Daemon boots; if userStore.isEmpty() → SetupState.mint() prints
|
|
9
|
+
// the URL to stdout/journal AND writes the token to
|
|
10
|
+
// /var/run/llmux/setup-token so operators with shell access can
|
|
11
|
+
// recover it.
|
|
12
|
+
// 2. Every incoming request: setupGate() — if no users exist AND the
|
|
13
|
+
// request isn't to /setup/* or /api/setup/* → 503 with "setup needed."
|
|
14
|
+
// Token validation happens in the handler, not the gate.
|
|
15
|
+
// 3. /setup wizard collects name + username + passphrase
|
|
16
|
+
// 4. POST /api/setup validates + creates user + mints bootstrap token
|
|
17
|
+
// + invalidates the setup token
|
|
18
|
+
// 5. Subsequent requests go through normal auth
|
|
19
|
+
//
|
|
20
|
+
// References:
|
|
21
|
+
// V2-SYSTEM-AUTH-DESIGN.md § "Setup wizard — what invokes it (v2)"
|
|
22
|
+
// V2-SYSTEM-AUTH-DESIGN.md § "Build plan" — Phase 5
|
|
23
|
+
|
|
24
|
+
import { mkdirSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
25
|
+
import { dirname } from 'node:path';
|
|
26
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
27
|
+
|
|
28
|
+
import type { UserStore } from './users.js';
|
|
29
|
+
import { UserValidationError } from './users.js';
|
|
30
|
+
import type { TokenStore, MintTokenResult } from './tokens.js';
|
|
31
|
+
|
|
32
|
+
export interface SetupTokenRecord {
|
|
33
|
+
/** Plaintext — kept in memory only, printed to operator out-of-band. */
|
|
34
|
+
token: string;
|
|
35
|
+
createdAt: string;
|
|
36
|
+
/** Soft expiry; the daemon clears the token on successful setup OR on restart. */
|
|
37
|
+
expiresAt: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SetupSubmitInput {
|
|
41
|
+
/** From the URL ?token=... query param or hidden form field. */
|
|
42
|
+
setupToken: string;
|
|
43
|
+
name: string;
|
|
44
|
+
username: string;
|
|
45
|
+
passphrase: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface SetupSubmitResult {
|
|
49
|
+
ok: boolean;
|
|
50
|
+
/** On success: bootstrap token for the new admin user (returned once). */
|
|
51
|
+
bootstrapToken?: MintTokenResult;
|
|
52
|
+
error?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface GateResult {
|
|
56
|
+
allow: boolean;
|
|
57
|
+
status?: number;
|
|
58
|
+
body?: unknown;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24h soft expiry
|
|
62
|
+
const SETUP_TOKEN_BYTES = 24; // 192 bits → 32-char base64url
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Holds the active setup token for the daemon process. One instance per
|
|
66
|
+
* daemon — constructed in the boot path. Lifecycle:
|
|
67
|
+
*
|
|
68
|
+
* - mint() — boot calls this if userStore is empty. Writes plaintext
|
|
69
|
+
* to disk for shell-access recovery, returns the record so
|
|
70
|
+
* the boot path can format the URL + print the banner.
|
|
71
|
+
* - isValid() — handler calls this when a setup-token bearing request
|
|
72
|
+
* arrives. Constant-time compare.
|
|
73
|
+
* - clear() — called by handleSetupSubmit on success. Wipes in-memory
|
|
74
|
+
* state + removes the on-disk file.
|
|
75
|
+
*
|
|
76
|
+
* Token state is intentionally NOT persisted across daemon restarts —
|
|
77
|
+
* if the daemon restarts mid-setup, the operator re-runs setup with a
|
|
78
|
+
* fresh token. This is by design.
|
|
79
|
+
*/
|
|
80
|
+
export class SetupState {
|
|
81
|
+
private activeToken: string | undefined;
|
|
82
|
+
private mintedAt: Date | undefined;
|
|
83
|
+
private readonly ttlMs: number;
|
|
84
|
+
private readonly fileMode: number;
|
|
85
|
+
|
|
86
|
+
constructor(
|
|
87
|
+
private readonly tokenFilePath: string,
|
|
88
|
+
opts?: { ttlMs?: number; fileMode?: number },
|
|
89
|
+
) {
|
|
90
|
+
this.ttlMs = opts?.ttlMs ?? DEFAULT_TTL_MS;
|
|
91
|
+
this.fileMode = opts?.fileMode ?? 0o600;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
mint(): SetupTokenRecord {
|
|
95
|
+
const token = randomBytes(SETUP_TOKEN_BYTES).toString('base64url');
|
|
96
|
+
const now = new Date();
|
|
97
|
+
this.activeToken = token;
|
|
98
|
+
this.mintedAt = now;
|
|
99
|
+
|
|
100
|
+
mkdirSync(dirname(this.tokenFilePath), { recursive: true });
|
|
101
|
+
writeFileSync(this.tokenFilePath, token + '\n', { mode: this.fileMode });
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
token,
|
|
105
|
+
createdAt: now.toISOString(),
|
|
106
|
+
expiresAt: new Date(now.getTime() + this.ttlMs).toISOString(),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
isValid(presented: unknown): boolean {
|
|
111
|
+
if (!this.activeToken || typeof presented !== 'string') return false;
|
|
112
|
+
if (this.isExpired()) return false;
|
|
113
|
+
const a = Buffer.from(this.activeToken);
|
|
114
|
+
const b = Buffer.from(presented);
|
|
115
|
+
if (a.length !== b.length) return false;
|
|
116
|
+
return timingSafeEqual(a, b);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
clear(): void {
|
|
120
|
+
this.activeToken = undefined;
|
|
121
|
+
this.mintedAt = undefined;
|
|
122
|
+
try { unlinkSync(this.tokenFilePath); } catch { /* file may not exist; ignore */ }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
get hasActiveToken(): boolean {
|
|
126
|
+
return this.activeToken !== undefined && !this.isExpired();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Read the active token if one is present + unexpired. Used by the
|
|
130
|
+
* HTTP layer to include the token in setup-redirect URLs so the
|
|
131
|
+
* operator never has to fish it out of the daemon log. Returns
|
|
132
|
+
* undefined if there's no active token or it has expired. */
|
|
133
|
+
get currentToken(): string | undefined {
|
|
134
|
+
return this.hasActiveToken ? this.activeToken : undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private isExpired(): boolean {
|
|
138
|
+
if (!this.mintedAt) return true;
|
|
139
|
+
return Date.now() > this.mintedAt.getTime() + this.ttlMs;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Per-request gate. In first-run mode (no users exist), only /setup and
|
|
145
|
+
* /api/setup paths are allowed through; everything else gets 503.
|
|
146
|
+
* Token validation is the handler's responsibility — keeps the gate
|
|
147
|
+
* stateless and lets the HTTP layer add nuance (HTML vs JSON responses,
|
|
148
|
+
* static assets, etc.) without re-implementing token checks.
|
|
149
|
+
*/
|
|
150
|
+
export async function setupGate(
|
|
151
|
+
userStore: UserStore,
|
|
152
|
+
path: string,
|
|
153
|
+
): Promise<GateResult> {
|
|
154
|
+
if (!(await userStore.isEmpty())) return { allow: true };
|
|
155
|
+
if (
|
|
156
|
+
path === '/setup' || path.startsWith('/setup/') || path.startsWith('/setup?') ||
|
|
157
|
+
path === '/api/setup' || path.startsWith('/api/setup/') || path.startsWith('/api/setup?')
|
|
158
|
+
) {
|
|
159
|
+
return { allow: true };
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
allow: false,
|
|
163
|
+
status: 503,
|
|
164
|
+
body: {
|
|
165
|
+
error: 'setup needed',
|
|
166
|
+
hint: 'Visit /setup?token=... with the token printed by the daemon at boot (or read /var/run/llmux/setup-token).',
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Handle POST /api/setup. Validates token + input, creates the admin
|
|
173
|
+
* user, mints the bootstrap token, invalidates the setup token. Returns
|
|
174
|
+
* the bootstrap token (plaintext) ONCE for the wizard to display and
|
|
175
|
+
* set as a session cookie.
|
|
176
|
+
*/
|
|
177
|
+
export async function handleSetupSubmit(
|
|
178
|
+
input: SetupSubmitInput,
|
|
179
|
+
userStore: UserStore,
|
|
180
|
+
tokenStore: TokenStore,
|
|
181
|
+
setup: SetupState,
|
|
182
|
+
): Promise<SetupSubmitResult> {
|
|
183
|
+
if (!setup.isValid(input.setupToken)) {
|
|
184
|
+
return { ok: false, error: 'invalid or expired setup token' };
|
|
185
|
+
}
|
|
186
|
+
if (!(await userStore.isEmpty())) {
|
|
187
|
+
// Belt-and-suspenders: setupGate would already have routed this
|
|
188
|
+
// through normal auth, but if we got here somehow, refuse.
|
|
189
|
+
return { ok: false, error: 'setup already complete' };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
const user = await userStore.createUser({
|
|
194
|
+
username: input.username,
|
|
195
|
+
name: input.name,
|
|
196
|
+
passphrase: input.passphrase,
|
|
197
|
+
admin: true,
|
|
198
|
+
});
|
|
199
|
+
const bootstrapToken = await tokenStore.mint({
|
|
200
|
+
username: user.username,
|
|
201
|
+
name: `${user.name}'s first device`,
|
|
202
|
+
});
|
|
203
|
+
setup.clear();
|
|
204
|
+
return { ok: true, bootstrapToken };
|
|
205
|
+
} catch (err) {
|
|
206
|
+
return {
|
|
207
|
+
ok: false,
|
|
208
|
+
error: err instanceof UserValidationError ? err.message : `setup failed: ${(err as Error).message}`,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Non-interactive bootstrap from env vars. For Docker / CI / headless
|
|
215
|
+
* deploys — bypasses the setup token gate but still uses the user store
|
|
216
|
+
* + token store to enforce uniqueness and validation.
|
|
217
|
+
*
|
|
218
|
+
* Reads:
|
|
219
|
+
* LLMUX_INIT_USERNAME, LLMUX_INIT_NAME, LLMUX_INIT_PASSPHRASE
|
|
220
|
+
*
|
|
221
|
+
* Returns undefined if no env vars are set (caller falls back to setup
|
|
222
|
+
* wizard). Throws if env vars are partial or if users already exist.
|
|
223
|
+
*/
|
|
224
|
+
export async function bootstrapFromEnv(
|
|
225
|
+
userStore: UserStore,
|
|
226
|
+
tokenStore: TokenStore,
|
|
227
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
228
|
+
): Promise<MintTokenResult | undefined> {
|
|
229
|
+
const username = env['LLMUX_INIT_USERNAME'];
|
|
230
|
+
const name = env['LLMUX_INIT_NAME'];
|
|
231
|
+
const passphrase = env['LLMUX_INIT_PASSPHRASE'];
|
|
232
|
+
|
|
233
|
+
const anySet = username !== undefined || name !== undefined || passphrase !== undefined;
|
|
234
|
+
if (!anySet) return undefined;
|
|
235
|
+
|
|
236
|
+
if (!username || !name || !passphrase) {
|
|
237
|
+
throw new Error(
|
|
238
|
+
'bootstrapFromEnv: LLMUX_INIT_USERNAME, LLMUX_INIT_NAME, LLMUX_INIT_PASSPHRASE must all be set',
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
if (!(await userStore.isEmpty())) {
|
|
242
|
+
throw new Error('bootstrapFromEnv: refusing — users already exist');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const user = await userStore.createUser({ username, name, passphrase, admin: true });
|
|
246
|
+
return tokenStore.mint({
|
|
247
|
+
username: user.username,
|
|
248
|
+
name: `${user.name}'s bootstrap device`,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Plain-text setup invocation banner — what the daemon prints to stdout
|
|
254
|
+
* when it mints a setup token at boot. Format is stable for log scrapers.
|
|
255
|
+
*/
|
|
256
|
+
export function setupBanner(setupUrl: string): string {
|
|
257
|
+
const sep = '─'.repeat(63);
|
|
258
|
+
return `\n┌${sep}┐\n` +
|
|
259
|
+
`│ llmux: first-run setup needed.${' '.repeat(31)}│\n` +
|
|
260
|
+
`│ Visit: ${setupUrl.padEnd(54)}│\n` +
|
|
261
|
+
`│ Don't share this URL — it grants admin to whoever uses it. │\n` +
|
|
262
|
+
`└${sep}┘\n`;
|
|
263
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// Identity-bound tokens.
|
|
2
|
+
//
|
|
3
|
+
// Every token is owned by exactly one user. The daemon enforces:
|
|
4
|
+
// - API requests authenticated by this token operate as that user
|
|
5
|
+
// - orch send/reply/next/etc. require the `from:` / `alias` field to
|
|
6
|
+
// match the token's owning user (no impersonation across users)
|
|
7
|
+
//
|
|
8
|
+
// Trust context: SERVICE.
|
|
9
|
+
//
|
|
10
|
+
// References:
|
|
11
|
+
// V2-SYSTEM-AUTH-DESIGN.md § "Token lifecycle (v2)"
|
|
12
|
+
// V2-SYSTEM-AUTH-DESIGN.md § "Data plane (v2)" — tokens.json row
|
|
13
|
+
// V2-SYSTEM-AUTH-DESIGN.md § "Build plan" — Phase 4
|
|
14
|
+
|
|
15
|
+
import { existsSync, readFileSync, writeFileSync, renameSync, chmodSync, mkdirSync } from 'node:fs';
|
|
16
|
+
import { dirname } from 'node:path';
|
|
17
|
+
import { randomBytes, createHash, timingSafeEqual } from 'node:crypto';
|
|
18
|
+
|
|
19
|
+
export interface IdentityToken {
|
|
20
|
+
/** Server-side token id (URL-safe random). Stable; safe to log. */
|
|
21
|
+
tokenId: string;
|
|
22
|
+
/** SHA-256 hash of the secret (base64). Plaintext never persisted. */
|
|
23
|
+
secretHash: string;
|
|
24
|
+
/** The user this token authenticates as. Owning user enforces `from:`. */
|
|
25
|
+
username: string;
|
|
26
|
+
/** Human-readable label. e.g. "alice-iphone", "ci-pipeline". */
|
|
27
|
+
name: string;
|
|
28
|
+
/** ISO-8601. */
|
|
29
|
+
createdAt: string;
|
|
30
|
+
/** ISO-8601. Optional — tokens without expiry are long-lived by default. */
|
|
31
|
+
expiresAt?: string;
|
|
32
|
+
/** ISO-8601. Refreshed by the daemon on each successful validate. */
|
|
33
|
+
lastUsedAt?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface MintTokenInput {
|
|
37
|
+
username: string;
|
|
38
|
+
name: string;
|
|
39
|
+
/** Optional expiry as ISO-8601. */
|
|
40
|
+
expiresAt?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface MintTokenResult {
|
|
44
|
+
token: IdentityToken;
|
|
45
|
+
/** The plaintext wire secret (`sas_<tokenId>.<secret>`). RETURNED ONCE. */
|
|
46
|
+
plaintextSecret: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface TokenValidation {
|
|
50
|
+
ok: boolean;
|
|
51
|
+
token?: IdentityToken;
|
|
52
|
+
reason?: 'not-found' | 'expired' | 'malformed';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface TokenStore {
|
|
56
|
+
/** Create a new token; returns plaintext ONCE. */
|
|
57
|
+
mint(input: MintTokenInput): Promise<MintTokenResult>;
|
|
58
|
+
/** Look up by tokenId. Doesn't validate secret. */
|
|
59
|
+
get(tokenId: string): Promise<IdentityToken | undefined>;
|
|
60
|
+
/** List tokens (admin sees all; pass a username to filter). */
|
|
61
|
+
list(filterUsername?: string): Promise<IdentityToken[]>;
|
|
62
|
+
/** Revoke by tokenId. Idempotent — no-op if already gone. */
|
|
63
|
+
revoke(tokenId: string): Promise<void>;
|
|
64
|
+
/** Revoke all tokens owned by a user. Used on user deletion. */
|
|
65
|
+
revokeAllForUser(username: string): Promise<number>;
|
|
66
|
+
/** Rename a token's label. No-op if id missing. Returns the updated row,
|
|
67
|
+
* or undefined if no token matched the id. */
|
|
68
|
+
rename(tokenId: string, newName: string): Promise<IdentityToken | undefined>;
|
|
69
|
+
/** Validate a presented wire secret. On success, touches lastUsedAt. */
|
|
70
|
+
validate(presentedSecret: string): Promise<TokenValidation>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class TokenValidationError extends Error {
|
|
74
|
+
constructor(message: string) { super(message); this.name = 'TokenValidationError'; }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Wire format for presented secrets: `sas_<tokenId>.<plaintextSecret>`.
|
|
79
|
+
* The tokenId prefix lets us look up the row in O(1) without scanning
|
|
80
|
+
* every hash. Same shape as GitHub PATs (`ghp_<id>_<secret>`).
|
|
81
|
+
*/
|
|
82
|
+
export const TOKEN_PREFIX = 'sas_'; // matches v1.x for migration
|
|
83
|
+
export const TOKEN_WIRE_SEPARATOR = '.';
|
|
84
|
+
|
|
85
|
+
const TOKEN_ID_BYTES = 12; // 96 bits of randomness → 16-char base64url
|
|
86
|
+
const TOKEN_SECRET_BYTES = 32; // 256 bits of randomness → 43-char base64url
|
|
87
|
+
|
|
88
|
+
function newTokenId(): string {
|
|
89
|
+
return randomBytes(TOKEN_ID_BYTES).toString('base64url');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function newSecret(): string {
|
|
93
|
+
return randomBytes(TOKEN_SECRET_BYTES).toString('base64url');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** SHA-256 over the secret (base64). Tokens are 256-bit random so a fast
|
|
97
|
+
* hash is appropriate — brute force is infeasible without slowing every
|
|
98
|
+
* authenticated request to scrypt speeds. */
|
|
99
|
+
function hashSecret(secret: string): string {
|
|
100
|
+
return createHash('sha256').update(secret).digest('base64');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface WireParts { tokenId: string; secret: string; }
|
|
104
|
+
|
|
105
|
+
function parseWire(presented: string): WireParts | undefined {
|
|
106
|
+
if (typeof presented !== 'string') return undefined;
|
|
107
|
+
if (!presented.startsWith(TOKEN_PREFIX)) return undefined;
|
|
108
|
+
const body = presented.slice(TOKEN_PREFIX.length);
|
|
109
|
+
const sepIdx = body.indexOf(TOKEN_WIRE_SEPARATOR);
|
|
110
|
+
if (sepIdx <= 0 || sepIdx >= body.length - 1) return undefined;
|
|
111
|
+
return { tokenId: body.slice(0, sepIdx), secret: body.slice(sepIdx + 1) };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isExpired(token: IdentityToken, now: Date = new Date()): boolean {
|
|
115
|
+
if (!token.expiresAt) return false;
|
|
116
|
+
const expMs = Date.parse(token.expiresAt);
|
|
117
|
+
if (Number.isNaN(expMs)) return false;
|
|
118
|
+
return expMs <= now.getTime();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* File-backed token store. Reads/writes config.dataDir/tokens.json.
|
|
123
|
+
*
|
|
124
|
+
* Concurrency model matches FileUserStore — single-process daemon, in-
|
|
125
|
+
* process write mutex, in-memory cache.
|
|
126
|
+
*
|
|
127
|
+
* lastUsedAt persistence: updated on every successful validate. At the
|
|
128
|
+
* daemon's expected request rate this is fine; if it ever becomes a
|
|
129
|
+
* hotspot we can debounce writes to once-per-N-seconds-per-token.
|
|
130
|
+
*/
|
|
131
|
+
export class FileTokenStore implements TokenStore {
|
|
132
|
+
private cache: IdentityToken[] | undefined;
|
|
133
|
+
private writeQueue: Promise<void> = Promise.resolve();
|
|
134
|
+
|
|
135
|
+
constructor(private storePath: string) {}
|
|
136
|
+
|
|
137
|
+
async mint(input: MintTokenInput): Promise<MintTokenResult> {
|
|
138
|
+
if (typeof input.username !== 'string' || input.username.length === 0) {
|
|
139
|
+
throw new TokenValidationError('username required');
|
|
140
|
+
}
|
|
141
|
+
if (typeof input.name !== 'string' || input.name.trim().length === 0) {
|
|
142
|
+
throw new TokenValidationError('name required');
|
|
143
|
+
}
|
|
144
|
+
if (input.expiresAt !== undefined) {
|
|
145
|
+
const t = Date.parse(input.expiresAt);
|
|
146
|
+
if (Number.isNaN(t)) throw new TokenValidationError('expiresAt must be ISO-8601');
|
|
147
|
+
if (t <= Date.now()) throw new TokenValidationError('expiresAt must be in the future');
|
|
148
|
+
}
|
|
149
|
+
return this.withLock(async () => {
|
|
150
|
+
const tokens = this.load();
|
|
151
|
+
let tokenId: string;
|
|
152
|
+
do { tokenId = newTokenId(); }
|
|
153
|
+
while (tokens.some(t => t.tokenId === tokenId));
|
|
154
|
+
const secret = newSecret();
|
|
155
|
+
const token: IdentityToken = {
|
|
156
|
+
tokenId,
|
|
157
|
+
secretHash: hashSecret(secret),
|
|
158
|
+
username: input.username,
|
|
159
|
+
name: input.name.trim(),
|
|
160
|
+
createdAt: new Date().toISOString(),
|
|
161
|
+
...(input.expiresAt ? { expiresAt: input.expiresAt } : {}),
|
|
162
|
+
};
|
|
163
|
+
tokens.push(token);
|
|
164
|
+
this.save(tokens);
|
|
165
|
+
return {
|
|
166
|
+
token,
|
|
167
|
+
plaintextSecret: `${TOKEN_PREFIX}${tokenId}${TOKEN_WIRE_SEPARATOR}${secret}`,
|
|
168
|
+
};
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async get(tokenId: string): Promise<IdentityToken | undefined> {
|
|
173
|
+
return this.load().find(t => t.tokenId === tokenId);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async list(filterUsername?: string): Promise<IdentityToken[]> {
|
|
177
|
+
const all = this.load();
|
|
178
|
+
if (filterUsername === undefined) return [...all];
|
|
179
|
+
return all.filter(t => t.username === filterUsername);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async revoke(tokenId: string): Promise<void> {
|
|
183
|
+
return this.withLock(async () => {
|
|
184
|
+
const tokens = this.load();
|
|
185
|
+
const idx = tokens.findIndex(t => t.tokenId === tokenId);
|
|
186
|
+
if (idx < 0) return; // idempotent
|
|
187
|
+
tokens.splice(idx, 1);
|
|
188
|
+
this.save(tokens);
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async revokeAllForUser(username: string): Promise<number> {
|
|
193
|
+
return this.withLock(async () => {
|
|
194
|
+
const tokens = this.load();
|
|
195
|
+
const before = tokens.length;
|
|
196
|
+
const remaining = tokens.filter(t => t.username !== username);
|
|
197
|
+
if (remaining.length === before) return 0;
|
|
198
|
+
this.save(remaining);
|
|
199
|
+
return before - remaining.length;
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async rename(tokenId: string, newName: string): Promise<IdentityToken | undefined> {
|
|
204
|
+
if (typeof newName !== 'string' || newName.trim().length === 0) {
|
|
205
|
+
throw new TokenValidationError('name required');
|
|
206
|
+
}
|
|
207
|
+
return this.withLock(async () => {
|
|
208
|
+
const tokens = this.load();
|
|
209
|
+
const idx = tokens.findIndex(t => t.tokenId === tokenId);
|
|
210
|
+
if (idx < 0) return undefined;
|
|
211
|
+
tokens[idx]!.name = newName.trim();
|
|
212
|
+
this.save(tokens);
|
|
213
|
+
return tokens[idx];
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async validate(presented: string): Promise<TokenValidation> {
|
|
218
|
+
const parts = parseWire(presented);
|
|
219
|
+
if (!parts) return { ok: false, reason: 'malformed' };
|
|
220
|
+
const tokens = this.load();
|
|
221
|
+
const row = tokens.find(t => t.tokenId === parts.tokenId);
|
|
222
|
+
if (!row) return { ok: false, reason: 'not-found' };
|
|
223
|
+
|
|
224
|
+
const presentedHashBuf = Buffer.from(hashSecret(parts.secret), 'base64');
|
|
225
|
+
const storedHashBuf = Buffer.from(row.secretHash, 'base64');
|
|
226
|
+
if (presentedHashBuf.length !== storedHashBuf.length) {
|
|
227
|
+
return { ok: false, reason: 'not-found' };
|
|
228
|
+
}
|
|
229
|
+
if (!timingSafeEqual(presentedHashBuf, storedHashBuf)) {
|
|
230
|
+
return { ok: false, reason: 'not-found' };
|
|
231
|
+
}
|
|
232
|
+
if (isExpired(row)) return { ok: false, reason: 'expired', token: row };
|
|
233
|
+
|
|
234
|
+
// Touch lastUsedAt. Use the write queue so we don't race with mint/revoke.
|
|
235
|
+
const now = new Date().toISOString();
|
|
236
|
+
row.lastUsedAt = now;
|
|
237
|
+
void this.withLock(async () => {
|
|
238
|
+
const live = this.load();
|
|
239
|
+
const liveRow = live.find(t => t.tokenId === row.tokenId);
|
|
240
|
+
if (liveRow) {
|
|
241
|
+
liveRow.lastUsedAt = now;
|
|
242
|
+
this.save(live);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
return { ok: true, token: row };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ── private ─────────────────────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
private load(): IdentityToken[] {
|
|
252
|
+
if (this.cache) return this.cache;
|
|
253
|
+
if (!existsSync(this.storePath)) {
|
|
254
|
+
this.cache = [];
|
|
255
|
+
return this.cache;
|
|
256
|
+
}
|
|
257
|
+
const raw = readFileSync(this.storePath, 'utf-8');
|
|
258
|
+
let parsed: unknown;
|
|
259
|
+
try {
|
|
260
|
+
parsed = JSON.parse(raw);
|
|
261
|
+
} catch (err) {
|
|
262
|
+
throw new Error(`tokens.json: invalid JSON at ${this.storePath}: ${(err as Error).message}`);
|
|
263
|
+
}
|
|
264
|
+
if (!Array.isArray(parsed)) {
|
|
265
|
+
throw new Error(`tokens.json: expected array at ${this.storePath}`);
|
|
266
|
+
}
|
|
267
|
+
this.cache = parsed as IdentityToken[];
|
|
268
|
+
return this.cache;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private save(tokens: IdentityToken[]): void {
|
|
272
|
+
mkdirSync(dirname(this.storePath), { recursive: true });
|
|
273
|
+
const tmp = this.storePath + '.tmp';
|
|
274
|
+
writeFileSync(tmp, JSON.stringify(tokens, null, 2) + '\n', { mode: 0o600 });
|
|
275
|
+
renameSync(tmp, this.storePath);
|
|
276
|
+
try { chmodSync(this.storePath, 0o600); } catch { /* best-effort */ }
|
|
277
|
+
this.cache = tokens;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private withLock<T>(fn: () => Promise<T>): Promise<T> {
|
|
281
|
+
const run = this.writeQueue.then(fn);
|
|
282
|
+
this.writeQueue = run.then(() => undefined, () => undefined);
|
|
283
|
+
return run;
|
|
284
|
+
}
|
|
285
|
+
}
|