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]>
14 files
changed +635
−22
README.md +14 −3
| @@ -5,9 +5,20 @@ A personal linked-notes vault, in the spirit of Obsidian — markdown, | ||
| 5 | 5 | |
| 6 | 6 | ## Using it |
| 7 | 7 | |
| 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. | |
| 11 | 22 | |
| 12 | 23 | - **Write** in markdown. It saves as you type (and with ⌘S). Edit, Split |
| 13 | 24 | (side by side) and Read views; ⌘E flips between reading and writing. |
contract/openapi.json +1 −1
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | "openapi": "3.1.0", |
| 3 | 3 | "info": { |
| 4 | 4 | "title": "notes", |
| 5 | "version": "1.0.0", | |
| 5 | "version": "1.1.0", | |
| 6 | 6 | "description": "A personal linked-notes vault — markdown, [[wikilinks]], backlinks, tags, graph." |
| 7 | 7 | }, |
| 8 | 8 | "paths": { |
package.json +1 −1
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "notes", |
| 3 | "version": "1.0.0", | |
| 3 | "version": "1.1.0", | |
| 4 | 4 | "description": "A personal linked-notes vault — markdown, [[wikilinks]], backlinks, tags, graph.", |
| 5 | 5 | "private": true, |
| 6 | 6 | "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( | ||
| 154 | 154 | return refuse(who, "Sign in to add a note.", "You may not add a note."); |
| 155 | 155 | const body = checkBody(input.body ?? ""); |
| 156 | 156 | 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.`); | |
| 157 | 162 | let title: string; |
| 158 | 163 | if (input.title === undefined || input.title.trim() === "") |
| 159 | 164 | title = freeTitle(db, who.id, "Untitled"); |
src/db.ts +16
| @@ -57,6 +57,22 @@ export const migrations: string[] = [ | ||
| 57 | 57 | `ALTER TABLE page RENAME TO note`, |
| 58 | 58 | `CREATE UNIQUE INDEX note_title ON note (author_id, title COLLATE NOCASE)`, |
| 59 | 59 | `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)`, | |
| 60 | 76 | ]; |
| 61 | 77 | |
| 62 | 78 | 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", () => { | ||
| 78 | 78 | ["color-tag-fg", "color-tag-bg"], |
| 79 | 79 | ["color-accent", "color-active"], |
| 80 | 80 | ["color-danger", "color-bg"], |
| 81 | ["color-bg", "color-danger"], | |
| 81 | 82 | ["color-link-missing", "color-bg"], |
| 82 | 83 | ["color-callout-info", "color-bg"], |
| 83 | 84 | ["color-callout-tip", "color-bg"], |
src/http.ts +13 −11
| @@ -75,17 +75,19 @@ export const back = (notice?: string): Response => | ||
| 75 | 75 | }); |
| 76 | 76 | |
| 77 | 77 | /** 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, | |
| 88 | 82 | }); |
| 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]; | |
| 89 | 91 | |
| 90 | 92 | /** The notice a redirect left, and the header that clears it. */ |
| 91 | 93 | export function readNotice(request: Request): { |
| @@ -104,7 +106,7 @@ export function readNotice(request: Request): { | ||
| 104 | 106 | |
| 105 | 107 | // Every cookie the app sets itself is HttpOnly and SameSite=Lax, and Secure |
| 106 | 108 | // 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 => | |
| 108 | 110 | `${name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${publicOrigin.startsWith("https:") ? "; Secure" : ""}`; |
| 109 | 111 | |
| 110 | 112 | // 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 @@ | ||
| 6 | 6 | * Without JavaScript every page still reads, and the editor still saves. |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | import { isAccount, type Account } from "./accounts.ts"; | |
| 9 | 10 | import { canDelete, type Note } from "./actions.ts"; |
| 10 | 11 | import { commit, description, env, publicOrigin, title, where } from "./env.ts"; |
| 11 | 12 | import { asset, escape } from "./http.ts"; |
| @@ -71,7 +72,7 @@ ${body} | ||
| 71 | 72 | `; |
| 72 | 73 | } |
| 73 | 74 | |
| 74 | /** Signed out: what this is, and the way in. */ | |
| 75 | /** Signed out: what this is, and the ways in. */ | |
| 75 | 76 | export function landing(notice?: string): string { |
| 76 | 77 | return layout( |
| 77 | 78 | `<div class="landing"> |
| @@ -79,8 +80,12 @@ export function landing(notice?: string): string { | ||
| 79 | 80 | <div class="mark" aria-hidden="true">${glyph}</div> |
| 80 | 81 | <h1>${escape(title)}</h1> |
| 81 | 82 | <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> | |
| 84 | 89 | </div> |
| 85 | 90 | ${toast(notice)} |
| 86 | 91 | </div>`, |
| @@ -89,6 +94,35 @@ export function landing(notice?: string): string { | ||
| 89 | 94 | ); |
| 90 | 95 | } |
| 91 | 96 | |
| 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 | ||
| 92 | 126 | 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>`; |
| 93 | 127 | |
| 94 | 128 | const toast = (notice?: string): string => |
| @@ -104,6 +138,7 @@ const icon = { | ||
| 104 | 138 | pin: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 4h6l-1 6 4 3v2H6v-2l4-3zM12 15v6"/></svg>`, |
| 105 | 139 | trash: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3"/></svg>`, |
| 106 | 140 | 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>`, | |
| 107 | 142 | download: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4v11M7 10l5 5 5-5M5 20h14"/></svg>`, |
| 108 | 143 | }; |
| 109 | 144 | |
| @@ -146,7 +181,11 @@ function left(s: Shell): string { | ||
| 146 | 181 | <div class="left-foot"> |
| 147 | 182 | <span class="you" title="${escape(where)}${commit ? ` · ${escape(commit)}` : ""}">${escape(s.who.name)}${env === "production" ? "" : ` · <span class="pill">${escape(where)}</span>`}</span> |
| 148 | 183 | <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 | } | |
| 150 | 189 | </div> |
| 151 | 190 | </aside>`; |
| 152 | 191 | } |
| @@ -306,6 +345,30 @@ export function graphPage(s: Shell): string { | ||
| 306 | 345 | return layout(shell(s, center), "/graph", { name: "Graph", kind: "page app-page graph-page", scripts: ["app.js", "graph.js"] }); |
| 307 | 346 | } |
| 308 | 347 | |
| 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 | ||
| 309 | 372 | /** The graph's data, for any page that draws one. */ |
| 310 | 373 | export const graphData = (vault: readonly Note[]): string => data("graph", graph(vault), CHANGES); |
| 311 | 374 |
src/routes.ts +129
| @@ -32,8 +32,18 @@ import { | ||
| 32 | 32 | type Note, |
| 33 | 33 | type Result, |
| 34 | 34 | } from "./actions.ts"; |
| 35 | import { | |
| 36 | changePassword, | |
| 37 | deleteAccount, | |
| 38 | getAccount, | |
| 39 | logIn, | |
| 40 | logOut, | |
| 41 | SESSION_DAYS, | |
| 42 | signUp, | |
| 43 | } from "./accounts.ts"; | |
| 35 | 44 | import { commit, env, forgeUrl, startedAt, version } from "./env.ts"; |
| 36 | 45 | import { |
| 46 | cookie, | |
| 37 | 47 | fields, |
| 38 | 48 | go, |
| 39 | 49 | html, |
| @@ -41,6 +51,7 @@ import { | ||
| 41 | 51 | NOTE_BYTES, |
| 42 | 52 | readForm, |
| 43 | 53 | readJson, |
| 54 | readCookie, | |
| 44 | 55 | readNotice, |
| 45 | 56 | SECURITY, |
| 46 | 57 | staticFile, |
| @@ -49,6 +60,8 @@ import { | ||
| 49 | 60 | import { events } from "./live.ts"; |
| 50 | 61 | import { rpc } from "./mcp.ts"; |
| 51 | 62 | import { |
| 63 | accountPage, | |
| 64 | authPage, | |
| 52 | 65 | emptyPage, |
| 53 | 66 | graphPage, |
| 54 | 67 | landing, |
| @@ -57,7 +70,18 @@ import { | ||
| 57 | 70 | searchPage, |
| 58 | 71 | } from "./page.ts"; |
| 59 | 72 | import { tokensCss } from "./tokens.ts"; |
| 73 | import { allow } from "./security.ts"; | |
| 60 | 74 | 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 }; | |
| 61 | 85 | import type { Who } from "./who.ts"; |
| 62 | 86 | |
| 63 | 87 | export interface Context { |
| @@ -406,6 +430,111 @@ export const routes: readonly Route[] = [ | ||
| 406 | 430 | }, |
| 407 | 431 | }, |
| 408 | 432 | |
| 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 | ||
| 409 | 538 | // The JSON API: the same actions, the same identity, plain status codes. |
| 410 | 539 | { |
| 411 | 540 | 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 | ||
| 222 | 222 | expect(JSON.stringify(heard)).not.toContain("private words"); |
| 223 | 223 | }); |
| 224 | 224 | |
| 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 | ||
| 225 | 272 | test("writes are rationed per person", async () => { |
| 226 | 273 | const flood = as("u9", "flood"); |
| 227 | 274 | const answers = await Promise.all( |
src/server.ts +7 −2
| @@ -9,7 +9,9 @@ | ||
| 9 | 9 | |
| 10 | 10 | import { openDb } from "./db.ts"; |
| 11 | 11 | 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"; | |
| 13 | 15 | import { match } from "./routes.ts"; |
| 14 | 16 | import { door } from "./security.ts"; |
| 15 | 17 | import { who } from "./who.ts"; |
| @@ -31,7 +33,10 @@ export function serve(options: ServeOptions) { | ||
| 31 | 33 | idleTimeout: 60, |
| 32 | 34 | async fetch(request, server) { |
| 33 | 35 | 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; | |
| 35 | 40 | const refused = door( |
| 36 | 41 | db, |
| 37 | 42 | request, |
static/app.css +46
| @@ -911,6 +911,52 @@ form { | ||
| 911 | 911 | color: var(); |
| 912 | 912 | } |
| 913 | 913 | |
| 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(); | |
| 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(); | |
| 933 | font-weight: 600; | |
| 934 | font-size: var(); | |
| 935 | } | |
| 936 | .field .input { | |
| 937 | width: 100%; | |
| 938 | font-weight: 400; | |
| 939 | } | |
| 940 | .auth-error { | |
| 941 | padding: var() var(); | |
| 942 | color: var(); | |
| 943 | border: 1px solid currentColor; | |
| 944 | border-radius: var(); | |
| 945 | text-align: center; | |
| 946 | } | |
| 947 | .settings { | |
| 948 | display: grid; | |
| 949 | gap: var(); | |
| 950 | max-width: 24rem; | |
| 951 | margin-top: var(); | |
| 952 | padding-top: var(); | |
| 953 | border-top: 1px solid var(); | |
| 954 | } | |
| 955 | .button-danger { | |
| 956 | background: var(); | |
| 957 | color: var(); | |
| 958 | } | |
| 959 | ||
| 914 | 960 | /* ── Overlays: the switcher, [[ completion, the toast ─────────────────── */ |
| 915 | 961 | .palette { |
| 916 | 962 | position: fixed; |