benjivers/notes Public Public: anyone can read this; nobody can change it.

Email accounts: sign up without a forge account, each with a vault of their own

The app's own accounts beside forge sign-in: argon2id password hashes,
hashed session tokens in an HttpOnly cookie, rationed log-in, change
password (ends other sessions), delete account with its notes. Account ids
are acct:<n>, so they can never collide with a forge id. NOTES_SIGNUP_CODE
closes open sign-up without a deploy. A vault holds at most 5,000 notes.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Benjivers-bit committed 9a96f69 Browse files
14 files changed +635 −22
README.md +14 −3
@@ -5,9 +5,20 @@ A personal linked-notes vault, in the spirit of Obsidian — markdown,
55
66 ## Using it
77
8 Open the app and sign in with your forge account. Your notes are **yours
9 alone**: the contract gives every verb on a note to its author, so nobody
10 else who can open the app — admins included — can list, read or change them.
8 Open the app and **create a vault** with an email and password, or sign in
9 with a forge account. Your notes are **yours alone**: the contract gives
10 every verb on a note to its author, so nobody else who uses the app —
11 admins included — can list, read or change them.
12
13 **Email accounts** (`src/accounts.ts`) are the app's own: argon2id password
14 hashes, a random session token in an HttpOnly cookie (only its SHA-256 is
15 stored), 30-day sessions, log-in attempts rationed per email. The account
16 page changes the password (ending every other session) or deletes the
17 account with all its notes. There is no email-based reset — the app sends
18 no mail. Set `NOTES_SIGNUP_CODE` in Settings → App → Environment to require
19 a code for new accounts (closing open sign-up without a deploy). A vault
20 holds at most 5,000 notes. Email accounts reach the page and the JSON API;
21 the MCP endpoint and the terminal are for forge accounts.
1122
1223 - **Write** in markdown. It saves as you type (and with ⌘S). Edit, Split
1324 (side by side) and Read views; ⌘E flips between reading and writing.
contract/openapi.json +1 −1
@@ -2,7 +2,7 @@
22 "openapi": "3.1.0",
33 "info": {
44 "title": "notes",
5 "version": "1.0.0",
5 "version": "1.1.0",
66 "description": "A personal linked-notes vault — markdown, [[wikilinks]], backlinks, tags, graph."
77 },
88 "paths": {
package.json +1 −1
@@ -1,6 +1,6 @@
11 {
22 "name": "notes",
3 "version": "1.0.0",
3 "version": "1.1.0",
44 "description": "A personal linked-notes vault — markdown, [[wikilinks]], backlinks, tags, graph.",
55 "private": true,
66 "type": "module",
src/accounts.test.ts added +95
@@ -0,0 +1,95 @@
1 /**
2 * The app's own accounts: a person with no forge account signs up, gets a
3 * vault of their own, and nobody — forge user, admin, or another account —
4 * shares it. Passwords and session tokens are never stored as given.
5 */
6 import { expect, test } from "bun:test";
7 import { mkdtempSync } from "node:fs";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import {
11 changePassword,
12 deleteAccount,
13 isAccount,
14 logIn,
15 logOut,
16 sessionWho,
17 signUp,
18 } from "./accounts.ts";
19 import { addNote, getNote, listNotes } from "./actions.ts";
20 import { openDb } from "./db.ts";
21 import type { Who } from "./who.ts";
22
23 const db = openDb(mkdtempSync(join(tmpdir(), "app-accounts-")));
24 const must = <T>(r: { ok: true; value: T } | { ok: false; message: string }): T => {
25 if (!r.ok) throw new Error(r.message);
26 return r.value;
27 };
28
29 test("sign up makes a person with a vault of their own", async () => {
30 const { account, token } = must(await signUp(db, { email: " [email protected] ", password: "correct horse", name: "Sam" }, ""));
31 expect(account.email).toBe("[email protected]");
32 const sam = sessionWho(db, token);
33 expect(sam).toEqual({ role: "user", id: `acct:${account.id}`, name: "Sam" });
34 expect(isAccount(sam)).toBe(true);
35 const note = must(addNote(db, sam, { title: "Sam's", body: "hidden" }));
36 // A forge user whose id happens to be the same number, and an admin, see nothing of it.
37 const forge: Who = { role: "user", id: String(account.id), name: "forge" };
38 const admin: Who = { role: "admin", id: "u0", name: "root" };
39 expect(must(listNotes(db, forge))).toEqual([]);
40 expect(must(listNotes(db, admin))).toEqual([]);
41 expect(getNote(db, admin, { id: note.id }).ok).toBe(false);
42 // Nothing stored is the password or the token itself.
43 const stored = JSON.stringify(db.query("SELECT * FROM account").all()) + JSON.stringify(db.query("SELECT * FROM session").all());
44 expect(stored).not.toContain("correct horse");
45 expect(stored).not.toContain(token);
46 });
47
48 test("sign up refuses a bad email, a short password, a taken email, or a wrong code", async () => {
49 for (const [input, code] of [
50 [{ email: "not-an-email", password: "longenough" }, ""],
51 [{ email: "[email protected]", password: "short" }, ""],
52 [{ email: "[email protected]", password: "longenough" }, ""],
53 [{ email: "[email protected]", password: "longenough", code: "nope" }, "secret"],
54 ] as const) {
55 const r = await signUp(db, input, code);
56 expect([input.email, r.ok]).toEqual([input.email, false]);
57 }
58 expect((await signUp(db, { email: "[email protected]", password: "longenough", code: "secret" }, "secret")).ok).toBe(true);
59 });
60
61 test("log in takes the right password only, and log out ends that session", async () => {
62 expect((await logIn(db, { email: "[email protected]", password: "wrong horse" })).ok).toBe(false);
63 expect((await logIn(db, { email: "[email protected]", password: "correct horse" })).ok).toBe(false);
64 const { token } = must(await logIn(db, { email: "[email protected]", password: "correct horse" }));
65 expect(sessionWho(db, token).role).toBe("user");
66 logOut(db, token);
67 expect(sessionWho(db, token)).toEqual({ role: "anon" });
68 expect(sessionWho(db, "x".repeat(43))).toEqual({ role: "anon" });
69 expect(sessionWho(db, undefined)).toEqual({ role: "anon" });
70 });
71
72 test("an expired session opens nothing", async () => {
73 const { token } = must(await logIn(db, { email: "[email protected]", password: "correct horse" }));
74 db.query("UPDATE session SET expires_at = '2000-01-01T00:00:00.000Z'").run();
75 expect(sessionWho(db, token)).toEqual({ role: "anon" });
76 });
77
78 test("a new password ends every other session; deleting takes the vault with it", async () => {
79 const first = must(await logIn(db, { email: "[email protected]", password: "correct horse" }));
80 const second = must(await logIn(db, { email: "[email protected]", password: "correct horse" }));
81 const sam = sessionWho(db, first.token);
82 expect((await changePassword(db, sam, { current: "wrong", next: "battery staple" })).ok).toBe(false);
83 const changed = must(await changePassword(db, sam, { current: "correct horse", next: "battery staple" }));
84 expect(sessionWho(db, first.token).role).toBe("anon");
85 expect(sessionWho(db, second.token).role).toBe("anon");
86 expect(sessionWho(db, changed.token).role).toBe("user");
87 expect((await logIn(db, { email: "[email protected]", password: "battery staple" })).ok).toBe(true);
88 expect((await deleteAccount(db, sam, { password: "correct horse" })).ok).toBe(false);
89 const gone = must(await deleteAccount(db, sam, { password: "battery staple" }));
90 expect(gone.notes).toBe(1);
91 expect(sessionWho(db, changed.token).role).toBe("anon");
92 expect((await logIn(db, { email: "[email protected]", password: "battery staple" })).ok).toBe(false);
93 // A forge user has no password here to change.
94 expect((await changePassword(db, { role: "user", id: "u1", name: "a" }, { current: "x", next: "yyyyyyyy" })).ok).toBe(false);
95 });
src/accounts.ts added +193
@@ -0,0 +1,193 @@
1 /**
2 * The app's own accounts: an email and a password, for people who have no
3 * forge account. Whoever signs in this way is a `user` — exactly what the
4 * forge would have signed for a person allowed to see the app — with an id
5 * of `acct:<n>`, which no forge id can be, so the contract treats both kinds
6 * of person the same and their vaults never meet. Admin is only ever the
7 * forge's word.
8 *
9 * Passwords are hashed with argon2id (`Bun.password`). A session is a random
10 * token in an HttpOnly cookie; only its SHA-256 is stored, so a copy of the
11 * database opens nobody's vault. Signing in replaces nothing — each device
12 * has its own session — and signing out ends that one.
13 *
14 * If `NOTES_SIGNUP_CODE` is set (Settings → App → Environment), creating an
15 * account needs it: the switch for closing sign-up without a deploy.
16 */
17
18 import type { Database } from "bun:sqlite";
19 import { createHash, randomBytes } from "node:crypto";
20 import { record } from "./audit.ts";
21 import type { Result } from "./actions.ts";
22 import type { Who } from "./who.ts";
23
24 export const SESSION_DAYS = 30;
25 export const PASSWORD_MIN = 8;
26 export const PASSWORD_MAX = 200;
27 const NAME_MAX = 60;
28
29 export interface Account {
30 readonly id: number;
31 readonly email: string;
32 readonly name: string;
33 readonly created_at: string;
34 }
35
36 const ok = <T>(value: T): Result<T> => ({ ok: true, value });
37 const fail = <T>(error: "invalid" | "forbidden" | "signin_required", message: string): Result<T> => ({
38 ok: false,
39 error,
40 message,
41 });
42
43 /** The person an account signs in as. */
44 export const whoOf = (a: Pick<Account, "id" | "name">): Who & { role: "user" } => ({
45 role: "user",
46 id: `acct:${a.id}`,
47 name: a.name,
48 });
49
50 /** Whether `who` signed in with the app's own account (and not the forge). */
51 export const isAccount = (who: Who): who is Who & { role: "user" } =>
52 who.role !== "anon" && who.id.startsWith("acct:");
53
54 const digest = (token: string): string => createHash("sha256").update(token).digest("hex");
55
56 const cleanEmail = (raw: string): string => raw.trim().toLowerCase();
57 const EMAIL = /^[^\s@]{1,64}@[^\s@]{1,190}\.[^\s@]{2,63}$/;
58
59 // Spent on a sign-in for an email nobody has, so it takes as long as a real one.
60 let decoy: string | undefined;
61 const decoyHash = async (): Promise<string> => (decoy ??= await Bun.password.hash("not a password at all"));
62
63 function checkPassword(password: string): Result<string> {
64 if (password.length < PASSWORD_MIN) return fail("invalid", `A password is at least ${PASSWORD_MIN} characters.`);
65 if (password.length > PASSWORD_MAX) return fail("invalid", `A password is at most ${PASSWORD_MAX} characters.`);
66 return ok(password);
67 }
68
69 /** A fresh session for an account: the token for the cookie, and only its hash kept. */
70 function startSession(db: Database, accountId: number): string {
71 const token = randomBytes(32).toString("base64url");
72 db.query(
73 `INSERT INTO session (token_hash, account_id, expires_at)
74 VALUES (?1, ?2, strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '+${SESSION_DAYS} days'))`,
75 ).run(digest(token), accountId);
76 // Expired sessions go as new ones come.
77 db.query("DELETE FROM session WHERE expires_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now')").run();
78 return token;
79 }
80
81 export async function signUp(
82 db: Database,
83 input: { email: string; password: string; name?: string; code?: string },
84 signupCode = process.env.NOTES_SIGNUP_CODE ?? "",
85 ): Promise<Result<{ account: Account; token: string }>> {
86 const anon: Who = { role: "anon" };
87 if (signupCode !== "" && (input.code ?? "").trim() !== signupCode) {
88 record(db, { who: anon, resource: "account", verb: "create", outcome: "forbidden" });
89 return fail("forbidden", "That sign-up code isn't right.");
90 }
91 const email = cleanEmail(input.email);
92 if (!EMAIL.test(email)) return fail("invalid", "That doesn't look like an email address.");
93 const password = checkPassword(input.password);
94 if (!password.ok) return password;
95 const name = (input.name ?? "").replace(/\s+/g, " ").trim().slice(0, NAME_MAX) || email.split("@")[0]!;
96 const taken = db.query<{ id: number }, [string]>("SELECT id FROM account WHERE email = ?1").get(email);
97 if (taken !== null) return fail("invalid", "There's already an account with that email. Log in instead.");
98 const hash = await Bun.password.hash(password.value);
99 const account = db
100 .query<Account, [string, string, string]>(
101 "INSERT INTO account (email, name, password_hash) VALUES (?1, ?2, ?3) RETURNING id, email, name, created_at",
102 )
103 .get(email, name, hash)!;
104 record(db, { who: whoOf(account), resource: "account", verb: "create", target: account.id, outcome: "ok" });
105 return ok({ account, token: startSession(db, account.id) });
106 }
107
108 export async function logIn(
109 db: Database,
110 input: { email: string; password: string },
111 ): Promise<Result<{ account: Account; token: string }>> {
112 const email = cleanEmail(input.email);
113 const row = db
114 .query<Account & { password_hash: string }, [string]>("SELECT * FROM account WHERE email = ?1")
115 .get(email);
116 const good = await Bun.password.verify(input.password.slice(0, PASSWORD_MAX), row?.password_hash ?? (await decoyHash()));
117 if (row === null || !good) {
118 record(db, { who: { role: "anon" }, resource: "session", verb: "create", outcome: "forbidden" });
119 return fail("forbidden", "That email and password don't match an account.");
120 }
121 const { password_hash: _, ...account } = row;
122 record(db, { who: whoOf(account), resource: "session", verb: "create", target: account.id, outcome: "ok" });
123 return ok({ account, token: startSession(db, account.id) });
124 }
125
126 /** The person a session cookie belongs to, or anonymous. */
127 export function sessionWho(db: Database, token: string | undefined): Who {
128 if (token === undefined || token.length < 20 || token.length > 100) return { role: "anon" };
129 const row = db
130 .query<{ id: number; name: string }, [string]>(
131 `SELECT a.id, a.name FROM session s JOIN account a ON a.id = s.account_id
132 WHERE s.token_hash = ?1 AND s.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`,
133 )
134 .get(digest(token));
135 return row === null ? { role: "anon" } : whoOf(row);
136 }
137
138 export function logOut(db: Database, token: string | undefined): void {
139 if (token !== undefined) db.query("DELETE FROM session WHERE token_hash = ?1").run(digest(token));
140 }
141
142 const accountIdOf = (who: Who): number | undefined =>
143 isAccount(who) ? Number(who.id.slice("acct:".length)) : undefined;
144
145 export function getAccount(db: Database, who: Who): Account | undefined {
146 const id = accountIdOf(who);
147 if (id === undefined) return undefined;
148 return db.query<Account, [number]>("SELECT id, email, name, created_at FROM account WHERE id = ?1").get(id) ?? undefined;
149 }
150
151 /** A new password, given the current one. Every other session ends; this device gets a fresh one. */
152 export async function changePassword(
153 db: Database,
154 who: Who,
155 input: { current: string; next: string },
156 ): Promise<Result<{ token: string }>> {
157 const id = accountIdOf(who);
158 if (id === undefined) return fail("signin_required", "Log in with an email account to change its password.");
159 const row = db.query<{ password_hash: string }, [number]>("SELECT password_hash FROM account WHERE id = ?1").get(id);
160 if (row === null || !(await Bun.password.verify(input.current.slice(0, PASSWORD_MAX), row.password_hash))) {
161 record(db, { who, resource: "account", verb: "update", target: id, outcome: "forbidden" });
162 return fail("forbidden", "Your current password isn't right.");
163 }
164 const next = checkPassword(input.next);
165 if (!next.ok) return next;
166 db.query("UPDATE account SET password_hash = ?1 WHERE id = ?2").run(await Bun.password.hash(next.value), id);
167 db.query("DELETE FROM session WHERE account_id = ?1").run(id);
168 record(db, { who, resource: "account", verb: "update", target: id, outcome: "ok" });
169 return ok({ token: startSession(db, id) });
170 }
171
172 /** The account, its sessions and every note in its vault — gone, given the password. */
173 export async function deleteAccount(
174 db: Database,
175 who: Who,
176 input: { password: string },
177 ): Promise<Result<{ notes: number }>> {
178 const id = accountIdOf(who);
179 if (id === undefined) return fail("signin_required", "Log in with an email account to delete it.");
180 const row = db.query<{ password_hash: string }, [number]>("SELECT password_hash FROM account WHERE id = ?1").get(id);
181 if (row === null || !(await Bun.password.verify(input.password.slice(0, PASSWORD_MAX), row.password_hash))) {
182 record(db, { who, resource: "account", verb: "delete", target: id, outcome: "forbidden" });
183 return fail("forbidden", "That password isn't right.");
184 }
185 const notes = db.transaction(() => {
186 const gone = db.query("DELETE FROM note WHERE author_id = ?1").run(`acct:${id}`).changes;
187 db.query("DELETE FROM session WHERE account_id = ?1").run(id);
188 db.query("DELETE FROM account WHERE id = ?1").run(id);
189 return gone;
190 })();
191 record(db, { who, resource: "account", verb: "delete", target: id, outcome: "ok" });
192 return ok({ notes });
193 }
src/actions.ts +5
@@ -154,6 +154,11 @@ function add(
154154 return refuse(who, "Sign in to add a note.", "You may not add a note.");
155155 const body = checkBody(input.body ?? "");
156156 if (!body.ok) return body;
157 const count = db
158 .query<{ n: number }, [string]>("SELECT count(*) AS n FROM note WHERE author_id = ?1")
159 .get(who.id)!.n;
160 if (count >= VAULT_MAX)
161 return fail("invalid", `A vault holds at most ${VAULT_MAX.toLocaleString("en")} notes.`);
157162 let title: string;
158163 if (input.title === undefined || input.title.trim() === "")
159164 title = freeTitle(db, who.id, "Untitled");
src/db.ts +16
@@ -57,6 +57,22 @@ export const migrations: string[] = [
5757 `ALTER TABLE page RENAME TO note`,
5858 `CREATE UNIQUE INDEX note_title ON note (author_id, title COLLATE NOCASE)`,
5959 `CREATE INDEX note_recent ON note (author_id, updated_at)`,
60 // The app's own accounts, for people without a forge account (src/accounts.ts).
61 // A session keeps only its token's hash.
62 `CREATE TABLE account (
63 id INTEGER PRIMARY KEY,
64 email TEXT NOT NULL UNIQUE CHECK (length(email) BETWEEN 3 AND 320),
65 name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 60),
66 password_hash TEXT NOT NULL,
67 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
68 )`,
69 `CREATE TABLE session (
70 token_hash TEXT PRIMARY KEY,
71 account_id INTEGER NOT NULL REFERENCES account (id) ON DELETE CASCADE,
72 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
73 expires_at TEXT NOT NULL
74 )`,
75 `CREATE INDEX session_account ON session (account_id)`,
6076 ];
6177
6278 export function openDb(dir = dataDir): Database {
src/design.test.ts +1
@@ -78,6 +78,7 @@ test("every pair a person reads is at least 4.5:1, in both themes", () => {
7878 ["color-tag-fg", "color-tag-bg"],
7979 ["color-accent", "color-active"],
8080 ["color-danger", "color-bg"],
81 ["color-bg", "color-danger"],
8182 ["color-link-missing", "color-bg"],
8283 ["color-callout-info", "color-bg"],
8384 ["color-callout-tip", "color-bg"],
src/http.ts +13 −11
@@ -75,17 +75,19 @@ export const back = (notice?: string): Response =>
7575 });
7676
7777 /** To one of the app's own pages, with a word for it to show once. Only a path on this site: never an address a request supplied. */
78 export const go = (path: string, notice?: string): Response =>
79 new Response(null, {
80 status: 303,
81 headers: {
82 location: path.startsWith("/") && !path.startsWith("//") ? path : "/",
83 ...SECURITY,
84 ...(notice === undefined
85 ? {}
86 : { "set-cookie": cookie("notice", encodeURIComponent(notice), 30) }),
87 },
78 export function go(path: string, notice?: string, cookies: string[] = []): Response {
79 const headers = new Headers({
80 location: path.startsWith("/") && !path.startsWith("//") ? path : "/",
81 ...SECURITY,
8882 });
83 if (notice !== undefined) headers.append("set-cookie", cookie("notice", encodeURIComponent(notice), 30));
84 for (const c of cookies) headers.append("set-cookie", c);
85 return new Response(null, { status: 303, headers });
86 }
87
88 /** One cookie the request carries, as sent. */
89 export const readCookie = (request: Request, name: string): string | undefined =>
90 new RegExp(`(?:^|;\\s*)${name}=([^;]*)`).exec(request.headers.get("cookie") ?? "")?.[1];
8991
9092 /** The notice a redirect left, and the header that clears it. */
9193 export function readNotice(request: Request): {
@@ -104,7 +106,7 @@ export function readNotice(request: Request): {
104106
105107 // Every cookie the app sets itself is HttpOnly and SameSite=Lax, and Secure
106108 // wherever the public address is https.
107 const cookie = (name: string, value: string, maxAge: number): string =>
109 export const cookie = (name: string, value: string, maxAge: number): string =>
108110 `${name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${publicOrigin.startsWith("https:") ? "; Secure" : ""}`;
109111
110112 // A request body is read up to a cap and no further, counting the bytes as
src/page.ts +67 −4
@@ -6,6 +6,7 @@
66 * Without JavaScript every page still reads, and the editor still saves.
77 */
88
9 import { isAccount, type Account } from "./accounts.ts";
910 import { canDelete, type Note } from "./actions.ts";
1011 import { commit, description, env, publicOrigin, title, where } from "./env.ts";
1112 import { asset, escape } from "./http.ts";
@@ -71,7 +72,7 @@ ${body}
7172 `;
7273 }
7374
74 /** Signed out: what this is, and the way in. */
75 /** Signed out: what this is, and the ways in. */
7576 export function landing(notice?: string): string {
7677 return layout(
7778 `<div class="landing">
@@ -79,8 +80,12 @@ export function landing(notice?: string): string {
7980 <div class="mark" aria-hidden="true">${glyph}</div>
8081 <h1>${escape(title)}</h1>
8182 <p class="lede">A private vault for your thinking. Markdown notes, linked with <code>[[double brackets]]</code>, with backlinks, tags and a graph of how it all connects.</p>
82 <a class="button button-big" href="/__forge/signin?to=/">Sign in to open your vault</a>
83 <p class="muted small">Your notes are yours alone — nobody else who can open this app can read them.</p>
83 <div class="row center">
84 <a class="button button-big" href="/signup">Create your vault</a>
85 <a class="button button-big button-quiet" href="/login">Log in</a>
86 </div>
87 <p class="muted small">Have a forge account? <a href="/__forge/signin?to=/">Sign in with the forge</a>.</p>
88 <p class="muted small">Your notes are yours alone — nobody else who uses this app can read them.</p>
8489 </div>
8590 ${toast(notice)}
8691 </div>`,
@@ -89,6 +94,35 @@ export function landing(notice?: string): string {
8994 );
9095 }
9196
97 /** Making an account, or logging in to one. */
98 export function authPage(input: {
99 mode: "signup" | "login";
100 notice?: string | undefined;
101 email?: string;
102 name?: string;
103 needsCode: boolean;
104 }): string {
105 const signup = input.mode === "signup";
106 return layout(
107 `<div class="landing">
108 <form class="landing-card auth" method="post" action="/${input.mode}">
109 <a class="mark" href="/" aria-label="Back">${glyph}</a>
110 <h1>${signup ? "Create your vault" : "Welcome back"}</h1>
111 ${input.notice === undefined ? "" : `<p class="auth-error" role="alert">${escape(input.notice)}</p>`}
112 ${signup ? `<label class="field"><span>Name <span class="muted">(optional)</span></span><input class="input" name="name" value="${escape(input.name ?? "")}" maxlength="60" autocomplete="name"></label>` : ""}
113 <label class="field"><span>Email</span><input class="input" type="email" name="email" value="${escape(input.email ?? "")}" required maxlength="320" autocomplete="email" autofocus></label>
114 <label class="field"><span>Password</span><input class="input" type="password" name="password" required minlength="${signup ? 8 : 1}" maxlength="200" autocomplete="${signup ? "new-password" : "current-password"}"></label>
115 ${signup && input.needsCode ? `<label class="field"><span>Sign-up code</span><input class="input" name="code" required autocomplete="off"></label>` : ""}
116 ${signup ? `<p class="muted small">At least 8 characters. There's no email reset, so keep it somewhere safe.</p>` : ""}
117 <button class="button button-big button-wide">${signup ? "Create account" : "Log in"}</button>
118 <p class="muted small">${signup ? `Already have one? <a href="/login">Log in</a>` : `New here? <a href="/signup">Create a vault</a>`} · <a href="/__forge/signin?to=/">Forge sign-in</a></p>
119 </form>
120 </div>`,
121 `/${input.mode}`,
122 { name: signup ? "Create your vault" : "Log in", kind: "page landing-page" },
123 );
124 }
125
92126 const glyph = `<svg viewBox="0 0 32 32" width="40" height="40"><path d="M16 2 28 9v14l-12 7L4 23V9z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="M16 9v16M10 12.5l12 7M22 12.5l-12 7" stroke="currentColor" stroke-width="1.5" opacity=".55"/><circle cx="16" cy="16" r="2.6" fill="currentColor"/></svg>`;
93127
94128 const toast = (notice?: string): string =>
@@ -104,6 +138,7 @@ const icon = {
104138 pin: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 4h6l-1 6 4 3v2H6v-2l4-3zM12 15v6"/></svg>`,
105139 trash: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3"/></svg>`,
106140 theme: `<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8"/><path d="M12 4a8 8 0 0 0 0 16z" fill="currentColor"/></svg>`,
141 user: `<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="4"/><path d="M4 21c1.5-4 4.5-6 8-6s6.5 2 8 6"/></svg>`,
107142 download: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4v11M7 10l5 5 5-5M5 20h14"/></svg>`,
108143 };
109144
@@ -146,7 +181,11 @@ function left(s: Shell): string {
146181 <div class="left-foot">
147182 <span class="you" title="${escape(where)}${commit ? ` · ${escape(commit)}` : ""}">${escape(s.who.name)}${env === "production" ? "" : ` · <span class="pill">${escape(where)}</span>`}</span>
148183 <a class="icon-button" href="/export" aria-label="Download every note" title="Download everything">${icon.download}</a>
149 <a class="button button-quiet" href="/__forge/signout">Sign out</a>
184 ${
185 isAccount(s.who)
186 ? `<a class="icon-button" href="/account" aria-label="Your account" title="Account">${icon.user}</a><form method="post" action="/logout"><button class="button button-quiet">Log out</button></form>`
187 : `<a class="button button-quiet" href="/__forge/signout">Sign out</a>`
188 }
150189 </div>
151190 </aside>`;
152191 }
@@ -306,6 +345,30 @@ export function graphPage(s: Shell): string {
306345 return layout(shell(s, center), "/graph", { name: "Graph", kind: "page app-page graph-page", scripts: ["app.js", "graph.js"] });
307346 }
308347
348 /** An email account: who you are, a new password, or leaving. */
349 export function accountPage(s: Shell & { account: Account }): string {
350 const a = s.account;
351 const center = `${toolbar(s, `<a href="/">Vault</a><span aria-hidden="true">/</span><span class="crumb-here">Account</span>`)}
352 <div class="sheet results">
353 <h1 class="results-head">${escape(a.name)}</h1>
354 <p class="muted">${escape(a.email)} · ${s.vault.length} ${s.vault.length === 1 ? "note" : "notes"} · since ${long(a.created_at)}</p>
355 <form class="settings" method="post" action="/account/password">
356 <h2 class="section">Change password</h2>
357 <label class="field"><span>Current password</span><input class="input" type="password" name="current" required autocomplete="current-password" maxlength="200"></label>
358 <label class="field"><span>New password</span><input class="input" type="password" name="next" required minlength="8" maxlength="200" autocomplete="new-password"></label>
359 <p class="muted small">Logs you out everywhere else.</p>
360 <button class="button">Change password</button>
361 </form>
362 <form class="settings" method="post" action="/account/delete" data-confirm="Delete your account and all ${s.vault.length} notes? This cannot be undone.">
363 <h2 class="section">Delete account</h2>
364 <p class="muted small">Your account and every note in your vault, gone for good. <a href="/export">Download everything</a> first.</p>
365 <label class="field"><span>Password</span><input class="input" type="password" name="password" required autocomplete="current-password" maxlength="200"></label>
366 <button class="button button-danger">Delete my account</button>
367 </form>
368 </div>`;
369 return layout(shell(s, center), "/account", { name: "Account", kind: "page app-page", scripts: ["app.js"] });
370 }
371
309372 /** The graph's data, for any page that draws one. */
310373 export const graphData = (vault: readonly Note[]): string => data("graph", graph(vault), CHANGES);
311374
src/routes.ts +129
@@ -32,8 +32,18 @@ import {
3232 type Note,
3333 type Result,
3434 } from "./actions.ts";
35 import {
36 changePassword,
37 deleteAccount,
38 getAccount,
39 logIn,
40 logOut,
41 SESSION_DAYS,
42 signUp,
43 } from "./accounts.ts";
3544 import { commit, env, forgeUrl, startedAt, version } from "./env.ts";
3645 import {
46 cookie,
3747 fields,
3848 go,
3949 html,
@@ -41,6 +51,7 @@ import {
4151 NOTE_BYTES,
4252 readForm,
4353 readJson,
54 readCookie,
4455 readNotice,
4556 SECURITY,
4657 staticFile,
@@ -49,6 +60,8 @@ import {
4960 import { events } from "./live.ts";
5061 import { rpc } from "./mcp.ts";
5162 import {
63 accountPage,
64 authPage,
5265 emptyPage,
5366 graphPage,
5467 landing,
@@ -57,7 +70,18 @@ import {
5770 searchPage,
5871 } from "./page.ts";
5972 import { tokensCss } from "./tokens.ts";
73 import { allow } from "./security.ts";
6074 import { search } from "./vault.ts";
75
76 /** The app's own sign-in: a random token, HttpOnly, for SESSION_DAYS. */
77 export const SESSION_COOKIE = "sid";
78 const session = (token: string): string => cookie(SESSION_COOKIE, token, SESSION_DAYS * 86_400);
79 const ended = (): string => cookie(SESSION_COOKIE, "", 0);
80 // Guessing is rationed: per email, and for everyone together (behind the
81 // forge every stranger arrives from one address).
82 const LOGIN_PER_EMAIL = { burst: 8, perSecond: 1 / 60 };
83 const LOGIN_ALL = { burst: 60, perSecond: 1 };
84 const SIGNUP_ALL = { burst: 10, perSecond: 1 / 360 };
6185 import type { Who } from "./who.ts";
6286
6387 export interface Context {
@@ -406,6 +430,111 @@ export const routes: readonly Route[] = [
406430 },
407431 },
408432
433 // The app's own accounts: sign up, log in, log out, and the account page.
434 {
435 method: "GET",
436 path: "/signup",
437 handle: (c) => {
438 if (c.who.role !== "anon") return go("/");
439 const { notice, clear } = readNotice(c.request);
440 return html(authPage({ mode: "signup", notice, needsCode: (process.env.NOTES_SIGNUP_CODE ?? "") !== "" }), 200, clear);
441 },
442 },
443 {
444 method: "POST",
445 path: "/signup",
446 handle: async (c) => {
447 const form = await readForm(c.request);
448 if (form === undefined) return text("too large", 413);
449 const email = String(form.get("email") ?? "");
450 const name = String(form.get("name") ?? "");
451 const again = (notice: string, status = 422) =>
452 html(authPage({ mode: "signup", notice, email, name, needsCode: (process.env.NOTES_SIGNUP_CODE ?? "") !== "" }), status);
453 if (!allow("signup", SIGNUP_ALL)) return again("Lots of people are signing up right now — try again in a few minutes.", 429);
454 const made = await signUp(c.db, {
455 email,
456 name,
457 password: String(form.get("password") ?? ""),
458 code: String(form.get("code") ?? ""),
459 });
460 if (!made.ok) return again(made.message, made.error === "forbidden" ? 403 : 422);
461 return go("/", undefined, [session(made.value.token)]);
462 },
463 },
464 {
465 method: "GET",
466 path: "/login",
467 handle: (c) => {
468 if (c.who.role !== "anon") return go("/");
469 const { notice, clear } = readNotice(c.request);
470 return html(authPage({ mode: "login", notice, needsCode: false }), 200, clear);
471 },
472 },
473 {
474 method: "POST",
475 path: "/login",
476 handle: async (c) => {
477 const form = await readForm(c.request);
478 if (form === undefined) return text("too large", 413);
479 const email = String(form.get("email") ?? "");
480 const again = (notice: string, status: number) =>
481 html(authPage({ mode: "login", notice, email, needsCode: false }), status);
482 if (!allow(`login:${email.trim().toLowerCase()}`, LOGIN_PER_EMAIL) || !allow("login", LOGIN_ALL))
483 return again("Too many tries. Wait a minute and try again.", 429);
484 const inside = await logIn(c.db, { email, password: String(form.get("password") ?? "") });
485 if (!inside.ok) return again(inside.message, 401);
486 return go("/", undefined, [session(inside.value.token)]);
487 },
488 },
489 {
490 method: "POST",
491 path: "/logout",
492 handle: (c) => {
493 logOut(c.db, readCookie(c.request, SESSION_COOKIE));
494 return go("/", "Logged out.", [ended()]);
495 },
496 },
497 {
498 method: "GET",
499 path: "/account",
500 handle: (c) => {
501 const v = vaultOf(c);
502 if (v instanceof Response) return v;
503 const account = getAccount(c.db, c.who);
504 if (account === undefined) return go("/", "Forge accounts are managed on the forge.");
505 return html(accountPage({ ...v, account }), 200, v.clear);
506 },
507 },
508 {
509 method: "POST",
510 path: "/account/password",
511 handle: async (c) => {
512 const form = await readForm(c.request);
513 if (form === undefined) return text("too large", 413);
514 if (!allow(`password:${c.who.role === "anon" ? "" : c.who.id}`, LOGIN_PER_EMAIL))
515 return go("/account", "Too many tries. Wait a minute and try again.");
516 const changed = await changePassword(c.db, c.who, {
517 current: String(form.get("current") ?? ""),
518 next: String(form.get("next") ?? ""),
519 });
520 if (!changed.ok) return go("/account", changed.message);
521 return go("/account", "Password changed.", [session(changed.value.token)]);
522 },
523 },
524 {
525 method: "POST",
526 path: "/account/delete",
527 handle: async (c) => {
528 const form = await readForm(c.request);
529 if (form === undefined) return text("too large", 413);
530 if (!allow(`password:${c.who.role === "anon" ? "" : c.who.id}`, LOGIN_PER_EMAIL))
531 return go("/account", "Too many tries. Wait a minute and try again.");
532 const gone = await deleteAccount(c.db, c.who, { password: String(form.get("password") ?? "") });
533 if (!gone.ok) return go("/account", gone.message);
534 return go("/", "Your account and notes are deleted.", [ended()]);
535 },
536 },
537
409538 // The JSON API: the same actions, the same identity, plain status codes.
410539 {
411540 method: "GET",
src/server.test.ts +47
@@ -222,6 +222,53 @@ test("a change is on its author's event stream only, and a late client hears wha
222222 expect(JSON.stringify(heard)).not.toContain("private words");
223223 });
224224
225 test("someone with no forge account signs up, gets their own vault, and logs out", async () => {
226 const form = (fields: Record<string, string>) => {
227 const f = new FormData();
228 for (const [k, v] of Object.entries(fields)) f.set(k, v);
229 return { method: "POST", body: f, redirect: "manual" as const };
230 };
231 const landing = await (await fetch(base + "/")).text();
232 expect(landing).toContain('href="/signup"');
233 expect(landing).toContain('href="/login"');
234 const made = await fetch(base + "/signup", form({ email: "[email protected]", password: "a long password", name: "Pat" }));
235 expect(made.status).toBe(303);
236 const set = made.headers.getSetCookie().find((c) => c.startsWith("sid="))!;
237 expect(set).toContain("HttpOnly");
238 expect(set).toContain("SameSite=Lax");
239 const sid = { cookie: set.split(";")[0]! };
240 // Signed in by the cookie alone: an empty vault, theirs.
241 const home = await (await api("/", {}, sid)).text();
242 expect(home).toContain("Your vault is empty");
243 expect(home).toContain('action="/logout"');
244 const note = await api("/api/notes", { method: "POST", ...jsonBody({ title: "Pat's secret" }) }, sid);
245 expect(note.status).toBe(201);
246 const id = ((await note.json()) as { id: number }).id;
247 // Not alice's, not a stranger's.
248 expect((await api(`/api/notes/${id}`, {}, alice)).status).toBe(403);
249 expect((await fetch(`${base}/api/notes/${id}`)).status).toBe(401);
250 expect(((await (await api("/api/notes", {}, alice)).json()) as { title: string }[]).map((n) => n.title)).not.toContain("Pat's secret");
251 // The same email can't sign up twice; the wrong password doesn't log in.
252 expect((await fetch(base + "/signup", form({ email: "[email protected]", password: "another password" }))).status).toBe(422);
253 expect((await fetch(base + "/login", form({ email: "[email protected]", password: "wrong password" }))).status).toBe(401);
254 // A session cookie made up is nobody.
255 expect((await api("/api/notes", {}, { cookie: `sid=${"A".repeat(43)}` })).status).toBe(401);
256 // Log out ends it; log in again finds the same vault.
257 const out = await api("/logout", { method: "POST", redirect: "manual" }, sid);
258 expect(out.headers.getSetCookie().some((c) => c.startsWith("sid=;") && c.includes("Max-Age=0"))).toBe(true);
259 expect((await api("/api/notes", {}, sid)).status).toBe(401);
260 const back = await fetch(base + "/login", form({ email: "[email protected]", password: "a long password" }));
261 expect(back.status).toBe(303);
262 const again = { cookie: back.headers.getSetCookie().find((c) => c.startsWith("sid="))!.split(";")[0]! };
263 const notes = (await (await api("/api/notes", {}, again)).json()) as { id: number }[];
264 expect(notes.map((n) => n.id)).toEqual([id]);
265 expect(await (await api("/account", {}, again)).text()).toContain("[email protected]");
266 // A cross-site page can't log someone in or out.
267 expect(
268 (await fetch(base + "/login", { ...form({ email: "[email protected]", password: "a long password" }), headers: { "sec-fetch-site": "cross-site" } })).status,
269 ).toBe(403);
270 });
271
225272 test("writes are rationed per person", async () => {
226273 const flood = as("u9", "flood");
227274 const answers = await Promise.all(
src/server.ts +7 −2
@@ -9,7 +9,9 @@
99
1010 import { openDb } from "./db.ts";
1111 import { dataDir, identityKey, title, where } from "./env.ts";
12 import { html, text } from "./http.ts";
12 import { sessionWho } from "./accounts.ts";
13 import { html, readCookie, text } from "./http.ts";
14 import { SESSION_COOKIE } from "./routes.ts";
1315 import { match } from "./routes.ts";
1416 import { door } from "./security.ts";
1517 import { who } from "./who.ts";
@@ -31,7 +33,10 @@ export function serve(options: ServeOptions) {
3133 idleTimeout: 60,
3234 async fetch(request, server) {
3335 const url = new URL(request.url);
34 const me = who(request, options.identityKey);
36 // The forge's signed word first; else the app's own session, if any.
37 const signed = who(request, options.identityKey);
38 const me =
39 signed.role === "anon" ? sessionWho(db, readCookie(request, SESSION_COOKIE)) : signed;
3540 const refused = door(
3641 db,
3742 request,
static/app.css +46
@@ -911,6 +911,52 @@ form {
911911 color: var(--color-muted);
912912 }
913913
914 /* Sign up, log in, account. */
915 .auth {
916 width: min(24rem, 100%);
917 justify-items: stretch;
918 text-align: left;
919 }
920 .auth h1 {
921 font-size: var(--text-2xl);
922 text-align: center;
923 }
924 .auth .mark {
925 justify-self: center;
926 }
927 .auth > p {
928 text-align: center;
929 }
930 .field {
931 display: grid;
932 gap: var(--space-1);
933 font-weight: 600;
934 font-size: var(--text-sm);
935 }
936 .field .input {
937 width: 100%;
938 font-weight: 400;
939 }
940 .auth-error {
941 padding: var(--space-2) var(--space-3);
942 color: var(--color-danger);
943 border: 1px solid currentColor;
944 border-radius: var(--radius-md);
945 text-align: center;
946 }
947 .settings {
948 display: grid;
949 gap: var(--space-3);
950 max-width: 24rem;
951 margin-top: var(--space-5);
952 padding-top: var(--space-2);
953 border-top: 1px solid var(--color-line);
954 }
955 .button-danger {
956 background: var(--color-danger);
957 color: var(--color-bg);
958 }
959
914960 /* ── Overlays: the switcher, [[ completion, the toast ─────────────────── */
915961 .palette {
916962 position: fixed;
↑↓ navigate ↵ open esc close

Keyboard shortcuts