@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 +4 -0
- package/package.json +3 -2
- package/src/apps/users/index.html +227 -0
- package/src/commands/deploy.ts +17 -0
- package/src/commands/init.ts +64 -1
- package/src/commands/workflows.ts +142 -0
- package/src/services/claude/index.ts +20 -20
- package/src/services/dashboard/public/index.html +178 -6
- package/src/services/gateway/auth-routes.ts +50 -4
- package/src/services/gateway/index.ts +206 -18
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.
|
|
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, "'")})' 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>
|
package/src/commands/deploy.ts
CHANGED
|
@@ -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
|
|
package/src/commands/init.ts
CHANGED
|
@@ -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
|
-
|
|
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="
|
|
261
|
-
:src="token &&
|
|
266
|
+
<iframe x-show="!['data','home','workflows','audit'].includes(rightPanel)" x-ref="rightIframe" class="w-full h-full border-none"
|
|
267
|
+
:src="token && !['data','home','workflows','audit'].includes(rightPanel) ? appUrl(rightPanel) : 'about:blank'"></iframe>
|
|
262
268
|
|
|
263
269
|
<!-- home panel -->
|
|
264
270
|
<div x-show="rightPanel === 'home'" class="p-8 max-w-2xl mx-auto">
|
|
@@ -285,6 +291,87 @@
|
|
|
285
291
|
</div>
|
|
286
292
|
</div>
|
|
287
293
|
|
|
294
|
+
<!-- audit panel -->
|
|
295
|
+
<div x-show="rightPanel === 'audit'" class="flex flex-col h-full overflow-y-auto">
|
|
296
|
+
<div class="px-4 py-3 border-b border-border flex items-center justify-between">
|
|
297
|
+
<span class="text-sm font-bold text-gray-200 font-mono">audit log</span>
|
|
298
|
+
<button @click="loadAuditLog()" class="text-xs text-muted border border-border px-2 py-1 rounded cursor-pointer hover:text-accent hover:border-accent font-mono">refresh</button>
|
|
299
|
+
</div>
|
|
300
|
+
|
|
301
|
+
<div x-show="auditEntries.length === 0" class="p-8 text-center text-muted text-sm">
|
|
302
|
+
no audit entries yet
|
|
303
|
+
</div>
|
|
304
|
+
|
|
305
|
+
<table x-show="auditEntries.length > 0" class="w-full text-xs">
|
|
306
|
+
<thead>
|
|
307
|
+
<tr class="border-b border-border text-muted sticky top-0 bg-surface">
|
|
308
|
+
<th class="text-left px-3 py-2 font-normal">time</th>
|
|
309
|
+
<th class="text-left px-3 py-2 font-normal">actor</th>
|
|
310
|
+
<th class="text-left px-3 py-2 font-normal">action</th>
|
|
311
|
+
<th class="text-left px-3 py-2 font-normal">target</th>
|
|
312
|
+
<th class="text-left px-3 py-2 font-normal">detail</th>
|
|
313
|
+
</tr>
|
|
314
|
+
</thead>
|
|
315
|
+
<tbody>
|
|
316
|
+
<template x-for="entry in auditEntries" :key="entry.id">
|
|
317
|
+
<tr class="border-b border-border/50 hover:bg-surface/50">
|
|
318
|
+
<td class="px-3 py-2 text-muted whitespace-nowrap" x-text="new Date(entry.ts).toLocaleString()"></td>
|
|
319
|
+
<td class="px-3 py-2 text-gray-200 font-mono" x-text="entry.actorEmail || entry.actorId || '—'"></td>
|
|
320
|
+
<td class="px-3 py-2">
|
|
321
|
+
<span class="px-1.5 py-0.5 rounded text-[10px] font-bold"
|
|
322
|
+
:class="{
|
|
323
|
+
'bg-green-500/20 text-green-400': entry.action?.includes('login'),
|
|
324
|
+
'bg-blue-500/20 text-blue-400': entry.action?.includes('signup'),
|
|
325
|
+
'bg-yellow-500/20 text-yellow-400': entry.action?.includes('impersonate'),
|
|
326
|
+
'bg-accent/20 text-accent': entry.action?.includes('session'),
|
|
327
|
+
'bg-purple-500/20 text-purple-400': entry.action?.includes('workflow'),
|
|
328
|
+
'bg-red-500/20 text-red-400': entry.action?.includes('commit'),
|
|
329
|
+
}"
|
|
330
|
+
x-text="entry.action"></span>
|
|
331
|
+
</td>
|
|
332
|
+
<td class="px-3 py-2 text-muted font-mono" x-text="entry.targetType ? entry.targetType + ':' + (entry.targetId || '') : '—'"></td>
|
|
333
|
+
<td class="px-3 py-2 text-muted font-mono max-w-[200px] truncate" x-text="entry.detail && Object.keys(entry.detail).length ? JSON.stringify(entry.detail) : ''"></td>
|
|
334
|
+
</tr>
|
|
335
|
+
</template>
|
|
336
|
+
</tbody>
|
|
337
|
+
</table>
|
|
338
|
+
</div>
|
|
339
|
+
|
|
340
|
+
<!-- workflows panel -->
|
|
341
|
+
<div x-show="rightPanel === 'workflows'" class="flex flex-col h-full overflow-y-auto">
|
|
342
|
+
<div class="px-4 py-3 border-b border-border flex items-center justify-between">
|
|
343
|
+
<span class="text-sm font-bold text-gray-200 font-mono">workflows</span>
|
|
344
|
+
<button @click="prompt = 'create a new workflow in workflows/ that '; $refs.chatInput.focus()"
|
|
345
|
+
class="text-xs text-muted border border-border px-3 py-1 rounded cursor-pointer hover:text-accent hover:border-accent font-mono">+ new workflow</button>
|
|
346
|
+
</div>
|
|
347
|
+
|
|
348
|
+
<div x-show="workflowsList.length === 0" class="p-8 text-center text-muted text-sm">
|
|
349
|
+
no workflows yet — ask Claude to create one, or add a .ts file to workflows/
|
|
350
|
+
</div>
|
|
351
|
+
|
|
352
|
+
<template x-for="wf in workflowsList" :key="wf.name">
|
|
353
|
+
<div class="px-4 py-3 border-b border-border/50 hover:bg-surface/50">
|
|
354
|
+
<div class="flex items-center justify-between mb-1">
|
|
355
|
+
<div class="flex items-center gap-2">
|
|
356
|
+
<span class="text-sm font-mono text-gray-200" x-text="wf.name"></span>
|
|
357
|
+
<span x-show="wf.cron" class="text-[10px] text-muted bg-border px-1.5 py-0.5 rounded font-mono" x-text="wf.cron"></span>
|
|
358
|
+
</div>
|
|
359
|
+
<div class="flex items-center gap-2">
|
|
360
|
+
<span x-show="wf._running" class="text-xs text-accent animate-pulse">running...</span>
|
|
361
|
+
<span x-show="wf._result !== undefined && !wf._running"
|
|
362
|
+
:class="wf._result === 0 ? 'text-green-400' : 'text-red-400'"
|
|
363
|
+
class="text-xs" x-text="wf._result === 0 ? 'success' : 'failed'"></span>
|
|
364
|
+
<button @click="runWorkflow(wf)"
|
|
365
|
+
:disabled="wf._running"
|
|
366
|
+
class="text-xs text-muted border border-border px-2 py-1 rounded cursor-pointer hover:text-accent hover:border-accent font-mono disabled:opacity-40">run</button>
|
|
367
|
+
</div>
|
|
368
|
+
</div>
|
|
369
|
+
<p x-show="wf.description" class="text-xs text-muted" x-text="wf.description"></p>
|
|
370
|
+
<pre x-show="wf._output" class="mt-2 text-[11px] text-gray-400 bg-bg rounded p-2 overflow-x-auto font-mono max-h-32 overflow-y-auto" x-text="wf._output"></pre>
|
|
371
|
+
</div>
|
|
372
|
+
</template>
|
|
373
|
+
</div>
|
|
374
|
+
|
|
288
375
|
<!-- data panel -->
|
|
289
376
|
<div x-show="rightPanel === 'data'" class="flex flex-col h-full" x-init="$watch('rightPanel', v => { if (v === 'data') loadTables() })">
|
|
290
377
|
<!-- table list sidebar + detail -->
|
|
@@ -379,6 +466,48 @@
|
|
|
379
466
|
</table>
|
|
380
467
|
</div>
|
|
381
468
|
</div>
|
|
469
|
+
|
|
470
|
+
<!-- RLS policies -->
|
|
471
|
+
<div class="border-t border-border">
|
|
472
|
+
<div class="px-4 py-2 text-xs border-b border-border flex items-center justify-between">
|
|
473
|
+
<div class="flex items-center gap-2">
|
|
474
|
+
<span class="text-muted">access policies</span>
|
|
475
|
+
<span x-show="tablePolicies.length > 0" class="text-muted" x-text="'(' + tablePolicies.length + ')'"></span>
|
|
476
|
+
</div>
|
|
477
|
+
<span x-show="tableRLSEnabled" class="text-green-500 text-xs">RLS enabled</span>
|
|
478
|
+
<span x-show="!tableRLSEnabled" class="text-muted text-xs">RLS not enabled</span>
|
|
479
|
+
</div>
|
|
480
|
+
|
|
481
|
+
<div x-show="tablePolicies.length === 0" class="px-4 py-4 text-xs text-muted">
|
|
482
|
+
<p>no policies — all authenticated users have full access.</p>
|
|
483
|
+
<button @click="prompt = 'enable RLS on the ' + selectedTable + ' table with sensible default policies: everyone can read, users can only update/delete their own rows (by owner_id or id), admins can do everything'; $refs.chatInput.focus()"
|
|
484
|
+
class="mt-2 text-accent hover:underline cursor-pointer">+ add default policies</button>
|
|
485
|
+
</div>
|
|
486
|
+
|
|
487
|
+
<template x-for="p in tablePolicies" :key="p.policy">
|
|
488
|
+
<div class="px-4 py-2.5 border-b border-border/50 hover:bg-surface/50">
|
|
489
|
+
<div class="flex items-center gap-2 mb-1">
|
|
490
|
+
<span class="font-mono text-xs text-gray-200" x-text="p.policy"></span>
|
|
491
|
+
<span class="px-1.5 py-0.5 rounded text-[10px] font-bold"
|
|
492
|
+
:class="{
|
|
493
|
+
'bg-green-500/20 text-green-400': p.operation === 'SELECT',
|
|
494
|
+
'bg-blue-500/20 text-blue-400': p.operation === 'INSERT',
|
|
495
|
+
'bg-yellow-500/20 text-yellow-400': p.operation === 'UPDATE',
|
|
496
|
+
'bg-red-500/20 text-red-400': p.operation === 'DELETE',
|
|
497
|
+
'bg-accent/20 text-accent': p.operation === 'ALL',
|
|
498
|
+
}"
|
|
499
|
+
x-text="p.operation"></span>
|
|
500
|
+
<span class="text-[10px] text-muted" x-text="p.permissive === 'PERMISSIVE' ? '' : 'RESTRICTIVE'"></span>
|
|
501
|
+
</div>
|
|
502
|
+
<div x-show="p.usingExpr" class="text-[11px] text-muted font-mono">
|
|
503
|
+
<span class="text-muted/60">USING</span> <span class="text-gray-400" x-text="p.usingExpr"></span>
|
|
504
|
+
</div>
|
|
505
|
+
<div x-show="p.checkExpr" class="text-[11px] text-muted font-mono">
|
|
506
|
+
<span class="text-muted/60">CHECK</span> <span class="text-gray-400" x-text="p.checkExpr"></span>
|
|
507
|
+
</div>
|
|
508
|
+
</div>
|
|
509
|
+
</template>
|
|
510
|
+
</div>
|
|
382
511
|
</div>
|
|
383
512
|
</div>
|
|
384
513
|
</div>
|
|
@@ -425,6 +554,12 @@ function dashboard() {
|
|
|
425
554
|
tableColumns: [],
|
|
426
555
|
sampleRows: [],
|
|
427
556
|
sampleRowKeys: [],
|
|
557
|
+
tablePolicies: [],
|
|
558
|
+
tableRLSEnabled: false,
|
|
559
|
+
allPolicies: [],
|
|
560
|
+
allRLSTables: [],
|
|
561
|
+
workflowsList: [],
|
|
562
|
+
auditEntries: [],
|
|
428
563
|
rightPanel: 'home',
|
|
429
564
|
apps: [],
|
|
430
565
|
appsOpen: false,
|
|
@@ -763,6 +898,13 @@ function dashboard() {
|
|
|
763
898
|
try {
|
|
764
899
|
const res = await this.authFetch('/api/tables');
|
|
765
900
|
if (res.ok) this.tables = (await res.json()).map(t => t.name);
|
|
901
|
+
// load policies for all tables
|
|
902
|
+
const polRes = await this.authFetch('/api/policies');
|
|
903
|
+
if (polRes.ok) {
|
|
904
|
+
const data = await polRes.json();
|
|
905
|
+
this.allPolicies = data.policies || [];
|
|
906
|
+
this.allRLSTables = (data.rlsTables || []).map(t => t.table);
|
|
907
|
+
}
|
|
766
908
|
} catch {}
|
|
767
909
|
},
|
|
768
910
|
|
|
@@ -771,13 +913,15 @@ function dashboard() {
|
|
|
771
913
|
this.tableColumns = [];
|
|
772
914
|
this.sampleRows = [];
|
|
773
915
|
this.sampleRowKeys = [];
|
|
916
|
+
this.tablePolicies = this.allPolicies.filter(p => p.table === name);
|
|
917
|
+
this.tableRLSEnabled = this.allRLSTables.includes(name);
|
|
774
918
|
try {
|
|
775
919
|
// fetch columns
|
|
776
920
|
const colRes = await this.authFetch(`/api/tables/${name}`);
|
|
777
921
|
if (colRes.ok) this.tableColumns = await colRes.json();
|
|
778
922
|
|
|
779
|
-
// fetch sample rows
|
|
780
|
-
const dataRes = await this.authFetch(`/api/data/${name}?limit=5&order=
|
|
923
|
+
// fetch sample rows
|
|
924
|
+
const dataRes = await this.authFetch(`/api/data/${name}?limit=5&order=created_at.desc`);
|
|
781
925
|
if (dataRes.ok) {
|
|
782
926
|
const rows = await dataRes.json();
|
|
783
927
|
this.sampleRows = rows;
|
|
@@ -863,11 +1007,39 @@ function dashboard() {
|
|
|
863
1007
|
this.sendPrompt();
|
|
864
1008
|
},
|
|
865
1009
|
|
|
1010
|
+
async loadAuditLog() {
|
|
1011
|
+
try {
|
|
1012
|
+
const res = await this.authFetch('/api/audit?limit=100');
|
|
1013
|
+
if (res.ok) this.auditEntries = await res.json();
|
|
1014
|
+
} catch {}
|
|
1015
|
+
},
|
|
1016
|
+
|
|
1017
|
+
async loadWorkflows() {
|
|
1018
|
+
try {
|
|
1019
|
+
const res = await this.authFetch('/api/workflows');
|
|
1020
|
+
if (res.ok) this.workflowsList = (await res.json()).map(w => ({ ...w, _running: false, _result: undefined, _output: '' }));
|
|
1021
|
+
} catch {}
|
|
1022
|
+
},
|
|
1023
|
+
|
|
1024
|
+
async runWorkflow(wf) {
|
|
1025
|
+
wf._running = true;
|
|
1026
|
+
wf._result = undefined;
|
|
1027
|
+
wf._output = '';
|
|
1028
|
+
try {
|
|
1029
|
+
const res = await this.authFetch(`/api/workflows/${wf.name}/run`, { method: 'POST' });
|
|
1030
|
+
const data = await res.json();
|
|
1031
|
+
wf._result = data.exitCode;
|
|
1032
|
+
wf._output = (data.stdout || '') + (data.stderr ? '\n' + data.stderr : '');
|
|
1033
|
+
} catch (err) {
|
|
1034
|
+
wf._result = 1;
|
|
1035
|
+
wf._output = err.message;
|
|
1036
|
+
}
|
|
1037
|
+
wf._running = false;
|
|
1038
|
+
},
|
|
1039
|
+
|
|
866
1040
|
refreshRightPanel() {
|
|
867
|
-
// refresh app iframe if showing
|
|
868
1041
|
const iframe = this.$refs.rightIframe;
|
|
869
1042
|
if (iframe?.src && iframe.src !== 'about:blank') iframe.src = iframe.src;
|
|
870
|
-
// refresh data panel if showing
|
|
871
1043
|
this.loadTables();
|
|
872
1044
|
if (this.selectedTable) this.selectTable(this.selectedTable);
|
|
873
1045
|
},
|
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import { sql } from "../../lib/db";
|
|
3
|
-
import { signToken, getJWKS } from "../../lib/auth";
|
|
3
|
+
import { signToken, getJWKS, verifyToken } from "../../lib/auth";
|
|
4
4
|
|
|
5
5
|
export const authRoutes = new Hono();
|
|
6
6
|
|
|
7
|
+
async function audit(action: string, opts: { actorId?: string; actorEmail?: string; targetType?: string; targetId?: string; detail?: any; ip?: string } = {}) {
|
|
8
|
+
try {
|
|
9
|
+
await sql`INSERT INTO audit.log (actor_id, actor_email, action, target_type, target_id, detail, ip)
|
|
10
|
+
VALUES (${opts.actorId || null}, ${opts.actorEmail || null}, ${action}, ${opts.targetType || null}, ${opts.targetId || null}, ${JSON.stringify(opts.detail || {})}, ${opts.ip || null})`;
|
|
11
|
+
} catch (err) {
|
|
12
|
+
console.error("[audit] failed:", err);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
7
16
|
// JWKS endpoint — public, Neon Authorize fetches this to validate JWTs
|
|
8
17
|
authRoutes.get("/.well-known/jwks.json", async (c) => {
|
|
9
18
|
const jwks = await getJWKS();
|
|
@@ -12,7 +21,17 @@ authRoutes.get("/.well-known/jwks.json", async (c) => {
|
|
|
12
21
|
|
|
13
22
|
// signup (disabled by default — admin creates users, or set SIGNUP_ENABLED=true)
|
|
14
23
|
authRoutes.post("/auth/signup", async (c) => {
|
|
15
|
-
if
|
|
24
|
+
// allow if signup is enabled OR if request has a valid admin token
|
|
25
|
+
const authHeader = c.req.header("Authorization");
|
|
26
|
+
let isAdminRequest = false;
|
|
27
|
+
if (authHeader?.startsWith("Bearer ")) {
|
|
28
|
+
try {
|
|
29
|
+
const payload = await import("../../lib/auth").then(m => m.verifyToken(authHeader.slice(7)));
|
|
30
|
+
if ((payload as any).app_role === "admin") isAdminRequest = true;
|
|
31
|
+
} catch {}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (process.env.SIGNUP_ENABLED !== "true" && !isAdminRequest) {
|
|
16
35
|
return c.json({ error: "signup is disabled — contact the admin" }, 403);
|
|
17
36
|
}
|
|
18
37
|
|
|
@@ -30,6 +49,7 @@ authRoutes.post("/auth/signup", async (c) => {
|
|
|
30
49
|
`;
|
|
31
50
|
|
|
32
51
|
const token = await signToken(user.id, user.email, user.role);
|
|
52
|
+
await audit("user.signup", { actorId: user.id, actorEmail: user.email, targetType: "user", targetId: user.id });
|
|
33
53
|
return c.json({ user, token }, 201);
|
|
34
54
|
} catch (err: any) {
|
|
35
55
|
if (err.code === "23505") return c.json({ error: "email already exists" }, 409);
|
|
@@ -50,12 +70,38 @@ authRoutes.post("/auth/login", async (c) => {
|
|
|
50
70
|
if (!valid) return c.json({ error: "invalid credentials" }, 401);
|
|
51
71
|
|
|
52
72
|
const token = await signToken(user.id, user.email, user.role);
|
|
73
|
+
await audit("user.login", { actorId: user.id, actorEmail: user.email, targetType: "user", targetId: user.id });
|
|
53
74
|
return c.json({
|
|
54
75
|
user: { id: user.id, email: user.email, role: user.role },
|
|
55
76
|
token,
|
|
56
77
|
});
|
|
57
78
|
});
|
|
58
79
|
|
|
80
|
+
// impersonate — admin only, mint a token as another user
|
|
81
|
+
authRoutes.post("/auth/impersonate", async (c) => {
|
|
82
|
+
const authHeader = c.req.header("Authorization");
|
|
83
|
+
if (!authHeader?.startsWith("Bearer ")) return c.json({ error: "unauthorized" }, 401);
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const payload = await verifyToken(authHeader.slice(7));
|
|
87
|
+
if ((payload as any).app_role !== "admin") return c.json({ error: "admin only" }, 403);
|
|
88
|
+
} catch {
|
|
89
|
+
return c.json({ error: "invalid token" }, 401);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const { user_id } = await c.req.json();
|
|
93
|
+
if (!user_id) return c.json({ error: "user_id required" }, 400);
|
|
94
|
+
|
|
95
|
+
const [user] = await sql`SELECT id, email, role FROM users WHERE id = ${user_id}`;
|
|
96
|
+
if (!user) return c.json({ error: "user not found" }, 404);
|
|
97
|
+
|
|
98
|
+
const token = await signToken(user.id, user.email, user.role);
|
|
99
|
+
const adminPayload = await verifyToken(authHeader!.slice(7));
|
|
100
|
+
await audit("user.impersonate", { actorId: (adminPayload as any).sub, actorEmail: (adminPayload as any).email, targetType: "user", targetId: user.id, detail: { impersonated: user.email } });
|
|
101
|
+
console.log(`[auth] impersonation: admin → ${user.email}`);
|
|
102
|
+
return c.json({ user, token });
|
|
103
|
+
});
|
|
104
|
+
|
|
59
105
|
// ---------- SSO / OAuth ----------
|
|
60
106
|
// Generic OAuth flow: works with Google, GitHub, Okta, Azure AD, whatever
|
|
61
107
|
// Configure via env: OAUTH_<PROVIDER>_CLIENT_ID, OAUTH_<PROVIDER>_CLIENT_SECRET, etc.
|
|
@@ -71,7 +117,7 @@ authRoutes.get("/auth/sso/:provider", async (c) => {
|
|
|
71
117
|
|
|
72
118
|
// store state for CSRF validation
|
|
73
119
|
await sql`
|
|
74
|
-
INSERT INTO oauth_states (state, provider, created_at)
|
|
120
|
+
INSERT INTO upend.oauth_states (state, provider, created_at)
|
|
75
121
|
VALUES (${state}, ${provider}, now())
|
|
76
122
|
`;
|
|
77
123
|
|
|
@@ -99,7 +145,7 @@ authRoutes.get("/auth/sso/:provider/callback", async (c) => {
|
|
|
99
145
|
|
|
100
146
|
// validate state
|
|
101
147
|
const [stateRow] = await sql`
|
|
102
|
-
DELETE FROM oauth_states WHERE state = ${state} AND provider = ${provider}
|
|
148
|
+
DELETE FROM upend.oauth_states WHERE state = ${state} AND provider = ${provider}
|
|
103
149
|
AND created_at > now() - interval '10 minutes'
|
|
104
150
|
RETURNING *
|
|
105
151
|
`;
|
|
@@ -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
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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}`);
|