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.
- package/.env.example +28 -0
- package/CHANGELOG.md +10 -0
- package/LEGAL_NOTICE.md +39 -0
- package/LICENSE +21 -0
- package/README.md +524 -0
- package/package.json +90 -0
- package/packages/cli/package.json +13 -0
- package/packages/cli/src/commands/analytics.ts +157 -0
- package/packages/cli/src/commands/attach.ts +299 -0
- package/packages/cli/src/commands/audit.ts +50 -0
- package/packages/cli/src/commands/auth.ts +261 -0
- package/packages/cli/src/commands/daemon.ts +162 -0
- package/packages/cli/src/commands/doctor.ts +303 -0
- package/packages/cli/src/commands/env-set.ts +162 -0
- package/packages/cli/src/commands/hook-forward.ts +268 -0
- package/packages/cli/src/commands/keys.ts +77 -0
- package/packages/cli/src/commands/kill.ts +19 -0
- package/packages/cli/src/commands/logs.ts +419 -0
- package/packages/cli/src/commands/model.ts +172 -0
- package/packages/cli/src/commands/policy-set.ts +185 -0
- package/packages/cli/src/commands/policy.ts +227 -0
- package/packages/cli/src/commands/providers.ts +114 -0
- package/packages/cli/src/commands/resize.ts +34 -0
- package/packages/cli/src/commands/send.ts +184 -0
- package/packages/cli/src/commands/sessions.ts +202 -0
- package/packages/cli/src/commands/slash.ts +92 -0
- package/packages/cli/src/commands/start.ts +243 -0
- package/packages/cli/src/commands/stop.ts +19 -0
- package/packages/cli/src/commands/tail.ts +137 -0
- package/packages/cli/src/commands/workflow.ts +786 -0
- package/packages/cli/src/commands/workspace.ts +127 -0
- package/packages/cli/src/lib/api.ts +78 -0
- package/packages/cli/src/lib/args.ts +72 -0
- package/packages/cli/src/lib/format.ts +93 -0
- package/packages/cli/src/main.ts +563 -0
- package/packages/core/package.json +7 -0
- package/packages/core/src/config.ts +30 -0
- package/packages/core/src/errors.ts +38 -0
- package/packages/core/src/eventBus.ts +56 -0
- package/packages/core/src/eventBusAdapter.ts +143 -0
- package/packages/core/src/index.ts +10 -0
- package/packages/core/src/sqliteEventBus.ts +336 -0
- package/packages/core/src/types.ts +63 -0
- package/packages/daemon/package.json +11 -0
- package/packages/daemon/src/api/analyticsRoutes.ts +343 -0
- package/packages/daemon/src/api/authRoutes.ts +344 -0
- package/packages/daemon/src/api/bodyLimit.ts +133 -0
- package/packages/daemon/src/api/envSetRoutes.ts +170 -0
- package/packages/daemon/src/api/gitRoutes.ts +409 -0
- package/packages/daemon/src/api/hooks.ts +588 -0
- package/packages/daemon/src/api/inputPolicy.ts +249 -0
- package/packages/daemon/src/api/notificationRoutes.ts +532 -0
- package/packages/daemon/src/api/policyRoutes.ts +234 -0
- package/packages/daemon/src/api/policySetRoutes.ts +445 -0
- package/packages/daemon/src/api/routeUtils.ts +28 -0
- package/packages/daemon/src/api/routes.ts +1004 -0
- package/packages/daemon/src/api/server.ts +1388 -0
- package/packages/daemon/src/api/settingsRoutes.ts +367 -0
- package/packages/daemon/src/api/sqliteErrors.ts +47 -0
- package/packages/daemon/src/api/stt.ts +143 -0
- package/packages/daemon/src/api/terminalRoutes.ts +200 -0
- package/packages/daemon/src/api/validation.ts +287 -0
- package/packages/daemon/src/api/validationRoutes.ts +174 -0
- package/packages/daemon/src/api/workflowRoutes.ts +567 -0
- package/packages/daemon/src/api/workspaceRoutes.ts +151 -0
- package/packages/daemon/src/api/ws.ts +1588 -0
- package/packages/daemon/src/auth/apiRateLimiter.ts +73 -0
- package/packages/daemon/src/auth/authMiddleware.ts +305 -0
- package/packages/daemon/src/auth/authService.ts +496 -0
- package/packages/daemon/src/auth/rateLimiter.ts +137 -0
- package/packages/daemon/src/config/pricing.ts +79 -0
- package/packages/daemon/src/crypto/encryption.ts +196 -0
- package/packages/daemon/src/db/db.ts +2745 -0
- package/packages/daemon/src/db/index.ts +16 -0
- package/packages/daemon/src/db/migrations.ts +182 -0
- package/packages/daemon/src/db/policyDb.ts +349 -0
- package/packages/daemon/src/db/schema.sql +408 -0
- package/packages/daemon/src/db/workflowDb.ts +464 -0
- package/packages/daemon/src/git/gitUtils.ts +544 -0
- package/packages/daemon/src/index.ts +6 -0
- package/packages/daemon/src/main.ts +525 -0
- package/packages/daemon/src/notifications/pushNotifier.ts +369 -0
- package/packages/daemon/src/providers/codexAppServerScaffold.ts +49 -0
- package/packages/daemon/src/providers/registry.ts +111 -0
- package/packages/daemon/src/providers/types.ts +82 -0
- package/packages/daemon/src/sessions/auditLogger.ts +103 -0
- package/packages/daemon/src/sessions/policyEngine.ts +165 -0
- package/packages/daemon/src/sessions/policyMatcher.ts +114 -0
- package/packages/daemon/src/sessions/policyTypes.ts +94 -0
- package/packages/daemon/src/sessions/ptyProcess.ts +141 -0
- package/packages/daemon/src/sessions/sessionManager.ts +1770 -0
- package/packages/daemon/src/sessions/tmuxSession.ts +1073 -0
- package/packages/daemon/src/sessions/transcriptManager.ts +110 -0
- package/packages/daemon/src/sessions/transcriptParser.ts +149 -0
- package/packages/daemon/src/sessions/transcriptTailer.ts +214 -0
- package/packages/daemon/src/tracking/tokenTracker.ts +168 -0
- package/packages/daemon/src/workflows/contextManager.ts +83 -0
- package/packages/daemon/src/workflows/index.ts +31 -0
- package/packages/daemon/src/workflows/resultExtractor.ts +118 -0
- package/packages/daemon/src/workflows/waitConditionPoller.ts +131 -0
- package/packages/daemon/src/workflows/workflowParser.ts +217 -0
- package/packages/daemon/src/workflows/workflowRunner.ts +969 -0
- package/packages/daemon/src/workflows/workflowTypes.ts +188 -0
- package/packages/daemon/src/workflows/workflowValidator.ts +533 -0
- package/packages/providers/claude-code/package.json +11 -0
- package/packages/providers/claude-code/src/index.ts +7 -0
- package/packages/providers/claude-code/src/overlaySettings.ts +198 -0
- package/packages/providers/claude-code/src/provider.ts +311 -0
- package/packages/web/dist/android-chrome-192x192.png +0 -0
- package/packages/web/dist/android-chrome-512x512.png +0 -0
- package/packages/web/dist/apple-touch-icon.png +0 -0
- package/packages/web/dist/assets/AnalyticsPage-BIopKWRf.js +17 -0
- package/packages/web/dist/assets/PoliciesPage-CjdLN3dl.js +11 -0
- package/packages/web/dist/assets/SessionDetailPage-BtSA0V0M.js +179 -0
- package/packages/web/dist/assets/SettingsPage-Dbbz4Ca5.js +37 -0
- package/packages/web/dist/assets/WorkflowsPage-Dv6f3GgU.js +1 -0
- package/packages/web/dist/assets/chart-vendor-DlOHLaCG.js +49 -0
- package/packages/web/dist/assets/codicon-ngg6Pgfi.ttf +0 -0
- package/packages/web/dist/assets/css.worker-BvV5MPou.js +93 -0
- package/packages/web/dist/assets/editor.worker-CKy7Pnvo.js +26 -0
- package/packages/web/dist/assets/html.worker-BLJhxQJQ.js +470 -0
- package/packages/web/dist/assets/index-BbdhRfr2.css +1 -0
- package/packages/web/dist/assets/index-hgphORiw.js +204 -0
- package/packages/web/dist/assets/json.worker-usMZ-FED.js +58 -0
- package/packages/web/dist/assets/monaco-core-B_19GPAS.css +1 -0
- package/packages/web/dist/assets/monaco-core-DQ5Mk8AK.js +1234 -0
- package/packages/web/dist/assets/monaco-react-DfZNWvtW.js +11 -0
- package/packages/web/dist/assets/monacoSetup-DvBj52bT.js +1 -0
- package/packages/web/dist/assets/pencil-Dbczxz59.js +11 -0
- package/packages/web/dist/assets/react-vendor-B5MgMUHH.js +136 -0
- package/packages/web/dist/assets/refresh-cw-B0MGsYPL.js +6 -0
- package/packages/web/dist/assets/tabs-C8LsWiR5.js +1 -0
- package/packages/web/dist/assets/terminal-vendor-Cs8KPbV3.js +9 -0
- package/packages/web/dist/assets/terminal-vendor-LcAfv9l9.css +32 -0
- package/packages/web/dist/assets/trash-2-Btlg0d4l.js +6 -0
- package/packages/web/dist/assets/ts.worker-DGHjMaqB.js +67731 -0
- package/packages/web/dist/favicon.ico +0 -0
- package/packages/web/dist/icon.svg +1 -0
- package/packages/web/dist/index.html +29 -0
- package/packages/web/dist/manifest.json +29 -0
- package/packages/web/dist/og-image.png +0 -0
- package/packages/web/dist/originals/android-chrome-192x192.png +0 -0
- package/packages/web/dist/originals/android-chrome-512x512.png +0 -0
- package/packages/web/dist/originals/apple-touch-icon.png +0 -0
- package/packages/web/dist/originals/favicon.ico +0 -0
- package/packages/web/dist/piper.svg +1 -0
- package/packages/web/dist/sounds/codepiper-soft-chime.wav +0 -0
- package/packages/web/dist/sw.js +257 -0
- 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
|
+
}
|