@upend/cli 0.1.1 → 0.1.4
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/bin/cli.ts +15 -0
- package/package.json +4 -2
- package/src/apps/users/index.html +227 -0
- package/src/commands/deploy.ts +17 -0
- package/src/commands/dev.ts +2 -2
- package/src/commands/init.ts +67 -4
- package/src/commands/workflows.ts +142 -0
- package/src/services/claude/index.ts +20 -20
- package/src/services/dashboard/public/index.html +211 -10
- package/src/services/gateway/auth-routes.ts +50 -4
- package/src/services/gateway/index.ts +206 -18
|
@@ -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'"
|
|
@@ -190,6 +196,25 @@
|
|
|
190
196
|
</div>
|
|
191
197
|
</div>
|
|
192
198
|
|
|
199
|
+
<!-- new app modal -->
|
|
200
|
+
<div x-show="showNewAppModal" x-transition class="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
|
201
|
+
<form @submit.prevent="submitNewApp()"
|
|
202
|
+
@keydown.escape="showNewAppModal = false"
|
|
203
|
+
class="bg-surface border border-border rounded-xl p-6 w-96 flex flex-col gap-4">
|
|
204
|
+
<h3 class="text-accent font-bold text-sm">new app</h3>
|
|
205
|
+
<input x-model="newAppName" x-ref="newAppNameInput" type="text" placeholder="app name (lowercase, no spaces)"
|
|
206
|
+
class="bg-bg border border-border rounded-md px-3 py-2 text-sm text-gray-200 font-mono outline-none focus:border-accent">
|
|
207
|
+
<textarea x-model="newAppDesc" rows="3" placeholder="what should this app do?"
|
|
208
|
+
class="bg-bg border border-border rounded-md px-3 py-2 text-sm text-gray-200 font-mono outline-none focus:border-accent resize-none"></textarea>
|
|
209
|
+
<div class="flex gap-2 justify-end">
|
|
210
|
+
<button type="button" @click="showNewAppModal = false"
|
|
211
|
+
class="text-muted text-xs px-3 py-1.5 rounded border border-border cursor-pointer hover:text-gray-200 font-mono">cancel</button>
|
|
212
|
+
<button type="submit" :disabled="!newAppName.trim() || !newAppDesc.trim()"
|
|
213
|
+
class="bg-accent text-black text-xs px-4 py-1.5 rounded font-bold cursor-pointer disabled:opacity-40 font-mono">create</button>
|
|
214
|
+
</div>
|
|
215
|
+
</form>
|
|
216
|
+
</div>
|
|
217
|
+
|
|
193
218
|
<!-- main panels -->
|
|
194
219
|
<div x-show="token" class="flex-1 flex min-h-0">
|
|
195
220
|
<!-- left: chat (native DOM) -->
|
|
@@ -238,8 +263,8 @@
|
|
|
238
263
|
<!-- right panel -->
|
|
239
264
|
<div class="flex-1 min-h-0 min-w-0 overflow-y-auto" id="panel-right">
|
|
240
265
|
<!-- app iframe -->
|
|
241
|
-
<iframe x-show="
|
|
242
|
-
: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>
|
|
243
268
|
|
|
244
269
|
<!-- home panel -->
|
|
245
270
|
<div x-show="rightPanel === 'home'" class="p-8 max-w-2xl mx-auto">
|
|
@@ -266,6 +291,87 @@
|
|
|
266
291
|
</div>
|
|
267
292
|
</div>
|
|
268
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
|
+
|
|
269
375
|
<!-- data panel -->
|
|
270
376
|
<div x-show="rightPanel === 'data'" class="flex flex-col h-full" x-init="$watch('rightPanel', v => { if (v === 'data') loadTables() })">
|
|
271
377
|
<!-- table list sidebar + detail -->
|
|
@@ -360,6 +466,48 @@
|
|
|
360
466
|
</table>
|
|
361
467
|
</div>
|
|
362
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>
|
|
363
511
|
</div>
|
|
364
512
|
</div>
|
|
365
513
|
</div>
|
|
@@ -385,6 +533,9 @@ function dashboard() {
|
|
|
385
533
|
showSessionModal: false,
|
|
386
534
|
showCloseModal: false,
|
|
387
535
|
showPublishModal: false,
|
|
536
|
+
showNewAppModal: false,
|
|
537
|
+
newAppName: '',
|
|
538
|
+
newAppDesc: '',
|
|
388
539
|
sessionTitle: '',
|
|
389
540
|
_pendingPrompt: '',
|
|
390
541
|
|
|
@@ -403,6 +554,12 @@ function dashboard() {
|
|
|
403
554
|
tableColumns: [],
|
|
404
555
|
sampleRows: [],
|
|
405
556
|
sampleRowKeys: [],
|
|
557
|
+
tablePolicies: [],
|
|
558
|
+
tableRLSEnabled: false,
|
|
559
|
+
allPolicies: [],
|
|
560
|
+
allRLSTables: [],
|
|
561
|
+
workflowsList: [],
|
|
562
|
+
auditEntries: [],
|
|
406
563
|
rightPanel: 'home',
|
|
407
564
|
apps: [],
|
|
408
565
|
appsOpen: false,
|
|
@@ -741,6 +898,13 @@ function dashboard() {
|
|
|
741
898
|
try {
|
|
742
899
|
const res = await this.authFetch('/api/tables');
|
|
743
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
|
+
}
|
|
744
908
|
} catch {}
|
|
745
909
|
},
|
|
746
910
|
|
|
@@ -749,13 +913,15 @@ function dashboard() {
|
|
|
749
913
|
this.tableColumns = [];
|
|
750
914
|
this.sampleRows = [];
|
|
751
915
|
this.sampleRowKeys = [];
|
|
916
|
+
this.tablePolicies = this.allPolicies.filter(p => p.table === name);
|
|
917
|
+
this.tableRLSEnabled = this.allRLSTables.includes(name);
|
|
752
918
|
try {
|
|
753
919
|
// fetch columns
|
|
754
920
|
const colRes = await this.authFetch(`/api/tables/${name}`);
|
|
755
921
|
if (colRes.ok) this.tableColumns = await colRes.json();
|
|
756
922
|
|
|
757
|
-
// fetch sample rows
|
|
758
|
-
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`);
|
|
759
925
|
if (dataRes.ok) {
|
|
760
926
|
const rows = await dataRes.json();
|
|
761
927
|
this.sampleRows = rows;
|
|
@@ -826,19 +992,54 @@ function dashboard() {
|
|
|
826
992
|
|
|
827
993
|
createNewApp() {
|
|
828
994
|
this.appsOpen = false;
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
995
|
+
this.newAppName = '';
|
|
996
|
+
this.newAppDesc = '';
|
|
997
|
+
this.showNewAppModal = true;
|
|
998
|
+
this.$nextTick(() => this.$refs.newAppNameInput?.focus());
|
|
999
|
+
},
|
|
1000
|
+
|
|
1001
|
+
submitNewApp() {
|
|
1002
|
+
const name = this.newAppName.trim().toLowerCase().replace(/\s+/g, '-');
|
|
1003
|
+
const desc = this.newAppDesc.trim();
|
|
1004
|
+
if (!name || !desc) return;
|
|
1005
|
+
this.showNewAppModal = false;
|
|
833
1006
|
this.prompt = `create an app called "${name}" in apps/${name}/. ${desc}`;
|
|
834
1007
|
this.sendPrompt();
|
|
835
1008
|
},
|
|
836
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
|
+
|
|
837
1040
|
refreshRightPanel() {
|
|
838
|
-
// refresh app iframe if showing
|
|
839
1041
|
const iframe = this.$refs.rightIframe;
|
|
840
1042
|
if (iframe?.src && iframe.src !== 'about:blank') iframe.src = iframe.src;
|
|
841
|
-
// refresh data panel if showing
|
|
842
1043
|
this.loadTables();
|
|
843
1044
|
if (this.selectedTable) this.selectTable(this.selectedTable);
|
|
844
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
|
`;
|