codepiper 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.
Files changed (149) hide show
  1. package/.env.example +28 -0
  2. package/CHANGELOG.md +10 -0
  3. package/LEGAL_NOTICE.md +39 -0
  4. package/LICENSE +21 -0
  5. package/README.md +524 -0
  6. package/package.json +90 -0
  7. package/packages/cli/package.json +13 -0
  8. package/packages/cli/src/commands/analytics.ts +157 -0
  9. package/packages/cli/src/commands/attach.ts +299 -0
  10. package/packages/cli/src/commands/audit.ts +50 -0
  11. package/packages/cli/src/commands/auth.ts +261 -0
  12. package/packages/cli/src/commands/daemon.ts +162 -0
  13. package/packages/cli/src/commands/doctor.ts +303 -0
  14. package/packages/cli/src/commands/env-set.ts +162 -0
  15. package/packages/cli/src/commands/hook-forward.ts +268 -0
  16. package/packages/cli/src/commands/keys.ts +77 -0
  17. package/packages/cli/src/commands/kill.ts +19 -0
  18. package/packages/cli/src/commands/logs.ts +419 -0
  19. package/packages/cli/src/commands/model.ts +172 -0
  20. package/packages/cli/src/commands/policy-set.ts +185 -0
  21. package/packages/cli/src/commands/policy.ts +227 -0
  22. package/packages/cli/src/commands/providers.ts +114 -0
  23. package/packages/cli/src/commands/resize.ts +34 -0
  24. package/packages/cli/src/commands/send.ts +184 -0
  25. package/packages/cli/src/commands/sessions.ts +202 -0
  26. package/packages/cli/src/commands/slash.ts +92 -0
  27. package/packages/cli/src/commands/start.ts +243 -0
  28. package/packages/cli/src/commands/stop.ts +19 -0
  29. package/packages/cli/src/commands/tail.ts +137 -0
  30. package/packages/cli/src/commands/workflow.ts +786 -0
  31. package/packages/cli/src/commands/workspace.ts +127 -0
  32. package/packages/cli/src/lib/api.ts +78 -0
  33. package/packages/cli/src/lib/args.ts +72 -0
  34. package/packages/cli/src/lib/format.ts +93 -0
  35. package/packages/cli/src/main.ts +563 -0
  36. package/packages/core/package.json +7 -0
  37. package/packages/core/src/config.ts +30 -0
  38. package/packages/core/src/errors.ts +38 -0
  39. package/packages/core/src/eventBus.ts +56 -0
  40. package/packages/core/src/eventBusAdapter.ts +143 -0
  41. package/packages/core/src/index.ts +10 -0
  42. package/packages/core/src/sqliteEventBus.ts +336 -0
  43. package/packages/core/src/types.ts +63 -0
  44. package/packages/daemon/package.json +11 -0
  45. package/packages/daemon/src/api/analyticsRoutes.ts +343 -0
  46. package/packages/daemon/src/api/authRoutes.ts +344 -0
  47. package/packages/daemon/src/api/bodyLimit.ts +133 -0
  48. package/packages/daemon/src/api/envSetRoutes.ts +170 -0
  49. package/packages/daemon/src/api/gitRoutes.ts +409 -0
  50. package/packages/daemon/src/api/hooks.ts +588 -0
  51. package/packages/daemon/src/api/inputPolicy.ts +249 -0
  52. package/packages/daemon/src/api/notificationRoutes.ts +532 -0
  53. package/packages/daemon/src/api/policyRoutes.ts +234 -0
  54. package/packages/daemon/src/api/policySetRoutes.ts +445 -0
  55. package/packages/daemon/src/api/routeUtils.ts +28 -0
  56. package/packages/daemon/src/api/routes.ts +1004 -0
  57. package/packages/daemon/src/api/server.ts +1388 -0
  58. package/packages/daemon/src/api/settingsRoutes.ts +367 -0
  59. package/packages/daemon/src/api/sqliteErrors.ts +47 -0
  60. package/packages/daemon/src/api/stt.ts +143 -0
  61. package/packages/daemon/src/api/terminalRoutes.ts +200 -0
  62. package/packages/daemon/src/api/validation.ts +287 -0
  63. package/packages/daemon/src/api/validationRoutes.ts +174 -0
  64. package/packages/daemon/src/api/workflowRoutes.ts +567 -0
  65. package/packages/daemon/src/api/workspaceRoutes.ts +151 -0
  66. package/packages/daemon/src/api/ws.ts +1588 -0
  67. package/packages/daemon/src/auth/apiRateLimiter.ts +73 -0
  68. package/packages/daemon/src/auth/authMiddleware.ts +305 -0
  69. package/packages/daemon/src/auth/authService.ts +496 -0
  70. package/packages/daemon/src/auth/rateLimiter.ts +137 -0
  71. package/packages/daemon/src/config/pricing.ts +79 -0
  72. package/packages/daemon/src/crypto/encryption.ts +196 -0
  73. package/packages/daemon/src/db/db.ts +2745 -0
  74. package/packages/daemon/src/db/index.ts +16 -0
  75. package/packages/daemon/src/db/migrations.ts +182 -0
  76. package/packages/daemon/src/db/policyDb.ts +349 -0
  77. package/packages/daemon/src/db/schema.sql +408 -0
  78. package/packages/daemon/src/db/workflowDb.ts +464 -0
  79. package/packages/daemon/src/git/gitUtils.ts +544 -0
  80. package/packages/daemon/src/index.ts +6 -0
  81. package/packages/daemon/src/main.ts +525 -0
  82. package/packages/daemon/src/notifications/pushNotifier.ts +369 -0
  83. package/packages/daemon/src/providers/codexAppServerScaffold.ts +49 -0
  84. package/packages/daemon/src/providers/registry.ts +111 -0
  85. package/packages/daemon/src/providers/types.ts +82 -0
  86. package/packages/daemon/src/sessions/auditLogger.ts +103 -0
  87. package/packages/daemon/src/sessions/policyEngine.ts +165 -0
  88. package/packages/daemon/src/sessions/policyMatcher.ts +114 -0
  89. package/packages/daemon/src/sessions/policyTypes.ts +94 -0
  90. package/packages/daemon/src/sessions/ptyProcess.ts +141 -0
  91. package/packages/daemon/src/sessions/sessionManager.ts +1770 -0
  92. package/packages/daemon/src/sessions/tmuxSession.ts +1073 -0
  93. package/packages/daemon/src/sessions/transcriptManager.ts +110 -0
  94. package/packages/daemon/src/sessions/transcriptParser.ts +149 -0
  95. package/packages/daemon/src/sessions/transcriptTailer.ts +214 -0
  96. package/packages/daemon/src/tracking/tokenTracker.ts +168 -0
  97. package/packages/daemon/src/workflows/contextManager.ts +83 -0
  98. package/packages/daemon/src/workflows/index.ts +31 -0
  99. package/packages/daemon/src/workflows/resultExtractor.ts +118 -0
  100. package/packages/daemon/src/workflows/waitConditionPoller.ts +131 -0
  101. package/packages/daemon/src/workflows/workflowParser.ts +217 -0
  102. package/packages/daemon/src/workflows/workflowRunner.ts +969 -0
  103. package/packages/daemon/src/workflows/workflowTypes.ts +188 -0
  104. package/packages/daemon/src/workflows/workflowValidator.ts +533 -0
  105. package/packages/providers/claude-code/package.json +11 -0
  106. package/packages/providers/claude-code/src/index.ts +7 -0
  107. package/packages/providers/claude-code/src/overlaySettings.ts +198 -0
  108. package/packages/providers/claude-code/src/provider.ts +311 -0
  109. package/packages/web/dist/android-chrome-192x192.png +0 -0
  110. package/packages/web/dist/android-chrome-512x512.png +0 -0
  111. package/packages/web/dist/apple-touch-icon.png +0 -0
  112. package/packages/web/dist/assets/AnalyticsPage-BIopKWRf.js +17 -0
  113. package/packages/web/dist/assets/PoliciesPage-CjdLN3dl.js +11 -0
  114. package/packages/web/dist/assets/SessionDetailPage-BtSA0V0M.js +179 -0
  115. package/packages/web/dist/assets/SettingsPage-Dbbz4Ca5.js +37 -0
  116. package/packages/web/dist/assets/WorkflowsPage-Dv6f3GgU.js +1 -0
  117. package/packages/web/dist/assets/chart-vendor-DlOHLaCG.js +49 -0
  118. package/packages/web/dist/assets/codicon-ngg6Pgfi.ttf +0 -0
  119. package/packages/web/dist/assets/css.worker-BvV5MPou.js +93 -0
  120. package/packages/web/dist/assets/editor.worker-CKy7Pnvo.js +26 -0
  121. package/packages/web/dist/assets/html.worker-BLJhxQJQ.js +470 -0
  122. package/packages/web/dist/assets/index-BbdhRfr2.css +1 -0
  123. package/packages/web/dist/assets/index-hgphORiw.js +204 -0
  124. package/packages/web/dist/assets/json.worker-usMZ-FED.js +58 -0
  125. package/packages/web/dist/assets/monaco-core-B_19GPAS.css +1 -0
  126. package/packages/web/dist/assets/monaco-core-DQ5Mk8AK.js +1234 -0
  127. package/packages/web/dist/assets/monaco-react-DfZNWvtW.js +11 -0
  128. package/packages/web/dist/assets/monacoSetup-DvBj52bT.js +1 -0
  129. package/packages/web/dist/assets/pencil-Dbczxz59.js +11 -0
  130. package/packages/web/dist/assets/react-vendor-B5MgMUHH.js +136 -0
  131. package/packages/web/dist/assets/refresh-cw-B0MGsYPL.js +6 -0
  132. package/packages/web/dist/assets/tabs-C8LsWiR5.js +1 -0
  133. package/packages/web/dist/assets/terminal-vendor-Cs8KPbV3.js +9 -0
  134. package/packages/web/dist/assets/terminal-vendor-LcAfv9l9.css +32 -0
  135. package/packages/web/dist/assets/trash-2-Btlg0d4l.js +6 -0
  136. package/packages/web/dist/assets/ts.worker-DGHjMaqB.js +67731 -0
  137. package/packages/web/dist/favicon.ico +0 -0
  138. package/packages/web/dist/icon.svg +1 -0
  139. package/packages/web/dist/index.html +29 -0
  140. package/packages/web/dist/manifest.json +29 -0
  141. package/packages/web/dist/og-image.png +0 -0
  142. package/packages/web/dist/originals/android-chrome-192x192.png +0 -0
  143. package/packages/web/dist/originals/android-chrome-512x512.png +0 -0
  144. package/packages/web/dist/originals/apple-touch-icon.png +0 -0
  145. package/packages/web/dist/originals/favicon.ico +0 -0
  146. package/packages/web/dist/piper.svg +1 -0
  147. package/packages/web/dist/sounds/codepiper-soft-chime.wav +0 -0
  148. package/packages/web/dist/sw.js +257 -0
  149. package/scripts/postinstall-link-workspaces.mjs +58 -0
