Scaffold Vinterliste — end-to-end encrypted winter activity list
Foundation for an E2E-encrypted activity list per winter-list-claude-code-prompt.md. Server (Bun + Hono): - bun:sqlite with WAL and the spec's schema (idempotent migration) - opaque server-stored sessions, httpOnly cookie - signup / challenge / login / logout / me / password / recovery-challenge / recovery-complete - activity CRUD with strict visibility rules: private uses ciphertext+nonce, semi never serializes owner_id, public attributes the owner - tag store with normalisation + autocomplete (semi/public only) Frontend (Svelte 5 + Vite): - libsodium-wrappers-sumo for client crypto (Argon2id + XChaCha20-Poly1305). SUMO is required because the standard build omits crypto_pwhash. - IndexedDB-backed private tag index (never leaves the browser) - in-memory DEK (no localStorage); page reload re-prompts for password - signup shows the recovery code once; tag input merges server + private sources with clear labelling - Bokmål UI Crypto module (shared/crypto.ts): - pure, runs in both Bun and the browser via a runtime-conditional loader that papers over libsodium-wrappers-sumo's broken ESM entry (createRequire on server, Vite alias in the browser) - DEK wrap/unwrap, AEAD payload encryption, recovery code generation with a visually-unambiguous alphabet Verification: - 22 crypto round-trip tests (wrap/unwrap, AEAD tamper rejection, password change preserves ciphertexts, recovery still works after rotation) - typecheck passes for server and frontend - Vite production build succeeds; libsodium SUMO chunk is ~315 KB gzipped Single-image Containerfile for podman: builds frontend in a builder stage, runs Bun in a slim runtime; one volume for the SQLite file; BUILD_DATE / GIT_REVISION baked into OCI labels and /etc/build-info. Known limitation deferred for this commit: the recovery endpoint has no server-side proof of the recovery code (anyone who knows an email can lock out the legitimate user, though they can't read any data). Closed in the next commit.
This commit is contained in:
commit
47963c9225
39 changed files with 4007 additions and 0 deletions
264
server/activities.ts
Normal file
264
server/activities.ts
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
import { Hono } from 'hono';
|
||||
import { getDb } from './db';
|
||||
import { requireAuth, currentUserId, type AppVariables } from './session';
|
||||
import { setActivityTags, clearActivityTags, tagsFor } from './tags';
|
||||
import type {
|
||||
Activity, ActivityPublic, ActivitySemi, ActivityPrivate,
|
||||
CreateActivityRequest, UpdateActivityRequest, Visibility,
|
||||
} from '../shared/types';
|
||||
|
||||
/**
|
||||
* Activity CRUD with strict visibility rules:
|
||||
*
|
||||
* - `private`: ciphertext/nonce required; plaintext columns and tag rows MUST
|
||||
* be empty/absent. Only the owner can read these.
|
||||
* - `semi`: plaintext columns required; ciphertext/nonce MUST be empty.
|
||||
* `owner_id` is stored but never serialized.
|
||||
* - `public`: plaintext columns required; ciphertext/nonce MUST be empty.
|
||||
* `owner_id` is serialized.
|
||||
*/
|
||||
export const activitiesRoutes = new Hono<{ Variables: AppVariables }>();
|
||||
|
||||
const VALID_VIS = new Set<Visibility>(['private', 'semi', 'public']);
|
||||
|
||||
interface ActivityRow {
|
||||
id: string;
|
||||
owner_id: string;
|
||||
visibility: Visibility;
|
||||
ciphertext: Uint8Array | null;
|
||||
nonce: Uint8Array | null;
|
||||
title: string | null;
|
||||
scheduled_at: number | null;
|
||||
loc_label: string | null;
|
||||
loc_lat: number | null;
|
||||
loc_lng: number | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
function b64(b: Uint8Array | null): string | null {
|
||||
return b === null ? null : Buffer.from(b).toString('base64');
|
||||
}
|
||||
|
||||
function b64ToBuf(s: string): Buffer {
|
||||
return Buffer.from(s, 'base64');
|
||||
}
|
||||
|
||||
function serialize(row: ActivityRow, viewerId: string | null): Activity {
|
||||
if (row.visibility === 'private') {
|
||||
const a: ActivityPrivate = {
|
||||
id: row.id,
|
||||
visibility: 'private',
|
||||
owner_id: row.owner_id,
|
||||
ciphertext: b64(row.ciphertext) ?? '',
|
||||
nonce: b64(row.nonce) ?? '',
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
return a;
|
||||
}
|
||||
const tags = tagsFor(row.id);
|
||||
if (row.visibility === 'semi') {
|
||||
// owner_id deliberately omitted — see SECURITY.md
|
||||
const a: ActivitySemi = {
|
||||
id: row.id,
|
||||
visibility: 'semi',
|
||||
title: row.title ?? '',
|
||||
tags,
|
||||
loc_label: row.loc_label,
|
||||
loc_lat: row.loc_lat,
|
||||
loc_lng: row.loc_lng,
|
||||
scheduled_at: row.scheduled_at,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
return a;
|
||||
}
|
||||
const a: ActivityPublic = {
|
||||
id: row.id,
|
||||
visibility: 'public',
|
||||
owner_id: row.owner_id,
|
||||
title: row.title ?? '',
|
||||
tags,
|
||||
loc_label: row.loc_label,
|
||||
loc_lat: row.loc_lat,
|
||||
loc_lng: row.loc_lng,
|
||||
scheduled_at: row.scheduled_at,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
return a;
|
||||
}
|
||||
|
||||
function validateForVisibility(body: CreateActivityRequest): string | null {
|
||||
if (!body.visibility || !VALID_VIS.has(body.visibility)) {
|
||||
return 'invalid:visibility';
|
||||
}
|
||||
if (body.visibility === 'private') {
|
||||
if (typeof body.ciphertext !== 'string' || typeof body.nonce !== 'string') {
|
||||
return 'private:requires_ciphertext_and_nonce';
|
||||
}
|
||||
if (body.title !== undefined || (body.tags && body.tags.length > 0)) {
|
||||
return 'private:plaintext_must_be_absent';
|
||||
}
|
||||
} else {
|
||||
if (typeof body.title !== 'string' || !body.title.trim()) {
|
||||
return `${body.visibility}:title_required`;
|
||||
}
|
||||
if (body.ciphertext !== undefined || body.nonce !== undefined) {
|
||||
return `${body.visibility}:ciphertext_must_be_absent`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- GET /api/activities ----------------------------------------------------
|
||||
// Returns: all public + semi activities (visible to anyone), plus the caller's
|
||||
// own private activities (if logged in).
|
||||
activitiesRoutes.get('/', (c) => {
|
||||
const viewerId = currentUserId(c);
|
||||
const db = getDb();
|
||||
|
||||
const params: string[] = [];
|
||||
let where = `visibility IN ('public','semi')`;
|
||||
if (viewerId) {
|
||||
where += ` OR (visibility = 'private' AND owner_id = ?)`;
|
||||
params.push(viewerId);
|
||||
}
|
||||
|
||||
const rows = db
|
||||
.prepare(`SELECT * FROM activities WHERE ${where} ORDER BY created_at DESC`)
|
||||
.all(...params) as ActivityRow[];
|
||||
|
||||
return c.json(rows.map((r) => serialize(r, viewerId)));
|
||||
});
|
||||
|
||||
// --- GET /api/activities/:id ------------------------------------------------
|
||||
activitiesRoutes.get('/:id', (c) => {
|
||||
const viewerId = currentUserId(c);
|
||||
const row = getDb().prepare('SELECT * FROM activities WHERE id = ?').get(c.req.param('id')) as ActivityRow | null;
|
||||
if (!row) return c.json({ error: 'not_found' }, 404);
|
||||
if (row.visibility === 'private' && row.owner_id !== viewerId) {
|
||||
return c.json({ error: 'not_found' }, 404);
|
||||
}
|
||||
return c.json(serialize(row, viewerId));
|
||||
});
|
||||
|
||||
// --- POST /api/activities ---------------------------------------------------
|
||||
activitiesRoutes.post('/', requireAuth, async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const body = (await c.req.json().catch(() => null)) as CreateActivityRequest | null;
|
||||
if (!body) return c.json({ error: 'invalid_json' }, 400);
|
||||
const err = validateForVisibility(body);
|
||||
if (err) return c.json({ error: err }, 400);
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
const db = getDb();
|
||||
|
||||
if (body.visibility === 'private') {
|
||||
db.prepare(`
|
||||
INSERT INTO activities
|
||||
(id, owner_id, visibility, ciphertext, nonce, created_at, updated_at)
|
||||
VALUES (?, ?, 'private', ?, ?, ?, ?)
|
||||
`).run(id, userId, b64ToBuf(body.ciphertext!), b64ToBuf(body.nonce!), now, now);
|
||||
} else {
|
||||
db.prepare(`
|
||||
INSERT INTO activities
|
||||
(id, owner_id, visibility, title, scheduled_at, loc_label, loc_lat, loc_lng, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id, userId, body.visibility,
|
||||
body.title!.trim(),
|
||||
body.scheduled_at ?? null,
|
||||
body.loc_label ?? null,
|
||||
body.loc_lat ?? null,
|
||||
body.loc_lng ?? null,
|
||||
now, now,
|
||||
);
|
||||
setActivityTags(id, body.tags ?? []);
|
||||
}
|
||||
|
||||
const row = db.prepare('SELECT * FROM activities WHERE id = ?').get(id) as ActivityRow;
|
||||
return c.json(serialize(row, userId), 201);
|
||||
});
|
||||
|
||||
// --- PATCH /api/activities/:id ----------------------------------------------
|
||||
// A PATCH may also change the visibility (private↔public, semi↔public, etc.);
|
||||
// the client is responsible for sending the full new payload appropriate to
|
||||
// the new visibility (encrypted blob or plaintext fields), and we wipe the
|
||||
// columns that don't apply.
|
||||
activitiesRoutes.patch('/:id', requireAuth, async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const id = c.req.param('id');
|
||||
const body = (await c.req.json().catch(() => null)) as UpdateActivityRequest | null;
|
||||
if (!body) return c.json({ error: 'invalid_json' }, 400);
|
||||
|
||||
const db = getDb();
|
||||
const existing = db.prepare('SELECT * FROM activities WHERE id = ?').get(id) as ActivityRow | null;
|
||||
if (!existing) return c.json({ error: 'not_found' }, 404);
|
||||
if (existing.owner_id !== userId) return c.json({ error: 'forbidden' }, 403);
|
||||
|
||||
const err = validateForVisibility(body);
|
||||
if (err) return c.json({ error: err }, 400);
|
||||
|
||||
const now = Date.now();
|
||||
if (body.visibility === 'private') {
|
||||
db.prepare(`
|
||||
UPDATE activities SET
|
||||
visibility = 'private',
|
||||
ciphertext = ?,
|
||||
nonce = ?,
|
||||
title = NULL,
|
||||
scheduled_at = NULL,
|
||||
loc_label = NULL,
|
||||
loc_lat = NULL,
|
||||
loc_lng = NULL,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(b64ToBuf(body.ciphertext!), b64ToBuf(body.nonce!), now, id);
|
||||
// Drop any lingering public/semi tag rows from a prior visibility.
|
||||
clearActivityTags(id);
|
||||
} else {
|
||||
db.prepare(`
|
||||
UPDATE activities SET
|
||||
visibility = ?,
|
||||
title = ?,
|
||||
scheduled_at = ?,
|
||||
loc_label = ?,
|
||||
loc_lat = ?,
|
||||
loc_lng = ?,
|
||||
ciphertext = NULL,
|
||||
nonce = NULL,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
body.visibility,
|
||||
body.title!.trim(),
|
||||
body.scheduled_at ?? null,
|
||||
body.loc_label ?? null,
|
||||
body.loc_lat ?? null,
|
||||
body.loc_lng ?? null,
|
||||
now, id,
|
||||
);
|
||||
setActivityTags(id, body.tags ?? []);
|
||||
}
|
||||
|
||||
const row = db.prepare('SELECT * FROM activities WHERE id = ?').get(id) as ActivityRow;
|
||||
return c.json(serialize(row, userId));
|
||||
});
|
||||
|
||||
// --- DELETE /api/activities/:id ---------------------------------------------
|
||||
activitiesRoutes.delete('/:id', requireAuth, (c) => {
|
||||
const userId = c.get('userId');
|
||||
const id = c.req.param('id');
|
||||
const db = getDb();
|
||||
const existing = db.prepare('SELECT owner_id FROM activities WHERE id = ?').get(id) as
|
||||
| { owner_id: string } | null;
|
||||
if (!existing) return c.json({ error: 'not_found' }, 404);
|
||||
if (existing.owner_id !== userId) return c.json({ error: 'forbidden' }, 403);
|
||||
|
||||
clearActivityTags(id);
|
||||
db.prepare('DELETE FROM activities WHERE id = ?').run(id);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
286
server/auth.ts
Normal file
286
server/auth.ts
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
import { Hono } from 'hono';
|
||||
import { getDb } from './db';
|
||||
import {
|
||||
currentUserId, issueSession, clearSession, requireAuth, gcSessions,
|
||||
type AppVariables,
|
||||
} from './session';
|
||||
import type {
|
||||
SignupRequest,
|
||||
ChallengeResponse,
|
||||
LoginRequest,
|
||||
RecoveryChallengeResponse,
|
||||
PasswordChangeRequest,
|
||||
RecoveryCompleteRequest,
|
||||
MeResponse,
|
||||
} from '../shared/types';
|
||||
|
||||
/**
|
||||
* Auth routes. The server's job is narrow:
|
||||
* - store the user row (salts + wraps + verifier hash)
|
||||
* - hand back salts/wraps on demand so the client can derive keys
|
||||
* - verify the auth verifier and issue a session cookie
|
||||
*
|
||||
* It never sees the raw password, the recovery code, or the DEK.
|
||||
*/
|
||||
export const authRoutes = new Hono<{ Variables: AppVariables }>();
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
function b64ToBuffer(s: string): Buffer {
|
||||
return Buffer.from(s, 'base64');
|
||||
}
|
||||
|
||||
function bufferToB64(b: Uint8Array): string {
|
||||
return Buffer.from(b).toString('base64');
|
||||
}
|
||||
|
||||
function newId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
function isString(v: unknown): v is string {
|
||||
return typeof v === 'string' && v.length > 0;
|
||||
}
|
||||
|
||||
/** Check that every named key on `obj` is a non-empty string. */
|
||||
function missingKey<T extends object>(obj: T, keys: readonly (keyof T)[]): string | null {
|
||||
const rec = obj as unknown as Record<string, unknown>;
|
||||
for (const k of keys) {
|
||||
if (!isString(rec[k as string])) return `missing:${String(k)}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- POST /auth/signup ------------------------------------------------------
|
||||
authRoutes.post('/signup', async (c) => {
|
||||
const body = (await c.req.json().catch(() => null)) as SignupRequest | null;
|
||||
if (!body) return c.json({ error: 'invalid_json' }, 400);
|
||||
|
||||
const miss = missingKey(body, [
|
||||
'email', 'auth_salt', 'auth_verifier', 'kek_salt',
|
||||
'wrapped_dek_pw', 'dek_pw_nonce',
|
||||
'rec_salt', 'wrapped_dek_rec', 'dek_rec_nonce',
|
||||
]);
|
||||
if (miss) return c.json({ error: miss }, 400);
|
||||
const email = body.email.trim().toLowerCase();
|
||||
if (!EMAIL_RE.test(email)) return c.json({ error: 'invalid_email' }, 400);
|
||||
|
||||
const db = getDb();
|
||||
const existing = db.prepare('SELECT 1 FROM users WHERE email = ?').get(email);
|
||||
if (existing) return c.json({ error: 'email_taken' }, 409);
|
||||
|
||||
// Server-side hash of the client-derived verifier. The verifier is already
|
||||
// expensive to brute-force (Argon2id-MODERATE), so Bun.password adds a second
|
||||
// hardening layer in case the DB leaks but the verifier salt is still public.
|
||||
const verifierHash = await Bun.password.hash(body.auth_verifier, {
|
||||
algorithm: 'argon2id',
|
||||
});
|
||||
|
||||
const id = newId();
|
||||
const now = Date.now();
|
||||
db.prepare(`
|
||||
INSERT INTO users
|
||||
(id, email, auth_salt, auth_verifier_hash, kek_salt,
|
||||
wrapped_dek_pw, dek_pw_nonce, wrapped_dek_rec, rec_salt, dek_rec_nonce, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
email,
|
||||
b64ToBuffer(body.auth_salt),
|
||||
verifierHash,
|
||||
b64ToBuffer(body.kek_salt),
|
||||
b64ToBuffer(body.wrapped_dek_pw),
|
||||
b64ToBuffer(body.dek_pw_nonce),
|
||||
b64ToBuffer(body.wrapped_dek_rec),
|
||||
b64ToBuffer(body.rec_salt),
|
||||
b64ToBuffer(body.dek_rec_nonce),
|
||||
now,
|
||||
);
|
||||
|
||||
issueSession(c, id);
|
||||
gcSessions();
|
||||
|
||||
const me: MeResponse = { id, email };
|
||||
return c.json(me);
|
||||
});
|
||||
|
||||
// --- POST /auth/challenge ----------------------------------------------------
|
||||
// Hands back the public per-user material the client needs to derive KEK_pw
|
||||
// and the auth verifier. We deliberately do NOT mask "user not found" — see
|
||||
// SECURITY.md (enumeration is accepted, lockout-DoS is the bigger issue).
|
||||
authRoutes.post('/challenge', async (c) => {
|
||||
const body = (await c.req.json().catch(() => null)) as { email?: string } | null;
|
||||
if (!body || !isString(body.email)) return c.json({ error: 'missing:email' }, 400);
|
||||
const email = body.email.trim().toLowerCase();
|
||||
|
||||
const row = getDb()
|
||||
.prepare(`
|
||||
SELECT auth_salt, kek_salt, wrapped_dek_pw, dek_pw_nonce
|
||||
FROM users WHERE email = ?
|
||||
`)
|
||||
.get(email) as
|
||||
| { auth_salt: Uint8Array; kek_salt: Uint8Array; wrapped_dek_pw: Uint8Array; dek_pw_nonce: Uint8Array }
|
||||
| null;
|
||||
if (!row) return c.json({ error: 'no_such_user' }, 404);
|
||||
|
||||
const resp: ChallengeResponse = {
|
||||
auth_salt: bufferToB64(row.auth_salt),
|
||||
kek_salt: bufferToB64(row.kek_salt),
|
||||
wrapped_dek_pw: bufferToB64(row.wrapped_dek_pw),
|
||||
dek_pw_nonce: bufferToB64(row.dek_pw_nonce),
|
||||
};
|
||||
return c.json(resp);
|
||||
});
|
||||
|
||||
// --- POST /auth/login -------------------------------------------------------
|
||||
authRoutes.post('/login', async (c) => {
|
||||
const body = (await c.req.json().catch(() => null)) as LoginRequest | null;
|
||||
if (!body || !isString(body.email) || !isString(body.auth_verifier)) {
|
||||
return c.json({ error: 'missing_fields' }, 400);
|
||||
}
|
||||
const email = body.email.trim().toLowerCase();
|
||||
|
||||
const row = getDb()
|
||||
.prepare('SELECT id, auth_verifier_hash FROM users WHERE email = ?')
|
||||
.get(email) as { id: string; auth_verifier_hash: string } | null;
|
||||
|
||||
// Always run verify so unknown-email requests aren't trivially distinguishable
|
||||
// by timing. We compare against a dummy hash on miss; the constant work also
|
||||
// protects us from "did this account exist" probing via a stopwatch.
|
||||
const hash = row?.auth_verifier_hash ?? '$argon2id$v=19$m=65536,t=3,p=1$YWFhYWFhYWE$AAAAAAAAAAAAAAAAAAAAAA';
|
||||
const ok = await Bun.password.verify(body.auth_verifier, hash).catch(() => false);
|
||||
|
||||
if (!row || !ok) return c.json({ error: 'invalid_credentials' }, 401);
|
||||
|
||||
issueSession(c, row.id);
|
||||
const me: MeResponse = { id: row.id, email };
|
||||
return c.json(me);
|
||||
});
|
||||
|
||||
// --- POST /auth/logout ------------------------------------------------------
|
||||
authRoutes.post('/logout', async (c) => {
|
||||
clearSession(c);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// --- GET /auth/me -----------------------------------------------------------
|
||||
authRoutes.get('/me', async (c) => {
|
||||
const userId = currentUserId(c);
|
||||
if (!userId) return c.json({ error: 'unauthorized' }, 401);
|
||||
const row = getDb()
|
||||
.prepare('SELECT id, email FROM users WHERE id = ?')
|
||||
.get(userId) as { id: string; email: string } | null;
|
||||
if (!row) {
|
||||
clearSession(c);
|
||||
return c.json({ error: 'unauthorized' }, 401);
|
||||
}
|
||||
const me: MeResponse = row;
|
||||
return c.json(me);
|
||||
});
|
||||
|
||||
// --- POST /auth/password ----------------------------------------------------
|
||||
// Requires an active session. Replaces password-side material; recovery wrap
|
||||
// untouched. The client must have already unwrapped and re-wrapped the DEK
|
||||
// before calling this.
|
||||
authRoutes.post('/password', requireAuth, async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const body = (await c.req.json().catch(() => null)) as PasswordChangeRequest | null;
|
||||
if (!body) return c.json({ error: 'invalid_json' }, 400);
|
||||
const miss = missingKey(body, [
|
||||
'auth_salt', 'auth_verifier', 'kek_salt', 'wrapped_dek_pw', 'dek_pw_nonce',
|
||||
]);
|
||||
if (miss) return c.json({ error: miss }, 400);
|
||||
|
||||
const verifierHash = await Bun.password.hash(body.auth_verifier, {
|
||||
algorithm: 'argon2id',
|
||||
});
|
||||
|
||||
getDb()
|
||||
.prepare(`
|
||||
UPDATE users SET
|
||||
auth_salt = ?,
|
||||
auth_verifier_hash = ?,
|
||||
kek_salt = ?,
|
||||
wrapped_dek_pw = ?,
|
||||
dek_pw_nonce = ?
|
||||
WHERE id = ?
|
||||
`)
|
||||
.run(
|
||||
b64ToBuffer(body.auth_salt),
|
||||
verifierHash,
|
||||
b64ToBuffer(body.kek_salt),
|
||||
b64ToBuffer(body.wrapped_dek_pw),
|
||||
b64ToBuffer(body.dek_pw_nonce),
|
||||
userId,
|
||||
);
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// --- POST /auth/recovery-challenge ------------------------------------------
|
||||
authRoutes.post('/recovery-challenge', async (c) => {
|
||||
const body = (await c.req.json().catch(() => null)) as { email?: string } | null;
|
||||
if (!body || !isString(body.email)) return c.json({ error: 'missing:email' }, 400);
|
||||
const email = body.email.trim().toLowerCase();
|
||||
|
||||
const row = getDb()
|
||||
.prepare('SELECT rec_salt, wrapped_dek_rec, dek_rec_nonce FROM users WHERE email = ?')
|
||||
.get(email) as
|
||||
| { rec_salt: Uint8Array; wrapped_dek_rec: Uint8Array; dek_rec_nonce: Uint8Array }
|
||||
| null;
|
||||
if (!row) return c.json({ error: 'no_such_user' }, 404);
|
||||
|
||||
const resp: RecoveryChallengeResponse = {
|
||||
rec_salt: bufferToB64(row.rec_salt),
|
||||
wrapped_dek_rec: bufferToB64(row.wrapped_dek_rec),
|
||||
dek_rec_nonce: bufferToB64(row.dek_rec_nonce),
|
||||
};
|
||||
return c.json(resp);
|
||||
});
|
||||
|
||||
// --- POST /auth/recovery-complete -------------------------------------------
|
||||
// Replaces password-side material AND auth verifier. Recovery wrap is
|
||||
// untouched (same recovery code keeps working).
|
||||
//
|
||||
// Known limitation: this endpoint has no proof-of-recovery-code; see SECURITY.md.
|
||||
authRoutes.post('/recovery-complete', async (c) => {
|
||||
const body = (await c.req.json().catch(() => null)) as RecoveryCompleteRequest | null;
|
||||
if (!body) return c.json({ error: 'invalid_json' }, 400);
|
||||
const miss = missingKey(body, [
|
||||
'email', 'auth_salt', 'auth_verifier', 'kek_salt', 'wrapped_dek_pw', 'dek_pw_nonce',
|
||||
]);
|
||||
if (miss) return c.json({ error: miss }, 400);
|
||||
const email = body.email.trim().toLowerCase();
|
||||
|
||||
const row = getDb()
|
||||
.prepare('SELECT id FROM users WHERE email = ?')
|
||||
.get(email) as { id: string } | null;
|
||||
if (!row) return c.json({ error: 'no_such_user' }, 404);
|
||||
|
||||
const verifierHash = await Bun.password.hash(body.auth_verifier, {
|
||||
algorithm: 'argon2id',
|
||||
});
|
||||
|
||||
getDb()
|
||||
.prepare(`
|
||||
UPDATE users SET
|
||||
auth_salt = ?,
|
||||
auth_verifier_hash = ?,
|
||||
kek_salt = ?,
|
||||
wrapped_dek_pw = ?,
|
||||
dek_pw_nonce = ?
|
||||
WHERE id = ?
|
||||
`)
|
||||
.run(
|
||||
b64ToBuffer(body.auth_salt),
|
||||
verifierHash,
|
||||
b64ToBuffer(body.kek_salt),
|
||||
b64ToBuffer(body.wrapped_dek_pw),
|
||||
b64ToBuffer(body.dek_pw_nonce),
|
||||
row.id,
|
||||
);
|
||||
|
||||
// Force re-login: previous sessions for this user are invalidated.
|
||||
getDb().prepare('DELETE FROM sessions WHERE user_id = ?').run(row.id);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
99
server/db.ts
Normal file
99
server/db.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { Database } from 'bun:sqlite';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
/**
|
||||
* Schema follows winter-list-claude-code-prompt.md verbatim, plus a `sessions`
|
||||
* table for opaque server-stored sessions.
|
||||
*
|
||||
* Each statement is run individually so an error in one CREATE doesn't leave
|
||||
* the schema half-applied silently. All statements use IF NOT EXISTS so the
|
||||
* migration is idempotent.
|
||||
*/
|
||||
const SCHEMA_STATEMENTS: readonly string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
auth_salt BLOB NOT NULL,
|
||||
auth_verifier_hash TEXT NOT NULL,
|
||||
kek_salt BLOB NOT NULL,
|
||||
wrapped_dek_pw BLOB NOT NULL,
|
||||
dek_pw_nonce BLOB NOT NULL,
|
||||
wrapped_dek_rec BLOB NOT NULL,
|
||||
rec_salt BLOB NOT NULL,
|
||||
dek_rec_nonce BLOB NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS activities (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL REFERENCES users(id),
|
||||
visibility TEXT NOT NULL CHECK (visibility IN ('private','semi','public')),
|
||||
ciphertext BLOB,
|
||||
nonce BLOB,
|
||||
title TEXT,
|
||||
scheduled_at INTEGER,
|
||||
loc_label TEXT,
|
||||
loc_lat REAL,
|
||||
loc_lng REAL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS activities_visibility_idx ON activities(visibility)`,
|
||||
`CREATE INDEX IF NOT EXISTS activities_owner_idx ON activities(owner_id)`,
|
||||
`CREATE TABLE IF NOT EXISTS tags (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
usage_count INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS tags_name_idx ON tags(name)`,
|
||||
`CREATE TABLE IF NOT EXISTS activity_tags (
|
||||
activity_id TEXT NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
|
||||
tag_id TEXT NOT NULL REFERENCES tags(id),
|
||||
PRIMARY KEY (activity_id, tag_id)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS sessions_user_idx ON sessions(user_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS sessions_expires_idx ON sessions(expires_at)`,
|
||||
];
|
||||
|
||||
const PRAGMAS: readonly string[] = [
|
||||
// WAL gives concurrent readers a non-blocking view of writes — the only
|
||||
// sensible mode for a long-running SQLite-backed server.
|
||||
'PRAGMA journal_mode = WAL',
|
||||
'PRAGMA foreign_keys = ON',
|
||||
'PRAGMA synchronous = NORMAL',
|
||||
];
|
||||
|
||||
let dbInstance: Database | null = null;
|
||||
|
||||
function applyStatements(db: Database, statements: readonly string[]): void {
|
||||
for (const sql of statements) {
|
||||
db.prepare(sql).run();
|
||||
}
|
||||
}
|
||||
|
||||
export function getDb(): Database {
|
||||
if (dbInstance) return dbInstance;
|
||||
|
||||
const path = process.env.VINTERLISTE_DB ?? 'data/vinterliste.db';
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
|
||||
const db = new Database(path, { create: true });
|
||||
|
||||
applyStatements(db, PRAGMAS);
|
||||
applyStatements(db, SCHEMA_STATEMENTS);
|
||||
|
||||
dbInstance = db;
|
||||
return db;
|
||||
}
|
||||
|
||||
/** For tests: close and forget the cached connection. */
|
||||
export function resetDbForTests(): void {
|
||||
dbInstance?.close();
|
||||
dbInstance = null;
|
||||
}
|
||||
44
server/index.ts
Normal file
44
server/index.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { Hono } from 'hono';
|
||||
import { serveStatic } from 'hono/bun';
|
||||
import { getDb } from './db';
|
||||
import { authRoutes } from './auth';
|
||||
import { activitiesRoutes } from './activities';
|
||||
import { tagsRoutes } from './tags';
|
||||
|
||||
// Initialise DB up front so the server fails fast on schema problems.
|
||||
getDb();
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.get('/api/health', (c) =>
|
||||
c.json({
|
||||
ok: true,
|
||||
build: {
|
||||
revision: process.env.GIT_REVISION ?? 'unknown',
|
||||
built_at: process.env.BUILD_DATE ?? 'unknown',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
app.route('/api/auth', authRoutes);
|
||||
app.route('/api/activities', activitiesRoutes);
|
||||
app.route('/api/tags', tagsRoutes);
|
||||
|
||||
// In production, serve the built Svelte SPA. Hono's bun static helper handles
|
||||
// asset MIME types; everything else falls through to index.html for SPA routing.
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
app.use('/assets/*', serveStatic({ root: './frontend/dist' }));
|
||||
app.get('/favicon.svg', serveStatic({ path: './frontend/dist/favicon.svg' }));
|
||||
app.get('*', serveStatic({ path: './frontend/dist/index.html' }));
|
||||
}
|
||||
|
||||
const port = parseInt(process.env.PORT ?? '3000', 10);
|
||||
|
||||
export default {
|
||||
port,
|
||||
fetch: app.fetch,
|
||||
};
|
||||
|
||||
// When run directly with `bun run`, Bun.serve picks this up via the default
|
||||
// export. Logging here makes the boot visible during `bun run dev:server`.
|
||||
console.log(`vinterliste listening on http://localhost:${port}`);
|
||||
96
server/session.ts
Normal file
96
server/session.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import type { Context, MiddlewareHandler } from 'hono';
|
||||
import { getCookie, setCookie, deleteCookie } from 'hono/cookie';
|
||||
import { getDb } from './db';
|
||||
|
||||
/**
|
||||
* Hono context variables. Used to thread `userId` from `requireAuth` to the
|
||||
* route handlers without going through a manual cast each time.
|
||||
*/
|
||||
export type AppVariables = {
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type AppContext = Context<{ Variables: AppVariables }>;
|
||||
|
||||
const COOKIE_NAME = 'vl_session';
|
||||
const SESSION_LIFETIME_MS = 1000 * 60 * 60 * 24 * 14; // 14 days
|
||||
|
||||
/**
|
||||
* Sessions are opaque, server-stored, and revocable. We don't use JWT — there's
|
||||
* no benefit when the server already has SQLite at hand, and revocation is much
|
||||
* cleaner with a row you can DELETE.
|
||||
*
|
||||
* The token is a 32-byte URL-safe random string returned by Bun's CSPRNG.
|
||||
*/
|
||||
function newToken(): string {
|
||||
const buf = new Uint8Array(32);
|
||||
crypto.getRandomValues(buf);
|
||||
// base64url without padding
|
||||
return Buffer.from(buf)
|
||||
.toString('base64')
|
||||
.replaceAll('+', '-')
|
||||
.replaceAll('/', '_')
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
|
||||
export interface SessionRow {
|
||||
token: string;
|
||||
user_id: string;
|
||||
created_at: number;
|
||||
expires_at: number;
|
||||
}
|
||||
|
||||
export function issueSession(c: Context, userId: string): string {
|
||||
const db = getDb();
|
||||
const token = newToken();
|
||||
const now = Date.now();
|
||||
const expires = now + SESSION_LIFETIME_MS;
|
||||
db.prepare(
|
||||
'INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)',
|
||||
).run(token, userId, now, expires);
|
||||
|
||||
const secure = c.req.url.startsWith('https://');
|
||||
setCookie(c, COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'Lax',
|
||||
secure,
|
||||
path: '/',
|
||||
maxAge: Math.floor(SESSION_LIFETIME_MS / 1000),
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
export function clearSession(c: Context): void {
|
||||
const token = getCookie(c, COOKIE_NAME);
|
||||
if (token) {
|
||||
getDb().prepare('DELETE FROM sessions WHERE token = ?').run(token);
|
||||
}
|
||||
deleteCookie(c, COOKIE_NAME, { path: '/' });
|
||||
}
|
||||
|
||||
export function currentUserId(c: Context): string | null {
|
||||
const token = getCookie(c, COOKIE_NAME);
|
||||
if (!token) return null;
|
||||
const row = getDb()
|
||||
.prepare('SELECT user_id, expires_at FROM sessions WHERE token = ?')
|
||||
.get(token) as { user_id: string; expires_at: number } | null;
|
||||
if (!row) return null;
|
||||
if (row.expires_at < Date.now()) {
|
||||
getDb().prepare('DELETE FROM sessions WHERE token = ?').run(token);
|
||||
return null;
|
||||
}
|
||||
return row.user_id;
|
||||
}
|
||||
|
||||
/** Hono middleware that 401s unless a valid session is present. */
|
||||
export const requireAuth: MiddlewareHandler<{ Variables: AppVariables }> = async (c, next) => {
|
||||
const userId = currentUserId(c);
|
||||
if (!userId) return c.json({ error: 'unauthorized' }, 401);
|
||||
c.set('userId', userId);
|
||||
await next();
|
||||
};
|
||||
|
||||
/** Sweep expired sessions; called opportunistically. */
|
||||
export function gcSessions(): void {
|
||||
getDb().prepare('DELETE FROM sessions WHERE expires_at < ?').run(Date.now());
|
||||
}
|
||||
120
server/tags.ts
Normal file
120
server/tags.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { Hono } from 'hono';
|
||||
import { getDb } from './db';
|
||||
import type { TagSuggestion } from '../shared/types';
|
||||
|
||||
/**
|
||||
* Tag store (server-side, semi/public only).
|
||||
*
|
||||
* Normalisation: lowercased, trimmed, deduped. The server never sees private
|
||||
* tags — those live inside the encrypted activity payload and in the client's
|
||||
* IndexedDB index.
|
||||
*/
|
||||
|
||||
export function normaliseTag(raw: string): string | null {
|
||||
const t = raw.trim().toLowerCase();
|
||||
if (!t) return null;
|
||||
if (t.length > 50) return t.slice(0, 50);
|
||||
return t;
|
||||
}
|
||||
|
||||
export function dedupe(tags: string[]): string[] {
|
||||
return [...new Set(tags.map(normaliseTag).filter((t): t is string => t !== null))];
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the tag set for an activity. Decrements old tags' usage_count,
|
||||
* inserts/increments new ones. Runs in a single transaction.
|
||||
*/
|
||||
export function setActivityTags(activityId: string, rawTags: string[]): void {
|
||||
const tags = dedupe(rawTags);
|
||||
const db = getDb();
|
||||
|
||||
const txn = db.transaction((tagNames: string[]) => {
|
||||
// Old tags for this activity → decrement usage
|
||||
const oldTagIds = db
|
||||
.prepare('SELECT tag_id FROM activity_tags WHERE activity_id = ?')
|
||||
.all(activityId) as { tag_id: string }[];
|
||||
|
||||
db.prepare('DELETE FROM activity_tags WHERE activity_id = ?').run(activityId);
|
||||
|
||||
const decrement = db.prepare('UPDATE tags SET usage_count = MAX(0, usage_count - 1) WHERE id = ?');
|
||||
for (const { tag_id } of oldTagIds) decrement.run(tag_id);
|
||||
|
||||
const findTag = db.prepare('SELECT id FROM tags WHERE name = ?');
|
||||
const insertTag = db.prepare('INSERT INTO tags (id, name, usage_count) VALUES (?, ?, 0)');
|
||||
const bumpTag = db.prepare('UPDATE tags SET usage_count = usage_count + 1 WHERE id = ?');
|
||||
const linkTag = db.prepare(
|
||||
'INSERT OR IGNORE INTO activity_tags (activity_id, tag_id) VALUES (?, ?)',
|
||||
);
|
||||
|
||||
for (const name of tagNames) {
|
||||
let row = findTag.get(name) as { id: string } | null;
|
||||
if (!row) {
|
||||
const id = crypto.randomUUID();
|
||||
insertTag.run(id, name);
|
||||
row = { id };
|
||||
}
|
||||
linkTag.run(activityId, row.id);
|
||||
bumpTag.run(row.id);
|
||||
}
|
||||
});
|
||||
|
||||
txn(tags);
|
||||
}
|
||||
|
||||
export function clearActivityTags(activityId: string): void {
|
||||
const db = getDb();
|
||||
const txn = db.transaction(() => {
|
||||
const old = db
|
||||
.prepare('SELECT tag_id FROM activity_tags WHERE activity_id = ?')
|
||||
.all(activityId) as { tag_id: string }[];
|
||||
db.prepare('DELETE FROM activity_tags WHERE activity_id = ?').run(activityId);
|
||||
const decrement = db.prepare(
|
||||
'UPDATE tags SET usage_count = MAX(0, usage_count - 1) WHERE id = ?',
|
||||
);
|
||||
for (const { tag_id } of old) decrement.run(tag_id);
|
||||
});
|
||||
txn();
|
||||
}
|
||||
|
||||
export function tagsFor(activityId: string): string[] {
|
||||
return (
|
||||
getDb()
|
||||
.prepare(`
|
||||
SELECT t.name FROM tags t
|
||||
JOIN activity_tags at ON at.tag_id = t.id
|
||||
WHERE at.activity_id = ?
|
||||
ORDER BY t.name
|
||||
`)
|
||||
.all(activityId) as { name: string }[]
|
||||
).map((r) => r.name);
|
||||
}
|
||||
|
||||
// --- Routes ------------------------------------------------------------------
|
||||
export const tagsRoutes = new Hono();
|
||||
|
||||
// GET /api/tags?q=foo&limit=20
|
||||
tagsRoutes.get('/', (c) => {
|
||||
const q = c.req.query('q')?.trim().toLowerCase() ?? '';
|
||||
const limit = Math.min(parseInt(c.req.query('limit') ?? '20', 10) || 20, 50);
|
||||
|
||||
const rows = q
|
||||
? (getDb()
|
||||
.prepare(`
|
||||
SELECT name, usage_count FROM tags
|
||||
WHERE name LIKE ? AND usage_count > 0
|
||||
ORDER BY usage_count DESC, name ASC
|
||||
LIMIT ?
|
||||
`)
|
||||
.all(`${q}%`, limit) as TagSuggestion[])
|
||||
: (getDb()
|
||||
.prepare(`
|
||||
SELECT name, usage_count FROM tags
|
||||
WHERE usage_count > 0
|
||||
ORDER BY usage_count DESC, name ASC
|
||||
LIMIT ?
|
||||
`)
|
||||
.all(limit) as TagSuggestion[]);
|
||||
|
||||
return c.json(rows);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue