@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,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
+ }