@@ -0,0 +1,343 @@
1
+ /**
2
+ * Analytics API routes for web dashboard
3
+ *
4
+ * Shows token consumption, cache efficiency, activity patterns,
5
+ * and estimated equivalent API cost (useful for Max plan users
6
+ * to understand the value of their subscription).
7
+ */
8
+
9
+ import type { EventBus } from "@codepiper/core";
10
+ import { calculateCost, getPricingForModel } from "../config/pricing";
11
+ import type { Database } from "../db/db";
12
+ import type { AuditLogger } from "../sessions/auditLogger";
13
+ import type { PolicyEngine } from "../sessions/policyEngine";
14
+ import type { SessionManager } from "../sessions/sessionManager";
15
+
16
+ export interface RouteContext {
17
+ sessionManager: SessionManager;
18
+ db: Database;
19
+ eventBus: EventBus;
20
+ policyEngine: PolicyEngine;
21
+ auditLogger: AuditLogger;
22
+ }
23
+
24
+ interface TimeRange {
25
+ start: number;
26
+ end: number;
27
+ }
28
+
29
+ function parseTimeRange(url: URL): TimeRange {
30
+ const range = url.searchParams.get("range") || "7d";
31
+ const now = Date.now();
32
+
33
+ switch (range) {
34
+ case "today":
35
+ return {
36
+ start: new Date(new Date().setHours(0, 0, 0, 0)).getTime(),
37
+ end: now,
38
+ };
39
+ case "7d":
40
+ return { start: now - 7 * 24 * 60 * 60 * 1000, end: now };
41
+ case "30d":
42
+ return { start: now - 30 * 24 * 60 * 60 * 1000, end: now };
43
+ case "all":
44
+ return { start: 0, end: now };
45
+ default:
46
+ return { start: now - 7 * 24 * 60 * 60 * 1000, end: now };
47
+ }
48
+ }
49
+
50
+ /**
51
+ * GET /analytics/overview
52
+ */
53
+ export async function handleAnalyticsOverview(req: Request, ctx: RouteContext): Promise<Response> {
54
+ const url = new URL(req.url);
55
+ const { start, end } = parseTimeRange(url);
56
+
57
+ const sessionsCount = ctx.db.db
58
+ .prepare("SELECT COUNT(*) as count FROM sessions WHERE created_at >= ? AND created_at <= ?")
59
+ .get(start, end) as { count: number };
60
+
61
+ const activeSessions = ctx.db.db
62
+ .prepare(
63
+ "SELECT COUNT(*) as count FROM sessions WHERE status IN ('RUNNING','STARTING','NEEDS_INPUT','NEEDS_PERMISSION')"
64
+ )
65
+ .get() as { count: number };
66
+
67
+ const totalTokens = ctx.db.db
68
+ .prepare(
69
+ "SELECT COALESCE(SUM(total_tokens), 0) as tokens FROM token_usage WHERE timestamp >= ? AND timestamp <= ?"
70
+ )
71
+ .get(start, end) as { tokens: number };
72
+
73
+ const tokenBreakdown = ctx.db.db
74
+ .prepare(
75
+ `SELECT
76
+ COALESCE(SUM(prompt_tokens), 0) as inputTokens,
77
+ COALESCE(SUM(completion_tokens), 0) as outputTokens,
78
+ COALESCE(SUM(cache_read_input_tokens), 0) as cacheReadTokens,
79
+ COALESCE(SUM(cache_creation_input_tokens), 0) as cacheCreationTokens
80
+ FROM token_usage WHERE timestamp >= ? AND timestamp <= ?`
81
+ )
82
+ .get(start, end) as {
83
+ inputTokens: number;
84
+ outputTokens: number;
85
+ cacheReadTokens: number;
86
+ cacheCreationTokens: number;
87
+ };
88
+
89
+ // Total messages (user + assistant transcript events)
90
+ const totalMessages = ctx.db.db
91
+ .prepare(
92
+ `SELECT COUNT(*) as count FROM events
93
+ WHERE source = 'transcript' AND type IN ('user', 'assistant')
94
+ AND ts >= ? AND ts <= ?`
95
+ )
96
+ .get(start, end) as { count: number };
97
+
98
+ // Cache hit rate
99
+ const cacheStats = ctx.db.db
100
+ .prepare(
101
+ `SELECT
102
+ COALESCE(SUM(cache_read_input_tokens), 0) as cache_hits,
103
+ COALESCE(SUM(prompt_tokens + cache_creation_input_tokens + cache_read_input_tokens), 0) as total_input
104
+ FROM token_usage
105
+ WHERE timestamp >= ? AND timestamp <= ?`
106
+ )
107
+ .get(start, end) as { cache_hits: number; total_input: number };
108
+
109
+ const cacheHitRate =
110
+ cacheStats.total_input > 0 ? (cacheStats.cache_hits / cacheStats.total_input) * 100 : 0;
111
+
112
+ // Compute estimated equivalent API cost
113
+ const costRows = ctx.db.db
114
+ .prepare(
115
+ `SELECT model,
116
+ COALESCE(SUM(prompt_tokens), 0) as prompt,
117
+ COALESCE(SUM(completion_tokens), 0) as completion,
118
+ COALESCE(SUM(cache_creation_input_tokens), 0) as cache_creation,
119
+ COALESCE(SUM(cache_read_input_tokens), 0) as cache_read
120
+ FROM token_usage
121
+ WHERE timestamp >= ? AND timestamp <= ?
122
+ GROUP BY model`
123
+ )
124
+ .all(start, end) as Array<{
125
+ model: string;
126
+ prompt: number;
127
+ completion: number;
128
+ cache_creation: number;
129
+ cache_read: number;
130
+ }>;
131
+
132
+ let costEstimate = 0;
133
+ for (const row of costRows) {
134
+ const pricing = getPricingForModel(row.model);
135
+ costEstimate += calculateCost(
136
+ row.prompt,
137
+ row.completion,
138
+ row.cache_creation,
139
+ row.cache_read,
140
+ pricing
141
+ );
142
+ }
143
+
144
+ return Response.json({
145
+ sessionsCount: sessionsCount.count,
146
+ activeSessions: activeSessions.count,
147
+ totalTokens: totalTokens.tokens,
148
+ inputTokens: tokenBreakdown.inputTokens,
149
+ outputTokens: tokenBreakdown.outputTokens,
150
+ cacheReadTokens: tokenBreakdown.cacheReadTokens,
151
+ cacheCreationTokens: tokenBreakdown.cacheCreationTokens,
152
+ totalMessages: totalMessages.count,
153
+ cacheHitRate: Math.round(cacheHitRate * 10) / 10,
154
+ costEstimate: Math.round(costEstimate * 100) / 100,
155
+ });
156
+ }
157
+
158
+ /**
159
+ * GET /analytics/activity-timeline
160
+ * Messages per day (user + assistant)
161
+ */
162
+ export async function handleActivityTimeline(req: Request, ctx: RouteContext): Promise<Response> {
163
+ const url = new URL(req.url);
164
+ const { start, end } = parseTimeRange(url);
165
+
166
+ const data = ctx.db.db
167
+ .prepare(
168
+ `SELECT
169
+ DATE(ts / 1000, 'unixepoch') as date,
170
+ SUM(CASE WHEN type = 'user' THEN 1 ELSE 0 END) as user_messages,
171
+ SUM(CASE WHEN type = 'assistant' THEN 1 ELSE 0 END) as assistant_messages,
172
+ COUNT(*) as total
173
+ FROM events
174
+ WHERE source = 'transcript' AND type IN ('user', 'assistant')
175
+ AND ts >= ? AND ts <= ?
176
+ GROUP BY date
177
+ ORDER BY date`
178
+ )
179
+ .all(start, end);
180
+
181
+ return Response.json(data);
182
+ }
183
+
184
+ /**
185
+ * GET /analytics/tokens-by-model
186
+ * Token distribution by model
187
+ */
188
+ export async function handleTokensByModel(req: Request, ctx: RouteContext): Promise<Response> {
189
+ const url = new URL(req.url);
190
+ const { start, end } = parseTimeRange(url);
191
+
192
+ const rows = ctx.db.db
193
+ .prepare(
194
+ `SELECT
195
+ model,
196
+ SUM(total_tokens) as tokens,
197
+ SUM(prompt_tokens) as prompt_tokens,
198
+ SUM(completion_tokens) as completion_tokens,
199
+ SUM(cache_read_input_tokens) as cache_read,
200
+ SUM(cache_creation_input_tokens) as cache_creation,
201
+ COUNT(*) as requests
202
+ FROM token_usage
203
+ WHERE timestamp >= ? AND timestamp <= ?
204
+ GROUP BY model
205
+ ORDER BY tokens DESC`
206
+ )
207
+ .all(start, end) as Array<{
208
+ model: string;
209
+ tokens: number;
210
+ prompt_tokens: number;
211
+ completion_tokens: number;
212
+ cache_read: number;
213
+ cache_creation: number;
214
+ requests: number;
215
+ }>;
216
+
217
+ const data = rows.map((row) => {
218
+ const pricing = getPricingForModel(row.model);
219
+ const cost = calculateCost(
220
+ row.prompt_tokens,
221
+ row.completion_tokens,
222
+ row.cache_creation,
223
+ row.cache_read,
224
+ pricing
225
+ );
226
+ return { ...row, costEstimate: Math.round(cost * 100) / 100 };
227
+ });
228
+
229
+ return Response.json(data);
230
+ }
231
+
232
+ /**
233
+ * GET /analytics/token-usage
234
+ * Token usage breakdown over time
235
+ */
236
+ export async function handleTokenUsage(req: Request, ctx: RouteContext): Promise<Response> {
237
+ const url = new URL(req.url);
238
+ const { start, end } = parseTimeRange(url);
239
+
240
+ const data = ctx.db.db
241
+ .prepare(
242
+ `SELECT
243
+ DATE(timestamp / 1000, 'unixepoch') as date,
244
+ COALESCE(SUM(prompt_tokens), 0) as prompt,
245
+ COALESCE(SUM(completion_tokens), 0) as completion,
246
+ COALESCE(SUM(cache_creation_input_tokens), 0) as cacheCreation,
247
+ COALESCE(SUM(cache_read_input_tokens), 0) as cacheRead
248
+ FROM token_usage
249
+ WHERE timestamp >= ? AND timestamp <= ?
250
+ GROUP BY date
251
+ ORDER BY date`
252
+ )
253
+ .all(start, end);
254
+
255
+ return Response.json(data);
256
+ }
257
+
258
+ /**
259
+ * GET /analytics/sessions-by-provider
260
+ */
261
+ export async function handleSessionsByProvider(req: Request, ctx: RouteContext): Promise<Response> {
262
+ const url = new URL(req.url);
263
+ const { start, end } = parseTimeRange(url);
264
+
265
+ const data = ctx.db.db
266
+ .prepare(
267
+ `SELECT provider, COUNT(*) as count
268
+ FROM sessions
269
+ WHERE created_at >= ? AND created_at <= ?
270
+ GROUP BY provider
271
+ ORDER BY count DESC`
272
+ )
273
+ .all(start, end);
274
+
275
+ return Response.json(data);
276
+ }
277
+
278
+ /**
279
+ * GET /analytics/tool-usage
280
+ * Top tools used across sessions
281
+ */
282
+ export async function handleToolUsage(req: Request, ctx: RouteContext): Promise<Response> {
283
+ const url = new URL(req.url);
284
+ const { start, end } = parseTimeRange(url);
285
+
286
+ // Tool uses are stored as assistant events with content arrays containing tool_use blocks.
287
+ // We extract tool names from the payload JSON.
288
+ const rows = ctx.db.db
289
+ .prepare(
290
+ `SELECT payload_json FROM events
291
+ WHERE source = 'transcript' AND type = 'assistant'
292
+ AND ts >= ? AND ts <= ?`
293
+ )
294
+ .all(start, end) as Array<{ payload_json: string }>;
295
+
296
+ const toolCounts: Record<string, number> = {};
297
+
298
+ for (const row of rows) {
299
+ try {
300
+ const parsed = JSON.parse(row.payload_json);
301
+ const content = parsed.message?.content;
302
+ if (Array.isArray(content)) {
303
+ for (const block of content) {
304
+ if (block.type === "tool_use" && block.name) {
305
+ toolCounts[block.name] = (toolCounts[block.name] || 0) + 1;
306
+ }
307
+ }
308
+ }
309
+ } catch {
310
+ // skip
311
+ }
312
+ }
313
+
314
+ const data = Object.entries(toolCounts)
315
+ .map(([tool, count]) => ({ tool, count }))
316
+ .sort((a, b) => b.count - a.count)
317
+ .slice(0, 15);
318
+
319
+ return Response.json(data);
320
+ }
321
+
322
+ /**
323
+ * GET /analytics/policy-decisions
324
+ */
325
+ export async function handlePolicyDecisions(req: Request, ctx: RouteContext): Promise<Response> {
326
+ const url = new URL(req.url);
327
+ const { start, end } = parseTimeRange(url);
328
+
329
+ const data = ctx.db.db
330
+ .prepare(
331
+ `SELECT
332
+ DATE(timestamp / 1000, 'unixepoch') as date,
333
+ decision,
334
+ COUNT(*) as count
335
+ FROM policy_decisions
336
+ WHERE timestamp >= ? AND timestamp <= ?
337
+ GROUP BY date, decision
338
+ ORDER BY date`
339
+ )
340
+ .all(start, end);
341
+
342
+ return Response.json(data);
343
+ }
@@ -0,0 +1,344 @@
1
+ /**
2
+ * Authentication API route handlers.
3
+ */
4
+
5
+ import {
6
+ buildClearOnboardingCookie,
7
+ buildClearSessionCookie,
8
+ buildOnboardingCookie,
9
+ buildSessionCookie,
10
+ extractAndHashOnboardingToken,
11
+ extractAndHashToken,
12
+ getClientIp,
13
+ shouldUseSecureCookies,
14
+ } from "../auth/authMiddleware";
15
+ import { AuthError } from "../auth/authService";
16
+ import type { RouteContext } from "./routes";
17
+
18
+ const SESSION_MAX_AGE_SECONDS = 7 * 24 * 60 * 60; // 7 days
19
+ const ONBOARDING_MAX_AGE_SECONDS = 24 * 60 * 60; // 24 hours
20
+
21
+ function getStringField(body: Record<string, unknown>, field: string): string | undefined {
22
+ const value = body[field];
23
+ return typeof value === "string" ? value : undefined;
24
+ }
25
+
26
+ function jsonWithCookies(
27
+ payload: Record<string, unknown>,
28
+ status: number,
29
+ cookies: string[]
30
+ ): Response {
31
+ const headers = new Headers({
32
+ "Content-Type": "application/json",
33
+ });
34
+ for (const cookie of cookies) {
35
+ headers.append("Set-Cookie", cookie);
36
+ }
37
+ return new Response(JSON.stringify(payload), { status, headers });
38
+ }
39
+
40
+ // ─── Public Routes ──────────────────────────────────────────────────
41
+
42
+ export async function handleAuthStatus(req: Request, ctx: RouteContext): Promise<Response> {
43
+ const authService = ctx.authService;
44
+ if (!authService) {
45
+ // Auth not configured — report disabled, NOT authenticated
46
+ return Response.json({
47
+ setupRequired: false,
48
+ mfaEnabled: false,
49
+ authenticated: false,
50
+ authEnabled: false,
51
+ });
52
+ }
53
+
54
+ const setupRequired = authService.isSetupRequired();
55
+ const mfaSetupRequired = !setupRequired && authService.isMfaSetupPending();
56
+ const tokenHash = extractAndHashToken(req);
57
+ const authenticated = tokenHash ? authService.validateSession(tokenHash) : false;
58
+
59
+ let mfaEnabled = false;
60
+ if (!setupRequired) {
61
+ const config = ctx.db.getAuthConfig();
62
+ mfaEnabled = config?.totpEnabled ?? false;
63
+ }
64
+
65
+ return Response.json({ setupRequired, mfaEnabled, mfaSetupRequired, authenticated });
66
+ }
67
+
68
+ export async function handleAuthSetup(req: Request, ctx: RouteContext): Promise<Response> {
69
+ const authService = ctx.authService;
70
+ if (!authService) {
71
+ return Response.json({ error: "Auth not configured" }, { status: 500 });
72
+ }
73
+
74
+ if (!authService.isSetupRequired()) {
75
+ return Response.json({ error: "Password is already configured" }, { status: 400 });
76
+ }
77
+
78
+ try {
79
+ const body = (await req.json()) as Record<string, unknown>;
80
+ const password = getStringField(body, "password");
81
+ if (!password) {
82
+ return Response.json({ error: "Password is required" }, { status: 400 });
83
+ }
84
+
85
+ const ip = getClientIp(req);
86
+ const ua = req.headers.get("User-Agent");
87
+ const result = await authService.setupPassword(password, ip, ua);
88
+ const secureCookie = shouldUseSecureCookies(req);
89
+
90
+ return jsonWithCookies({ ok: true, mfaSetupRequired: true }, 200, [
91
+ buildOnboardingCookie(result.onboardingToken, ONBOARDING_MAX_AGE_SECONDS, secureCookie),
92
+ buildClearSessionCookie(secureCookie),
93
+ ]);
94
+ } catch (error) {
95
+ if (error instanceof AuthError) {
96
+ return Response.json({ error: error.message, code: error.code }, { status: 400 });
97
+ }
98
+ throw error;
99
+ }
100
+ }
101
+
102
+ export async function handleAuthLogin(req: Request, ctx: RouteContext): Promise<Response> {
103
+ const authService = ctx.authService;
104
+ const rateLimiter = ctx.rateLimiter;
105
+ if (!authService) {
106
+ return Response.json({ error: "Auth not configured" }, { status: 500 });
107
+ }
108
+
109
+ const ip = getClientIp(req);
110
+ const secureCookie = shouldUseSecureCookies(req);
111
+
112
+ // Rate limit check
113
+ if (rateLimiter) {
114
+ const check = rateLimiter.check(ip);
115
+ if (!check.allowed) {
116
+ const retryAfter = Math.ceil((check.retryAfterMs || 0) / 1000);
117
+ return new Response(JSON.stringify({ error: "Too many login attempts", retryAfter }), {
118
+ status: 429,
119
+ headers: {
120
+ "Content-Type": "application/json",
121
+ "Retry-After": String(retryAfter),
122
+ },
123
+ });
124
+ }
125
+ }
126
+
127
+ try {
128
+ const body = (await req.json()) as Record<string, unknown>;
129
+ const password = getStringField(body, "password");
130
+ const totpCode = getStringField(body, "totpCode");
131
+ if (!password) {
132
+ return Response.json({ error: "Password is required" }, { status: 400 });
133
+ }
134
+
135
+ const ua = req.headers.get("User-Agent");
136
+ const result = await authService.login(password, totpCode, ip, ua);
137
+
138
+ if ("mfaSetupRequired" in result) {
139
+ rateLimiter?.recordSuccess(ip);
140
+ return jsonWithCookies({ mfaSetupRequired: true }, 401, [
141
+ buildOnboardingCookie(result.onboardingToken, ONBOARDING_MAX_AGE_SECONDS, secureCookie),
142
+ buildClearSessionCookie(secureCookie),
143
+ ]);
144
+ }
145
+
146
+ if ("mfaRequired" in result) {
147
+ return Response.json({ mfaRequired: true }, { status: 401 });
148
+ }
149
+
150
+ // Success — reset rate limiter
151
+ rateLimiter?.recordSuccess(ip);
152
+
153
+ return new Response(JSON.stringify({ ok: true, token: result.token }), {
154
+ status: 200,
155
+ headers: {
156
+ "Content-Type": "application/json",
157
+ "Set-Cookie": buildSessionCookie(result.token, SESSION_MAX_AGE_SECONDS, secureCookie),
158
+ },
159
+ });
160
+ } catch (error) {
161
+ if (error instanceof AuthError) {
162
+ rateLimiter?.recordFailure(ip);
163
+ // Return generic message — don't reveal whether password or TOTP failed
164
+ if (error.code === "INVALID_CREDENTIALS" || error.code === "INVALID_TOTP") {
165
+ return Response.json(
166
+ { error: "Invalid credentials", code: "INVALID_CREDENTIALS" },
167
+ { status: 401 }
168
+ );
169
+ }
170
+ return Response.json({ error: error.message, code: error.code }, { status: 400 });
171
+ }
172
+ throw error;
173
+ }
174
+ }
175
+
176
+ // ─── Authenticated Routes ───────────────────────────────────────────
177
+
178
+ export async function handleAuthLogout(req: Request, ctx: RouteContext): Promise<Response> {
179
+ const authService = ctx.authService;
180
+ if (!authService) {
181
+ return Response.json({ error: "Auth not configured" }, { status: 500 });
182
+ }
183
+
184
+ const tokenHash = extractAndHashToken(req);
185
+ if (tokenHash) {
186
+ authService.revokeSession(tokenHash);
187
+ }
188
+ const secureCookie = shouldUseSecureCookies(req);
189
+
190
+ return jsonWithCookies({ ok: true }, 200, [
191
+ buildClearSessionCookie(secureCookie),
192
+ buildClearOnboardingCookie(secureCookie),
193
+ ]);
194
+ }
195
+
196
+ export async function handleAuthChangePassword(req: Request, ctx: RouteContext): Promise<Response> {
197
+ const authService = ctx.authService;
198
+ if (!authService) {
199
+ return Response.json({ error: "Auth not configured" }, { status: 500 });
200
+ }
201
+
202
+ try {
203
+ const body = (await req.json()) as Record<string, unknown>;
204
+ const currentPassword = getStringField(body, "currentPassword");
205
+ const newPassword = getStringField(body, "newPassword");
206
+ if (!(currentPassword && newPassword)) {
207
+ return Response.json(
208
+ { error: "Both currentPassword and newPassword are required" },
209
+ { status: 400 }
210
+ );
211
+ }
212
+
213
+ await authService.changePassword(currentPassword, newPassword);
214
+ const secureCookie = shouldUseSecureCookies(req);
215
+
216
+ return jsonWithCookies({ ok: true }, 200, [
217
+ buildClearSessionCookie(secureCookie),
218
+ buildClearOnboardingCookie(secureCookie),
219
+ ]);
220
+ } catch (error) {
221
+ if (error instanceof AuthError) {
222
+ return Response.json({ error: error.message, code: error.code }, { status: 400 });
223
+ }
224
+ throw error;
225
+ }
226
+ }
227
+
228
+ // ─── MFA Routes ─────────────────────────────────────────────────────
229
+
230
+ export async function handleMfaSetup(_req: Request, ctx: RouteContext): Promise<Response> {
231
+ const authService = ctx.authService;
232
+ if (!authService) {
233
+ return Response.json({ error: "Auth not configured" }, { status: 500 });
234
+ }
235
+
236
+ const result = await authService.generateTotpSetup();
237
+ return Response.json(result);
238
+ }
239
+
240
+ export async function handleMfaVerify(req: Request, ctx: RouteContext): Promise<Response> {
241
+ const authService = ctx.authService;
242
+ if (!authService) {
243
+ return Response.json({ error: "Auth not configured" }, { status: 500 });
244
+ }
245
+
246
+ try {
247
+ const body = (await req.json()) as Record<string, unknown>;
248
+ const totpCode = getStringField(body, "totpCode");
249
+ if (!totpCode) {
250
+ return Response.json({ error: "TOTP code is required" }, { status: 400 });
251
+ }
252
+
253
+ const onboardingTokenHash = extractAndHashOnboardingToken(req);
254
+ const isOnboardingRequest =
255
+ onboardingTokenHash !== null && authService.validateOnboardingToken(onboardingTokenHash);
256
+
257
+ if (isOnboardingRequest) {
258
+ const ip = getClientIp(req);
259
+ const ua = req.headers.get("User-Agent");
260
+ const result = await authService.completeOnboardingMfa(totpCode, ip, ua);
261
+ const secureCookie = shouldUseSecureCookies(req);
262
+ return jsonWithCookies({ recoveryCodes: result.recoveryCodes, token: result.token }, 200, [
263
+ buildSessionCookie(result.token, SESSION_MAX_AGE_SECONDS, secureCookie),
264
+ buildClearOnboardingCookie(secureCookie),
265
+ ]);
266
+ }
267
+
268
+ const result = await authService.verifyAndEnableTotp(totpCode);
269
+ return Response.json(result);
270
+ } catch (error) {
271
+ if (error instanceof AuthError) {
272
+ return Response.json({ error: error.message, code: error.code }, { status: 400 });
273
+ }
274
+ throw error;
275
+ }
276
+ }
277
+
278
+ // ─── Session Management Routes ──────────────────────────────────────
279
+
280
+ export async function handleAuthSessions(_req: Request, ctx: RouteContext): Promise<Response> {
281
+ const authService = ctx.authService;
282
+ if (!authService) {
283
+ return Response.json({ error: "Auth not configured" }, { status: 500 });
284
+ }
285
+
286
+ const sessions = authService.listSessions();
287
+ // Redact token hashes — only show metadata
288
+ const redacted = sessions.map((s) => ({
289
+ createdAt: s.createdAt,
290
+ expiresAt: s.expiresAt,
291
+ lastUsedAt: s.lastUsedAt,
292
+ ipAddress: s.ipAddress,
293
+ userAgent: s.userAgent,
294
+ }));
295
+
296
+ return Response.json({ sessions: redacted });
297
+ }
298
+
299
+ export async function handleAuthRevokeAll(req: Request, ctx: RouteContext): Promise<Response> {
300
+ const authService = ctx.authService;
301
+ if (!authService) {
302
+ return Response.json({ error: "Auth not configured" }, { status: 500 });
303
+ }
304
+
305
+ const currentTokenHash = extractAndHashToken(req);
306
+ authService.revokeAllSessions(currentTokenHash ?? undefined);
307
+
308
+ return Response.json({ ok: true });
309
+ }
310
+
311
+ // ─── CLI-Only Routes (Unix socket access only) ──────────────────────
312
+
313
+ export async function handleCliResetPassword(req: Request, ctx: RouteContext): Promise<Response> {
314
+ const authService = ctx.authService;
315
+ if (!authService) {
316
+ return Response.json({ error: "Auth not configured" }, { status: 500 });
317
+ }
318
+
319
+ try {
320
+ const body = (await req.json()) as Record<string, unknown>;
321
+ const password = getStringField(body, "password");
322
+ if (!password) {
323
+ return Response.json({ error: "Password is required" }, { status: 400 });
324
+ }
325
+
326
+ await authService.resetPassword(password);
327
+ return Response.json({ ok: true });
328
+ } catch (error) {
329
+ if (error instanceof AuthError) {
330
+ return Response.json({ error: error.message, code: error.code }, { status: 400 });
331
+ }
332
+ throw error;
333
+ }
334
+ }
335
+
336
+ export async function handleCliResetMfa(_req: Request, ctx: RouteContext): Promise<Response> {
337
+ const authService = ctx.authService;
338
+ if (!authService) {
339
+ return Response.json({ error: "Auth not configured" }, { status: 500 });
340
+ }
341
+
342
+ authService.resetMfa();
343
+ return Response.json({ ok: true });
344
+ }