Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
23 changes: 23 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ allowBuilds:
esbuild: true
minimumReleaseAgeExclude:
- '@profullstack/x402-gateway@0.11.0'
- '@profullstack/profile@0.1.3'
9 changes: 8 additions & 1 deletion src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, string | null> }>(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 };
}

Expand Down
13 changes: 13 additions & 0 deletions src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } };
Expand Down Expand Up @@ -156,6 +158,17 @@ export function buildApp(fleet: SubmitFleet, opts: { allowedOrigins?: string[];
const A = (c: Context<Env>) => 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<Env>) => {
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)));
Expand Down
29 changes: 29 additions & 0 deletions src/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
`,
},
];
31 changes: 28 additions & 3 deletions src/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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' }]))),
Expand All @@ -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();
Expand All @@ -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) => {
Expand Down
47 changes: 43 additions & 4 deletions test/web.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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/);
Expand All @@ -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}` });
Expand Down
17 changes: 17 additions & 0 deletions web/assets/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
12 changes: 9 additions & 3 deletions web/assets/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 });
Expand Down
4 changes: 4 additions & 0 deletions web/assets/site.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading