feat(auth): guest login with random telegraph operator name
No email required — players pick male/female/mystery, get assigned a humorous period-appropriate operator name (e.g. "Dusty Clicksworth", "Ada Dottsworth", "The Phantom Sender"), can reroll, then hit "Begin Transmitting!" for instant play. Email/magic link moved to a collapsible section for cross-device access only. - server: POST /api/auth/guest creates guest profile + session - server/db: profiles.email now nullable, is_guest column added, migration recreates table for existing installs - client: names.ts with 15 names per gender category - client: LoginPage redesigned — gender picker → name display → play - client: useAuth gains setAuth() for direct session injection - 55 server + 44 client tests pass Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+31
-1
@@ -8,11 +8,33 @@ function initDb(dbPath) {
|
||||
instance.pragma('journal_mode = WAL')
|
||||
instance.pragma('foreign_keys = ON')
|
||||
|
||||
// Migration: add is_guest + make email nullable on existing installs
|
||||
const profileCols = instance.pragma('table_info(profiles)').map(c => c.name)
|
||||
if (profileCols.length > 0 && !profileCols.includes('is_guest')) {
|
||||
instance.pragma('foreign_keys = OFF')
|
||||
instance.exec(`
|
||||
CREATE TABLE profiles_v2 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT UNIQUE,
|
||||
display_name TEXT,
|
||||
is_guest INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO profiles_v2 (id, email, display_name, is_guest, created_at, last_seen)
|
||||
SELECT id, email, display_name, 0, created_at, last_seen FROM profiles;
|
||||
DROP TABLE profiles;
|
||||
ALTER TABLE profiles_v2 RENAME TO profiles;
|
||||
`)
|
||||
instance.pragma('foreign_keys = ON')
|
||||
}
|
||||
|
||||
instance.exec(`
|
||||
CREATE TABLE IF NOT EXISTS profiles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
email TEXT UNIQUE,
|
||||
display_name TEXT,
|
||||
is_guest INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL
|
||||
);
|
||||
@@ -56,6 +78,13 @@ function initDb(dbPath) {
|
||||
);
|
||||
`)
|
||||
|
||||
function createGuestProfile(displayName) {
|
||||
const now = Date.now()
|
||||
return instance.prepare(
|
||||
'INSERT INTO profiles (email, display_name, is_guest, created_at, last_seen) VALUES (NULL,?,1,?,?) RETURNING *'
|
||||
).get(displayName, now, now)
|
||||
}
|
||||
|
||||
function createProfile(email, displayName) {
|
||||
const now = Date.now()
|
||||
const stmt = instance.prepare(
|
||||
@@ -230,6 +259,7 @@ function initDb(dbPath) {
|
||||
|
||||
return {
|
||||
_db: instance,
|
||||
createGuestProfile,
|
||||
createProfile,
|
||||
findProfileByEmail,
|
||||
findProfileById,
|
||||
|
||||
@@ -62,6 +62,22 @@ function createServer(db) {
|
||||
|
||||
// ── Auth routes ──────────────────────────────────────────────────────────
|
||||
|
||||
if (pathname === '/api/auth/guest' && method === 'POST') {
|
||||
const body = await readBody(req)
|
||||
const name = body.display_name
|
||||
if (!name || typeof name !== 'string' || name.trim().length < 2 || name.trim().length > 80) {
|
||||
return send(res, 400, { error: 'display_name required (2-80 chars)' })
|
||||
}
|
||||
const profile = db.createGuestProfile(name.trim())
|
||||
db.getOrCreateProgress(profile.id)
|
||||
const sessionToken = crypto.randomBytes(32).toString('hex')
|
||||
db.createSession(sessionToken, profile.id)
|
||||
return send(res, 200, {
|
||||
token: sessionToken,
|
||||
profile: { id: profile.id, email: null, display_name: profile.display_name, is_guest: true },
|
||||
})
|
||||
}
|
||||
|
||||
if (pathname === '/api/auth/request' && method === 'POST') {
|
||||
const body = await readBody(req)
|
||||
const email = body.email
|
||||
@@ -102,6 +118,7 @@ function createServer(db) {
|
||||
id: profile.id,
|
||||
email: profile.email,
|
||||
display_name: profile.display_name,
|
||||
is_guest: profile.is_guest === 1,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,37 @@ after(() => {
|
||||
return new Promise(resolve => server.close(resolve))
|
||||
})
|
||||
|
||||
describe('POST /api/auth/guest', () => {
|
||||
test('creates guest profile and returns token + profile', async () => {
|
||||
const r = await request(server, 'POST', '/api/auth/guest', { display_name: 'Dusty Clicksworth' })
|
||||
assert.equal(r.status, 200)
|
||||
assert.ok(r.body.token)
|
||||
assert.equal(r.body.profile.display_name, 'Dusty Clicksworth')
|
||||
assert.equal(r.body.profile.is_guest, true)
|
||||
assert.equal(r.body.profile.email, null)
|
||||
})
|
||||
|
||||
test('session token from guest login works with /api/auth/me', async () => {
|
||||
const g = await request(server, 'POST', '/api/auth/guest', { display_name: 'Flash Henderson' })
|
||||
const me = await request(server, 'GET', '/api/auth/me', null, {
|
||||
Authorization: `Bearer ${g.body.token}`,
|
||||
})
|
||||
assert.equal(me.status, 200)
|
||||
assert.equal(me.body.display_name, 'Flash Henderson')
|
||||
assert.equal(me.body.is_guest, true)
|
||||
})
|
||||
|
||||
test('returns 400 if display_name missing', async () => {
|
||||
const r = await request(server, 'POST', '/api/auth/guest', {})
|
||||
assert.equal(r.status, 400)
|
||||
})
|
||||
|
||||
test('returns 400 if display_name too short', async () => {
|
||||
const r = await request(server, 'POST', '/api/auth/guest', { display_name: 'X' })
|
||||
assert.equal(r.status, 400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/auth/request', () => {
|
||||
test('returns 200 ok for valid email', async () => {
|
||||
const r = await request(server, 'POST', '/api/auth/request', { email: 'test@example.com' })
|
||||
|
||||
Reference in New Issue
Block a user