@upend/cli 0.1.2 → 0.1.5
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/README.md +39 -0
- package/bin/cli.ts +11 -0
- package/package.json +3 -2
- package/src/apps/users/index.html +227 -0
- package/src/commands/deploy.ts +17 -0
- package/src/commands/init.ts +64 -1
- package/src/commands/logs.ts +43 -0
- package/src/commands/ssh.ts +32 -0
- package/src/commands/status.ts +47 -0
- package/src/commands/workflows.ts +142 -0
- package/src/lib/middleware.ts +1 -1
- package/src/services/claude/index.ts +20 -20
- package/src/services/dashboard/public/index.html +178 -6
- package/src/services/gateway/auth-routes.ts +50 -4
- package/src/services/gateway/index.ts +206 -18
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { log } from "../lib/log";
|
|
2
|
+
import { exec } from "../lib/exec";
|
|
3
|
+
import { readdirSync, readFileSync } from "fs";
|
|
4
|
+
import { join, resolve } from "path";
|
|
5
|
+
|
|
6
|
+
type Workflow = {
|
|
7
|
+
name: string;
|
|
8
|
+
file: string;
|
|
9
|
+
cron: string | null;
|
|
10
|
+
description: string;
|
|
11
|
+
installed: boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export default async function workflows(args: string[]) {
|
|
15
|
+
const sub = args[0];
|
|
16
|
+
const projectDir = resolve(".");
|
|
17
|
+
const workflowsDir = join(projectDir, "workflows");
|
|
18
|
+
|
|
19
|
+
switch (sub) {
|
|
20
|
+
case "list":
|
|
21
|
+
case undefined:
|
|
22
|
+
await list(workflowsDir);
|
|
23
|
+
break;
|
|
24
|
+
case "run":
|
|
25
|
+
await run(workflowsDir, args[1]);
|
|
26
|
+
break;
|
|
27
|
+
case "install":
|
|
28
|
+
await install(workflowsDir);
|
|
29
|
+
break;
|
|
30
|
+
case "uninstall":
|
|
31
|
+
await uninstall();
|
|
32
|
+
break;
|
|
33
|
+
default:
|
|
34
|
+
// treat it as a workflow name to run
|
|
35
|
+
await run(workflowsDir, sub);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseWorkflows(dir: string): Workflow[] {
|
|
40
|
+
let files: string[];
|
|
41
|
+
try {
|
|
42
|
+
files = readdirSync(dir).filter(f => f.endsWith(".ts") || f.endsWith(".js"));
|
|
43
|
+
} catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return files.map(file => {
|
|
48
|
+
const content = readFileSync(join(dir, file), "utf-8");
|
|
49
|
+
const cronMatch = content.match(/\/\/\s*@cron\s+(.+)/);
|
|
50
|
+
const descMatch = content.match(/\/\/\s*@description\s+(.+)/);
|
|
51
|
+
const cron = cronMatch ? cronMatch[1].trim() : null;
|
|
52
|
+
const description = descMatch ? descMatch[1].trim() : "";
|
|
53
|
+
const name = file.replace(/\.(ts|js)$/, "");
|
|
54
|
+
return { name, file, cron, description, installed: false };
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function getCrontab(): Promise<string> {
|
|
59
|
+
const { stdout } = await exec(["crontab", "-l"], { silent: true });
|
|
60
|
+
return stdout;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function list(dir: string) {
|
|
64
|
+
const wfs = parseWorkflows(dir);
|
|
65
|
+
if (wfs.length === 0) {
|
|
66
|
+
log.info("no workflows found in workflows/");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const crontab = await getCrontab();
|
|
71
|
+
|
|
72
|
+
log.header("workflows");
|
|
73
|
+
for (const wf of wfs) {
|
|
74
|
+
const installed = crontab.includes(`workflows/${wf.file}`);
|
|
75
|
+
const status = installed ? "installed" : wf.cron ? "not installed" : "manual only";
|
|
76
|
+
const statusColor = installed ? "\x1b[32m" : "\x1b[90m";
|
|
77
|
+
console.log(` ${wf.name}`);
|
|
78
|
+
if (wf.description) console.log(` ${wf.description}`);
|
|
79
|
+
if (wf.cron) console.log(` cron: ${wf.cron} [${statusColor}${status}\x1b[0m]`);
|
|
80
|
+
else console.log(` [manual only]`);
|
|
81
|
+
console.log();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function run(dir: string, name?: string) {
|
|
86
|
+
if (!name) {
|
|
87
|
+
log.error("usage: upend workflows run <name>");
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const file = name.endsWith(".ts") || name.endsWith(".js") ? name : `${name}.ts`;
|
|
92
|
+
const path = join(dir, file);
|
|
93
|
+
|
|
94
|
+
log.info(`running ${file}...`);
|
|
95
|
+
const { stdout, stderr, exitCode } = await exec(["bun", path]);
|
|
96
|
+
if (stdout) console.log(stdout);
|
|
97
|
+
if (stderr) console.error(stderr);
|
|
98
|
+
|
|
99
|
+
if (exitCode === 0) {
|
|
100
|
+
log.success(`${file} complete`);
|
|
101
|
+
} else {
|
|
102
|
+
log.error(`${file} failed (exit ${exitCode})`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function install(dir: string) {
|
|
107
|
+
const wfs = parseWorkflows(dir).filter(w => w.cron);
|
|
108
|
+
if (wfs.length === 0) {
|
|
109
|
+
log.info("no workflows with @cron found");
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const crontab = await getCrontab();
|
|
114
|
+
const lines = crontab.split("\n").filter(l => !l.includes("# upend-workflow:"));
|
|
115
|
+
const projectDir = resolve(".");
|
|
116
|
+
|
|
117
|
+
for (const wf of wfs) {
|
|
118
|
+
lines.push(`${wf.cron} cd ${projectDir} && bun workflows/${wf.file} >> /tmp/upend-workflow-${wf.name}.log 2>&1 # upend-workflow: ${wf.name}`);
|
|
119
|
+
log.info(`installing ${wf.name}: ${wf.cron}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const newCrontab = lines.filter(Boolean).join("\n") + "\n";
|
|
123
|
+
const proc = Bun.spawn(["crontab", "-"], { stdin: "pipe" });
|
|
124
|
+
proc.stdin.write(newCrontab);
|
|
125
|
+
proc.stdin.end();
|
|
126
|
+
await proc.exited;
|
|
127
|
+
|
|
128
|
+
log.success(`${wfs.length} workflow(s) installed`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function uninstall() {
|
|
132
|
+
const crontab = await getCrontab();
|
|
133
|
+
const lines = crontab.split("\n").filter(l => !l.includes("# upend-workflow:"));
|
|
134
|
+
const newCrontab = lines.filter(Boolean).join("\n") + "\n";
|
|
135
|
+
|
|
136
|
+
const proc = Bun.spawn(["crontab", "-"], { stdin: "pipe" });
|
|
137
|
+
proc.stdin.write(newCrontab);
|
|
138
|
+
proc.stdin.end();
|
|
139
|
+
await proc.exited;
|
|
140
|
+
|
|
141
|
+
log.success("all upend workflows removed from crontab");
|
|
142
|
+
}
|
package/src/lib/middleware.ts
CHANGED
|
@@ -27,7 +27,7 @@ export const requireAuth = createMiddleware<{
|
|
|
27
27
|
const user = {
|
|
28
28
|
sub: payload.sub as string,
|
|
29
29
|
email: payload.email as string,
|
|
30
|
-
role: (payload.role as string) || "user",
|
|
30
|
+
role: (payload.app_role as string) || (payload.role as string) || "user",
|
|
31
31
|
};
|
|
32
32
|
console.log(`[auth] ${user.email} → ${method} ${path}`);
|
|
33
33
|
c.set("user", user);
|
|
@@ -59,8 +59,8 @@ app.post("/sessions", async (c) => {
|
|
|
59
59
|
|
|
60
60
|
const activeSessions = await sql`
|
|
61
61
|
SELECT es.*,
|
|
62
|
-
(SELECT sm.content FROM session_messages sm WHERE sm.session_id = es.id ORDER BY sm.created_at DESC LIMIT 1) as last_message
|
|
63
|
-
FROM editing_sessions es WHERE es.status = 'active' ORDER BY es.created_at DESC
|
|
62
|
+
(SELECT sm.content FROM upend.session_messages sm WHERE sm.session_id = es.id ORDER BY sm.created_at DESC LIMIT 1) as last_message
|
|
63
|
+
FROM upend.editing_sessions es WHERE es.status = 'active' ORDER BY es.created_at DESC
|
|
64
64
|
`;
|
|
65
65
|
|
|
66
66
|
if (activeSessions.length > 0 && !force) {
|
|
@@ -87,13 +87,13 @@ app.post("/sessions", async (c) => {
|
|
|
87
87
|
const claudeSessionId = crypto.randomUUID();
|
|
88
88
|
|
|
89
89
|
const [session] = await sql`
|
|
90
|
-
INSERT INTO editing_sessions (prompt, status, claude_session_id, snapshot_name, title, context)
|
|
90
|
+
INSERT INTO upend.editing_sessions (prompt, status, claude_session_id, snapshot_name, title, context)
|
|
91
91
|
VALUES (${prompt}, 'active', ${claudeSessionId}, ${sessionName}, ${title || null}, ${JSON.stringify({ root: worktree.path, worktree: sessionName, branch: worktree.branch })})
|
|
92
92
|
RETURNING *
|
|
93
93
|
`;
|
|
94
94
|
|
|
95
95
|
const [msg] = await sql`
|
|
96
|
-
INSERT INTO session_messages (session_id, role, content, status)
|
|
96
|
+
INSERT INTO upend.session_messages (session_id, role, content, status)
|
|
97
97
|
VALUES (${session.id}, 'user', ${prompt}, 'pending')
|
|
98
98
|
RETURNING *
|
|
99
99
|
`;
|
|
@@ -108,17 +108,17 @@ app.post("/sessions/:id/messages", async (c) => {
|
|
|
108
108
|
const { prompt } = await c.req.json();
|
|
109
109
|
if (!prompt) return c.json({ error: "prompt is required" }, 400);
|
|
110
110
|
|
|
111
|
-
const [session] = await sql`SELECT * FROM editing_sessions WHERE id = ${sessionId}`;
|
|
111
|
+
const [session] = await sql`SELECT * FROM upend.editing_sessions WHERE id = ${sessionId}`;
|
|
112
112
|
if (!session) return c.json({ error: "session not found" }, 404);
|
|
113
113
|
if (session.status !== "active") return c.json({ error: `session is ${session.status}` }, 400);
|
|
114
114
|
|
|
115
115
|
const [running] = await sql`
|
|
116
|
-
SELECT id FROM session_messages WHERE session_id = ${sessionId} AND status = 'running'
|
|
116
|
+
SELECT id FROM upend.session_messages WHERE session_id = ${sessionId} AND status = 'running'
|
|
117
117
|
`;
|
|
118
118
|
if (running) return c.json({ error: "a message is already running" }, 409);
|
|
119
119
|
|
|
120
120
|
const [msg] = await sql`
|
|
121
|
-
INSERT INTO session_messages (session_id, role, content, status)
|
|
121
|
+
INSERT INTO upend.session_messages (session_id, role, content, status)
|
|
122
122
|
VALUES (${sessionId}, 'user', ${prompt}, 'pending')
|
|
123
123
|
RETURNING *
|
|
124
124
|
`;
|
|
@@ -133,20 +133,20 @@ app.post("/sessions/:id/messages", async (c) => {
|
|
|
133
133
|
|
|
134
134
|
app.get("/sessions/:id", async (c) => {
|
|
135
135
|
const id = c.req.param("id");
|
|
136
|
-
const [session] = await sql`SELECT * FROM editing_sessions WHERE id = ${id}`;
|
|
136
|
+
const [session] = await sql`SELECT * FROM upend.editing_sessions WHERE id = ${id}`;
|
|
137
137
|
if (!session) return c.json({ error: "not found" }, 404);
|
|
138
|
-
const messages = await sql`SELECT * FROM session_messages WHERE session_id = ${id} ORDER BY created_at`;
|
|
138
|
+
const messages = await sql`SELECT * FROM upend.session_messages WHERE session_id = ${id} ORDER BY created_at`;
|
|
139
139
|
return c.json({ ...session, messages });
|
|
140
140
|
});
|
|
141
141
|
|
|
142
142
|
app.get("/sessions", async (c) => {
|
|
143
|
-
const rows = await sql`SELECT * FROM editing_sessions ORDER BY created_at DESC LIMIT 50`;
|
|
143
|
+
const rows = await sql`SELECT * FROM upend.editing_sessions ORDER BY created_at DESC LIMIT 50`;
|
|
144
144
|
return c.json(rows);
|
|
145
145
|
});
|
|
146
146
|
|
|
147
147
|
app.post("/sessions/:id/end", async (c) => {
|
|
148
148
|
const id = c.req.param("id");
|
|
149
|
-
await sql`UPDATE editing_sessions SET status = 'ended' WHERE id = ${id}`;
|
|
149
|
+
await sql`UPDATE upend.editing_sessions SET status = 'ended' WHERE id = ${id}`;
|
|
150
150
|
activeProcesses.delete(Number(id));
|
|
151
151
|
return c.json({ ended: true });
|
|
152
152
|
});
|
|
@@ -157,7 +157,7 @@ app.post("/sessions/:id/kill", async (c) => {
|
|
|
157
157
|
if (!proc) return c.json({ error: "nothing running" }, 404);
|
|
158
158
|
proc.kill();
|
|
159
159
|
activeProcesses.delete(id);
|
|
160
|
-
await sql`UPDATE session_messages SET status = 'killed' WHERE session_id = ${id} AND status = 'running'`;
|
|
160
|
+
await sql`UPDATE upend.session_messages SET status = 'killed' WHERE session_id = ${id} AND status = 'running'`;
|
|
161
161
|
broadcast(id, { type: "status", status: "killed" });
|
|
162
162
|
return c.json({ killed: true });
|
|
163
163
|
});
|
|
@@ -167,7 +167,7 @@ app.post("/sessions/:id/kill", async (c) => {
|
|
|
167
167
|
// check if a session can merge cleanly
|
|
168
168
|
app.get("/sessions/:id/mergeable", async (c) => {
|
|
169
169
|
const id = c.req.param("id");
|
|
170
|
-
const [session] = await sql`SELECT * FROM editing_sessions WHERE id = ${id}`;
|
|
170
|
+
const [session] = await sql`SELECT * FROM upend.editing_sessions WHERE id = ${id}`;
|
|
171
171
|
if (!session) return c.json({ error: "not found" }, 404);
|
|
172
172
|
|
|
173
173
|
const ctx = typeof session.context === 'string' ? JSON.parse(session.context) : session.context;
|
|
@@ -187,7 +187,7 @@ app.get("/sessions/:id/mergeable", async (c) => {
|
|
|
187
187
|
app.post("/sessions/:id/commit", async (c) => {
|
|
188
188
|
const id = c.req.param("id");
|
|
189
189
|
const user = c.get("user") as { sub: string; email: string };
|
|
190
|
-
const [session] = await sql`SELECT * FROM editing_sessions WHERE id = ${id}`;
|
|
190
|
+
const [session] = await sql`SELECT * FROM upend.editing_sessions WHERE id = ${id}`;
|
|
191
191
|
if (!session) return c.json({ error: "not found" }, 404);
|
|
192
192
|
if (session.status !== "active") return c.json({ error: `session is ${session.status}` }, 400);
|
|
193
193
|
|
|
@@ -206,7 +206,7 @@ app.post("/sessions/:id/commit", async (c) => {
|
|
|
206
206
|
}
|
|
207
207
|
|
|
208
208
|
// mark session as committed
|
|
209
|
-
await sql`UPDATE editing_sessions SET status = 'committed' WHERE id = ${id}`;
|
|
209
|
+
await sql`UPDATE upend.editing_sessions SET status = 'committed' WHERE id = ${id}`;
|
|
210
210
|
|
|
211
211
|
// restart live services so changes take effect
|
|
212
212
|
restartServices();
|
|
@@ -310,7 +310,7 @@ async function runMessage(
|
|
|
310
310
|
cwd: string = PROJECT_ROOT
|
|
311
311
|
) {
|
|
312
312
|
try {
|
|
313
|
-
await sql`UPDATE session_messages SET status = 'running' WHERE id = ${messageId}`;
|
|
313
|
+
await sql`UPDATE upend.session_messages SET status = 'running' WHERE id = ${messageId}`;
|
|
314
314
|
broadcast(sessionId, { type: "status", status: "running", messageId });
|
|
315
315
|
console.log(`[claude:${sessionId}] message ${messageId} → running (user: ${user.email})`);
|
|
316
316
|
|
|
@@ -383,7 +383,7 @@ async function runMessage(
|
|
|
383
383
|
if (block.type === "text") {
|
|
384
384
|
resultText += block.text;
|
|
385
385
|
// update DB with partial result as it streams
|
|
386
|
-
await sql`UPDATE session_messages SET result = ${resultText} WHERE id = ${messageId}`;
|
|
386
|
+
await sql`UPDATE upend.session_messages SET result = ${resultText} WHERE id = ${messageId}`;
|
|
387
387
|
broadcast(sessionId, { type: "text", text: block.text, messageId });
|
|
388
388
|
} else if (block.type === "tool_use") {
|
|
389
389
|
broadcast(sessionId, { type: "tool_use", name: block.name, input: block.input, messageId });
|
|
@@ -419,12 +419,12 @@ async function runMessage(
|
|
|
419
419
|
const errMsg = `claude error: ${errorDetail}`;
|
|
420
420
|
console.error(`[claude:${sessionId}] FULL OUTPUT:\n${fullOutput}`);
|
|
421
421
|
console.error(`[claude:${sessionId}] ERROR: ${errMsg}`);
|
|
422
|
-
await sql`UPDATE session_messages SET status = 'error', result = ${errMsg} WHERE id = ${messageId}`;
|
|
422
|
+
await sql`UPDATE upend.session_messages SET status = 'error', result = ${errMsg} WHERE id = ${messageId}`;
|
|
423
423
|
broadcast(sessionId, { type: "status", status: "error", error: errMsg, messageId });
|
|
424
424
|
return;
|
|
425
425
|
}
|
|
426
426
|
|
|
427
|
-
await sql`UPDATE session_messages SET status = 'complete', result = ${resultText} WHERE id = ${messageId}`;
|
|
427
|
+
await sql`UPDATE upend.session_messages SET status = 'complete', result = ${resultText} WHERE id = ${messageId}`;
|
|
428
428
|
broadcast(sessionId, { type: "status", status: "complete", messageId });
|
|
429
429
|
console.log(`[claude:${sessionId}] complete: "${resultText.slice(0, 100)}"`);
|
|
430
430
|
|
|
@@ -433,7 +433,7 @@ async function runMessage(
|
|
|
433
433
|
} catch (err: any) {
|
|
434
434
|
console.error(`[claude:${sessionId}] EXCEPTION:`, err);
|
|
435
435
|
activeProcesses.delete(sessionId);
|
|
436
|
-
await sql`UPDATE session_messages SET status = 'error', result = ${err.message} WHERE id = ${messageId}`;
|
|
436
|
+
await sql`UPDATE upend.session_messages SET status = 'error', result = ${err.message} WHERE id = ${messageId}`;
|
|
437
437
|
broadcast(sessionId, { type: "status", status: "error", error: err.message, messageId });
|
|
438
438
|
}
|
|
439
439
|
}
|
|
@@ -63,6 +63,12 @@
|
|
|
63
63
|
<button @click="rightPanel = 'data'"
|
|
64
64
|
:class="rightPanel === 'data' ? 'text-accent bg-accent-dim' : 'text-muted hover:text-gray-200 hover:bg-border'"
|
|
65
65
|
class="px-3 py-1 text-xs rounded cursor-pointer font-mono">data</button>
|
|
66
|
+
<button @click="rightPanel = 'workflows'; loadWorkflows()"
|
|
67
|
+
:class="rightPanel === 'workflows' ? 'text-accent bg-accent-dim' : 'text-muted hover:text-gray-200 hover:bg-border'"
|
|
68
|
+
class="px-3 py-1 text-xs rounded cursor-pointer font-mono">workflows</button>
|
|
69
|
+
<button @click="rightPanel = 'audit'; loadAuditLog()"
|
|
70
|
+
:class="rightPanel === 'audit' ? 'text-accent bg-accent-dim' : 'text-muted hover:text-gray-200 hover:bg-border'"
|
|
71
|
+
class="px-3 py-1 text-xs rounded cursor-pointer font-mono">audit</button>
|
|
66
72
|
<div class="relative" @click.away="appsOpen = false">
|
|
67
73
|
<button @click="appsOpen = !appsOpen; loadApps()"
|
|
68
74
|
:class="rightPanel !== 'data' && rightPanel !== 'home' ? 'text-accent bg-accent-dim' : 'text-muted hover:text-gray-200 hover:bg-border'"
|
|
@@ -257,8 +263,8 @@
|
|
|
257
263
|
<!-- right panel -->
|
|
258
264
|
<div class="flex-1 min-h-0 min-w-0 overflow-y-auto" id="panel-right">
|
|
259
265
|
<!-- app iframe -->
|
|
260
|
-
<iframe x-show="
|
|
261
|
-
:src="token &&
|
|
266
|
+
<iframe x-show="!['data','home','workflows','audit'].includes(rightPanel)" x-ref="rightIframe" class="w-full h-full border-none"
|
|
267
|
+
:src="token && !['data','home','workflows','audit'].includes(rightPanel) ? appUrl(rightPanel) : 'about:blank'"></iframe>
|
|
262
268
|
|
|
263
269
|
<!-- home panel -->
|
|
264
270
|
<div x-show="rightPanel === 'home'" class="p-8 max-w-2xl mx-auto">
|
|
@@ -285,6 +291,87 @@
|
|
|
285
291
|
</div>
|
|
286
292
|
</div>
|
|
287
293
|
|
|
294
|
+
<!-- audit panel -->
|
|
295
|
+
<div x-show="rightPanel === 'audit'" class="flex flex-col h-full overflow-y-auto">
|
|
296
|
+
<div class="px-4 py-3 border-b border-border flex items-center justify-between">
|
|
297
|
+
<span class="text-sm font-bold text-gray-200 font-mono">audit log</span>
|
|
298
|
+
<button @click="loadAuditLog()" class="text-xs text-muted border border-border px-2 py-1 rounded cursor-pointer hover:text-accent hover:border-accent font-mono">refresh</button>
|
|
299
|
+
</div>
|
|
300
|
+
|
|
301
|
+
<div x-show="auditEntries.length === 0" class="p-8 text-center text-muted text-sm">
|
|
302
|
+
no audit entries yet
|
|
303
|
+
</div>
|
|
304
|
+
|
|
305
|
+
<table x-show="auditEntries.length > 0" class="w-full text-xs">
|
|
306
|
+
<thead>
|
|
307
|
+
<tr class="border-b border-border text-muted sticky top-0 bg-surface">
|
|
308
|
+
<th class="text-left px-3 py-2 font-normal">time</th>
|
|
309
|
+
<th class="text-left px-3 py-2 font-normal">actor</th>
|
|
310
|
+
<th class="text-left px-3 py-2 font-normal">action</th>
|
|
311
|
+
<th class="text-left px-3 py-2 font-normal">target</th>
|
|
312
|
+
<th class="text-left px-3 py-2 font-normal">detail</th>
|
|
313
|
+
</tr>
|
|
314
|
+
</thead>
|
|
315
|
+
<tbody>
|
|
316
|
+
<template x-for="entry in auditEntries" :key="entry.id">
|
|
317
|
+
<tr class="border-b border-border/50 hover:bg-surface/50">
|
|
318
|
+
<td class="px-3 py-2 text-muted whitespace-nowrap" x-text="new Date(entry.ts).toLocaleString()"></td>
|
|
319
|
+
<td class="px-3 py-2 text-gray-200 font-mono" x-text="entry.actorEmail || entry.actorId || '—'"></td>
|
|
320
|
+
<td class="px-3 py-2">
|
|
321
|
+
<span class="px-1.5 py-0.5 rounded text-[10px] font-bold"
|
|
322
|
+
:class="{
|
|
323
|
+
'bg-green-500/20 text-green-400': entry.action?.includes('login'),
|
|
324
|
+
'bg-blue-500/20 text-blue-400': entry.action?.includes('signup'),
|
|
325
|
+
'bg-yellow-500/20 text-yellow-400': entry.action?.includes('impersonate'),
|
|
326
|
+
'bg-accent/20 text-accent': entry.action?.includes('session'),
|
|
327
|
+
'bg-purple-500/20 text-purple-400': entry.action?.includes('workflow'),
|
|
328
|
+
'bg-red-500/20 text-red-400': entry.action?.includes('commit'),
|
|
329
|
+
}"
|
|
330
|
+
x-text="entry.action"></span>
|
|
331
|
+
</td>
|
|
332
|
+
<td class="px-3 py-2 text-muted font-mono" x-text="entry.targetType ? entry.targetType + ':' + (entry.targetId || '') : '—'"></td>
|
|
333
|
+
<td class="px-3 py-2 text-muted font-mono max-w-[200px] truncate" x-text="entry.detail && Object.keys(entry.detail).length ? JSON.stringify(entry.detail) : ''"></td>
|
|
334
|
+
</tr>
|
|
335
|
+
</template>
|
|
336
|
+
</tbody>
|
|
337
|
+
</table>
|
|
338
|
+
</div>
|
|
339
|
+
|
|
340
|
+
<!-- workflows panel -->
|
|
341
|
+
<div x-show="rightPanel === 'workflows'" class="flex flex-col h-full overflow-y-auto">
|
|
342
|
+
<div class="px-4 py-3 border-b border-border flex items-center justify-between">
|
|
343
|
+
<span class="text-sm font-bold text-gray-200 font-mono">workflows</span>
|
|
344
|
+
<button @click="prompt = 'create a new workflow in workflows/ that '; $refs.chatInput.focus()"
|
|
345
|
+
class="text-xs text-muted border border-border px-3 py-1 rounded cursor-pointer hover:text-accent hover:border-accent font-mono">+ new workflow</button>
|
|
346
|
+
</div>
|
|
347
|
+
|
|
348
|
+
<div x-show="workflowsList.length === 0" class="p-8 text-center text-muted text-sm">
|
|
349
|
+
no workflows yet — ask Claude to create one, or add a .ts file to workflows/
|
|
350
|
+
</div>
|
|
351
|
+
|
|
352
|
+
<template x-for="wf in workflowsList" :key="wf.name">
|
|
353
|
+
<div class="px-4 py-3 border-b border-border/50 hover:bg-surface/50">
|
|
354
|
+
<div class="flex items-center justify-between mb-1">
|
|
355
|
+
<div class="flex items-center gap-2">
|
|
356
|
+
<span class="text-sm font-mono text-gray-200" x-text="wf.name"></span>
|
|
357
|
+
<span x-show="wf.cron" class="text-[10px] text-muted bg-border px-1.5 py-0.5 rounded font-mono" x-text="wf.cron"></span>
|
|
358
|
+
</div>
|
|
359
|
+
<div class="flex items-center gap-2">
|
|
360
|
+
<span x-show="wf._running" class="text-xs text-accent animate-pulse">running...</span>
|
|
361
|
+
<span x-show="wf._result !== undefined && !wf._running"
|
|
362
|
+
:class="wf._result === 0 ? 'text-green-400' : 'text-red-400'"
|
|
363
|
+
class="text-xs" x-text="wf._result === 0 ? 'success' : 'failed'"></span>
|
|
364
|
+
<button @click="runWorkflow(wf)"
|
|
365
|
+
:disabled="wf._running"
|
|
366
|
+
class="text-xs text-muted border border-border px-2 py-1 rounded cursor-pointer hover:text-accent hover:border-accent font-mono disabled:opacity-40">run</button>
|
|
367
|
+
</div>
|
|
368
|
+
</div>
|
|
369
|
+
<p x-show="wf.description" class="text-xs text-muted" x-text="wf.description"></p>
|
|
370
|
+
<pre x-show="wf._output" class="mt-2 text-[11px] text-gray-400 bg-bg rounded p-2 overflow-x-auto font-mono max-h-32 overflow-y-auto" x-text="wf._output"></pre>
|
|
371
|
+
</div>
|
|
372
|
+
</template>
|
|
373
|
+
</div>
|
|
374
|
+
|
|
288
375
|
<!-- data panel -->
|
|
289
376
|
<div x-show="rightPanel === 'data'" class="flex flex-col h-full" x-init="$watch('rightPanel', v => { if (v === 'data') loadTables() })">
|
|
290
377
|
<!-- table list sidebar + detail -->
|
|
@@ -379,6 +466,48 @@
|
|
|
379
466
|
</table>
|
|
380
467
|
</div>
|
|
381
468
|
</div>
|
|
469
|
+
|
|
470
|
+
<!-- RLS policies -->
|
|
471
|
+
<div class="border-t border-border">
|
|
472
|
+
<div class="px-4 py-2 text-xs border-b border-border flex items-center justify-between">
|
|
473
|
+
<div class="flex items-center gap-2">
|
|
474
|
+
<span class="text-muted">access policies</span>
|
|
475
|
+
<span x-show="tablePolicies.length > 0" class="text-muted" x-text="'(' + tablePolicies.length + ')'"></span>
|
|
476
|
+
</div>
|
|
477
|
+
<span x-show="tableRLSEnabled" class="text-green-500 text-xs">RLS enabled</span>
|
|
478
|
+
<span x-show="!tableRLSEnabled" class="text-muted text-xs">RLS not enabled</span>
|
|
479
|
+
</div>
|
|
480
|
+
|
|
481
|
+
<div x-show="tablePolicies.length === 0" class="px-4 py-4 text-xs text-muted">
|
|
482
|
+
<p>no policies — all authenticated users have full access.</p>
|
|
483
|
+
<button @click="prompt = 'enable RLS on the ' + selectedTable + ' table with sensible default policies: everyone can read, users can only update/delete their own rows (by owner_id or id), admins can do everything'; $refs.chatInput.focus()"
|
|
484
|
+
class="mt-2 text-accent hover:underline cursor-pointer">+ add default policies</button>
|
|
485
|
+
</div>
|
|
486
|
+
|
|
487
|
+
<template x-for="p in tablePolicies" :key="p.policy">
|
|
488
|
+
<div class="px-4 py-2.5 border-b border-border/50 hover:bg-surface/50">
|
|
489
|
+
<div class="flex items-center gap-2 mb-1">
|
|
490
|
+
<span class="font-mono text-xs text-gray-200" x-text="p.policy"></span>
|
|
491
|
+
<span class="px-1.5 py-0.5 rounded text-[10px] font-bold"
|
|
492
|
+
:class="{
|
|
493
|
+
'bg-green-500/20 text-green-400': p.operation === 'SELECT',
|
|
494
|
+
'bg-blue-500/20 text-blue-400': p.operation === 'INSERT',
|
|
495
|
+
'bg-yellow-500/20 text-yellow-400': p.operation === 'UPDATE',
|
|
496
|
+
'bg-red-500/20 text-red-400': p.operation === 'DELETE',
|
|
497
|
+
'bg-accent/20 text-accent': p.operation === 'ALL',
|
|
498
|
+
}"
|
|
499
|
+
x-text="p.operation"></span>
|
|
500
|
+
<span class="text-[10px] text-muted" x-text="p.permissive === 'PERMISSIVE' ? '' : 'RESTRICTIVE'"></span>
|
|
501
|
+
</div>
|
|
502
|
+
<div x-show="p.usingExpr" class="text-[11px] text-muted font-mono">
|
|
503
|
+
<span class="text-muted/60">USING</span> <span class="text-gray-400" x-text="p.usingExpr"></span>
|
|
504
|
+
</div>
|
|
505
|
+
<div x-show="p.checkExpr" class="text-[11px] text-muted font-mono">
|
|
506
|
+
<span class="text-muted/60">CHECK</span> <span class="text-gray-400" x-text="p.checkExpr"></span>
|
|
507
|
+
</div>
|
|
508
|
+
</div>
|
|
509
|
+
</template>
|
|
510
|
+
</div>
|
|
382
511
|
</div>
|
|
383
512
|
</div>
|
|
384
513
|
</div>
|
|
@@ -425,6 +554,12 @@ function dashboard() {
|
|
|
425
554
|
tableColumns: [],
|
|
426
555
|
sampleRows: [],
|
|
427
556
|
sampleRowKeys: [],
|
|
557
|
+
tablePolicies: [],
|
|
558
|
+
tableRLSEnabled: false,
|
|
559
|
+
allPolicies: [],
|
|
560
|
+
allRLSTables: [],
|
|
561
|
+
workflowsList: [],
|
|
562
|
+
auditEntries: [],
|
|
428
563
|
rightPanel: 'home',
|
|
429
564
|
apps: [],
|
|
430
565
|
appsOpen: false,
|
|
@@ -763,6 +898,13 @@ function dashboard() {
|
|
|
763
898
|
try {
|
|
764
899
|
const res = await this.authFetch('/api/tables');
|
|
765
900
|
if (res.ok) this.tables = (await res.json()).map(t => t.name);
|
|
901
|
+
// load policies for all tables
|
|
902
|
+
const polRes = await this.authFetch('/api/policies');
|
|
903
|
+
if (polRes.ok) {
|
|
904
|
+
const data = await polRes.json();
|
|
905
|
+
this.allPolicies = data.policies || [];
|
|
906
|
+
this.allRLSTables = (data.rlsTables || []).map(t => t.table);
|
|
907
|
+
}
|
|
766
908
|
} catch {}
|
|
767
909
|
},
|
|
768
910
|
|
|
@@ -771,13 +913,15 @@ function dashboard() {
|
|
|
771
913
|
this.tableColumns = [];
|
|
772
914
|
this.sampleRows = [];
|
|
773
915
|
this.sampleRowKeys = [];
|
|
916
|
+
this.tablePolicies = this.allPolicies.filter(p => p.table === name);
|
|
917
|
+
this.tableRLSEnabled = this.allRLSTables.includes(name);
|
|
774
918
|
try {
|
|
775
919
|
// fetch columns
|
|
776
920
|
const colRes = await this.authFetch(`/api/tables/${name}`);
|
|
777
921
|
if (colRes.ok) this.tableColumns = await colRes.json();
|
|
778
922
|
|
|
779
|
-
// fetch sample rows
|
|
780
|
-
const dataRes = await this.authFetch(`/api/data/${name}?limit=5&order=
|
|
923
|
+
// fetch sample rows
|
|
924
|
+
const dataRes = await this.authFetch(`/api/data/${name}?limit=5&order=created_at.desc`);
|
|
781
925
|
if (dataRes.ok) {
|
|
782
926
|
const rows = await dataRes.json();
|
|
783
927
|
this.sampleRows = rows;
|
|
@@ -863,11 +1007,39 @@ function dashboard() {
|
|
|
863
1007
|
this.sendPrompt();
|
|
864
1008
|
},
|
|
865
1009
|
|
|
1010
|
+
async loadAuditLog() {
|
|
1011
|
+
try {
|
|
1012
|
+
const res = await this.authFetch('/api/audit?limit=100');
|
|
1013
|
+
if (res.ok) this.auditEntries = await res.json();
|
|
1014
|
+
} catch {}
|
|
1015
|
+
},
|
|
1016
|
+
|
|
1017
|
+
async loadWorkflows() {
|
|
1018
|
+
try {
|
|
1019
|
+
const res = await this.authFetch('/api/workflows');
|
|
1020
|
+
if (res.ok) this.workflowsList = (await res.json()).map(w => ({ ...w, _running: false, _result: undefined, _output: '' }));
|
|
1021
|
+
} catch {}
|
|
1022
|
+
},
|
|
1023
|
+
|
|
1024
|
+
async runWorkflow(wf) {
|
|
1025
|
+
wf._running = true;
|
|
1026
|
+
wf._result = undefined;
|
|
1027
|
+
wf._output = '';
|
|
1028
|
+
try {
|
|
1029
|
+
const res = await this.authFetch(`/api/workflows/${wf.name}/run`, { method: 'POST' });
|
|
1030
|
+
const data = await res.json();
|
|
1031
|
+
wf._result = data.exitCode;
|
|
1032
|
+
wf._output = (data.stdout || '') + (data.stderr ? '\n' + data.stderr : '');
|
|
1033
|
+
} catch (err) {
|
|
1034
|
+
wf._result = 1;
|
|
1035
|
+
wf._output = err.message;
|
|
1036
|
+
}
|
|
1037
|
+
wf._running = false;
|
|
1038
|
+
},
|
|
1039
|
+
|
|
866
1040
|
refreshRightPanel() {
|
|
867
|
-
// refresh app iframe if showing
|
|
868
1041
|
const iframe = this.$refs.rightIframe;
|
|
869
1042
|
if (iframe?.src && iframe.src !== 'about:blank') iframe.src = iframe.src;
|
|
870
|
-
// refresh data panel if showing
|
|
871
1043
|
this.loadTables();
|
|
872
1044
|
if (this.selectedTable) this.selectTable(this.selectedTable);
|
|
873
1045
|
},
|
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import { sql } from "../../lib/db";
|
|
3
|
-
import { signToken, getJWKS } from "../../lib/auth";
|
|
3
|
+
import { signToken, getJWKS, verifyToken } from "../../lib/auth";
|
|
4
4
|
|
|
5
5
|
export const authRoutes = new Hono();
|
|
6
6
|
|
|
7
|
+
async function audit(action: string, opts: { actorId?: string; actorEmail?: string; targetType?: string; targetId?: string; detail?: any; ip?: string } = {}) {
|
|
8
|
+
try {
|
|
9
|
+
await sql`INSERT INTO audit.log (actor_id, actor_email, action, target_type, target_id, detail, ip)
|
|
10
|
+
VALUES (${opts.actorId || null}, ${opts.actorEmail || null}, ${action}, ${opts.targetType || null}, ${opts.targetId || null}, ${JSON.stringify(opts.detail || {})}, ${opts.ip || null})`;
|
|
11
|
+
} catch (err) {
|
|
12
|
+
console.error("[audit] failed:", err);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
7
16
|
// JWKS endpoint — public, Neon Authorize fetches this to validate JWTs
|
|
8
17
|
authRoutes.get("/.well-known/jwks.json", async (c) => {
|
|
9
18
|
const jwks = await getJWKS();
|
|
@@ -12,7 +21,17 @@ authRoutes.get("/.well-known/jwks.json", async (c) => {
|
|
|
12
21
|
|
|
13
22
|
// signup (disabled by default — admin creates users, or set SIGNUP_ENABLED=true)
|
|
14
23
|
authRoutes.post("/auth/signup", async (c) => {
|
|
15
|
-
if
|
|
24
|
+
// allow if signup is enabled OR if request has a valid admin token
|
|
25
|
+
const authHeader = c.req.header("Authorization");
|
|
26
|
+
let isAdminRequest = false;
|
|
27
|
+
if (authHeader?.startsWith("Bearer ")) {
|
|
28
|
+
try {
|
|
29
|
+
const payload = await import("../../lib/auth").then(m => m.verifyToken(authHeader.slice(7)));
|
|
30
|
+
if ((payload as any).app_role === "admin") isAdminRequest = true;
|
|
31
|
+
} catch {}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (process.env.SIGNUP_ENABLED !== "true" && !isAdminRequest) {
|
|
16
35
|
return c.json({ error: "signup is disabled — contact the admin" }, 403);
|
|
17
36
|
}
|
|
18
37
|
|
|
@@ -30,6 +49,7 @@ authRoutes.post("/auth/signup", async (c) => {
|
|
|
30
49
|
`;
|
|
31
50
|
|
|
32
51
|
const token = await signToken(user.id, user.email, user.role);
|
|
52
|
+
await audit("user.signup", { actorId: user.id, actorEmail: user.email, targetType: "user", targetId: user.id });
|
|
33
53
|
return c.json({ user, token }, 201);
|
|
34
54
|
} catch (err: any) {
|
|
35
55
|
if (err.code === "23505") return c.json({ error: "email already exists" }, 409);
|
|
@@ -50,12 +70,38 @@ authRoutes.post("/auth/login", async (c) => {
|
|
|
50
70
|
if (!valid) return c.json({ error: "invalid credentials" }, 401);
|
|
51
71
|
|
|
52
72
|
const token = await signToken(user.id, user.email, user.role);
|
|
73
|
+
await audit("user.login", { actorId: user.id, actorEmail: user.email, targetType: "user", targetId: user.id });
|
|
53
74
|
return c.json({
|
|
54
75
|
user: { id: user.id, email: user.email, role: user.role },
|
|
55
76
|
token,
|
|
56
77
|
});
|
|
57
78
|
});
|
|
58
79
|
|
|
80
|
+
// impersonate — admin only, mint a token as another user
|
|
81
|
+
authRoutes.post("/auth/impersonate", async (c) => {
|
|
82
|
+
const authHeader = c.req.header("Authorization");
|
|
83
|
+
if (!authHeader?.startsWith("Bearer ")) return c.json({ error: "unauthorized" }, 401);
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const payload = await verifyToken(authHeader.slice(7));
|
|
87
|
+
if ((payload as any).app_role !== "admin") return c.json({ error: "admin only" }, 403);
|
|
88
|
+
} catch {
|
|
89
|
+
return c.json({ error: "invalid token" }, 401);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const { user_id } = await c.req.json();
|
|
93
|
+
if (!user_id) return c.json({ error: "user_id required" }, 400);
|
|
94
|
+
|
|
95
|
+
const [user] = await sql`SELECT id, email, role FROM users WHERE id = ${user_id}`;
|
|
96
|
+
if (!user) return c.json({ error: "user not found" }, 404);
|
|
97
|
+
|
|
98
|
+
const token = await signToken(user.id, user.email, user.role);
|
|
99
|
+
const adminPayload = await verifyToken(authHeader!.slice(7));
|
|
100
|
+
await audit("user.impersonate", { actorId: (adminPayload as any).sub, actorEmail: (adminPayload as any).email, targetType: "user", targetId: user.id, detail: { impersonated: user.email } });
|
|
101
|
+
console.log(`[auth] impersonation: admin → ${user.email}`);
|
|
102
|
+
return c.json({ user, token });
|
|
103
|
+
});
|
|
104
|
+
|
|
59
105
|
// ---------- SSO / OAuth ----------
|
|
60
106
|
// Generic OAuth flow: works with Google, GitHub, Okta, Azure AD, whatever
|
|
61
107
|
// Configure via env: OAUTH_<PROVIDER>_CLIENT_ID, OAUTH_<PROVIDER>_CLIENT_SECRET, etc.
|
|
@@ -71,7 +117,7 @@ authRoutes.get("/auth/sso/:provider", async (c) => {
|
|
|
71
117
|
|
|
72
118
|
// store state for CSRF validation
|
|
73
119
|
await sql`
|
|
74
|
-
INSERT INTO oauth_states (state, provider, created_at)
|
|
120
|
+
INSERT INTO upend.oauth_states (state, provider, created_at)
|
|
75
121
|
VALUES (${state}, ${provider}, now())
|
|
76
122
|
`;
|
|
77
123
|
|
|
@@ -99,7 +145,7 @@ authRoutes.get("/auth/sso/:provider/callback", async (c) => {
|
|
|
99
145
|
|
|
100
146
|
// validate state
|
|
101
147
|
const [stateRow] = await sql`
|
|
102
|
-
DELETE FROM oauth_states WHERE state = ${state} AND provider = ${provider}
|
|
148
|
+
DELETE FROM upend.oauth_states WHERE state = ${state} AND provider = ${provider}
|
|
103
149
|
AND created_at > now() - interval '10 minutes'
|
|
104
150
|
RETURNING *
|
|
105
151
|
`;
|