@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.
@@ -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="rightPanel !== 'data' && rightPanel !== 'home'" x-ref="rightIframe" class="w-full h-full border-none"
242
- :src="token && rightPanel !== 'data' && rightPanel !== 'home' ? appUrl(rightPanel) : 'about:blank'"></iframe>
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 via Neon Data API
758
- const dataRes = await this.authFetch(`/api/data/${name}?limit=5&order=id.desc`);
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
- const name = window.prompt('app name (lowercase, no spaces):');
830
- if (!name) return;
831
- const desc = window.prompt('what should this app do?');
832
- if (!desc) return;
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 (process.env.SIGNUP_ENABLED !== "true") {
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
  `;