@upend/cli 0.1.2 → 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 CHANGED
@@ -21,6 +21,7 @@ const commands: Record<string, () => Promise<void>> = {
21
21
  migrate: () => import("../src/commands/migrate").then((m) => m.default(args.slice(1))),
22
22
  infra: () => import("../src/commands/infra").then((m) => m.default(args.slice(1))),
23
23
  env: () => import("../src/commands/env").then((m) => m.default(args.slice(1))),
24
+ workflows: () => import("../src/commands/workflows").then((m) => m.default(args.slice(1))),
24
25
  };
25
26
 
26
27
  if (!command || command === "--help" || command === "-h") {
@@ -33,6 +34,9 @@ if (!command || command === "--help" || command === "-h") {
33
34
  upend deploy deploy to remote instance
34
35
  upend migrate run database migrations
35
36
  upend env:set <K> <V> set an env var (decrypts, sets, re-encrypts)
37
+ upend workflows list workflows
38
+ upend workflows run <n> run a workflow manually
39
+ upend workflows install install cron schedules
36
40
  upend infra:aws provision AWS infrastructure
37
41
 
38
42
  options:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@upend/cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Anti-SaaS stack. Deploy live apps with Claude, Postgres, and rsync.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,7 +9,8 @@
9
9
  "files": [
10
10
  "bin",
11
11
  "src",
12
- "templates"
12
+ "templates",
13
+ "src/apps"
13
14
  ],
14
15
  "keywords": ["cli", "deploy", "postgres", "claude", "rsync", "caddy", "bun"],
15
16
  "author": "cif",
@@ -0,0 +1,227 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>users</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <script>tailwind.config = { theme: { extend: { colors: { bg: '#0a0a0a', surface: '#141414', border: '#262626', accent: '#f97316', muted: '#737373' } } } }</script>
9
+ <style>body { background: #0a0a0a; font-family: ui-monospace, monospace; }</style>
10
+ </head>
11
+ <body class="text-gray-200 min-h-screen p-6">
12
+ <div class="max-w-4xl mx-auto">
13
+ <div class="flex items-center justify-between mb-6">
14
+ <h1 class="text-accent font-bold text-lg">users</h1>
15
+ <div class="flex items-center gap-3">
16
+ <span id="my-info" class="text-xs text-muted"></span>
17
+ <button id="btn-add" onclick="showModal('add')" class="bg-accent text-black text-xs px-3 py-1.5 rounded font-bold cursor-pointer hidden">+ add user</button>
18
+ </div>
19
+ </div>
20
+
21
+ <table class="w-full text-sm">
22
+ <thead>
23
+ <tr class="text-muted text-xs text-left border-b border-border">
24
+ <th class="pb-2 font-normal">email</th>
25
+ <th class="pb-2 font-normal">role</th>
26
+ <th class="pb-2 font-normal">created</th>
27
+ <th class="pb-2 font-normal w-40"></th>
28
+ </tr>
29
+ </thead>
30
+ <tbody id="users-table"></tbody>
31
+ </table>
32
+ </div>
33
+
34
+ <!-- edit modal -->
35
+ <div id="edit-modal" class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 hidden" onclick="if(event.target===this)hideModal('edit')">
36
+ <form onsubmit="saveUser(event)" class="bg-surface border border-border rounded-xl p-6 w-96 flex flex-col gap-4">
37
+ <h3 class="text-accent font-bold text-sm" id="edit-title">edit user</h3>
38
+ <input id="edit-email" type="email" placeholder="email"
39
+ class="bg-bg border border-border rounded-md px-3 py-2 text-sm text-gray-200 font-mono outline-none focus:border-accent">
40
+ <div id="edit-role-wrap">
41
+ <select id="edit-role"
42
+ class="w-full bg-bg border border-border rounded-md px-3 py-2 text-sm text-gray-200 font-mono outline-none focus:border-accent">
43
+ <option value="user">user</option>
44
+ <option value="admin">admin</option>
45
+ </select>
46
+ </div>
47
+ <input type="hidden" id="edit-id">
48
+ <div class="flex gap-2 justify-end">
49
+ <button type="button" onclick="hideModal('edit')"
50
+ class="text-muted text-xs px-3 py-1.5 rounded border border-border cursor-pointer hover:text-gray-200 font-mono">cancel</button>
51
+ <button type="submit"
52
+ class="bg-accent text-black text-xs px-4 py-1.5 rounded font-bold cursor-pointer font-mono">save</button>
53
+ </div>
54
+ </form>
55
+ </div>
56
+
57
+ <!-- add modal -->
58
+ <div id="add-modal" class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 hidden" onclick="if(event.target===this)hideModal('add')">
59
+ <form onsubmit="addUser(event)" class="bg-surface border border-border rounded-xl p-6 w-96 flex flex-col gap-4">
60
+ <h3 class="text-accent font-bold text-sm">add user</h3>
61
+ <input id="add-email" type="email" placeholder="email" required
62
+ class="bg-bg border border-border rounded-md px-3 py-2 text-sm text-gray-200 font-mono outline-none focus:border-accent">
63
+ <input id="add-password" type="password" placeholder="password" required
64
+ class="bg-bg border border-border rounded-md px-3 py-2 text-sm text-gray-200 font-mono outline-none focus:border-accent">
65
+ <select id="add-role"
66
+ class="bg-bg border border-border rounded-md px-3 py-2 text-sm text-gray-200 font-mono outline-none focus:border-accent">
67
+ <option value="user">user</option>
68
+ <option value="admin">admin</option>
69
+ </select>
70
+ <div class="flex gap-2 justify-end">
71
+ <button type="button" onclick="hideModal('add')"
72
+ class="text-muted text-xs px-3 py-1.5 rounded border border-border cursor-pointer hover:text-gray-200 font-mono">cancel</button>
73
+ <button type="submit"
74
+ class="bg-accent text-black text-xs px-4 py-1.5 rounded font-bold cursor-pointer font-mono">create</button>
75
+ </div>
76
+ </form>
77
+ </div>
78
+
79
+ <!-- impersonation banner -->
80
+ <div id="imp-banner" class="fixed top-0 left-0 right-0 bg-yellow-500/20 border-b border-yellow-500/50 text-yellow-400 text-xs text-center py-1.5 z-50 hidden font-mono">
81
+ viewing as <span id="imp-email"></span>
82
+ <button onclick="exitImpersonation()" class="ml-3 underline cursor-pointer">exit</button>
83
+ </div>
84
+
85
+ <script>
86
+ const token = localStorage.getItem('upend_token');
87
+ if (!token) window.location.href = '/';
88
+
89
+ const claims = JSON.parse(atob(token.split('.')[1]));
90
+ const isAdmin = claims.app_role === 'admin';
91
+ const myId = claims.sub;
92
+
93
+ // impersonation check
94
+ const realToken = localStorage.getItem('upend_real_token');
95
+ if (realToken) {
96
+ document.getElementById('imp-banner').classList.remove('hidden');
97
+ document.getElementById('imp-email').textContent = claims.email;
98
+ }
99
+
100
+ document.getElementById('my-info').textContent = `${claims.email} (${claims.app_role || 'user'})`;
101
+ if (isAdmin) document.getElementById('btn-add').classList.remove('hidden');
102
+
103
+ const headers = { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' };
104
+
105
+ async function api(url, opts = {}) {
106
+ const res = await fetch(url, { headers, ...opts });
107
+ if (res.status === 401) { localStorage.removeItem('upend_token'); window.location.href = '/'; }
108
+ return res;
109
+ }
110
+
111
+ async function load() {
112
+ // everyone sees all users
113
+ const res = await api('/api/data/users?select=id,email,role,created_at&order=created_at.desc');
114
+ if (!res.ok) { console.error('failed to load users', await res.text()); return; }
115
+ const users = await res.json();
116
+ render(users);
117
+ }
118
+
119
+ function render(users) {
120
+ document.getElementById('users-table').innerHTML = users.map(u => {
121
+ const isMe = u.id === myId;
122
+ const canEdit = isMe || isAdmin;
123
+ return `
124
+ <tr class="border-b border-border/50 hover:bg-surface/50">
125
+ <td class="py-2.5">${u.email} ${isMe ? '<span class="text-accent text-xs">(you)</span>' : ''}</td>
126
+ <td class="py-2.5">
127
+ <span class="px-2 py-0.5 rounded text-xs ${u.role === 'admin' ? 'bg-accent/20 text-accent' : 'bg-border text-muted'}">${u.role}</span>
128
+ </td>
129
+ <td class="py-2.5 text-muted text-xs">${new Date(u.created_at).toLocaleDateString()}</td>
130
+ <td class="py-2.5 text-right space-x-2">
131
+ ${canEdit ? `<button onclick='editUser(${JSON.stringify(u).replace(/'/g, "&#39;")})' class="text-muted text-xs hover:text-gray-200 cursor-pointer">edit</button>` : ''}
132
+ ${isAdmin && !isMe ? `<button onclick='impersonate("${u.id}")' class="text-muted text-xs hover:text-yellow-400 cursor-pointer">impersonate</button>` : ''}
133
+ ${isAdmin && !isMe ? `<button onclick='deleteUser("${u.id}")' class="text-muted text-xs hover:text-red-400 cursor-pointer">delete</button>` : ''}
134
+ </td>
135
+ </tr>`;
136
+ }).join('');
137
+ }
138
+
139
+ function showModal(name) {
140
+ document.getElementById(name + '-modal').classList.remove('hidden');
141
+ const input = document.getElementById(name + '-email');
142
+ if (input) setTimeout(() => input.focus(), 50);
143
+ }
144
+ function hideModal(name) { document.getElementById(name + '-modal').classList.add('hidden'); }
145
+
146
+ function editUser(user) {
147
+ const isMe = user.id === myId;
148
+ document.getElementById('edit-id').value = user.id;
149
+ document.getElementById('edit-email').value = user.email;
150
+ document.getElementById('edit-email').readOnly = !isAdmin && !isMe;
151
+ document.getElementById('edit-role').value = user.role;
152
+ document.getElementById('edit-title').textContent = isMe ? 'my profile' : 'edit user';
153
+
154
+ // only admins can change role, and not their own
155
+ const roleWrap = document.getElementById('edit-role-wrap');
156
+ if (!isAdmin || isMe) {
157
+ roleWrap.innerHTML = `<input type="text" value="${user.role}" readonly class="w-full bg-bg border border-border rounded-md px-3 py-2 text-sm text-gray-400 font-mono">`;
158
+ } else {
159
+ roleWrap.innerHTML = `<select id="edit-role" class="w-full bg-bg border border-border rounded-md px-3 py-2 text-sm text-gray-200 font-mono outline-none focus:border-accent">
160
+ <option value="user" ${user.role === 'user' ? 'selected' : ''}>user</option>
161
+ <option value="admin" ${user.role === 'admin' ? 'selected' : ''}>admin</option>
162
+ </select>`;
163
+ }
164
+ showModal('edit');
165
+ }
166
+
167
+ async function saveUser(e) {
168
+ e.preventDefault();
169
+ const id = document.getElementById('edit-id').value;
170
+ const email = document.getElementById('edit-email').value;
171
+ const roleEl = document.getElementById('edit-role');
172
+ const body = { email };
173
+ if (roleEl && roleEl.tagName === 'SELECT') body.role = roleEl.value;
174
+ await api(`/api/data/users?id=eq.${id}`, { method: 'PATCH', body: JSON.stringify(body) });
175
+ hideModal('edit');
176
+ load();
177
+ }
178
+
179
+ async function addUser(e) {
180
+ e.preventDefault();
181
+ const res = await api('/api/auth/signup', {
182
+ method: 'POST',
183
+ body: JSON.stringify({
184
+ email: document.getElementById('add-email').value,
185
+ password: document.getElementById('add-password').value,
186
+ role: document.getElementById('add-role').value,
187
+ }),
188
+ });
189
+ if (res.ok) { hideModal('add'); load(); }
190
+ else { const err = await res.json(); alert(err.error || 'failed'); }
191
+ }
192
+
193
+ async function deleteUser(id) {
194
+ if (!confirm('delete this user?')) return;
195
+ await api(`/api/data/users?id=eq.${id}`, { method: 'DELETE' });
196
+ load();
197
+ }
198
+
199
+ async function impersonate(userId) {
200
+ const res = await api('/api/auth/impersonate', {
201
+ method: 'POST',
202
+ body: JSON.stringify({ user_id: userId }),
203
+ });
204
+ if (res.ok) {
205
+ const { token: newToken } = await res.json();
206
+ localStorage.setItem('upend_real_token', token);
207
+ localStorage.setItem('upend_token', newToken);
208
+ window.location.reload();
209
+ } else {
210
+ const err = await res.json();
211
+ alert(err.error || 'impersonation failed');
212
+ }
213
+ }
214
+
215
+ function exitImpersonation() {
216
+ const real = localStorage.getItem('upend_real_token');
217
+ if (real) {
218
+ localStorage.setItem('upend_token', real);
219
+ localStorage.removeItem('upend_real_token');
220
+ window.location.reload();
221
+ }
222
+ }
223
+
224
+ load();
225
+ </script>
226
+ </body>
227
+ </html>
@@ -57,6 +57,23 @@ export default async function deploy(args: string[]) {
57
57
  sleep 3
58
58
  curl -s -o /dev/null -w "API: %{http_code}\\n" http://localhost:3001/
59
59
  curl -s -o /dev/null -w "Caddy: %{http_code}\\n" http://localhost:80/
60
+
61
+ # install workflow cron schedules
62
+ if [ -d ${appDir}/workflows ]; then
63
+ # strip old upend entries
64
+ crontab -l 2>/dev/null | grep -v "# upend-workflow:" > /tmp/upend-crontab-clean || true
65
+ # add new entries from @cron comments
66
+ for f in ${appDir}/workflows/*.ts; do
67
+ [ -f "$f" ] || continue
68
+ name=$(basename "$f" .ts)
69
+ cron=$(grep -oP "//\\s*@cron\\s+\\K.+" "$f" || true)
70
+ if [ -n "$cron" ]; then
71
+ echo "$cron cd ${appDir} && bun $f >> /tmp/upend-workflow-$name.log 2>&1 # upend-workflow: $name" >> /tmp/upend-crontab-clean
72
+ fi
73
+ done
74
+ crontab /tmp/upend-crontab-clean
75
+ echo "workflows installed: $(crontab -l 2>/dev/null | grep -c upend-workflow) cron entries"
76
+ fi
60
77
  '`);
61
78
  log.success("deployed");
62
79
 
@@ -215,10 +215,36 @@ CREATE TABLE IF NOT EXISTS users (
215
215
 
216
216
  // apps + services
217
217
  mkdirSync(join(projectDir, "apps"), { recursive: true });
218
- writeFile(projectDir, "apps/.gitkeep", "");
218
+ // copy built-in users app
219
+ const usersAppSrc = new URL("../apps/users/index.html", import.meta.url).pathname;
220
+ mkdirSync(join(projectDir, "apps/users"), { recursive: true });
221
+ writeFileSync(join(projectDir, "apps/users/index.html"), readFileSync(usersAppSrc, "utf-8"));
219
222
  mkdirSync(join(projectDir, "services"), { recursive: true });
220
223
  writeFile(projectDir, "services/.gitkeep", "");
221
224
 
225
+ // workflows
226
+ mkdirSync(join(projectDir, "workflows"), { recursive: true });
227
+ writeFile(projectDir, "workflows/example.ts", `// example workflow — runs on a schedule or manually
228
+ // @cron 0 */6 * * *
229
+ // @description clean up ended sessions older than 7 days
230
+
231
+ import postgres from "postgres";
232
+
233
+ const sql = postgres(process.env.DATABASE_URL!);
234
+
235
+ export async function run() {
236
+ const deleted = await sql\`
237
+ DELETE FROM upend.editing_sessions
238
+ WHERE status = 'ended' AND created_at < now() - interval '7 days'
239
+ RETURNING id
240
+ \`;
241
+ console.log(\`cleaned up \${deleted.length} old sessions\`);
242
+ }
243
+
244
+ // run directly: bun workflows/example.ts
245
+ run().then(() => process.exit(0));
246
+ `);
247
+
222
248
  // CLAUDE.md
223
249
  writeFile(projectDir, "CLAUDE.md", `# ${name}
224
250
 
@@ -247,6 +273,43 @@ Apps talk to Neon Data API at \`/api/data/<table>\`:
247
273
  - DELETE \`/api/data/example?id=eq.5\` — delete
248
274
  All requests need \`Authorization: Bearer <jwt>\` header.
249
275
 
276
+ ## Access control
277
+ Apps read JWT claims to decide what to show:
278
+ \`\`\`js
279
+ const token = localStorage.getItem('upend_token');
280
+ const claims = JSON.parse(atob(token.split('.')[1]));
281
+ const isAdmin = claims.app_role === 'admin';
282
+ const myId = claims.sub;
283
+ \`\`\`
284
+
285
+ The data API at \`/api/data/:table\` sets JWT claims as Postgres session variables before each query,
286
+ so RLS policies have access to the current user.
287
+
288
+ When the user asks for access control, create RLS policies in a migration:
289
+ \`\`\`sql
290
+ ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
291
+ ALTER TABLE projects FORCE ROW LEVEL SECURITY;
292
+
293
+ -- everyone can read
294
+ CREATE POLICY "read" ON projects FOR SELECT USING (true);
295
+
296
+ -- users can only update their own rows
297
+ CREATE POLICY "update_own" ON projects FOR UPDATE
298
+ USING (owner_id = current_setting('request.jwt.sub')::uuid);
299
+
300
+ -- only admins can delete
301
+ CREATE POLICY "admin_delete" ON projects FOR DELETE
302
+ USING (current_setting('request.jwt.role') = 'admin');
303
+ \`\`\`
304
+
305
+ Available session variables in RLS policies:
306
+ - \`current_setting('request.jwt.sub')\` — user ID
307
+ - \`current_setting('request.jwt.role')\` — user role (admin/user)
308
+ - \`current_setting('request.jwt.email')\` — user email
309
+
310
+ The dashboard data tab shows active RLS policies per table so users can see what rules are in place.
311
+ The \`apps/users/\` app is an example of the access control pattern.
312
+
250
313
  ## Conventions
251
314
  - Migrations: plain SQL in \`migrations/\`, numbered \`001_name.sql\`
252
315
  - Apps: static HTML/JS/CSS in \`apps/<name>/\`
@@ -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
+ }
@@ -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="rightPanel !== 'data' && rightPanel !== 'home'" x-ref="rightIframe" class="w-full h-full border-none"
261
- :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>
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 via Neon Data API
780
- 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`);
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 (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
  `;
@@ -8,6 +8,16 @@ import { requireAuth } from "../../lib/middleware";
8
8
 
9
9
  const app = new Hono();
10
10
 
11
+ // audit log — append-only, used across all services
12
+ export async function audit(action: string, opts: { actorId?: string; actorEmail?: string; targetType?: string; targetId?: string; detail?: any; ip?: string } = {}) {
13
+ try {
14
+ await sql`INSERT INTO audit.log (actor_id, actor_email, action, target_type, target_id, detail, ip)
15
+ VALUES (${opts.actorId || null}, ${opts.actorEmail || null}, ${action}, ${opts.targetType || null}, ${opts.targetId || null}, ${JSON.stringify(opts.detail || {})}, ${opts.ip || null})`;
16
+ } catch (err) {
17
+ console.error("[audit] failed to write:", err);
18
+ }
19
+ }
20
+
11
21
  app.use("*", logger());
12
22
  app.use("*", timing());
13
23
  app.use("*", cors());
@@ -39,24 +49,202 @@ app.get("/tables/:name", requireAuth, async (c) => {
39
49
  return c.json(columns);
40
50
  });
41
51
 
42
- // data API info point apps to Neon Data API (PostgREST)
43
- app.get("/data", (c) => c.json({
44
- message: "Use Neon Data API for data access. Configure it in your Neon console.",
45
- setup: {
46
- steps: [
47
- "1. Enable Data API in Neon console for your project",
48
- "2. Set JWKS URL to: <your-upend-domain>/.well-known/jwks.json",
49
- "3. Set JWT audience to: upend",
50
- "4. Data API exposes all tables in the public schema as REST endpoints",
51
- ],
52
- docs: "https://neon.com/docs/data-api/overview",
53
- },
54
- schemas: {
55
- public: "User-facing tables (things, users) — exposed via Data API",
56
- upend: "Internal tables (sessions, messages) — not exposed",
57
- auth: "Auth functions (user_id()) — used by RLS policies",
58
- },
59
- }));
52
+ // ---------- simple CRUD for public schema tables ----------
53
+ // works without Neon Data API — direct postgres queries
54
+ // sets JWT claims as session vars so RLS policies work
55
+
56
+ function getUser(c: any) {
57
+ return c.get("user") as { sub: string; email: string; role: string };
58
+ }
59
+
60
+ async function withRLS<T>(user: { sub: string; email: string; role: string }, fn: (sql: any) => Promise<T>): Promise<T> {
61
+ return sql.begin(async (tx) => {
62
+ await tx.unsafe(`SET LOCAL request.jwt.sub = '${user.sub}'`);
63
+ await tx.unsafe(`SET LOCAL request.jwt.role = '${user.role}'`);
64
+ await tx.unsafe(`SET LOCAL request.jwt.email = '${user.email}'`);
65
+ return fn(tx);
66
+ });
67
+ }
68
+
69
+ app.get("/data/:table", requireAuth, async (c) => {
70
+ const table = c.req.param("table");
71
+ const select = c.req.query("select") || "*";
72
+ const order = c.req.query("order") || "created_at.desc";
73
+ const limit = c.req.query("limit") || "100";
74
+
75
+ // parse order: "created_at.desc" → "created_at DESC"
76
+ const [orderCol, orderDir] = order.split(".");
77
+ const orderSql = `${orderCol} ${orderDir === "asc" ? "ASC" : "DESC"}`;
78
+
79
+ // parse filters from query params (PostgREST-style: ?field=eq.value)
80
+ const filters: string[] = [];
81
+ const values: any[] = [];
82
+ for (const [key, val] of Object.entries(c.req.query())) {
83
+ if (["select", "order", "limit"].includes(key)) continue;
84
+ const match = (val as string).match(/^(eq|neq|gt|gte|lt|lte|like|ilike|is)\.(.+)$/);
85
+ if (match) {
86
+ const [, op, v] = match;
87
+ const ops: Record<string, string> = { eq: "=", neq: "!=", gt: ">", gte: ">=", lt: "<", lte: "<=", like: "LIKE", ilike: "ILIKE", is: "IS" };
88
+ values.push(v === "null" ? null : v);
89
+ filters.push(`"${key}" ${ops[op]} $${values.length}`);
90
+ }
91
+ }
92
+
93
+ const where = filters.length ? `WHERE ${filters.join(" AND ")}` : "";
94
+ const query = `SELECT ${select} FROM "${table}" ${where} ORDER BY ${orderSql} LIMIT ${Number(limit)}`;
95
+ const user = getUser(c);
96
+ const rows = await withRLS(user, (tx) => tx.unsafe(query, values));
97
+ return c.json(rows);
98
+ });
99
+
100
+ app.post("/data/:table", requireAuth, async (c) => {
101
+ const table = c.req.param("table");
102
+ const body = await c.req.json();
103
+ const cols = Object.keys(body);
104
+ const vals = Object.values(body);
105
+ const placeholders = cols.map((_, i) => `$${i + 1}`).join(", ");
106
+ const query = `INSERT INTO "${table}" (${cols.map(c => `"${c}"`).join(", ")}) VALUES (${placeholders}) RETURNING *`;
107
+ const user = getUser(c);
108
+ const [row] = await withRLS(user, (tx) => tx.unsafe(query, vals));
109
+ return c.json(row, 201);
110
+ });
111
+
112
+ app.patch("/data/:table", requireAuth, async (c) => {
113
+ const table = c.req.param("table");
114
+ const body = await c.req.json();
115
+
116
+ // parse filters from query params
117
+ const filters: string[] = [];
118
+ const filterVals: any[] = [];
119
+ for (const [key, val] of Object.entries(c.req.query())) {
120
+ const match = (val as string).match(/^(eq)\.(.+)$/);
121
+ if (match) {
122
+ filterVals.push(match[2]);
123
+ filters.push(`"${key}" = $${filterVals.length}`);
124
+ }
125
+ }
126
+ if (!filters.length) return c.json({ error: "filter required for PATCH" }, 400);
127
+
128
+ const setCols = Object.keys(body);
129
+ const setVals = Object.values(body);
130
+ const setClause = setCols.map((col, i) => `"${col}" = $${filterVals.length + i + 1}`).join(", ");
131
+ const query = `UPDATE "${table}" SET ${setClause} WHERE ${filters.join(" AND ")} RETURNING *`;
132
+ const user = getUser(c);
133
+ const rows = await withRLS(user, (tx) => tx.unsafe(query, [...filterVals, ...setVals]));
134
+ return c.json(rows);
135
+ });
136
+
137
+ app.delete("/data/:table", requireAuth, async (c) => {
138
+ const table = c.req.param("table");
139
+
140
+ const filters: string[] = [];
141
+ const vals: any[] = [];
142
+ for (const [key, val] of Object.entries(c.req.query())) {
143
+ const match = (val as string).match(/^(eq)\.(.+)$/);
144
+ if (match) {
145
+ vals.push(match[2]);
146
+ filters.push(`"${key}" = $${vals.length}`);
147
+ }
148
+ }
149
+ if (!filters.length) return c.json({ error: "filter required for DELETE" }, 400);
150
+
151
+ const query = `DELETE FROM "${table}" WHERE ${filters.join(" AND ")} RETURNING *`;
152
+ const user = getUser(c);
153
+ const rows = await withRLS(user, (tx) => tx.unsafe(query, vals));
154
+ return c.json(rows);
155
+ });
156
+
157
+ // RLS policies — used by dashboard to display access rules
158
+ app.get("/policies", requireAuth, async (c) => {
159
+ const policies = await sql`
160
+ SELECT
161
+ schemaname as schema,
162
+ tablename as table,
163
+ policyname as policy,
164
+ permissive,
165
+ roles,
166
+ cmd as operation,
167
+ qual as using_expr,
168
+ with_check as check_expr
169
+ FROM pg_policies
170
+ WHERE schemaname = 'public'
171
+ ORDER BY tablename, policyname
172
+ `;
173
+
174
+ // also get which tables have RLS enabled
175
+ const rlsTables = await sql`
176
+ SELECT relname as table, relrowsecurity as rls_enabled, relforcerowsecurity as rls_forced
177
+ FROM pg_class
178
+ WHERE relnamespace = 'public'::regnamespace AND relkind = 'r' AND relrowsecurity = true
179
+ `;
180
+
181
+ return c.json({ policies, rlsTables });
182
+ });
183
+
184
+ // ---------- audit log ----------
185
+
186
+ app.get("/audit", requireAuth, async (c) => {
187
+ const limit = c.req.query("limit") || "100";
188
+ const rows = await sql`
189
+ SELECT * FROM audit.log ORDER BY ts DESC LIMIT ${Number(limit)}
190
+ `;
191
+ return c.json(rows);
192
+ });
193
+
194
+ // ---------- workflows ----------
195
+
196
+ import { readdirSync, readFileSync } from "fs";
197
+ import { join } from "path";
198
+
199
+ const WORKFLOWS_DIR = join(process.env.UPEND_PROJECT || process.cwd(), "workflows");
200
+
201
+ app.get("/workflows", requireAuth, async (c) => {
202
+ let files: string[];
203
+ try {
204
+ files = readdirSync(WORKFLOWS_DIR).filter(f => f.endsWith(".ts") || f.endsWith(".js"));
205
+ } catch {
206
+ return c.json([]);
207
+ }
208
+
209
+ const workflows = files.map(file => {
210
+ const content = readFileSync(join(WORKFLOWS_DIR, file), "utf-8");
211
+ const cronMatch = content.match(/\/\/\s*@cron\s+(.+)/);
212
+ const descMatch = content.match(/\/\/\s*@description\s+(.+)/);
213
+ return {
214
+ name: file.replace(/\.(ts|js)$/, ""),
215
+ file,
216
+ cron: cronMatch ? cronMatch[1].trim() : null,
217
+ description: descMatch ? descMatch[1].trim() : "",
218
+ };
219
+ });
220
+
221
+ return c.json(workflows);
222
+ });
223
+
224
+ app.post("/workflows/:name/run", requireAuth, async (c) => {
225
+ const user = getUser(c);
226
+ if (user.role !== "admin") return c.json({ error: "admin only" }, 403);
227
+
228
+ const name = c.req.param("name");
229
+ const file = `${name}.ts`;
230
+ const path = join(WORKFLOWS_DIR, file);
231
+
232
+ console.log(`[workflow] ${name} triggered by ${user.email}`);
233
+ const proc = Bun.spawn(["bun", path], {
234
+ cwd: process.env.UPEND_PROJECT || process.cwd(),
235
+ env: process.env,
236
+ stdout: "pipe",
237
+ stderr: "pipe",
238
+ });
239
+
240
+ const stdout = await new Response(proc.stdout).text();
241
+ const stderr = await new Response(proc.stderr).text();
242
+ const exitCode = await proc.exited;
243
+
244
+ console.log(`[workflow] ${name} exit=${exitCode}`);
245
+ await audit("workflow.run", { actorId: user.sub, actorEmail: user.email, targetType: "workflow", targetId: name, detail: { exitCode, stdout: stdout.slice(0, 500) } });
246
+ return c.json({ name, exitCode, stdout, stderr });
247
+ });
60
248
 
61
249
  const port = Number(process.env.API_PORT) || 3001;
62
250
  console.log(`[api] running on :${port}`);