Skip to content

Commit 31b9eb3

Browse files
committed
refactor(val): split big route files + add junior-dev-friendly comments
auth-routes.ts (216 lines) -> three sibling modules: - auth-request.ts POST /auth/request (code email, rate-limit per IP) - auth-verify.ts POST /auth/verify (code check, JWT mint, rate-limit per email) - auth-session.ts GET /auth/check + POST /auth/logout (jti revoke) auth-routes.ts now just composes the three. comment-routes.ts (231 lines) -> four sibling modules: - comments-shared.ts rowToComment helper (used by every route file) - comments-read.ts GET list / unresolved / export - comments-create.ts POST create (author from JWT, never body) - comments-mutate.ts PATCH + DELETE (author-only gate) comment-routes.ts now just composes them. Inline comments added in auth-request, auth-verify, auth-session, comments-create, comments-mutate, comments-read, middleware, util — explain the *why* (salted code hashes, per-email rate-limit rationale, jti revocation, 404-vs-403 tradeoff, headers hardening, etc.) rather than restating what the code does. scripts/walkthrough.mts::deployVal files list updated so the new modules upload to Val Town. Val Town's file API expects flat paths, so the split is file-per-concern with naming prefixes (auth-*, comments-*) rather than subdirectories. All existing types still check (tsgo --noEmit), linter clean, val malware audit still produces the same closure (1 package: hono@4.12.14).
1 parent 43c0d03 commit 31b9eb3

12 files changed

Lines changed: 672 additions & 400 deletions

