@upend/cli 0.1.2 → 0.1.5

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.
@@ -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}`);