updating types

This commit is contained in:
Devin Zuczek
2026-06-30 23:57:29 -04:00
parent 8e513c1275
commit ba33003343
40 changed files with 49940 additions and 16163 deletions
+58 -4
View File
@@ -45,12 +45,40 @@ const parseAll = (rows: AccountRow[]): Account[] => rows.map((r) => JSON.parse(r
/** Word lists for auto-assigned usernames (players don't pick one on signup). */
const ADJECTIVES = [
'Swift', 'Brave', 'Clever', 'Happy', 'Mighty', 'Lucky', 'Sunny', 'Cosmic',
'Witty', 'Nimble', 'Jolly', 'Bold', 'Gentle', 'Fuzzy', 'Speedy', 'Shiny',
'Swift',
'Brave',
'Clever',
'Happy',
'Mighty',
'Lucky',
'Sunny',
'Cosmic',
'Witty',
'Nimble',
'Jolly',
'Bold',
'Gentle',
'Fuzzy',
'Speedy',
'Shiny',
]
const NOUNS = [
'Fox', 'Otter', 'Falcon', 'Panda', 'Tiger', 'Comet', 'Maple', 'Pixel',
'Robin', 'Wolf', 'Koala', 'Dragon', 'Penguin', 'Badger', 'Heron', 'Lynx',
'Fox',
'Otter',
'Falcon',
'Panda',
'Tiger',
'Comet',
'Maple',
'Pixel',
'Robin',
'Wolf',
'Koala',
'Dragon',
'Penguin',
'Badger',
'Heron',
'Lynx',
]
/** A random, readable username (e.g. "SwiftFox4821"). */
@@ -117,3 +145,29 @@ export async function createAccount(
await db.prepare('INSERT INTO accounts (data) VALUES (?1)').bind(JSON.stringify(account)).run()
return account
}
/**
* Read the account's stored password hash (`salt:hash`), or null when the account
* has none / doesn't exist. Kept in the account JSON blob but out of the public
* account DTO (which projects only known fields), so it never leaks.
*/
export async function getPasswordHash(db: D1Database, id: number): Promise<string | null> {
const row = await db
.prepare(
"SELECT json_extract(data, '$.passwordHash') AS hash FROM accounts WHERE account_id = ?1"
)
.bind(id)
.first<{ hash: string | null }>()
return row?.hash ?? null
}
/** Persist the account's password hash. Returns false when no such account exists. */
export async function setPasswordHash(db: D1Database, id: number, hash: string): Promise<boolean> {
const { meta } = await db
.prepare(
"UPDATE accounts SET data = json_set(data, '$.passwordHash', ?2) WHERE account_id = ?1"
)
.bind(id, hash)
.run()
return meta.changes > 0
}
+39 -4
View File
@@ -3,9 +3,11 @@ import { useWorkersLogger } from 'workers-tagged-logger'
import { logger, withNotFound, withOnError } from '@repo/hono-helpers'
import { createAccount } from './accounts-db'
import { generateToken, TOKEN_TTL_SECONDS } from './jwt'
import { createAccount, getPasswordHash, setPasswordHash } from './accounts-db'
import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from './jwt'
import { hashPassword, verifyPassword } from './password'
import type { Context } from 'hono'
import type { App } from './context'
/** OAuth scopes granted by `/connect/token`. */
@@ -54,8 +56,7 @@ async function placeNewPlayerInOrientation(env: App['Bindings'], accountId: numb
const room = JSON.parse(row.data) as Record<string, unknown>
const subRooms = room.SubRooms
const sub = (Array.isArray(subRooms) ? subRooms[0] : undefined) as
| Record<string, unknown>
| undefined
Record<string, unknown> | undefined
const str = (v: unknown, fallback = '') => (typeof v === 'string' ? v : fallback)
const num = (v: unknown, fallback: number) => (typeof v === 'number' ? v : fallback)
@@ -92,6 +93,15 @@ async function placeNewPlayerInOrientation(env: App['Bindings'], accountId: numb
})
}
/** The Bearer token's account id (`sub`), or null when there's no valid token. */
async function authedId(c: Context<App>): Promise<number | null> {
const authHeader = c.req.header('Authorization') ?? ''
if (!authHeader.toLowerCase().startsWith('bearer ')) return null
const sub = await validateAndGetAccountId(authHeader.slice('Bearer '.length))
const id = sub ? Number.parseInt(sub, 10) : Number.NaN
return Number.isNaN(id) ? null : id
}
const app = new Hono<App>()
.use(
'*',
@@ -170,6 +180,31 @@ const app = new Hono<App>()
})
})
// Change the caller's password. Auth-gated. Stores a PBKDF2 hash on the account
// row (the raw password is never persisted). When the account already has a
// password, `oldPassword` must match; the first time it's set, `oldPassword` is
// empty (as the client sends).
.post('/account/me/changepassword', async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const oldPassword = typeof body.oldPassword === 'string' ? body.oldPassword : ''
const newPassword = typeof body.newPassword === 'string' ? body.newPassword : ''
if (newPassword === '') {
return c.json({ success: false, error: 'You must enter a new password.' }, 400)
}
const currentHash = await getPasswordHash(c.env.DB, id)
if (currentHash && !(await verifyPassword(oldPassword, currentHash))) {
return c.json({ success: false, error: 'Your old password is incorrect.' }, 400)
}
const ok = await setPasswordHash(c.env.DB, id, await hashPassword(newPassword))
if (!ok) return c.body(null, 404)
return c.json({ success: true })
})
// Developer role lookup. Not implemented yet.
.get('/role/developer/:id', (c) => {
const { id } = c.req.param()
+45
View File
@@ -18,6 +18,51 @@ function base64url(input: ArrayBuffer | string): string {
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
function base64urlToBytes(input: string): Uint8Array {
const padded = input.replace(/-/g, '+').replace(/_/g, '/')
const binary = atob(padded + '='.repeat((4 - (padded.length % 4)) % 4))
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
return bytes
}
/**
* Validate an HS256 token and return its `sub` (account id) claim, or `null` when
* the token is malformed, has a bad signature, or is expired.
*/
export async function validateAndGetAccountId(
token: string,
secret: string = DEV_SECRET
): Promise<string | null> {
const parts = token.split('.')
if (parts.length !== 3) return null
const [header, payload, signature] = parts
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
)
const valid = await crypto.subtle.verify(
'HMAC',
key,
base64urlToBytes(signature),
new TextEncoder().encode(`${header}.${payload}`)
)
if (!valid) return null
let claims: { sub?: string; exp?: number }
try {
claims = JSON.parse(new TextDecoder().decode(base64urlToBytes(payload)))
} catch {
return null
}
if (typeof claims.exp === 'number' && claims.exp < Math.floor(Date.now() / 1000)) return null
return claims.sub ?? null
}
/** Scopes stamped onto every token (as a claim array). */
const TOKEN_SCOPES = [
'profile',
+39
View File
@@ -0,0 +1,39 @@
/**
* Password hashing for /account/me/changepassword. PBKDF2-SHA256 with a random
* per-password salt, stored as `salt:hash` (both base64). Not login-critical yet
* (login is account-id based), but we never store the raw password.
*/
const ITERATIONS = 100_000
const b64 = (bytes: Uint8Array): string => btoa(String.fromCharCode(...bytes))
const fromB64 = (s: string): Uint8Array => Uint8Array.from(atob(s), (ch) => ch.charCodeAt(0))
async function deriveBits(password: string, salt: Uint8Array): Promise<Uint8Array> {
const keyMaterial = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(password),
'PBKDF2',
false,
['deriveBits']
)
const bits = await crypto.subtle.deriveBits(
{ name: 'PBKDF2', salt, iterations: ITERATIONS, hash: 'SHA-256' },
keyMaterial,
256
)
return new Uint8Array(bits)
}
/** Hash a password into a `salt:hash` string (both base64). */
export async function hashPassword(password: string): Promise<string> {
const salt = crypto.getRandomValues(new Uint8Array(16))
return `${b64(salt)}:${b64(await deriveBits(password, salt))}`
}
/** Verify a password against a stored `salt:hash`. */
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
const [saltB64, hashB64] = stored.split(':')
if (!saltB64 || !hashB64) return false
const actual = b64(await deriveBits(password, fromB64(saltB64)))
return actual === hashB64
}
+50 -3
View File
@@ -48,14 +48,29 @@ function decodePayload(token: string): Record<string, unknown> {
) as Record<string, unknown>
}
async function tokenFor(body: string): Promise<Record<string, unknown>> {
async function accessTokenFor(body: string): Promise<string> {
const res = await exports.default.fetch(`${ORIGIN}/connect/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
const { access_token } = (await res.json()) as { access_token: string }
return decodePayload(access_token)
return ((await res.json()) as { access_token: string }).access_token
}
async function tokenFor(body: string): Promise<Record<string, unknown>> {
return decodePayload(await accessTokenFor(body))
}
/** POST a form-urlencoded body to changepassword with an optional bearer token. */
function changePassword(body: string, token?: string): Promise<Response> {
return exports.default.fetch(`${ORIGIN}/account/me/changepassword`, {
method: 'POST',
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
})
}
describe('auth worker routes', () => {
@@ -165,6 +180,38 @@ describe('auth worker routes', () => {
expect(await res.json()).toEqual([])
})
test('POST /account/me/changepassword 401s without a token', async () => {
const res = await changePassword('oldPassword=&newPassword=secret123')
expect(res.status).toBe(401)
})
test('POST /account/me/changepassword 400s without a new password', async () => {
const token = await accessTokenFor('grant_type=create_account&platform_id=steam-pw0')
const res = await changePassword('oldPassword=&newPassword=', token)
expect(res.status).toBe(400)
})
test('POST /account/me/changepassword sets then rotates the password', async () => {
const token = await accessTokenFor('grant_type=create_account&platform_id=steam-pw1')
// First set — oldPassword is empty (as the client sends it).
const set = await changePassword('oldPassword=&newPassword=first-password', token)
expect(set.status).toBe(200)
expect(await set.json()).toEqual({ success: true })
// A wrong old password is now rejected.
const wrong = await changePassword('oldPassword=nope&newPassword=second-password', token)
expect(wrong.status).toBe(400)
// The correct old password rotates it.
const rotate = await changePassword(
'oldPassword=first-password&newPassword=second-password',
token
)
expect(rotate.status).toBe(200)
expect(await rotate.json()).toEqual({ success: true })
})
test('GET /role/developer/:id returns ok', async () => {
const res = await exports.default.fetch(`${ORIGIN}/role/developer/42`)
expect(res.status).toBe(200)