This commit is contained in:
Nicholas Keller
2026-06-10 00:40:26 -04:00
parent 6cf48a9a9e
commit df1cd6836c
16 changed files with 1502 additions and 183 deletions
+272
View File
@@ -0,0 +1,272 @@
import {
upsertUser,
createSession,
getSessionUser,
deleteSession,
type User,
} from "./db";
export const BASE_URL = process.env.BASE_URL ?? "http://localhost:3000";
const SESSION_COOKIE = "spark_session";
const STATE_COOKIE = "spark_oauth_state";
const SESSION_DAYS = 30;
interface ProviderConfig {
id: "github" | "google";
label: string;
clientId?: string;
clientSecret?: string;
}
const providers: ProviderConfig[] = [
{
id: "github",
label: "GitHub",
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
},
{
id: "google",
label: "Google",
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
},
];
export function configuredProviders() {
return providers
.filter((p) => p.clientId && p.clientSecret)
.map((p) => ({ id: p.id, label: p.label }));
}
function provider(id: string): ProviderConfig | undefined {
return providers.find(
(p) => p.id === id && p.clientId && p.clientSecret
);
}
// --- token / cookie helpers ---
function randomToken(bytes = 32): string {
const arr = new Uint8Array(bytes);
crypto.getRandomValues(arr);
return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join("");
}
export { randomToken };
export function parseCookies(req: Request): Record<string, string> {
const header = req.headers.get("cookie");
if (!header) return {};
const out: Record<string, string> = {};
for (const part of header.split(";")) {
const idx = part.indexOf("=");
if (idx === -1) continue;
out[part.slice(0, idx).trim()] = decodeURIComponent(
part.slice(idx + 1).trim()
);
}
return out;
}
function cookie(
name: string,
value: string,
maxAgeSeconds: number
): string {
const secure = BASE_URL.startsWith("https") ? " Secure;" : "";
return `${name}=${encodeURIComponent(
value
)}; Path=/; HttpOnly; SameSite=Lax;${secure} Max-Age=${maxAgeSeconds}`;
}
export function currentUser(req: Request): User | null {
const token = parseCookies(req)[SESSION_COOKIE];
if (!token) return null;
return getSessionUser(token);
}
// --- OAuth flow ---
function redirect(location: string, headers: Record<string, string> = {}) {
return new Response(null, { status: 302, headers: { Location: location, ...headers } });
}
// Step 1: send the user to the provider's consent screen.
export function startOAuth(providerId: string): Response {
const p = provider(providerId);
if (!p) return new Response("Unknown or unconfigured provider", { status: 404 });
const state = randomToken(16);
const redirectUri = `${BASE_URL}/auth/${p.id}/callback`;
let url: string;
if (p.id === "github") {
const q = new URLSearchParams({
client_id: p.clientId!,
redirect_uri: redirectUri,
scope: "read:user user:email",
state,
});
url = `https://github.com/login/oauth/authorize?${q}`;
} else {
const q = new URLSearchParams({
client_id: p.clientId!,
redirect_uri: redirectUri,
response_type: "code",
scope: "openid email profile",
state,
access_type: "online",
prompt: "select_account",
});
url = `https://accounts.google.com/o/oauth2/v2/auth?${q}`;
}
// Bind the state to the chosen provider so we can validate the callback.
return redirect(url, {
"Set-Cookie": cookie(STATE_COOKIE, `${p.id}:${state}`, 600),
});
}
// Step 2: handle the provider's redirect back to us.
export async function handleCallback(
providerId: string,
req: Request
): Promise<Response> {
const p = provider(providerId);
if (!p) return new Response("Unknown provider", { status: 404 });
const url = new URL(req.url);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const expected = parseCookies(req)[STATE_COOKIE];
if (!code || !state || expected !== `${p.id}:${state}`) {
return redirect("/app?auth_error=state");
}
const redirectUri = `${BASE_URL}/auth/${p.id}/callback`;
try {
const profile =
p.id === "github"
? await githubProfile(p, code, redirectUri)
: await googleProfile(p, code, redirectUri);
const user = upsertUser(profile);
const token = randomToken(32);
const expires = new Date(Date.now() + SESSION_DAYS * 86400_000);
createSession(token, user.id, isoSqlite(expires));
// Clear state cookie, set session cookie.
return redirect("/app", {
"Set-Cookie": cookie(SESSION_COOKIE, token, SESSION_DAYS * 86400),
});
} catch (e) {
console.error("OAuth callback failed:", e);
return redirect("/app?auth_error=exchange");
}
}
export function logout(req: Request): Response {
const token = parseCookies(req)[SESSION_COOKIE];
if (token) deleteSession(token);
return redirect("/", {
"Set-Cookie": cookie(SESSION_COOKIE, "", 0),
});
}
// --- provider-specific profile fetching ---
async function githubProfile(p: ProviderConfig, code: string, redirectUri: string) {
const tokenRes = await fetch("https://github.com/login/oauth/access_token", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({
client_id: p.clientId,
client_secret: p.clientSecret,
code,
redirect_uri: redirectUri,
}),
});
const tokenJson = (await tokenRes.json()) as { access_token?: string };
const accessToken = tokenJson.access_token;
if (!accessToken) throw new Error("No access token from GitHub");
const auth = { Authorization: `Bearer ${accessToken}`, "User-Agent": "spark-slop" };
const userRes = await fetch("https://api.github.com/user", { headers: auth });
const gh = (await userRes.json()) as {
id: number;
login: string;
name?: string;
avatar_url?: string;
email?: string;
};
// GitHub may not expose a public email; fetch the primary verified one.
let email = gh.email ?? null;
if (!email) {
const emailRes = await fetch("https://api.github.com/user/emails", {
headers: auth,
});
if (emailRes.ok) {
const emails = (await emailRes.json()) as {
email: string;
primary: boolean;
verified: boolean;
}[];
email =
emails.find((e) => e.primary && e.verified)?.email ??
emails.find((e) => e.verified)?.email ??
emails[0]?.email ??
null;
}
}
return {
provider: "github",
provider_id: String(gh.id),
email,
name: gh.name || gh.login,
avatar_url: gh.avatar_url ?? null,
};
}
async function googleProfile(p: ProviderConfig, code: string, redirectUri: string) {
const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: p.clientId!,
client_secret: p.clientSecret!,
code,
grant_type: "authorization_code",
redirect_uri: redirectUri,
}),
});
const tokenJson = (await tokenRes.json()) as { access_token?: string };
if (!tokenJson.access_token) throw new Error("No access token from Google");
const userRes = await fetch(
"https://www.googleapis.com/oauth2/v2/userinfo",
{ headers: { Authorization: `Bearer ${tokenJson.access_token}` } }
);
const g = (await userRes.json()) as {
id: string;
email?: string;
name?: string;
picture?: string;
};
return {
provider: "google",
provider_id: g.id,
email: g.email ?? null,
name: g.name || g.email || "Google user",
avatar_url: g.picture ?? null,
};
}
// Format a Date as SQLite's "YYYY-MM-DD HH:MM:SS" in UTC.
function isoSqlite(d: Date): string {
return d.toISOString().slice(0, 19).replace("T", " ");
}
+235 -22
View File
@@ -6,13 +6,40 @@ export const db = new Database("spark_slop.db", { create: true });
db.exec("PRAGMA journal_mode = WAL;");
db.exec("PRAGMA foreign_keys = ON;");
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider TEXT NOT NULL,
provider_id TEXT NOT NULL,
email TEXT,
name TEXT NOT NULL DEFAULT '',
avatar_url TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (provider, provider_id)
);
`);
db.exec(`
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
`);
db.exec(`
CREATE TABLE IF NOT EXISTS ideas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
visibility TEXT NOT NULL DEFAULT 'private',
share_token TEXT UNIQUE,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
`);
@@ -27,14 +54,55 @@ db.exec(`
`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_notes_idea_id ON notes(idea_id);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_ideas_user_id ON ideas(user_id);`);
// --- Lightweight migration: add columns missing from older databases ---
function columns(table: string): Set<string> {
const rows = db.query(`PRAGMA table_info(${table})`).all() as {
name: string;
}[];
return new Set(rows.map((r) => r.name));
}
const ideaCols = columns("ideas");
if (!ideaCols.has("user_id"))
db.exec(`ALTER TABLE ideas ADD COLUMN user_id INTEGER`);
if (!ideaCols.has("visibility"))
db.exec(
`ALTER TABLE ideas ADD COLUMN visibility TEXT NOT NULL DEFAULT 'private'`
);
if (!ideaCols.has("share_token"))
db.exec(`ALTER TABLE ideas ADD COLUMN share_token TEXT`);
export type Visibility = "private" | "public";
export interface User {
id: number;
provider: string;
provider_id: string;
email: string | null;
name: string;
avatar_url: string | null;
created_at: string;
}
export interface Session {
token: string;
user_id: number;
created_at: string;
expires_at: string;
}
export interface Idea {
id: number;
user_id: number;
title: string;
description: string;
visibility: Visibility;
share_token: string | null;
created_at: string;
updated_at: string;
note_count?: number;
author_name?: string;
}
export interface Note {
@@ -44,35 +112,140 @@ export interface Note {
created_at: string;
}
// --- Idea queries ---
// --- User queries ---
export function listIdeas(): Idea[] {
export function upsertUser(u: {
provider: string;
provider_id: string;
email: string | null;
name: string;
avatar_url: string | null;
}): User {
const existing = db
.query(`SELECT * FROM users WHERE provider = ? AND provider_id = ?`)
.get(u.provider, u.provider_id) as User | null;
if (existing) {
return db
.query(
`UPDATE users SET email = ?, name = ?, avatar_url = ?
WHERE id = ? RETURNING *`
)
.get(u.email, u.name, u.avatar_url, existing.id) as User;
}
return db
.query(
`INSERT INTO users (provider, provider_id, email, name, avatar_url)
VALUES (?, ?, ?, ?, ?) RETURNING *`
)
.get(u.provider, u.provider_id, u.email, u.name, u.avatar_url) as User;
}
// --- Session queries ---
export function createSession(
token: string,
userId: number,
expiresAt: string
): void {
db.query(
`INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)`
).run(token, userId, expiresAt);
}
export function getSessionUser(token: string): User | null {
return (
(db
.query(
`SELECT u.* FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token = ? AND s.expires_at > datetime('now')`
)
.get(token) as User) ?? null
);
}
export function deleteSession(token: string): void {
db.query(`DELETE FROM sessions WHERE token = ?`).run(token);
}
// --- Idea queries (ownership-scoped) ---
export function listIdeas(userId: number): Idea[] {
return db
.query(
`SELECT i.*, COUNT(n.id) AS note_count
FROM ideas i
LEFT JOIN notes n ON n.idea_id = i.id
WHERE i.user_id = ?
GROUP BY i.id
ORDER BY i.updated_at DESC`
)
.all(userId) as Idea[];
}
export function getOwnedIdea(id: number, userId: number): Idea | null {
return (
(db
.query(`SELECT * FROM ideas WHERE id = ? AND user_id = ?`)
.get(id, userId) as Idea) ?? null
);
}
export function getPublicIdeaById(id: number): Idea | null {
return (
(db
.query(
`SELECT i.*, u.name AS author_name
FROM ideas i JOIN users u ON u.id = i.user_id
WHERE i.id = ? AND i.visibility = 'public'`
)
.get(id) as Idea) ?? null
);
}
export function getIdeaByShareToken(token: string): Idea | null {
return (
(db
.query(
`SELECT i.*, u.name AS author_name
FROM ideas i JOIN users u ON u.id = i.user_id
WHERE i.share_token = ?`
)
.get(token) as Idea) ?? null
);
}
export function listPublicIdeas(): Idea[] {
return db
.query(
`SELECT i.*, u.name AS author_name, COUNT(n.id) AS note_count
FROM ideas i
JOIN users u ON u.id = i.user_id
LEFT JOIN notes n ON n.idea_id = i.id
WHERE i.visibility = 'public'
GROUP BY i.id
ORDER BY i.updated_at DESC
LIMIT 200`
)
.all() as Idea[];
}
export function getIdea(id: number): Idea | null {
return (db.query(`SELECT * FROM ideas WHERE id = ?`).get(id) as Idea) ?? null;
}
export function createIdea(title: string, description: string): Idea {
const row = db
export function createIdea(
userId: number,
title: string,
description: string
): Idea {
return db
.query(
`INSERT INTO ideas (title, description) VALUES (?, ?) RETURNING *`
`INSERT INTO ideas (user_id, title, description) VALUES (?, ?, ?) RETURNING *`
)
.get(title, description) as Idea;
return row;
.get(userId, title, description) as Idea;
}
export function updateIdea(
id: number,
userId: number,
title: string,
description: string
): Idea | null {
@@ -81,23 +254,56 @@ export function updateIdea(
.query(
`UPDATE ideas
SET title = ?, description = ?, updated_at = datetime('now')
WHERE id = ?
WHERE id = ? AND user_id = ?
RETURNING *`
)
.get(title, description, id) as Idea) ?? null
.get(title, description, id, userId) as Idea) ?? null
);
}
export function deleteIdea(id: number): boolean {
const res = db.query(`DELETE FROM ideas WHERE id = ?`).run(id);
return res.changes > 0;
export function setVisibility(
id: number,
userId: number,
visibility: Visibility
): Idea | null {
return (
(db
.query(
`UPDATE ideas SET visibility = ?, updated_at = datetime('now')
WHERE id = ? AND user_id = ? RETURNING *`
)
.get(visibility, id, userId) as Idea) ?? null
);
}
export function setShareToken(
id: number,
userId: number,
token: string | null
): Idea | null {
return (
(db
.query(
`UPDATE ideas SET share_token = ? WHERE id = ? AND user_id = ? RETURNING *`
)
.get(token, id, userId) as Idea) ?? null
);
}
export function deleteIdea(id: number, userId: number): boolean {
return (
db.query(`DELETE FROM ideas WHERE id = ? AND user_id = ?`).run(id, userId)
.changes > 0
);
}
// --- Note queries ---
export function listNotes(ideaId: number): Note[] {
return db
.query(`SELECT * FROM notes WHERE idea_id = ? ORDER BY created_at ASC, id ASC`)
.query(
`SELECT * FROM notes WHERE idea_id = ? ORDER BY created_at ASC, id ASC`
)
.all(ideaId) as Note[];
}
@@ -105,14 +311,21 @@ export function createNote(ideaId: number, body: string): Note {
const note = db
.query(`INSERT INTO notes (idea_id, body) VALUES (?, ?) RETURNING *`)
.get(ideaId, body) as Note;
// Touch the parent idea so it sorts to the top.
db.query(`UPDATE ideas SET updated_at = datetime('now') WHERE id = ?`).run(
ideaId
);
return note;
}
export function deleteNote(id: number): boolean {
const res = db.query(`DELETE FROM notes WHERE id = ?`).run(id);
return res.changes > 0;
// Returns the idea_id of the deleted note, or null if it didn't exist /
// wasn't owned by the user (ownership enforced via the join).
export function deleteNoteOwned(noteId: number, userId: number): boolean {
return (
db
.query(
`DELETE FROM notes
WHERE id = ? AND idea_id IN (SELECT id FROM ideas WHERE user_id = ?)`
)
.run(noteId, userId).changes > 0
);
}
+137 -19
View File
@@ -1,13 +1,27 @@
import {
listIdeas,
getIdea,
getOwnedIdea,
getPublicIdeaById,
getIdeaByShareToken,
listPublicIdeas,
createIdea,
updateIdea,
setVisibility,
setShareToken,
deleteIdea,
listNotes,
createNote,
deleteNote,
deleteNoteOwned,
type User,
} from "./db";
import {
currentUser,
configuredProviders,
startOAuth,
handleCallback,
logout,
randomToken,
} from "./auth";
const PORT = Number(process.env.PORT ?? 3000);
const PUBLIC_DIR = new URL("../public/", import.meta.url).pathname;
@@ -18,11 +32,9 @@ function json(data: unknown, status = 200): Response {
headers: { "Content-Type": "application/json" },
});
}
function err(message: string, status = 400): Response {
return json({ error: message }, status);
}
async function readJson(req: Request): Promise<any> {
try {
return await req.json();
@@ -31,14 +43,30 @@ async function readJson(req: Request): Promise<any> {
}
}
// Expose only safe, owner-facing fields.
function publicView(idea: any, includeNotes = false) {
const base = {
id: idea.id,
title: idea.title,
description: idea.description,
visibility: idea.visibility,
author_name: idea.author_name ?? null,
created_at: idea.created_at,
updated_at: idea.updated_at,
note_count: idea.note_count,
};
return includeNotes ? { ...base, notes: listNotes(idea.id) } : base;
}
async function serveStatic(pathname: string): Promise<Response> {
// Map "/" -> index.html, "/app" -> app.html, otherwise the literal file.
let rel = pathname === "/" ? "index.html" : pathname.slice(1);
if (rel === "app") rel = "app.html";
if (rel === "explore") rel = "explore.html";
// /share/:token and /share?idea= -> the read-only viewer page.
if (rel === "share" || rel.startsWith("share/")) rel = "share.html";
let file = Bun.file(PUBLIC_DIR + rel);
if (!(await file.exists())) {
// SPA-ish fallback for unknown non-asset routes.
if (!rel.includes(".")) file = Bun.file(PUBLIC_DIR + "index.html");
}
if (!(await file.exists())) return new Response("Not found", { status: 404 });
@@ -51,7 +79,8 @@ const server = Bun.serve({
const url = new URL(req.url);
const { pathname } = url;
// ---------- API ----------
if (pathname.startsWith("/auth/")) return handleAuthRoutes(req, pathname);
if (pathname.startsWith("/api/")) {
try {
return await handleApi(req, pathname);
@@ -61,23 +90,76 @@ const server = Bun.serve({
}
}
// ---------- Static ----------
return serveStatic(pathname);
},
});
function handleAuthRoutes(req: Request, pathname: string): Promise<Response> | Response {
let m = pathname.match(/^\/auth\/(github|google)$/);
if (m) return startOAuth(m[1]!);
m = pathname.match(/^\/auth\/(github|google)\/callback$/);
if (m) return handleCallback(m[1]!, req);
if (pathname === "/auth/logout") return logout(req);
return new Response("Not found", { status: 404 });
}
async function handleApi(req: Request, pathname: string): Promise<Response> {
const method = req.method;
// ---- Public, no auth required ----
if (pathname === "/api/auth/providers" && method === "GET") {
return json({ providers: configuredProviders() });
}
if (pathname === "/api/me" && method === "GET") {
const user = currentUser(req);
if (!user) return err("Not authenticated", 401);
return json(safeUser(user));
}
if (pathname === "/api/explore" && method === "GET") {
return json(listPublicIdeas().map((i) => publicView(i)));
}
// Public idea detail (read-only) — visibility must be 'public'.
let m = pathname.match(/^\/api\/public\/ideas\/(\d+)$/);
if (m && method === "GET") {
const idea = getPublicIdeaById(Number(m[1]));
return idea ? json(publicView(idea, true)) : err("Not found", 404);
}
// Shared read-only view by secret token.
m = pathname.match(/^\/api\/shared\/([a-f0-9]+)$/);
if (m && method === "GET") {
const idea = getIdeaByShareToken(m[1]!);
return idea ? json(publicView(idea, true)) : err("Not found", 404);
}
// ---- Everything below requires authentication ----
const user = currentUser(req);
if (!user) return err("Not authenticated", 401);
return handleAuthedApi(req, pathname, method, user);
}
async function handleAuthedApi(
req: Request,
pathname: string,
method: string,
user: User
): Promise<Response> {
// /api/ideas
if (pathname === "/api/ideas") {
if (method === "GET") return json(listIdeas());
if (method === "GET") return json(listIdeas(user.id));
if (method === "POST") {
const { title, description } = await readJson(req);
if (!title || typeof title !== "string" || !title.trim())
return err("Title is required");
return json(
createIdea(title.trim(), (description ?? "").toString().trim()),
createIdea(user.id, title.trim(), (description ?? "").toString().trim()),
201
);
}
@@ -89,7 +171,7 @@ async function handleApi(req: Request, pathname: string): Promise<Response> {
if (m) {
const id = Number(m[1]);
if (method === "GET") {
const idea = getIdea(id);
const idea = getOwnedIdea(id, user.id);
return idea ? json(idea) : err("Idea not found", 404);
}
if (method === "PUT") {
@@ -97,13 +179,41 @@ async function handleApi(req: Request, pathname: string): Promise<Response> {
if (!title || !title.trim()) return err("Title is required");
const updated = updateIdea(
id,
user.id,
title.trim(),
(description ?? "").toString().trim()
);
return updated ? json(updated) : err("Idea not found", 404);
}
if (method === "DELETE") {
return deleteIdea(id) ? json({ ok: true }) : err("Idea not found", 404);
return deleteIdea(id, user.id)
? json({ ok: true })
: err("Idea not found", 404);
}
return err("Method not allowed", 405);
}
// /api/ideas/:id/visibility { visibility: 'public' | 'private' }
m = pathname.match(/^\/api\/ideas\/(\d+)\/visibility$/);
if (m && method === "PUT") {
const { visibility } = await readJson(req);
if (visibility !== "public" && visibility !== "private")
return err("visibility must be 'public' or 'private'");
const updated = setVisibility(Number(m[1]), user.id, visibility);
return updated ? json(updated) : err("Idea not found", 404);
}
// /api/ideas/:id/share POST = enable/rotate, DELETE = disable
m = pathname.match(/^\/api\/ideas\/(\d+)\/share$/);
if (m) {
const id = Number(m[1]);
if (method === "POST") {
const updated = setShareToken(id, user.id, randomToken(16));
return updated ? json(updated) : err("Idea not found", 404);
}
if (method === "DELETE") {
const updated = setShareToken(id, user.id, null);
return updated ? json(updated) : err("Idea not found", 404);
}
return err("Method not allowed", 405);
}
@@ -112,7 +222,7 @@ async function handleApi(req: Request, pathname: string): Promise<Response> {
m = pathname.match(/^\/api\/ideas\/(\d+)\/notes$/);
if (m) {
const ideaId = Number(m[1]);
if (!getIdea(ideaId)) return err("Idea not found", 404);
if (!getOwnedIdea(ideaId, user.id)) return err("Idea not found", 404);
if (method === "GET") return json(listNotes(ideaId));
if (method === "POST") {
const { body } = await readJson(req);
@@ -124,15 +234,23 @@ async function handleApi(req: Request, pathname: string): Promise<Response> {
// /api/notes/:id
m = pathname.match(/^\/api\/notes\/(\d+)$/);
if (m) {
const id = Number(m[1]);
if (method === "DELETE") {
return deleteNote(id) ? json({ ok: true }) : err("Note not found", 404);
}
return err("Method not allowed", 405);
if (m && method === "DELETE") {
return deleteNoteOwned(Number(m[1]), user.id)
? json({ ok: true })
: err("Note not found", 404);
}
return err("Not found", 404);
}
function safeUser(u: User) {
return {
id: u.id,
name: u.name,
email: u.email,
avatar_url: u.avatar_url,
provider: u.provider,
};
}
console.log(`✨ Spark Slop running at http://localhost:${server.port}`);