@upend/cli 0.1.0

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.
@@ -0,0 +1,203 @@
1
+ import { Hono } from "hono";
2
+ import { sql } from "../../lib/db";
3
+ import { signToken, getJWKS } from "../../lib/auth";
4
+
5
+ export const authRoutes = new Hono();
6
+
7
+ // JWKS endpoint — public, Neon Authorize fetches this to validate JWTs
8
+ authRoutes.get("/.well-known/jwks.json", async (c) => {
9
+ const jwks = await getJWKS();
10
+ return c.json(jwks);
11
+ });
12
+
13
+ // signup
14
+ authRoutes.post("/auth/signup", async (c) => {
15
+ const { email, password, role } = await c.req.json();
16
+ console.log(`[auth] signup attempt: ${email}`);
17
+ if (!email || !password) return c.json({ error: "email and password required" }, 400);
18
+
19
+ const passwordHash = await Bun.password.hash(password, { algorithm: "argon2id" });
20
+
21
+ try {
22
+ const [user] = await sql`
23
+ INSERT INTO users (email, password_hash, role)
24
+ VALUES (${email}, ${passwordHash}, ${role || "user"})
25
+ RETURNING id, email, role, created_at
26
+ `;
27
+
28
+ const token = await signToken(user.id, user.email, user.role);
29
+ return c.json({ user, token }, 201);
30
+ } catch (err: any) {
31
+ if (err.code === "23505") return c.json({ error: "email already exists" }, 409);
32
+ throw err;
33
+ }
34
+ });
35
+
36
+ // login
37
+ authRoutes.post("/auth/login", async (c) => {
38
+ const { email, password } = await c.req.json();
39
+ console.log(`[auth] login attempt: ${email}`);
40
+ if (!email || !password) return c.json({ error: "email and password required" }, 400);
41
+
42
+ const [user] = await sql`SELECT * FROM users WHERE email = ${email}`;
43
+ if (!user) return c.json({ error: "invalid credentials" }, 401);
44
+
45
+ const valid = await Bun.password.verify(password, user.passwordHash);
46
+ if (!valid) return c.json({ error: "invalid credentials" }, 401);
47
+
48
+ const token = await signToken(user.id, user.email, user.role);
49
+ return c.json({
50
+ user: { id: user.id, email: user.email, role: user.role },
51
+ token,
52
+ });
53
+ });
54
+
55
+ // ---------- SSO / OAuth ----------
56
+ // Generic OAuth flow: works with Google, GitHub, Okta, Azure AD, whatever
57
+ // Configure via env: OAUTH_<PROVIDER>_CLIENT_ID, OAUTH_<PROVIDER>_CLIENT_SECRET, etc.
58
+
59
+ // initiate OAuth login — redirects to provider
60
+ authRoutes.get("/auth/sso/:provider", async (c) => {
61
+ const provider = c.req.param("provider");
62
+ const config = getOAuthConfig(provider);
63
+ if (!config) return c.json({ error: `unknown provider: ${provider}` }, 400);
64
+
65
+ const state = crypto.randomUUID();
66
+ const redirectUri = `${getBaseUrl(c)}/auth/sso/${provider}/callback`;
67
+
68
+ // store state for CSRF validation
69
+ await sql`
70
+ INSERT INTO oauth_states (state, provider, created_at)
71
+ VALUES (${state}, ${provider}, now())
72
+ `;
73
+
74
+ const params = new URLSearchParams({
75
+ client_id: config.clientId,
76
+ redirect_uri: redirectUri,
77
+ response_type: "code",
78
+ scope: config.scope,
79
+ state,
80
+ });
81
+
82
+ return c.redirect(`${config.authorizeUrl}?${params}`);
83
+ });
84
+
85
+ // OAuth callback — exchange code for token, find/create user, issue our JWT
86
+ authRoutes.get("/auth/sso/:provider/callback", async (c) => {
87
+ const provider = c.req.param("provider");
88
+ const config = getOAuthConfig(provider);
89
+ if (!config) return c.json({ error: `unknown provider: ${provider}` }, 400);
90
+
91
+ const code = c.req.query("code");
92
+ const state = c.req.query("state");
93
+
94
+ if (!code || !state) return c.json({ error: "missing code or state" }, 400);
95
+
96
+ // validate state
97
+ const [stateRow] = await sql`
98
+ DELETE FROM oauth_states WHERE state = ${state} AND provider = ${provider}
99
+ AND created_at > now() - interval '10 minutes'
100
+ RETURNING *
101
+ `;
102
+ if (!stateRow) return c.json({ error: "invalid or expired state" }, 400);
103
+
104
+ // exchange code for tokens
105
+ const redirectUri = `${getBaseUrl(c)}/auth/sso/${provider}/callback`;
106
+ const tokenRes = await fetch(config.tokenUrl, {
107
+ method: "POST",
108
+ headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
109
+ body: new URLSearchParams({
110
+ client_id: config.clientId,
111
+ client_secret: config.clientSecret,
112
+ code,
113
+ redirect_uri: redirectUri,
114
+ grant_type: "authorization_code",
115
+ }),
116
+ });
117
+
118
+ const tokens = await tokenRes.json() as any;
119
+ if (!tokens.access_token) return c.json({ error: "token exchange failed", detail: tokens }, 400);
120
+
121
+ // get user info from provider
122
+ const userInfoRes = await fetch(config.userInfoUrl, {
123
+ headers: { Authorization: `Bearer ${tokens.access_token}` },
124
+ });
125
+ const userInfo = await userInfoRes.json() as any;
126
+
127
+ const email = userInfo.email || userInfo.mail;
128
+ if (!email) return c.json({ error: "could not get email from provider" }, 400);
129
+
130
+ // find or create user
131
+ let [user] = await sql`SELECT * FROM users WHERE email = ${email}`;
132
+ if (!user) {
133
+ [user] = await sql`
134
+ INSERT INTO users (email, password_hash, role)
135
+ VALUES (${email}, ${"sso:" + provider}, 'user')
136
+ RETURNING *
137
+ `;
138
+ }
139
+
140
+ // issue OUR JWT — same as email/password login
141
+ const token = await signToken(user.id, email, user.role);
142
+
143
+ // redirect with token (frontend picks it up)
144
+ const frontendUrl = process.env.FRONTEND_URL || "/";
145
+ return c.redirect(`${frontendUrl}?token=${token}`);
146
+ });
147
+
148
+ // ---------- OAuth provider configs ----------
149
+
150
+ type OAuthConfig = {
151
+ clientId: string;
152
+ clientSecret: string;
153
+ authorizeUrl: string;
154
+ tokenUrl: string;
155
+ userInfoUrl: string;
156
+ scope: string;
157
+ };
158
+
159
+ function getOAuthConfig(provider: string): OAuthConfig | null {
160
+ const prefix = `OAUTH_${provider.toUpperCase()}`;
161
+ const clientId = process.env[`${prefix}_CLIENT_ID`];
162
+ const clientSecret = process.env[`${prefix}_CLIENT_SECRET`];
163
+
164
+ if (!clientId || !clientSecret) return null;
165
+
166
+ const providers: Record<string, Omit<OAuthConfig, "clientId" | "clientSecret">> = {
167
+ google: {
168
+ authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
169
+ tokenUrl: "https://oauth2.googleapis.com/token",
170
+ userInfoUrl: "https://www.googleapis.com/oauth2/v2/userinfo",
171
+ scope: "email profile",
172
+ },
173
+ github: {
174
+ authorizeUrl: "https://github.com/login/oauth/authorize",
175
+ tokenUrl: "https://github.com/login/oauth/access_token",
176
+ userInfoUrl: "https://api.github.com/user",
177
+ scope: "user:email",
178
+ },
179
+ microsoft: {
180
+ authorizeUrl: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
181
+ tokenUrl: "https://login.microsoftonline.com/common/oauth2/v2.0/token",
182
+ userInfoUrl: "https://graph.microsoft.com/v1.0/me",
183
+ scope: "openid email profile",
184
+ },
185
+ };
186
+
187
+ const p = providers[provider];
188
+ if (!p) {
189
+ // generic OIDC — provide all URLs via env
190
+ const authorizeUrl = process.env[`${prefix}_AUTHORIZE_URL`];
191
+ const tokenUrl = process.env[`${prefix}_TOKEN_URL`];
192
+ const userInfoUrl = process.env[`${prefix}_USERINFO_URL`];
193
+ const scope = process.env[`${prefix}_SCOPE`] || "openid email profile";
194
+ if (!authorizeUrl || !tokenUrl || !userInfoUrl) return null;
195
+ return { clientId, clientSecret, authorizeUrl, tokenUrl, userInfoUrl, scope };
196
+ }
197
+
198
+ return { clientId, clientSecret, ...p };
199
+ }
200
+
201
+ function getBaseUrl(c: any): string {
202
+ return process.env.BASE_URL || `${c.req.url.split("/auth")[0]}`;
203
+ }
@@ -0,0 +1,64 @@
1
+ import { Hono } from "hono";
2
+ import { logger } from "hono/logger";
3
+ import { timing } from "hono/timing";
4
+ import { cors } from "hono/cors";
5
+ import { authRoutes } from "./auth-routes";
6
+ import { sql } from "../../lib/db";
7
+ import { requireAuth } from "../../lib/middleware";
8
+
9
+ const app = new Hono();
10
+
11
+ app.use("*", logger());
12
+ app.use("*", timing());
13
+ app.use("*", cors());
14
+
15
+ app.get("/", (c) => c.json({ service: "api", status: "up", ts: Date.now() }));
16
+
17
+ // auth routes — signup, login, SSO, JWKS
18
+ app.route("/", authRoutes);
19
+
20
+ // schema introspection — used by the dashboard
21
+ app.get("/tables", requireAuth, async (c) => {
22
+ const tables = await sql`
23
+ SELECT t.tablename as name,
24
+ (SELECT count(*) FROM information_schema.columns c WHERE c.table_name = t.tablename AND c.table_schema = 'public') as columns
25
+ FROM pg_tables t WHERE t.schemaname = 'public' AND t.tablename != '_migrations'
26
+ ORDER BY t.tablename
27
+ `;
28
+ return c.json(tables);
29
+ });
30
+
31
+ app.get("/tables/:name", requireAuth, async (c) => {
32
+ const name = c.req.param("name");
33
+ const columns = await sql`
34
+ SELECT column_name, data_type, is_nullable, column_default
35
+ FROM information_schema.columns
36
+ WHERE table_schema = 'public' AND table_name = ${name}
37
+ ORDER BY ordinal_position
38
+ `;
39
+ return c.json(columns);
40
+ });
41
+
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
+ }));
60
+
61
+ const port = Number(process.env.API_PORT) || 3001;
62
+ console.log(`[api] running on :${port}`);
63
+
64
+ export default { port, fetch: app.fetch };