@upend/cli 0.1.1 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.ts +15 -0
- package/package.json +4 -2
- package/src/apps/users/index.html +227 -0
- package/src/commands/deploy.ts +17 -0
- package/src/commands/dev.ts +2 -2
- package/src/commands/init.ts +67 -4
- package/src/commands/workflows.ts +142 -0
- package/src/services/claude/index.ts +20 -20
- package/src/services/dashboard/public/index.html +211 -10
- package/src/services/gateway/auth-routes.ts +50 -4
- package/src/services/gateway/index.ts +206 -18
|
@@ -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}`);
|