@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.
- package/README.md +231 -0
- package/bin/cli.ts +48 -0
- package/package.json +26 -0
- package/src/commands/deploy.ts +67 -0
- package/src/commands/dev.ts +96 -0
- package/src/commands/infra.ts +227 -0
- package/src/commands/init.ts +323 -0
- package/src/commands/migrate.ts +64 -0
- package/src/config.ts +18 -0
- package/src/index.ts +2 -0
- package/src/lib/auth.ts +89 -0
- package/src/lib/db.ts +14 -0
- package/src/lib/exec.ts +38 -0
- package/src/lib/log.ts +16 -0
- package/src/lib/middleware.ts +51 -0
- package/src/services/claude/index.ts +507 -0
- package/src/services/claude/snapshots.ts +142 -0
- package/src/services/claude/worktree.ts +151 -0
- package/src/services/dashboard/public/index.html +888 -0
- package/src/services/gateway/auth-routes.ts +203 -0
- package/src/services/gateway/index.ts +64 -0
|
@@ -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 };
|