scripts/walkthrough.mts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -973,7 +973,14 @@ async function deployVal(args: readonly string[]): Promise<void> {
973973
{ path: 'audit.ts', type: 'script' },
974974
{ path: 'util.ts', type: 'script' },
975975
{ path: 'middleware.ts', type: 'script' },
976+
{ path: 'auth-request.ts', type: 'script' },
977+
{ path: 'auth-verify.ts', type: 'script' },
978+
{ path: 'auth-session.ts', type: 'script' },
976979
{ path: 'auth-routes.ts', type: 'script' },
980+
{ path: 'comments-shared.ts', type: 'script' },
981+
{ path: 'comments-read.ts', type: 'script' },
982+
{ path: 'comments-create.ts', type: 'script' },
983+
{ path: 'comments-mutate.ts', type: 'script' },
977984
{ path: 'comment-routes.ts', type: 'script' },
978985
]
979986
for (const f of files) {

val/auth-request.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/**
2+
* @fileoverview POST /auth/request — step 1 of email-login.
3+
*
4+
* User submits their email; we generate a 6-digit code, store a hash
5+
* of it in sqlite, and email the plaintext code. The response is
6+
* always `{ok: true}` regardless of whether the email was accepted,
7+
* so an attacker can't use this endpoint to enumerate which addresses
8+
* exist in our system.
9+
*/
10+
11+
import type { Hono } from 'npm:hono@4.12.14'
12+
import { sqlite } from 'https://esm.town/v/std/sqlite/main.ts'
13+
import { email as sendEmail } from 'https://esm.town/v/std/email'
14+
15+
import { generateCode, sha256Hex } from './crypto.ts'
16+
import { emailDomain, isValidEmail, normalizeEmail } from './validate.ts'
17+
import { renderLoginEmail, renderLoginEmailText } from './email-template.ts'
18+
import {
19+
ALLOWED_EMAIL_DOMAIN,
20+
CODE_RATE_LIMIT_PER_IP,
21+
CODE_TTL_SECONDS,
22+
EMAIL_FROM,
23+
EMAIL_REPLY_TO,
24+
MAX_BODY_BYTES_AUTH,
25+
} from './config.ts'
26+
import { cleanOldRows, ensureDb } from './db.ts'
27+
import { audit } from './audit.ts'
28+
import { now, readBoundedJson } from './util.ts'
29+
import type { AppEnv } from './types.ts'
30+
31+
export const registerAuthRequest = (app: Hono<AppEnv>): void => {
32+
app.post('/auth/request', async c => {
33+
await ensureDb
34+
// Piggyback retention cleanup on code-request. It's the lowest-
35+
// frequency auth endpoint, so the extra work rarely lands on a
36+
// hot path, and we avoid needing a separate cron.
37+
await cleanOldRows()
38+
39+
// Cap the body size. Without this, a client could POST a huge JSON
40+
// blob and OOM the val. 4 KB is plenty for `{ email: "..." }`.
41+
const body = await readBoundedJson<{ email?: unknown }>(
42+
c,
43+
MAX_BODY_BYTES_AUTH,
44+
)
45+
const rawEmail = normalizeEmail(body?.email)
46+
const emailValid = isValidEmail(rawEmail)
47+
const domainOk = emailDomain(rawEmail) === ALLOWED_EMAIL_DOMAIN
48+
49+
const ip = c.get('ip')
50+
const nowSec = now()
51+
const windowStart = nowSec - 3600
52+
53+
// Rate-limit per IP: how many codes has this IP asked for in the
54+
// last hour? If it's over the threshold, silently refuse. Makes
55+
// brute-force + email-flooding impractical.
56+
const ipCount = await sqlite.execute({
57+
sql: 'SELECT COUNT(*) AS n FROM login_codes WHERE ip = :ip AND created_at > :since',
58+
args: { ip, since: windowStart },
59+
})
60+
const ipHits = Number((ipCount.rows[0] as { n?: number | bigint })?.n ?? 0)
61+
const rateLimited = ipHits >= CODE_RATE_LIMIT_PER_IP
62+
63+
if (emailValid && domainOk && !rateLimited) {
64+
const code = generateCode()
65+
// Store a hash of the code, not the code itself. If the DB leaks,
66+
// an attacker can't read off live codes — they'd have to brute-
67+
// force sha256. Salt the hash with the email so two users who
68+
// happen to get the same random code produce different hashes.
69+
const codeHash = await sha256Hex(`${rawEmail}:${code}`)
70+
const expiresAt = nowSec + CODE_TTL_SECONDS
71+
await sqlite.execute({
72+
sql: 'INSERT INTO login_codes (email, code_hash, ip, expires_at) VALUES (:email, :codeHash, :ip, :expiresAt)',
73+
args: { email: rawEmail, codeHash, ip, expiresAt },
74+
})
75+
try {
76+
await sendEmail({
77+
to: rawEmail,
78+
from: EMAIL_FROM,
79+
replyTo: EMAIL_REPLY_TO,
80+
subject: 'Your Socket walkthrough login code',
81+
html: renderLoginEmail(code),
82+
text: renderLoginEmailText(code),
83+
})
84+
} catch (err) {
85+
// Log-and-swallow. If the email provider hiccups we don't
86+
// reveal it to the client — the audit log preserves that we
87+
// tried, and the user realizes when their code doesn't arrive.
88+
console.error('[val] email send failed', err)
89+
}
90+
await audit(c, 'login_code_sent', { actor: rawEmail, success: true })
91+
} else {
92+
// Record why we refused so we can spot abuse patterns later, but
93+
// never tell the client. See the always-`{ok: true}` below.
94+
await audit(c, 'login_code_refused', {
95+
actor: emailValid ? rawEmail : null,
96+
success: false,
97+
meta: {
98+
reason: !emailValid
99+
? 'invalid-email'
100+
: !domainOk
101+
? 'domain'
102+
: 'rate-limit',
103+
},
104+
})
105+
}
106+
107+
// Always the same response shape — no info leak about whether the
108+
// address is known, in the allowed domain, or rate-limited.
109+
return c.json({ ok: true })
110+
})
111+
}

val/auth-routes.ts

Lines changed: 11 additions & 177 deletions
Original file line numberDiff line numberDiff line change
@@ -1,190 +1,24 @@
11
/**
2-
* @fileoverview Auth routes — request code, verify code, check session,
3-
* logout. Registers handlers on a provided Hono app so the entry point
4-
* can compose modules without circular imports.
2+
* @fileoverview Auth-route orchestrator.
3+
*
4+
* Composes the three auth-endpoint modules onto a shared Hono app so
5+
* the entry point can register everything with one call. Each module
6+
* owns its own sqlite + Hono imports — this file is just a dispatcher.
57
*/
68

79
import type { Hono, Next, Context } from 'npm:hono@4.12.14'
8-
import { sqlite } from 'https://esm.town/v/std/sqlite/main.ts'
9-
import { email as sendEmail } from 'https://esm.town/v/std/email'
1010

11-
import { generateCode, sha256Hex, signJwt, verifyJwt } from './crypto.ts'
12-
import {
13-
emailDomain,
14-
isValidCode,
15-
isValidEmail,
16-
normalizeEmail,
17-
} from './validate.ts'
18-
import { renderLoginEmail, renderLoginEmailText } from './email-template.ts'
19-
import {
20-
ALLOWED_EMAIL_DOMAIN,
21-
CODE_RATE_LIMIT_PER_IP,
22-
CODE_TTL_SECONDS,
23-
EMAIL_FROM,
24-
EMAIL_REPLY_TO,
25-
MAX_BODY_BYTES_AUTH,
26-
SESSION_TTL_SECONDS,
27-
VERIFY_RATE_LIMIT_PER_EMAIL,
28-
VERIFY_WINDOW_SECONDS,
29-
} from './config.ts'
30-
import { cleanOldRows, ensureDb } from './db.ts'
31-
import { audit } from './audit.ts'
32-
import { jsonError, now, readBoundedJson } from './util.ts'
11+
import { registerAuthRequest } from './auth-request.ts'
12+
import { registerAuthVerify } from './auth-verify.ts'
13+
import { registerAuthSession } from './auth-session.ts'
3314
import type { AppEnv } from './types.ts'
3415

3516
export const registerAuthRoutes = (
3617
app: Hono<AppEnv>,
3718
hmacKey: CryptoKey,
3819
requireAuth: (c: Context<AppEnv>, n: Next) => Promise<Response | void>,
3920
): void => {
40-
app.post('/auth/request', async c => {
41-
await ensureDb
42-
await cleanOldRows()
43-
44-
const body = await readBoundedJson<{ email?: unknown }>(
45-
c,
46-
MAX_BODY_BYTES_AUTH,
47-
)
48-
const rawEmail = normalizeEmail(body?.email)
49-
const emailValid = isValidEmail(rawEmail)
50-
const domainOk = emailDomain(rawEmail) === ALLOWED_EMAIL_DOMAIN
51-
52-
const ip = c.get('ip')
53-
const nowSec = now()
54-
const windowStart = nowSec - 3600
55-
56-
const ipCount = await sqlite.execute({
57-
sql: 'SELECT COUNT(*) AS n FROM login_codes WHERE ip = :ip AND created_at > :since',
58-
args: { ip, since: windowStart },
59-
})
60-
const ipHits = Number((ipCount.rows[0] as { n?: number | bigint })?.n ?? 0)
61-
const rateLimited = ipHits >= CODE_RATE_LIMIT_PER_IP
62-
63-
if (emailValid && domainOk && !rateLimited) {
64-
const code = generateCode()
65-
const codeHash = await sha256Hex(`${rawEmail}:${code}`)
66-
const expiresAt = nowSec + CODE_TTL_SECONDS
67-
await sqlite.execute({
68-
sql: 'INSERT INTO login_codes (email, code_hash, ip, expires_at) VALUES (:email, :codeHash, :ip, :expiresAt)',
69-
args: { email: rawEmail, codeHash, ip, expiresAt },
70-
})
71-
try {
72-
await sendEmail({
73-
to: rawEmail,
74-
from: EMAIL_FROM,
75-
replyTo: EMAIL_REPLY_TO,
76-
subject: 'Your Socket walkthrough login code',
77-
html: renderLoginEmail(code),
78-
text: renderLoginEmailText(code),
79-
})
80-
} catch (err) {
81-
console.error('[val] email send failed', err)
82-
}
83-
await audit(c, 'login_code_sent', { actor: rawEmail, success: true })
84-
} else {
85-
await audit(c, 'login_code_refused', {
86-
actor: emailValid ? rawEmail : null,
87-
success: false,
88-
meta: {
89-
reason: !emailValid
90-
? 'invalid-email'
91-
: !domainOk
92-
? 'domain'
93-
: 'rate-limit',
94-
},
95-
})
96-
}
97-
98-
return c.json({ ok: true })
99-
})
100-
101-
app.post('/auth/verify', async c => {
102-
await ensureDb
103-
const body = await readBoundedJson<{ email?: unknown; code?: unknown }>(
104-
c,
105-
MAX_BODY_BYTES_AUTH,
106-
)
107-
const email = normalizeEmail(body?.email)
108-
const code = typeof body?.code === 'string' ? body.code.trim() : ''
109-
110-
if (!isValidEmail(email) || !isValidCode(code)) {
111-
return jsonError(c, 400, 'email and 6-digit code required')
112-
}
113-
114-
const nowSec = now()
115-
const windowStart = nowSec - VERIFY_WINDOW_SECONDS
116-
const attemptCount = await sqlite.execute({
117-
sql: 'SELECT COUNT(*) AS n FROM verify_attempts WHERE email = :email AND success = 0 AND created_at > :since',
118-
args: { email, since: windowStart },
119-
})
120-
const attempts = Number(
121-
(attemptCount.rows[0] as { n?: number | bigint })?.n ?? 0,
122-
)
123-
if (attempts >= VERIFY_RATE_LIMIT_PER_EMAIL) {
124-
await audit(c, 'login_verify_rate_limited', {
125-
actor: email,
126-
success: false,
127-
})
128-
return jsonError(
129-
c,
130-
429,
131-
'too many attempts; try again later',
132-
'rate_limit',
133-
)
134-
}
135-
136-
const codeHash = await sha256Hex(`${email}:${code}`)
137-
const result = await sqlite.execute({
138-
sql: 'SELECT rowid FROM login_codes WHERE email = :email AND code_hash = :codeHash AND consumed = 0 AND expires_at > :now ORDER BY created_at DESC LIMIT 1',
139-
args: { email, codeHash, now: nowSec },
140-
})
141-
if (result.rows.length === 0) {
142-
await sqlite.execute({
143-
sql: 'INSERT INTO verify_attempts (email, ip, success) VALUES (:email, :ip, 0)',
144-
args: { email, ip: c.get('ip') },
145-
})
146-
await audit(c, 'login_verify_failed', { actor: email, success: false })
147-
return jsonError(c, 401, 'invalid or expired code')
148-
}
149-
150-
await sqlite.execute({
151-
sql: 'UPDATE login_codes SET consumed = 1 WHERE rowid = :rowid',
152-
args: { rowid: (result.rows[0] as { rowid: number }).rowid },
153-
})
154-
await sqlite.execute({
155-
sql: 'INSERT INTO verify_attempts (email, ip, success) VALUES (:email, :ip, 1)',
156-
args: { email, ip: c.get('ip') },
157-
})
158-
159-
const exp = nowSec + SESSION_TTL_SECONDS
160-
const jti = crypto.randomUUID()
161-
const jwt = await signJwt(hmacKey, { email, exp, iat: nowSec, jti })
162-
await audit(c, 'login_success', { actor: email, success: true })
163-
return c.json({ token: jwt, email, expiresAt: exp })
164-
})
165-
166-
app.get('/auth/check', requireAuth, c => {
167-
const auth = c.get('auth')
168-
return c.json({ ok: true, email: auth.email })
169-
})
170-
171-
app.post('/auth/logout', requireAuth, async c => {
172-
await ensureDb
173-
const auth = c.get('auth')
174-
const header = c.req.header('authorization') || ''
175-
const match = header.match(/^Bearer\s+(.+)$/i)
176-
if (!match) {
177-
return jsonError(c, 401, 'unauthorized')
178-
}
179-
const payload = await verifyJwt(hmacKey, match[1])
180-
if (!payload) {
181-
return jsonError(c, 401, 'unauthorized')
182-
}
183-
await sqlite.execute({
184-
sql: 'INSERT OR IGNORE INTO revoked_jtis (jti, expires_at) VALUES (:jti, :exp)',
185-
args: { jti: payload.jti, exp: payload.exp },
186-
})
187-
await audit(c, 'logout', { actor: auth.email, success: true })
188-
return c.json({ ok: true })
189-
})
21+
registerAuthRequest(app)
22+
registerAuthVerify(app, hmacKey)
23+
registerAuthSession(app, hmacKey, requireAuth)
19024
}

val/auth-session.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* @fileoverview Session endpoints — /auth/check and /auth/logout.
3+
*
4+
* Both operate on an existing JWT. `requireAuth` middleware has
5+
* already validated the bearer token by the time our handler runs;
6+
* we just echo info (check) or revoke it (logout).
7+
*/
8+
9+
import type { Hono, Next, Context } from 'npm:hono@4.12.14'
10+
import { sqlite } from 'https://esm.town/v/std/sqlite/main.ts'
11+
12+
import { verifyJwt } from './crypto.ts'
13+
import { ensureDb } from './db.ts'
14+
import { audit } from './audit.ts'
15+
import { jsonError } from './util.ts'
16+
import type { AppEnv } from './types.ts'
17+
18+
export const registerAuthSession = (
19+
app: Hono<AppEnv>,
20+
hmacKey: CryptoKey,
21+
requireAuth: (c: Context<AppEnv>, n: Next) => Promise<Response | void>,
22+
): void => {
23+
// Client-facing "am I still logged in?" probe. The middleware
24+
// already verified the JWT; if control reaches the handler, yes.
25+
app.get('/auth/check', requireAuth, c => {
26+
const auth = c.get('auth')
27+
return c.json({ ok: true, email: auth.email })
28+
})
29+
30+
// Revoke this JWT's jti so a leaked token can't be re-used. The
31+
// revoked_jtis table is checked on every protected request (via
32+
// isJtiRevoked in middleware), and rows expire with their token.
33+
app.post('/auth/logout', requireAuth, async c => {
34+
await ensureDb
35+
const auth = c.get('auth')
36+
// Re-read the raw Authorization header. We need the original jti
37+
// + exp to write the revocation row — those live in the JWT
38+
// payload, not in c.get('auth') which only exposes email + jti.
39+
// (The middleware could expose exp too, but the verify call here
40+
// is cheap and keeps the middleware contract narrow.)
41+
const header = c.req.header('authorization') || ''
42+
const match = header.match(/^Bearer\s+(.+)$/i)
43+
if (!match) {
44+
return jsonError(c, 401, 'unauthorized')
45+
}
46+
const payload = await verifyJwt(hmacKey, match[1])
47+
if (!payload) {
48+
return jsonError(c, 401, 'unauthorized')
49+
}
50+
// INSERT OR IGNORE: if the jti is already revoked (double-logout,
51+
// network retry) we just treat it as a no-op.
52+
await sqlite.execute({
53+
sql: 'INSERT OR IGNORE INTO revoked_jtis (jti, expires_at) VALUES (:jti, :exp)',
54+
args: { jti: payload.jti, exp: payload.exp },
55+
})
56+
await audit(c, 'logout', { actor: auth.email, success: true })
57+
return c.json({ ok: true })
58+
})
59+
}

0 commit comments

Comments
 (0)