From fe6c3f8cb1482571af54806acc4c075ce4dd8f75 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 26 Oct 2026 10:53:00 +0000 Subject: [PATCH] feat(profile): signup profile fields via @profullstack/profile /signup asks for first and last name (required) plus company domain, job title and LinkedIn (optional). The signup handler validates them before the link is sent; the validated profile rides on the email token and is saved to user_profiles (migration 3, backfilled from users.name) when the account is created. GET/PUT/PATCH /api/v1/me/profile, and a Profile section in Settings. The company-domain helper is served as /assets/profile-enhance.js for the CSP. Co-Authored-By: Claude Opus 5.5 --- package.json | 1 + pnpm-lock.yaml | 23 ++++++++++++++++++++++ pnpm-workspace.yaml | 1 + src/auth.ts | 9 ++++++++- src/http.ts | 13 +++++++++++++ src/migrations.ts | 29 ++++++++++++++++++++++++++++ src/web.ts | 31 +++++++++++++++++++++++++++--- test/web.test.ts | 47 +++++++++++++++++++++++++++++++++++++++++---- web/assets/app.js | 17 ++++++++++++++++ web/assets/auth.js | 12 +++++++++--- web/assets/site.css | 4 ++++ web/auth.html | 6 ++---- 12 files changed, 178 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index 5c441b5..def2f13 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "@libsql/client": "0.18.0", "@modelcontextprotocol/sdk": "1.32.1", "@profullstack/footer": "^0.2.4", + "@profullstack/profile": "0.1.3", "@profullstack/x402-gateway": "^0.11.0", "@simplewebauthn/server": "^13.3.3", "hono": "4.13.13", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 011c97c..d39e8f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@profullstack/footer': specifier: ^0.2.4 version: 0.2.4 + '@profullstack/profile': + specifier: 0.1.3 + version: 0.1.3(@libsql/client@0.18.0) '@profullstack/x402-gateway': specifier: ^0.11.0 version: 0.11.0 @@ -367,6 +370,22 @@ packages: resolution: {integrity: sha512-Vys47LvS5ivhdZu4SYOxh9Le3DEmWhjP0+CtrU45RhXAbY0ZFG/2Dk9V1bIIUGDp8UWMJqctvUD21JrNjfh04w==} engines: {node: '>=20.11'} + '@profullstack/profile@0.1.3': + resolution: {integrity: sha512-P+VdOqCnNGWa5W8EGLU/ls3hwovRcY8bOBOhWZp3h23sW2LDMFyeWF/pUfO+iiYH3E2Tie2/9xyUnpO4ctwSHw==} + engines: {node: '>=20'} + hasBin: true + peerDependencies: + '@libsql/client': '>=0.6.0' + postgres: ^3.4.0 + react: '>=18' + peerDependenciesMeta: + '@libsql/client': + optional: true + postgres: + optional: true + react: + optional: true + '@profullstack/x402-gateway@0.11.0': resolution: {integrity: sha512-Lfrz+SRTwQZfcoCaVtaaMNPJx74Lp/5oyS7vJJAEuJmH1TjBzhE7dWquISkj1p42oc+VW5I9xH9p7YhEKqmr4Q==} engines: {node: '>=20.11'} @@ -1581,6 +1600,10 @@ snapshots: '@profullstack/keys@0.1.0': {} + '@profullstack/profile@0.1.3(@libsql/client@0.18.0)': + optionalDependencies: + '@libsql/client': 0.18.0 + '@profullstack/x402-gateway@0.11.0': dependencies: '@profullstack/keys': 0.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 940cde8..8cc3ebb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,3 +2,4 @@ allowBuilds: esbuild: true minimumReleaseAgeExclude: - '@profullstack/x402-gateway@0.11.0' + - '@profullstack/profile@0.1.3' diff --git a/src/auth.ts b/src/auth.ts index 517ba6d..ffaf6ad 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -7,6 +7,7 @@ import { type AuthenticatorTransportFuture, type RegistrationResponseJSON, } from '@simplewebauthn/server'; +import { createProfileStore, saveSignupProfile } from '@profullstack/profile'; import { randomBytes, scrypt as scryptCb, timingSafeEqual } from 'node:crypto'; import { promisify } from 'node:util'; import { all, one, run, type Db } from './db.js'; @@ -178,13 +179,19 @@ export async function consumeEmailToken(db: Db, token: string, now = Date.now()) const n = await run(db, 'UPDATE email_tokens SET used_at = ? WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?', ts, h, ts); if (n !== 1) throw new SubmitFleetError('link_invalid', 'this link has expired or was already used; request a new one'); const row = (await one<{ email: string; purpose: LinkPurpose; data_json: string }>(db, 'SELECT email, purpose, data_json FROM email_tokens WHERE token_hash = ?', h))!; - const data = parseJson<{ name?: string; password_hash?: string }>(row.data_json, {}); + const data = parseJson<{ name?: string; password_hash?: string; profile?: Record }>(row.data_json, {}); const existing = await getUserByEmail(db, row.email); if (existing) { await run(db, 'UPDATE users SET email_verified_at = COALESCE(email_verified_at, ?), failed_logins = 0, locked_until = NULL WHERE id = ?', ts, existing.id); return { user: existing, created: false, purpose: row.purpose }; } const user = await createUser(db, row.email, { name: data.name ?? null, passwordHash: data.password_hash ?? null }); + if (data.profile) { + // The fields were validated when the link was requested; a failed save never blocks sign-up. + await saveSignupProfile(createProfileStore(db), user.id, data.profile, { email: user.email }).catch((e) => + console.error('[submitfleet] signup profile save failed:', e instanceof Error ? e.message : e), + ); + } return { user, created: true, purpose: row.purpose }; } diff --git a/src/http.ts b/src/http.ts index 2a151a3..8ad0d40 100644 --- a/src/http.ts +++ b/src/http.ts @@ -10,6 +10,8 @@ import { createCrawlGate, type CrawlGate } from './crawl-gate.js'; import { x402Gateway } from '@profullstack/x402-gateway/hono'; import type { SubmitFleet } from './service.js'; import { SubmitFleetError } from './util.js'; +import { createProfileStore, mountProfileRoutes } from '@profullstack/profile'; +import { getUser } from './auth.js'; import type { Actor } from './workspaces.js'; type Env = { Variables: { actor: Actor } }; @@ -156,6 +158,17 @@ export function buildApp(fleet: SubmitFleet, opts: { allowedOrigins?: string[]; const A = (c: Context) => c.get('actor'); const page = (c: Context) => ({ limit: Math.min(Number(c.req.query('limit') ?? 100) || 100, 500), offset: Number(c.req.query('offset') ?? 0) || 0 }); + // GET/PUT/PATCH /api/v1/me/profile: the signed-in person's profile (session or a user's token). + mountProfileRoutes(api, { + path: '/me/profile', + store: createProfileStore(fleet.db), + getUser: async (_req: Request, c: Context) => { + const id = c.get('actor').id; + const user = id.startsWith('user:') ? await getUser(fleet.db, id.slice(5)) : null; + return user ? { id: user.id, email: user.email } : null; + }, + }); + api.get('/whoami', (c) => c.json({ workspace_id: A(c).workspaceId, role: A(c).role, key_id: A(c).id })); api.get('/products', async (c) => c.json(await fleet.productList(A(c), page(c).limit, page(c).offset))); diff --git a/src/migrations.ts b/src/migrations.ts index 289c83f..0ecc9b6 100644 --- a/src/migrations.ts +++ b/src/migrations.ts @@ -343,6 +343,35 @@ ALTER TABLE api_keys ADD COLUMN user_id TEXT REFERENCES users(id); ALTER TABLE api_keys ADD COLUMN expires_at TEXT; ALTER TABLE api_keys ADD COLUMN last_used_at TEXT; ALTER TABLE products ADD COLUMN logo_mime TEXT +`, + }, + { + // Signup profile (@profullstack/profile): `profile schema --dialect sqlite --key user_id --key-type text`, + // then a one-time backfill of every user's email and their name split on the first space. + version: 3, + sql: ` +CREATE TABLE IF NOT EXISTS "user_profiles" ( + "user_id" text primary key, + "email" text, + "first_name" text, + "last_name" text, + "company_domain" text, + "job_title" text, + "linkedin_url" text, + "created_at" text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + "updated_at" text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); +CREATE INDEX IF NOT EXISTS "user_profiles_email" ON "user_profiles" (lower("email")); +CREATE INDEX IF NOT EXISTS "user_profiles_company_domain" ON "user_profiles" ("company_domain"); +INSERT INTO user_profiles (user_id, email, first_name, last_name) +SELECT id, lower(email), + NULLIF(CASE WHEN instr(trim(COALESCE(name, '')), ' ') > 0 THEN substr(trim(name), 1, instr(trim(name), ' ') - 1) ELSE trim(COALESCE(name, '')) END, ''), + NULLIF(CASE WHEN instr(trim(COALESCE(name, '')), ' ') > 0 THEN trim(substr(trim(name), instr(trim(name), ' ') + 1)) END, '') +FROM users WHERE true +ON CONFLICT (user_id) DO UPDATE SET + email = COALESCE(user_profiles.email, excluded.email), + first_name = COALESCE(user_profiles.first_name, excluded.first_name), + last_name = COALESCE(user_profiles.last_name, excluded.last_name) `, }, ]; diff --git a/src/web.ts b/src/web.ts index 63bd5fa..e47a69e 100644 --- a/src/web.ts +++ b/src/web.ts @@ -7,6 +7,7 @@ import { deleteCookie, getCookie, setCookie } from 'hono/cookie'; import { z } from 'zod'; import { descriptorResponse, footerHtml, latestTemplate } from '@profullstack/footer'; import * as auth from './auth.js'; +import { PROFILE_ENHANCE_SCRIPT, renderProfileFields, validateProfile } from '@profullstack/profile'; import { escapeHtml } from './adapters/tinylaunch.js'; import { loginMail, signupMail, type Mailer } from './mail.js'; import * as oauth from './oauth.js'; @@ -161,7 +162,10 @@ export function mountWeb(app: Hono<{ Variables: { actor: Actor } }>, fleet: Subm .replaceAll('{{AUTOMATED}}', String(stats.automated)); return html(c, body); }); - for (const p of ['/signup', '/login']) app.get(p, (c) => html(c, page('auth.html'))); + // Sign-up asks for the profile fields (the form has its own email box); sign-in never does. + const signupFields = renderProfileFields({ fields: ['first_name', 'last_name', 'company_domain', 'job_title', 'linkedin_url'] }); + app.get('/signup', (c) => html(c, page('auth.html').replace('{{PROFILE_FIELDS}}', signupFields))); + app.get('/login', (c) => html(c, page('auth.html').replace('{{PROFILE_FIELDS}}', ''))); app.get('/auth/link', (c) => html(c, page('link.html'))); app.get('/docs', async (c) => html(c, page('docs.html').replace('{{FOOTER}}', await siteFooter([{ label: 'Home', href: '/' }, { label: 'GitHub', href: 'https://github.com/profullstack/submitfleet' }]))), @@ -181,6 +185,10 @@ export function mountWeb(app: Hono<{ Variables: { actor: Actor } }>, fleet: Subm 'cache-control': 'public, max-age=300', }), ); + // Fills company domain from a work email; a file, not inline, because of PAGE_CSP. + app.get('/assets/profile-enhance.js', (c) => + c.body(PROFILE_ENHANCE_SCRIPT, 200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'public, max-age=300' }), + ); app.get('/assets/*', (c) => { const f = files.get(c.req.path.replace(/^\//, '')); if (!f) return c.notFound(); @@ -202,11 +210,28 @@ export function mountWeb(app: Hono<{ Variables: { actor: Actor } }>, fleet: Subm const emailBody = z.object({ email: z.string().max(254) }); app.post('/api/auth/signup', async (c) => { guard(c); - const b = await json(c, z.object({ email: z.string().max(254), password: z.string().max(200).optional(), name: z.string().max(80).optional() })); + const field = z.string().max(300).nullish(); + const b = await json( + c, + z.object({ + email: z.string().max(254), + password: z.string().max(200).optional(), + name: z.string().max(80).optional(), + first_name: field, + last_name: field, + company_domain: field, + job_title: field, + linkedin_url: field, + }), + ); const email = auth.normalizeEmail(b.email); if (b.password) auth.checkPasswordStrength(b.password); + const profile = validateProfile({ ...b, email }); + if (!profile.ok) throw new SubmitFleetError('invalid_request', Object.values(profile.errors)[0] ?? 'check the profile fields', profile.errors); if (limited(c, 'signup', 10, 10)) return c.json(SENT); - await sendLink(c, email, 'signup', { name: b.name?.trim() || null, password_hash: b.password ? await auth.hashPassword(b.password) : null }); + const name = b.name?.trim() || [profile.profile.first_name, profile.profile.last_name].filter(Boolean).join(' ') || null; + // The validated profile rides on the link and is saved when the account is created. + await sendLink(c, email, 'signup', { name, password_hash: b.password ? await auth.hashPassword(b.password) : null, profile: profile.profile }); return c.json(SENT); }); app.post('/api/auth/magic', async (c) => { diff --git a/test/web.test.ts b/test/web.test.ts index fc12286..acf768e 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -97,7 +97,7 @@ describe('webring footer', () => { describe('accounts', () => { it('signs up through the emailed link only, then signs in with the password', async () => { jar.clear(); - const r = await req('POST', '/api/auth/signup', { email: 'Ann@Example.com', password: 'correct horse battery', name: 'Ann' }); + const r = await req('POST', '/api/auth/signup', { email: 'Ann@Example.com', password: 'correct horse battery', name: 'Ann', first_name: 'Ann', last_name: 'Example' }); expect(r.status).toBe(200); expect(mails).toHaveLength(1); expect(mails[0]!.to).toBe('ann@example.com'); @@ -117,13 +117,52 @@ describe('accounts', () => { expect((await req('GET', '/app')).status).toBe(200); }); + it('asks for the profile on sign-up only, refuses a sign-up without a name, and stores the fields on confirm', async () => { + jar.clear(); + const signupHtml = await (await req('GET', '/signup')).text(); + for (const f of ['first_name', 'last_name', 'company_domain', 'job_title', 'linkedin_url']) expect(signupHtml).toContain(`name="${f}"`); + expect(signupHtml.match(/name="email"/g)).toHaveLength(1); + expect(await (await req('GET', '/login')).text()).not.toContain('name="job_title"'); + expect((await req('GET', '/assets/profile-enhance.js')).headers.get('content-type')).toContain('javascript'); + + const bad = await req('POST', '/api/auth/signup', { email: 'ada@acme.com', password: 'correct horse battery' }); + expect(bad.status).toBe(400); + expect(mails).toHaveLength(0); + + await req('POST', '/api/auth/signup', { + email: 'Ada@Acme.com', + password: 'correct horse battery', + first_name: 'Ada', + last_name: 'Lovelace', + company_domain: 'https://www.acme.com/about', + job_title: 'VP Engineering', + linkedin_url: 'in/ada-lovelace', + }); + expect((await req('GET', '/api/v1/me/profile')).status).toBe(401); + await req('POST', '/api/auth/link', { token: tokenFrom(mails[0]!) }); + const me = await (await req('GET', '/api/me')).json(); + expect(me.user.name).toBe('Ada Lovelace'); + const { profile } = await (await req('GET', '/api/v1/me/profile')).json(); + expect(profile).toMatchObject({ + email: 'ada@acme.com', + first_name: 'Ada', + last_name: 'Lovelace', + company_domain: 'acme.com', + job_title: 'VP Engineering', + linkedin_url: 'https://www.linkedin.com/in/ada-lovelace', + }); + const patched = await (await req('PATCH', '/api/v1/me/profile', { job_title: 'CTO' })).json(); + expect(patched.profile.job_title).toBe('CTO'); + expect((await req('PATCH', '/api/v1/me/profile', { job_title: 'CEO' }, { origin: 'https://evil.example' })).status).toBe(403); + }); + it('never reveals whether an address has an account', async () => { jar.clear(); await req('POST', '/api/auth/magic', { email: 'new@example.com' }); await req('POST', '/api/auth/link', { token: tokenFrom(mails[0]!) }); jar.clear(); - const a = await (await req('POST', '/api/auth/signup', { email: 'new@example.com', password: 'another password 1' })).json(); - const b = await (await req('POST', '/api/auth/signup', { email: 'nobody@example.com', password: 'another password 1' })).json(); + const a = await (await req('POST', '/api/auth/signup', { email: 'new@example.com', password: 'another password 1', first_name: 'N', last_name: 'B' })).json(); + const b = await (await req('POST', '/api/auth/signup', { email: 'nobody@example.com', password: 'another password 1', first_name: 'N', last_name: 'B' })).json(); expect(a).toEqual(b); // The existing account got a plain sign-in link; its (absent) password was not set by the sign-up attempt. expect(mails[1]!.subject).toMatch(/sign-in link/); @@ -134,7 +173,7 @@ describe('accounts', () => { it('locks password sign-in after ten failures but the emailed link still works', async () => { jar.clear(); - await req('POST', '/api/auth/signup', { email: 'lock@example.com', password: 'the real password' }); + await req('POST', '/api/auth/signup', { email: 'lock@example.com', password: 'the real password', first_name: 'L', last_name: 'K' }); await req('POST', '/api/auth/link', { token: tokenFrom(mails[0]!) }); jar.clear(); for (let i = 0; i < 10; i++) await req('POST', '/api/auth/login', { email: 'lock@example.com', password: `nope nope ${i}` }); diff --git a/web/assets/app.js b/web/assets/app.js index 367089a..3a12f0a 100644 --- a/web/assets/app.js +++ b/web/assets/app.js @@ -616,6 +616,23 @@ async function settingsPage() { const keys = await api('/keys'); const root = h('div', {}, pageHead('Settings', me.user?.email)); + // Profile (@profullstack/profile fields; GET/PATCH /api/v1/me/profile) + const { profile } = await api('/me/profile'); + const PROFILE = [['first_name', 'First name', 'text', 'given-name'], ['last_name', 'Last name', 'text', 'family-name'], ['company_domain', 'Company domain', 'text', 'organization'], ['job_title', 'Job title', 'text', 'organization-title'], ['linkedin_url', 'LinkedIn', 'url', 'url']]; + const inputs = Object.fromEntries(PROFILE.map(([k, , type, ac]) => [k, h('input', { type, name: k, autocomplete: ac, value: profile?.[k] ?? '' })])); + root.append(h('div', { class: 'section-title' }, 'Profile'), + h('div', { class: 'card' }, PROFILE.map(([k, label]) => h('div', { class: 'field' }, h('label', {}, label), inputs[k])), + h('div', { class: 'btn-row' }, h('button', { class: 'btn', type: 'button', onclick: async () => { + try { + const body = Object.fromEntries(PROFILE.map(([k]) => [k, inputs[k].value.trim() || null])); + const r = await api('/me/profile', { method: 'PATCH', body: JSON.stringify(body) }); + for (const [k] of PROFILE) inputs[k].value = r.profile?.[k] ?? ''; + toast('Profile saved'); + } catch (e) { + toast(e.message); + } + } }, 'Save profile')))); + // Passkeys const pkList = h('div', { class: 'list' }, me.passkeys.length ? me.passkeys.map((k) => h('div', { class: 'item' }, h('span', {}, '๐Ÿ”‘'), h('div', {}, h('div', { class: 'title' }, k.name || 'Passkey'), h('div', { class: 'sub' }, h('span', {}, `added ${when(k.created_at)}`), k.last_used_at ? h('span', {}, `ยท last used ${when(k.last_used_at)}`) : null)), diff --git a/web/assets/auth.js b/web/assets/auth.js index 65e1782..cadd791 100644 --- a/web/assets/auth.js +++ b/web/assets/auth.js @@ -52,7 +52,6 @@ if (location.pathname === '/auth/link') { if (signup) { $('#title').textContent = 'Create your account'; $('#subtitle').textContent = 'Free to start. We email you once to confirm the address.'; - $('#name-field').hidden = false; $('#password').autocomplete = 'new-password'; $('#password-hint').hidden = false; $('#submit').textContent = 'Create account'; @@ -93,15 +92,22 @@ if (location.pathname === '/auth/link') { const email = $('#email').value.trim(); const password = $('#password').value; if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return say('Enter a valid email address.', 'bad'); + // Sign-up profile fields (rendered on /signup only); first and last name are required. + const profile = {}; + for (const k of ['first_name', 'last_name', 'company_domain', 'job_title', 'linkedin_url']) { + const el = document.querySelector(`#form [name="${k}"]`); + if (el) profile[k] = el.value.trim(); + } + if (signup && (!profile.first_name || !profile.last_name)) return say('Enter your first and last name.', 'bad'); const btn = $('#submit'); btn.disabled = true; try { if (mode === 'link') { - const r = await post(signup ? '/api/auth/signup' : '/api/auth/magic', { email, name: $('#name').value.trim() || undefined }); + const r = await post(signup ? '/api/auth/signup' : '/api/auth/magic', signup ? { email, ...profile } : { email }); say(r.message, 'ok'); } else if (signup) { if (password.length < 10) throw new Error('Use a password of at least 10 characters, or choose "Email link only".'); - const r = await post('/api/auth/signup', { email, password, name: $('#name').value.trim() || undefined }); + const r = await post('/api/auth/signup', { email, password, ...profile }); say(`${r.message} Open it to confirm your account.`, 'ok'); } else { await post('/api/auth/login', { email, password }); diff --git a/web/assets/site.css b/web/assets/site.css index e2dd0a7..1c01420 100644 --- a/web/assets/site.css +++ b/web/assets/site.css @@ -90,6 +90,10 @@ pre code { background: none; border: 0; padding: 0; } label { display: block; font-weight: 600; font-size: 13px; margin: 0 0 6px; color: var(--fg-2); } .field { margin: 0 0 16px; } .hint { font-size: 12.5px; color: var(--muted); margin-top: 5px; } +.pfs-profile { border: 0; padding: 0; margin: 0; min-width: 0; } +.pfs-profile__row { margin: 0 0 16px; } +.pfs-profile__optional { font-weight: 400; color: var(--muted); } +.pfs-profile__error { font-size: 12.5px; color: var(--bad, #c0392b); margin: 5px 0 0; } input[type=text], input[type=email], input[type=password], input[type=url], input[type=search], textarea, select { width: 100%; min-height: 42px; padding: 9px 12px; border-radius: var(--radius-sm); border: 1px solid var(--line-2); background: var(--card); color: var(--fg); font: 15px/1.4 var(--font); diff --git a/web/auth.html b/web/auth.html index d346156..d56e021 100644 --- a/web/auth.html +++ b/web/auth.html @@ -23,14 +23,11 @@

Sign in

-
+ {{PROFILE_FIELDS}}
@@ -43,5 +40,6 @@

Sign in

+