diff --git a/apps/web/package.json b/apps/web/package.json
index fe83c7f..b833b6c 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -7,6 +7,7 @@
"dependencies": {
"@profullstack/footer": "latest",
"@profullstack/emailer": "^1.0.3",
+ "@profullstack/profile": "0.1.2",
"@profullstack/throttle": "^0.3.3",
"@profullstack/x402-gateway": "^0.11.0",
"@r4ck/auth": "workspace:*",
diff --git a/apps/web/public/styles.css b/apps/web/public/styles.css
index 3e77b62..c0173ec 100644
--- a/apps/web/public/styles.css
+++ b/apps/web/public/styles.css
@@ -454,6 +454,7 @@ button.button {
input[type="search"],
input[type="text"],
input[type="email"],
+input[type="url"],
select {
height: var(--control-h);
border-radius: 10px;
@@ -495,6 +496,34 @@ label {
gap: 0.75rem;
margin-bottom: 1rem;
}
+/* The shared profile fields (@profullstack/profile renderProfileFields): a bare
+ fieldset of label + input rows, made to read like the email box above them. */
+.pfs-profile {
+ display: grid;
+ gap: 0.75rem;
+ border: 0;
+ padding: 0;
+ margin: 0;
+ min-width: 0;
+}
+.pfs-profile__row {
+ display: grid;
+ gap: 0.35rem;
+}
+.pfs-profile__row label {
+ font-size: 0.9rem;
+ font-weight: 500;
+}
+.pfs-profile__optional {
+ color: var(--muted);
+ font-weight: 400;
+ font-size: 0.8rem;
+}
+.pfs-profile__error {
+ color: var(--err);
+ font-size: 0.85rem;
+ margin: 0;
+}
.inline-form {
display: flex;
gap: 0.5rem;
diff --git a/apps/web/src/lib/profile.js b/apps/web/src/lib/profile.js
new file mode 100644
index 0000000..935473e
--- /dev/null
+++ b/apps/web/src/lib/profile.js
@@ -0,0 +1,15 @@
+import { createProfileStore } from '@profullstack/profile';
+import { sql } from '@r4ck/db';
+
+/**
+ * The shared Profullstack profile (@profullstack/profile): email, names, company
+ * domain, job title and LinkedIn, in the user_profiles table keyed by users.id
+ * (migration 0002). Private to the account; never shown to anyone else.
+ */
+export const profiles = createProfileStore(sql);
+
+/** The session user as the package's handlers want it: `{ id, email }` or null. */
+export const profileUser = (c) => {
+ const user = c.get('user');
+ return user ? { id: user.id, email: user.email } : null;
+};
diff --git a/apps/web/src/routes/api.js b/apps/web/src/routes/api.js
index ede9dcb..d643d17 100644
--- a/apps/web/src/routes/api.js
+++ b/apps/web/src/routes/api.js
@@ -1,15 +1,17 @@
+import { mountProfileRoutes } from '@profullstack/profile';
import * as auth from '@r4ck/auth';
import { config } from '@r4ck/config';
import { KINDS, SORTS } from '@r4ck/core/facets';
import { currentDaemon, openserverOrigins } from '@r4ck/sync/daemon';
import { meterApi, pricing } from '../lib/gate.js';
import { Denied, requireUser } from '../lib/http.js';
+import { profiles, profileUser } from '../lib/profile.js';
import * as svc from '../lib/service.js';
const CORS = {
'access-control-allow-origin': '*',
'access-control-allow-headers': 'content-type, authorization, x-api-key, x-crawl-pass',
- 'access-control-allow-methods': 'GET, POST, DELETE, OPTIONS',
+ 'access-control-allow-methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
'access-control-expose-headers':
'x-plan, x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset',
};
@@ -64,6 +66,7 @@ export function registerApi(app) {
'/api/v1/deals',
'/api/v1/stats',
'/api/v1/me',
+ '/api/v1/me/profile',
'/api/v1/keys',
'/api/v1/saved',
],
@@ -180,6 +183,8 @@ export function registerApi(app) {
saved: (await svc.listSaved(user)).saved,
});
});
+ // The shared @profullstack/profile row: GET/PUT/PATCH, the package's own handlers.
+ mountProfileRoutes(app, { store: profiles, getUser: (_req, c) => profileUser(c) });
app.get('/api/v1/keys', async (c) =>
c.json({ keys: (await auth.listApiKeys(requireUser(c).id)).map(keyOut) }),
);
diff --git a/apps/web/src/routes/auth.js b/apps/web/src/routes/auth.js
index 4a98e94..17d5b91 100644
--- a/apps/web/src/routes/auth.js
+++ b/apps/web/src/routes/auth.js
@@ -1,9 +1,11 @@
+import { saveSignupProfile, validateProfile } from '@profullstack/profile';
import * as auth from '@r4ck/auth';
import { config } from '@r4ck/config';
import * as accounts from '@r4ck/db/accounts';
import { getCookie, setCookie } from 'hono/cookie';
import { render, requireUser, respond } from '../lib/http.js';
import { sendLoginLink } from '../lib/mail.js';
+import { profiles } from '../lib/profile.js';
import { SignIn } from '../views/account.jsx';
const PK_COOKIE = 'r4k_pk';
@@ -23,23 +25,46 @@ export function registerAuth(app) {
/** Request a link. The answer is identical whether or not the address exists. */
app.post('/api/auth/magic', async (c) => {
const body = await c.req.parseBody();
+ const json = (c.req.header('accept') ?? '').includes('application/json');
+
+ /*
+ * The signup form carries the shared profile fields; plain sign-in does not and
+ * is never asked for them. They are checked here, before any link exists, and
+ * a refusal goes back to the form with what was typed. The same answer for a
+ * known or unknown address, so it says nothing about who has an account.
+ */
+ let profile = null;
+ if (body.mode === 'signup') {
+ const check = validateProfile(body);
+ if (!check.ok) {
+ if (json) return c.json({ error: 'Some fields need fixing.', errors: check.errors }, 400);
+ return c.html(
+ await render(
+ ,
+ ),
+ 400,
+ );
+ }
+ profile = check.profile;
+ }
+
const email = String(body.email ?? '')
.trim()
.toLowerCase();
const next = String(body.next ?? '/settings');
if (/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
try {
- const url = await auth.createLoginLink(email, { next });
+ const url = await auth.createLoginLink(email, { next, profile });
await sendLoginLink({ email, url });
} catch (err) {
console.error('[auth] link send failed:', err.message);
if (!config.mail.enabled)
console.log(
- `[auth] mail is not configured; the link would have been ${await auth.createLoginLink(email, { next })}`,
+ `[auth] mail is not configured; the link would have been ${await auth.createLoginLink(email, { next, profile })}`,
);
}
}
- if ((c.req.header('accept') ?? '').includes('application/json')) return c.json({ ok: true });
+ if (json) return c.json({ ok: true });
return c.html(await render());
});
@@ -57,6 +82,14 @@ export function registerAuth(app) {
),
400,
);
+ // The fields typed on the signup form, saved now that the account exists and
+ // the link has proved the mailbox. A failure here never costs anybody their
+ // sign-in: settings asks again.
+ if (result.profile) {
+ await saveSignupProfile(profiles, result.user.id, result.profile, {
+ email: result.user.email,
+ }).catch((err) => console.error('[auth] signup profile save failed:', err.message));
+ }
c.header('set-cookie', auth.sessionCookie(result.sessionId));
const next = c.req.query('next');
return c.redirect(next?.startsWith('/') ? next : '/settings', 303);
diff --git a/apps/web/src/routes/pages.js b/apps/web/src/routes/pages.js
index 0ed870a..8836b1e 100644
--- a/apps/web/src/routes/pages.js
+++ b/apps/web/src/routes/pages.js
@@ -1,8 +1,10 @@
+import { validateProfile } from '@profullstack/profile';
import * as auth from '@r4ck/auth';
import { config } from '@r4ck/config';
import * as accounts from '@r4ck/db/accounts';
import { accessFor } from '../lib/gate.js';
import { Denied, notice, render, requireUser, respond, wantsJson } from '../lib/http.js';
+import { profiles } from '../lib/profile.js';
import * as svc from '../lib/service.js';
import { Settings } from '../views/account.jsx';
import { Deals } from '../views/deals.jsx';
@@ -105,10 +107,11 @@ export function registerPages(app) {
/* ------------------------------------------------------------ account -- */
app.get('/settings', async (c) => {
const user = requireUser(c);
- const [keys, passkeys, saved] = await Promise.all([
+ const [keys, passkeys, saved, contact] = await Promise.all([
auth.listApiKeys(user.id),
accounts.listPasskeys(user.id),
svc.listSaved(user),
+ profiles.get(user.id),
]);
return c.html(
await render(
@@ -117,12 +120,34 @@ export function registerPages(app) {
keys={keys}
passkeys={passkeys}
saved={saved.saved}
+ contact={contact ?? { email: user.email }}
freshKey={c.req.query('key')}
{...notice(c)}
/>,
),
);
});
+ // The shared profile (names, company, title, LinkedIn), for a page without
+ // script; /api/v1/me/profile is the same thing for API callers. Not under
+ // /api/, where every answer is JSON, so a plain form post gets its redirect.
+ app.post('/settings/contact', async (c) => {
+ const user = requireUser(c);
+ const body = await c.req.parseBody();
+ const check = validateProfile({ ...body, email: user.email });
+ if (!check.ok)
+ return respond(c, {
+ json: { error: 'Some fields need fixing.', errors: check.errors },
+ status: 400,
+ redirectTo: '/settings',
+ error: Object.values(check.errors)[0],
+ });
+ const profile = await profiles.save(user.id, check.profile);
+ return respond(c, {
+ json: { profile },
+ redirectTo: '/settings',
+ notice: 'Contact details saved.',
+ });
+ });
app.post('/api/keys', async (c) => {
const user = requireUser(c);
const body = await c.req.parseBody();
@@ -159,6 +184,7 @@ export function registerPages(app) {
const user = requireUser(c);
const { sql } = await import('@r4ck/db');
await sql`delete from users where id = ${user.id}`;
+ await profiles.remove(user.id);
c.header('set-cookie', auth.sessionCookie('', { clear: true }));
return respond(c, { redirectTo: '/', notice: 'Account deleted.' });
});
diff --git a/apps/web/src/routes/static.js b/apps/web/src/routes/static.js
index a5ca71c..6d840e9 100644
--- a/apps/web/src/routes/static.js
+++ b/apps/web/src/routes/static.js
@@ -1,4 +1,5 @@
import { join } from 'node:path';
+import { PROFILE_ENHANCE_SCRIPT } from '@profullstack/profile';
import { config } from '@r4ck/config';
import { sql } from '@r4ck/db';
import { assetVersion, PUBLIC } from '../lib/assets.js';
@@ -50,6 +51,14 @@ export function registerStatic(app) {
? serve(c, `icons/${c.req.param('file')}`, { cache: 'public, max-age=86400' })
: c.notFound(),
);
+ // The signup form's company-from-work-email helper, from @profullstack/profile.
+ // Served as a file because the policy allows no inline script.
+ app.get('/vendor-profile.js', (c) =>
+ c.body(PROFILE_ENHANCE_SCRIPT, 200, {
+ 'content-type': TYPES.js,
+ 'cache-control': 'public, max-age=3600',
+ }),
+ );
app.get('/sw.js', (c) => serve(c, 'sw.js', { cache: 'no-cache' }));
// curl -fsSL https://r4ck.dev/install.sh | sh
app.get('/install.sh', (c) => serve(c, 'install.sh', { cache: 'public, max-age=300' }));
diff --git a/apps/web/src/views/account.jsx b/apps/web/src/views/account.jsx
index b606867..b2c3a5d 100644
--- a/apps/web/src/views/account.jsx
+++ b/apps/web/src/views/account.jsx
@@ -1,14 +1,32 @@
+import { COLUMNS as PROFILE_COLUMNS, renderProfileFields } from '@profullstack/profile';
import { config } from '@r4ck/config';
+import { raw } from 'hono/html';
import { ago } from './home.jsx';
import { Layout } from './Layout.jsx';
-export function SignIn({ mode = 'login', next, sent = false, error, user = null }) {
+/*
+ * The rest of the shared profile (@profullstack/profile), asked for on the signup
+ * page only. Email is the form's own box, so the package renders the other five.
+ * A plain sign-in never sees or needs them.
+ */
+const PROFILE_FIELDS = PROFILE_COLUMNS.filter((f) => f !== 'email');
+
+export function SignIn({
+ mode = 'login',
+ next,
+ sent = false,
+ error,
+ user = null,
+ values = null,
+ errors = null,
+}) {
const signup = mode === 'signup';
return (
: null}
>
{signup ? 'Create an account' : 'Sign in'}
@@ -27,6 +45,7 @@ export function SignIn({ mode = 'login', next, sent = false, error, user = null
<>
+
Saved searches
{saved.length ? (
diff --git a/bun.lock b/bun.lock
index 2a2e035..01bff64 100644
--- a/bun.lock
+++ b/bun.lock
@@ -41,6 +41,7 @@
"dependencies": {
"@profullstack/emailer": "^1.0.3",
"@profullstack/footer": "latest",
+ "@profullstack/profile": "0.1.2",
"@profullstack/r4ck": "workspace:*",
"@profullstack/throttle": "^0.3.3",
"@profullstack/x402-gateway": "^0.11.0",
@@ -191,6 +192,8 @@
"@profullstack/keys": ["@profullstack/keys@0.1.0", "", {}, "sha512-Vys47LvS5ivhdZu4SYOxh9Le3DEmWhjP0+CtrU45RhXAbY0ZFG/2Dk9V1bIIUGDp8UWMJqctvUD21JrNjfh04w=="],
+ "@profullstack/profile": ["@profullstack/profile@0.1.2", "", { "peerDependencies": { "@libsql/client": ">=0.6.0", "postgres": "^3.4.0", "react": ">=18" }, "optionalPeers": ["@libsql/client", "postgres", "react"], "bin": { "profile": "src/cli.js" } }, "sha512-IRpMa8xFWIs5UnZiOZt5GSQCsNM7utq1ZgSqqJ/Buye89GcKCxhMtfRIe1zRHe+96Lc2egEuKeCfmNtxa3ej5Q=="],
+
"@profullstack/r4ck": ["@profullstack/r4ck@workspace:apps/cli"],
"@profullstack/scraper-daemon": ["@profullstack/scraper-daemon@0.2.1", "", { "peerDependencies": { "bullmq": ">=6", "ioredis": ">=5" }, "optionalPeers": ["bullmq", "ioredis"], "bin": { "scraper-daemon": "bin/scraper-daemon.js" } }, "sha512-oqmN0eOPmNAz7Q88XOIL2xEb87jc32IbIWyteUtmIhqqLHHQhu19lsgrrtoNClH/6tQoI7cSQFCAnoFH1YoNXw=="],
diff --git a/packages/auth/src/index.js b/packages/auth/src/index.js
index f900e8a..81ec2d5 100644
--- a/packages/auth/src/index.js
+++ b/packages/auth/src/index.js
@@ -31,12 +31,14 @@ export const expectedOrigins = (() => {
const hashToken = (t) => createHash('sha256').update(t).digest();
-export async function createLoginLink(email, { next } = {}) {
+export async function createLoginLink(email, { next, profile = null } = {}) {
const token = randomBytes(32).toString('base64url');
await q.insertLoginToken({
tokenHash: hashToken(token),
email: email.trim().toLowerCase(),
expiresAt: new Date(Date.now() + TOKEN_TTL_MINUTES * 60_000),
+ // Signup form fields, already validated by the caller; saved on verify.
+ profile,
});
const url = new URL('/auth/magic', config.siteUrl);
url.searchParams.set('t', token);
@@ -46,8 +48,10 @@ export async function createLoginLink(email, { next } = {}) {
/** Also the registration path: an unknown address gets an account here. */
export async function consumeLoginLink(token, { userAgent } = {}) {
- const email = await q.consumeLoginToken(hashToken(token));
+ const tokenHash = hashToken(token);
+ const email = await q.consumeLoginToken(tokenHash);
if (!email) return null;
+ const profile = await q.takeSignupProfile(tokenHash);
const user = await q.findOrCreateUser(email, {
admin: config.adminEmails.includes(String(email).toLowerCase()),
});
@@ -56,7 +60,7 @@ export async function consumeLoginLink(token, { userAgent } = {}) {
ttlDays: config.session.ttlDays,
userAgent,
});
- return { user, sessionId };
+ return { user, sessionId, profile };
}
export async function passkeyRegistrationOptions(user) {
diff --git a/packages/db/migrations/0002_user_profiles.sql b/packages/db/migrations/0002_user_profiles.sql
new file mode 100644
index 0000000..4ce74ae
--- /dev/null
+++ b/packages/db/migrations/0002_user_profiles.sql
@@ -0,0 +1,39 @@
+-- The shared Profullstack profile: email, first_name, last_name, company_domain,
+-- job_title, linkedin_url, owned by @profullstack/profile.
+--
+-- The package's own table, keyed by users.id. DDL is its
+-- `profile schema --key user_id --key-type uuid` output. No foreign key, as the
+-- package documents; deleting a user is the host's job (/api/account/delete).
+create table if not exists "user_profiles" (
+ "user_id" uuid primary key,
+ "email" text,
+ "first_name" text,
+ "last_name" text,
+ "company_domain" text,
+ "job_title" text,
+ "linkedin_url" text,
+ "created_at" timestamptz not null default now(),
+ "updated_at" timestamptz not null default 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");
+
+-- The account row is only created when the emailed link is spent, so the fields a
+-- signup typed ride on the token until then. Validated before they are stored
+-- here, and saved only once the link proves the mailbox.
+alter table login_tokens add column if not exists profile jsonb;
+
+-- One-time backfill from users.display_name. First word to first_name, the rest
+-- to last_name. Only blanks are filled, so a re-run never overwrites anything.
+insert into user_profiles (user_id, email, first_name, last_name)
+select
+ u.id,
+ lower(u.email::text),
+ split_part(btrim(u.display_name), ' ', 1),
+ nullif(btrim(substr(btrim(u.display_name), length(split_part(btrim(u.display_name), ' ', 1)) + 1)), '')
+from users u
+where nullif(btrim(u.display_name), '') is not null
+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/packages/db/src/accounts.js b/packages/db/src/accounts.js
index ae6dba3..bf643e4 100644
--- a/packages/db/src/accounts.js
+++ b/packages/db/src/accounts.js
@@ -1,4 +1,4 @@
-import { pgArray, sql, withJson } from './index.js';
+import { jsonb, pgArray, sql, withJson } from './index.js';
/* ------------------------------------------------------------- accounts -- */
@@ -26,8 +26,37 @@ export async function countUsers() {
return n;
}
-export async function insertLoginToken({ tokenHash, email, expiresAt }) {
- await sql`insert into login_tokens ${sql({ token_hash: tokenHash, email, expires_at: expiresAt })}`;
+/**
+ * `profile` is the validated signup form (@profullstack/profile fields), held on
+ * the token because the account row does not exist until the link is spent.
+ * Bound as text and cast via ::text: Bun SQL binds a bare `::jsonb` string as a JSON string scalar.
+ */
+export async function insertLoginToken({ tokenHash, email, expiresAt, profile = null }) {
+ await sql`
+ insert into login_tokens (token_hash, email, expires_at, profile)
+ values (${tokenHash}, ${email}, ${expiresAt}, ${profile ? JSON.stringify(profile) : null}::text::jsonb)
+ `;
+}
+
+/**
+ * The signup fields a just-spent link carried, handed out once and then cleared.
+ * Only a consumed token answers, so this can never run ahead of the sign-in.
+ */
+export async function takeSignupProfile(tokenHash) {
+ // RETURNING sees the row after the update, so the old value comes from a
+ // subquery: `returning profile` here would always hand back the null.
+ const [row] = await sql`
+ update login_tokens t set profile = null
+ from (
+ select token_hash, profile from login_tokens
+ where token_hash = ${tokenHash} and consumed_at is not null and profile is not null
+ for update
+ ) old
+ where t.token_hash = old.token_hash
+ returning old.profile
+ `;
+ if (!row) return null;
+ return jsonb(row.profile, null);
}
/** Single-use by construction: the update is the consumption. Returns the address. */
diff --git a/test/api.test.js b/test/api.test.js
index 4379661..a30e805 100644
--- a/test/api.test.js
+++ b/test/api.test.js
@@ -375,6 +375,105 @@ if (!url) {
).status,
).toBe(400);
});
+ test('signup asks for the profile fields; sign-in does not', async () => {
+ const signup = await (
+ await app.request('/signup', { headers: { accept: 'text/html' } })
+ ).text();
+ for (const name of ['first_name', 'last_name', 'company_domain', 'job_title', 'linkedin_url'])
+ expect(signup).toContain(`name="${name}"`);
+ expect(signup.match(/name="email"/g)?.length).toBe(1);
+ expect(signup).toContain('/vendor-profile.js');
+ const login = await (
+ await app.request('/login', { headers: { accept: 'text/html' } })
+ ).text();
+ expect(login).not.toContain('name="first_name"');
+ const script = await app.request('/vendor-profile.js');
+ expect(script.headers.get('content-type')).toContain('javascript');
+ });
+ test('a signup with the fields stores them on verify', async () => {
+ const post = (fields) =>
+ app.request('/api/auth/magic', {
+ method: 'POST',
+ headers: { accept: 'text/html', 'content-type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({ mode: 'signup', next: '/settings', ...fields }),
+ });
+ await sql`delete from login_tokens where email = 'ada@acme.test'`;
+ await sql`delete from users where email = 'ada@acme.test'`;
+ // Refused before any link exists, with what was typed kept.
+ const refused = await post({ email: 'ada@acme.test', first_name: 'Ada' });
+ expect(refused.status).toBe(400);
+ const page = await refused.text();
+ expect(page).toContain('value="Ada"');
+ expect(page).toContain('name="last_name"');
+ expect((await sql`select 1 from login_tokens where email = 'ada@acme.test'`).length).toBe(0);
+
+ const ok = await post({
+ email: 'Ada@Acme.test',
+ first_name: 'Ada',
+ last_name: 'Lovelace',
+ company_domain: 'https://www.acme.test/about',
+ job_title: 'VP Engineering',
+ linkedin_url: 'in/ada-lovelace',
+ });
+ expect(ok.status).toBe(200);
+ // Carried on the token, normalized, as a jsonb object (not a JSON string).
+ const [row] = await sql`
+ select profile, jsonb_typeof(profile) as t from login_tokens
+ where email = 'ada@acme.test' and profile is not null limit 1
+ `;
+ expect(row.t).toBe('object');
+ // The emailed URL is not readable here, so a link is minted with the same
+ // carried fields; spending it must save them.
+ const carried = typeof row.profile === 'string' ? JSON.parse(row.profile) : row.profile;
+ const link = await auth.createLoginLink('ada@acme.test', { profile: carried });
+ const r = await app.request(link.replace(/^https?:\/\/[^/]+/, ''), {
+ headers: { accept: 'text/html' },
+ });
+ expect(r.status).toBe(303);
+ const cookie = r.headers.get('set-cookie').split(';')[0];
+ const got = await app.request('/api/v1/me/profile', {
+ headers: { accept: 'application/json', cookie },
+ });
+ expect(got.status).toBe(200);
+ expect(JSON.stringify(await got.json())).toContain('"company_domain":"acme.test"');
+ const [saved] = await sql`
+ select p.* from user_profiles p join users u on u.id = p.user_id
+ where u.email = 'ada@acme.test'
+ `;
+ expect(saved).toMatchObject({
+ email: 'ada@acme.test',
+ first_name: 'Ada',
+ last_name: 'Lovelace',
+ company_domain: 'acme.test',
+ job_title: 'VP Engineering',
+ linkedin_url: 'https://www.linkedin.com/in/ada-lovelace',
+ });
+ // Settings shows the same fields, prefilled, and saves them without script.
+ const settings = await app.request('/settings', { headers: { accept: 'text/html', cookie } });
+ expect(await settings.text()).toContain('value="VP Engineering"');
+ const edit = await app.request('/settings/contact', {
+ method: 'POST',
+ headers: {
+ accept: 'text/html',
+ cookie,
+ 'content-type': 'application/x-www-form-urlencoded',
+ },
+ body: new URLSearchParams({ first_name: 'Ada', last_name: 'King', job_title: 'CTO' }),
+ });
+ expect(edit.status).toBe(303);
+ const [edited] = await sql`
+ select p.last_name, p.job_title from user_profiles p join users u on u.id = p.user_id
+ where u.email = 'ada@acme.test'
+ `;
+ expect(edited).toEqual({ last_name: 'King', job_title: 'CTO' });
+ // Handed out once: the spent token no longer holds it.
+ const spent = await sql`
+ select 1 from login_tokens
+ where email = 'ada@acme.test' and consumed_at is not null and profile is not null
+ `;
+ expect(spent.length).toBe(0);
+ expect((await app.request('/api/v1/me/profile')).status).toBe(401);
+ });
});
describe('mcp', () => {