Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"@profullstack/cli": "^0.1.0",
"@profullstack/footer": "latest",
"@profullstack/hqtui": "^0.11.0",
"@profullstack/profile": "0.1.3",
"@profullstack/synconfig": "^0.1.3",
"@profullstack/x402-gateway": "^0.11.0",
"highlight.js": "^11.12.0",
Expand Down
39 changes: 30 additions & 9 deletions server/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { Database } from "bun:sqlite";
import { createHash, randomBytes } from "node:crypto";
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { saveSignupProfile, validateProfile } from "@profullstack/profile";
import { profiles } from "./profile.ts";

const secret = () => randomBytes(32).toString("base64url");
const id = () => randomBytes(12).toString("base64url");
Expand All @@ -13,7 +15,7 @@ export type Account = { id: string; username: string; displayName: string; admin
export type Mail = { to: string; url: string };
export type SendMail = (mail: Mail) => Promise<void>;
export class AccountError extends Error {
constructor(public status: number, message: string) { super(message); }
constructor(public status: number, message: string, public errors?: Record<string, string>) { super(message); }
}
const publicColumns = "u.id,u.username,u.displayName,u.admin,u.createdAt,e.email,e.emailVerifiedAt";
/**
Expand Down Expand Up @@ -67,6 +69,11 @@ export class Accounts {
${IDENTITY_SCHEMA}
${OAUTH_SCHEMA}
`);
// The signup form's profile fields ride on the emailed link until it is spent.
const challengeColumns = this.db.query("PRAGMA table_info(email_challenges)").all() as { name: string }[];
if (!challengeColumns.some(column => column.name === "profile"))
this.db.exec("ALTER TABLE email_challenges ADD COLUMN profile TEXT");
profiles(this.db);
}
private rate(key: string, limit: number, seconds: number) {
const current = Date.now();
Expand Down Expand Up @@ -105,22 +112,29 @@ export class Accounts {
}
private challenge(token: unknown) {
if (typeof token !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(token)) throw new AccountError(400, "This sign-in link is invalid or has expired. Request a new link.");
const row = this.db.query("SELECT email FROM email_challenges WHERE tokenHash=? AND expiresAt>?").get(hash(token), stamp()) as { email: string } | null;
const row = this.db.query("SELECT email, profile FROM email_challenges WHERE tokenHash=? AND expiresAt>?").get(hash(token), stamp()) as { email: string; profile: string | null } | null;
if (!row) throw new AccountError(400, "This sign-in link is invalid or has expired. Request a new link.");
return row;
}
private async requestLink(input: unknown, ip: string, requestedNext?: unknown) {
private async requestLink(input: unknown, ip: string, requestedNext?: unknown, signup?: Record<string, unknown>) {
const next = typeof requestedNext === "string" && SAFE_NEXT.test(requestedNext) ? requestedNext : null;
this.rate(`send-ip:${ip}`, 20, 3600);
const email = typeof input === "string" ? input.trim().toLowerCase() : "";
if (email.length > 254 || !/^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/i.test(email) || email.split("@")[0].length > 64) {
throw new AccountError(400, "Enter a valid email address.");
}
// Creating an account asks for the shared profile fields; a plain sign-in never does.
let profile: string | null = null;
if (signup) {
const check = validateProfile({ ...signup, email });
if (!check.ok) throw new AccountError(400, Object.values(check.errors)[0] ?? "Some fields need fixing.", check.errors);
profile = JSON.stringify(check.profile);
}
this.rate(`send-email-minute:${email}`, 1, 60);
this.rate(`send-email-hour:${email}`, 5, 3600);
const token = secret();
this.db.query("DELETE FROM email_challenges WHERE expiresAt<=?").run(stamp());
this.db.query("INSERT INTO email_challenges VALUES (?,?,?,?)").run(hash(token), email, new Date(Date.now() + 15 * 60000).toISOString(), stamp());
this.db.query("INSERT INTO email_challenges (tokenHash,email,expiresAt,createdAt,profile) VALUES (?,?,?,?,?)").run(hash(token), email, new Date(Date.now() + 15 * 60000).toISOString(), stamp(), profile);
try { await this.sendMail({ to: email, url: `${this.origin}/account${next ? "?next=" + encodeURIComponent(next) : ""}#verify=${token}` }); }
catch {
this.db.query("DELETE FROM email_challenges WHERE tokenHash=?").run(hash(token));
Expand All @@ -130,7 +144,7 @@ export class Accounts {
}
private verify(token: unknown, oldToken: string) {
return this.db.transaction(() => {
const { email } = this.challenge(token);
const { email, profile } = this.challenge(token);
let identity = this.db.query("SELECT userId FROM account_emails WHERE email=?").get(email) as { userId: string } | null;
if (!identity) {
const userId = id();
Expand All @@ -142,9 +156,15 @@ export class Accounts {
}
this.db.query("DELETE FROM email_challenges WHERE email=?").run(email);
const session = this.startSession(identity.userId, oldToken, "Email sign-in");
return { user: this.authenticate(session)!, session };
return { user: this.authenticate(session)!, session, profile };
})();
}
/** Save the fields typed on the signup form. A failure never costs anybody their sign-in: the account page asks again. */
private async saveProfile(userId: string, email: string | null, profile: string | null) {
if (!profile) return;
try { await saveSignupProfile(await profiles(this.db).ready, userId, JSON.parse(profile), { email }); }
catch (error) { console.error("Signup profile save failed", error instanceof Error ? error.message : error); }
}
async handle(request: Request, ip = "unknown"): Promise<Response | null> {
const path = new URL(request.url).pathname;
if (!path.startsWith("/api/auth/")) return null;
Expand All @@ -162,11 +182,12 @@ export class Accounts {
let args: Record<string, unknown>;
try { args = JSON.parse(body); if (!args || typeof args !== "object" || Array.isArray(args)) throw new Error(); }
catch { throw new AccountError(400, "Invalid request."); }
if (path === "/api/auth/email") return reply(await this.requestLink(args.email, ip, args.next));
if (path === "/api/auth/email") return reply(await this.requestLink(args.email, ip, args.next, args.mode === "signup" ? args : undefined));
if (path === "/api/auth/preview" || path === "/api/auth/verify") {
this.rate(`verify:${ip}`, 60, 60);
if (path.endsWith("preview")) return reply(this.challenge(args.token));
if (path.endsWith("preview")) return reply({ email: this.challenge(args.token).email });
const result = this.verify(args.token, this.token(request));
await this.saveProfile(result.user.id, result.user.email, result.profile);
return reply({ user: result.user }, 200, this.cookie(result.session));
}
if (path === "/api/auth/logout") {
Expand All @@ -187,7 +208,7 @@ export class Accounts {
}
return reply({ error: "Not found." }, 404);
} catch (error) {
if (error instanceof AccountError) return reply({ error: error.message }, error.status);
if (error instanceof AccountError) return reply({ error: error.message, ...(error.errors ? { errors: error.errors } : {}) }, error.status);
console.error("Account request failed", error instanceof Error ? error.name : "Unknown error");
return reply({ error: "Something went wrong. Please try again." }, 500);
}
Expand Down
17 changes: 17 additions & 0 deletions server/api.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { createProfileHandlers } from "@profullstack/profile";
import { profiles } from "./profile.ts";
import { Store, HttpError, checksum, now, text, type User } from "./store.ts";
import { type CoinPay, isCoinPayToken } from "./coinpay.ts";
import { operate } from "./operations.ts";
Expand Down Expand Up @@ -157,6 +159,21 @@ export function createApi(
if (user) store.ensureWorkspace(user);
const path = url.pathname.slice(8);
if (path === "me" && request.method === "GET") return json({ user });
// The shared @profullstack/profile fields; private to the signed-in account.
if (path === "me/profile") {
const method = request.method as "GET" | "PUT" | "PATCH";
if (!["GET", "PUT", "PATCH"].includes(method)) throw new HttpError(405, "Method not allowed.");
if (method !== "GET" && request.headers.has("cookie") && !request.headers.has("authorization") && !sentOrigin)
throw new HttpError(403, "Browser writes require a same-origin Origin header.");
const handlers = createProfileHandlers({
store: await profiles(store.db).ready,
getUser: () => user && {
id: user.id,
email: store.get<{ email: string }>("SELECT email FROM account_emails WHERE userId=?", user.id)?.email ?? null,
},
});
return handlers[method](request);
}
// CoinPay's webhook: no session, the signature over the raw body is the credential.
if (path === "billing/coinpay/webhook" && request.method === "POST") {
if (!money) throw new HttpError(404, "API route not found.");
Expand Down
49 changes: 49 additions & 0 deletions server/profile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* The shared Profullstack profile (@profullstack/profile): email, first and last
* name, company domain, job title and LinkedIn URL, in the package's own
* `user_profiles` table keyed by users.id. Private to the account: nothing here is
* shown to other people. One store per database, created and migrated on first use
* the way the rest of the schema is.
*/
import type { Database } from "bun:sqlite";
import { createProfileStore, migrate, splitFullName, type ProfileStore } from "@profullstack/profile";

const stores = new WeakMap<Database, { store: ProfileStore; ready: Promise<ProfileStore> }>();

/** The profile store for this database; `ready` resolves once its table exists. */
export function profiles(db: Database) {
let entry = stores.get(db);
if (!entry) {
// bun:sqlite's cached db.query() statements, not the package's db.prepare(): a
// prepared statement nobody finalizes keeps the file open after close(), which
// Windows refuses to delete.
const adapter = { dialect: "sqlite" as const, query: async (text: string, params: unknown[] = []) => db.query(text).all(...(params as never[])) as Record<string, unknown>[] };
const store = createProfileStore(adapter, { keyType: "text" });
const ready = migrate(store).then(async () => {
await backfill(db, store);
return store;
});
ready.catch(error => console.error("Profile migration failed", error instanceof Error ? error.message : error));
entry = { store, ready };
stores.set(db, entry);
}
return entry;
}

/**
* One-time and idempotent: a display name someone typed as "First Last" becomes the
* profile's names. A single word, or the email's local part (the default name of an
* email signup), is a handle, not a name, and is left alone. fillBlanks never
* overwrites what a person saved.
*/
async function backfill(db: Database, store: ProfileStore) {
const rows = db.query(`SELECT u.id, u.displayName, e.email FROM users u LEFT JOIN account_emails e ON e.userId=u.id
WHERE instr(trim(u.displayName), ' ')>0 AND NOT EXISTS (SELECT 1 FROM user_profiles p WHERE p.user_id=u.id)`)
.all() as { id: string; displayName: string; email: string | null }[];
for (const row of rows) {
if (row.email && row.displayName.trim().toLowerCase() === row.email.split("@")[0]) continue;
const name = splitFullName(row.displayName);
try { await store.fillBlanks(row.id, { ...name, ...(row.email ? { email: row.email } : {}) }); }
catch { /* a name the validator refuses (a link, say) is skipped */ }
}
}
74 changes: 74 additions & 0 deletions server/test/profile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { afterEach, describe, expect, test } from "bun:test";
import { Accounts, type Mail } from "../accounts.ts";
import { createApi } from "../api.ts";
import { profiles } from "../profile.ts";
import { Store } from "../store.ts";

/**
* The shared @profullstack/profile fields: asked for only when creating an account,
* carried on the emailed link (the user row exists only once it is spent), saved on
* verify, and served at /api/v1/me/profile.
*/
const origin = "https://readm3.com";
const opened: Accounts[] = [];
afterEach(() => { for (const app of opened.splice(0)) app.db.close(); });
function fixture() {
const mail: Mail[] = [];
const app = new Accounts(":memory:", origin, async message => { mail.push(message); });
opened.push(app);
const store = new Store(app.db);
const api = createApi(store, origin);
const request = (action: string, args: unknown, cookie?: string) => app.handle(new Request(`${origin}/api/auth/${action}`, {
method: "POST",
headers: { "content-type": "application/json", origin, ...(cookie ? { cookie } : {}) },
body: JSON.stringify(args),
}), "127.0.0.1").then(r => r!);
const token = () => new URLSearchParams(new URL(mail.at(-1)!.url).hash.slice(1)).get("verify")!;
return { app, mail, api, request, token };
}

describe("signup profile", () => {
test("a signup with the fields stores them once the link is verified", async () => {
const { app, api, request, token } = fixture();
const sent = await request("email", {
email: "Ada@Acme.com", mode: "signup", first_name: "Ada", last_name: "Lovelace",
company_domain: "https://www.acme.com/about", job_title: "CTO", linkedin_url: "in/ada-lovelace",
});
expect(sent.status).toBe(200);
expect(app.db.query("SELECT * FROM users").all()).toHaveLength(0);
const verified = await request("verify", { token: token() });
expect(verified.status).toBe(200);
const { user } = await verified.json();
const saved = await (await profiles(app.db).ready).get(user.id);
expect(saved).toMatchObject({
email: "ada@acme.com", first_name: "Ada", last_name: "Lovelace",
company_domain: "acme.com", job_title: "CTO", linkedin_url: "https://www.linkedin.com/in/ada-lovelace",
});
const cookie = verified.headers.get("set-cookie")!.split(";")[0];
const response = await api(new Request(`${origin}/api/v1/me/profile`, { headers: { cookie } }));
expect(response!.status).toBe(200);
expect((await response!.json()).profile.job_title).toBe("CTO");
});

test("a signup missing a name is refused before any link is sent", async () => {
const { app, mail, request } = fixture();
const response = await request("email", { email: "ada@acme.com", mode: "signup", first_name: "Ada" });
expect(response.status).toBe(400);
expect((await response.json()).errors.last_name).toBeTruthy();
expect(mail).toHaveLength(0);
expect(app.db.query("SELECT * FROM email_challenges").all()).toHaveLength(0);
});

test("plain sign-in never asks for the fields", async () => {
const { app, request, token } = fixture();
expect((await request("email", { email: "person@example.com" })).status).toBe(200);
const { user } = await (await request("verify", { token: token() })).json();
expect(await (await profiles(app.db).ready).get(user.id)).toBeNull();
});

test("the profile API needs a session", async () => {
const { api } = fixture();
const response = await api(new Request(`${origin}/api/v1/me/profile`));
expect(response!.status).toBe(401);
});
});
Loading
Loading