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
3 changes: 3 additions & 0 deletions bun.lock

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

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"@profullstack/footer": "latest",
"@profullstack/hqtui": "^0.10.3",
"@profullstack/player": "^0.14.0",
"@profullstack/profile": "0.1.3",
"@profullstack/x402-gateway": "^0.11.0",
"jsonwebtoken": "^9.0.2",
"pg": "^8.23.1"
Expand Down
61 changes: 59 additions & 2 deletions src/server/accounts.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ import { AuthSystem, MemoryAdapter, PostgresAdapter } from '@profullstack/auth-s
import { createOAuthServer, memoryStore as oauthMemoryStore } from '@profullstack/auth-system/oauth2';
import { OAUTH2_SCHEMA, postgresStore as oauthPostgresStore } from '@profullstack/auth-system/oauth2/postgres';
import { createEmailer } from '@profullstack/emailer';
import {
createProfileHandlers,
createProfileStore,
saveSignupProfile,
schemaSql,
validateProfile,
} from '@profullstack/profile';
import jwt from 'jsonwebtoken';

/** The CLI, TUI and stdio MCP sign in as this client (code + PKCE, loopback). */
Expand Down Expand Up @@ -72,6 +79,9 @@ function postgresStore(adapter) {
blob TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now())`);
// The shared profile (name, company, title, LinkedIn): @profullstack/profile's
// own user_profiles table, keyed by the auth-system user id (text).
await adapter.query(schemaSql({ keyType: 'text' }));
})());
return {
init,
Expand Down Expand Up @@ -175,6 +185,21 @@ export function createAccounts(opts = {}) {
opts.adapter ||
(env.DATABASE_URL ? new PostgresAdapter({ connectionString: env.DATABASE_URL }) : new MemoryAdapter());
const store = adapter instanceof PostgresAdapter ? postgresStore(adapter) : memoryStore(adapter);
// Profiles ride the same pg pool; with no database, an in-memory SQLite.
let profilesReady = null;
const profileStore = () =>
(profilesReady ??= (async () => {
if (adapter instanceof PostgresAdapter) {
await store.init();
return createProfileStore(adapter, { keyType: 'text' });
}
const db = globalThis.Bun
? new (await import('bun:sqlite')).Database(':memory:')
: new (await import('node:sqlite')).DatabaseSync(':memory:');
const profiles = createProfileStore(db);
await profiles.migrate();
return profiles;
})());
const box = sealer(env.HDTILT_LIBRARY_KEY || `${secret || 'dev'}:library`);
const mediaKey = createHash('sha256')
.update(`${secret || 'hdtilt-dev-secret'}:media`)
Expand Down Expand Up @@ -282,7 +307,7 @@ export function createAccounts(opts = {}) {
await store.init();
await oauth.revoke(token);
},
async register({ username, email, password }) {
async register({ username, email, password, ...fields }) {
username = String(username || '').trim();
email = String(email || '')
.trim()
Expand All @@ -291,19 +316,51 @@ export function createAccounts(opts = {}) {
throw new Error('Pick a username of 3 to 30 letters, numbers, dots, dashes or underscores');
if (!EMAIL.test(email)) throw new Error('That email address does not look right');
if (String(password || '').length < 8) throw new Error('Use a password of at least 8 characters');
const check = validateProfile({ ...fields, email });
if (!check.ok) {
const err = new Error(Object.values(check.errors)[0]);
err.code = 'invalid_profile';
err.fields = check.errors;
throw err;
}
await store.init();
if (await store.emailForUsername(username)) throw new Error('That username is taken');
let created;
try {
await auth.register({ email, password, profile: { username } });
created = await auth.register({ email, password, profile: { username } });
} catch (e) {
// Same answer whether or not the address exists, so the form cannot be
// used to find out who has an account.
if (/exist/i.test(e.message)) return { ok: true, sent: true };
if (/unique|duplicate/i.test(e.message)) throw new Error('That username is taken');
throw e;
}
try {
await saveSignupProfile(await profileStore(), created.user.id, check.profile, { email });
} catch (e) {
// The account exists and the email is on its way: a profile that did not
// save is filled in later from the account sheet, never a failed signup.
console.warn(`hdtilt: signup profile not saved: ${e.message}`);
}
return { ok: true, sent: true };
},
/** GET/PUT/PATCH /api/v1/me/profile for a signed-in user (Web Request in, Response out). */
async profile(claims, request) {
const handlers = createProfileHandlers({
store: await profileStore(),
getUser: async () => {
const u = await adapter.getUserById(claims.userId);
return u ? { id: u.id, email: u.email } : null;
},
});
const handle = handlers[request.method];
return handle
? handle(request)
: new Response(JSON.stringify({ error: 'GET, PUT or PATCH' }), {
status: 405,
headers: { 'content-type': 'application/json' },
});
},
async verify(token) {
let r;
try {
Expand Down
21 changes: 19 additions & 2 deletions src/server/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,8 @@ export function createApp(opts = {}) {
path.startsWith('/api/auth/') ||
path === '/api/me' ||
path === '/api/library' ||
path === '/api/oauth/authorize'
path === '/api/oauth/authorize' ||
path === '/api/v1/me/profile'
) {
if (!accounts) return send(res, 404, { error: 'accounts are not enabled on this server' });
return account(req, res, path);
Expand Down Expand Up @@ -385,7 +386,11 @@ export function createApp(opts = {}) {

async function account(req, res, path) {
const fail = (e, status = 400) =>
send(res, e.code === 'conflict' ? 409 : status, { error: e.message, ...(e.code ? { code: e.code } : {}) });
send(res, e.code === 'conflict' ? 409 : status, {
error: e.message,
...(e.code ? { code: e.code } : {}),
...(e.fields ? { errors: e.fields } : {}),
});
if (path.startsWith('/api/auth/')) {
if (req.method !== 'POST') return send(res, 405, { error: 'POST' });
if (limited(clientIp(req))) return send(res, 429, { error: 'Too many tries; wait a minute' });
Expand Down Expand Up @@ -431,6 +436,18 @@ export function createApp(opts = {}) {
if (!claims) return send(res, 401, { error: 'Sign in first' });
try {
if (path === '/api/me') return send(res, 200, await accounts.me(claims));
if (path === '/api/v1/me/profile') {
const write = req.method === 'PUT' || req.method === 'PATCH';
const r = await accounts.profile(
claims,
new Request(`http://hdtilt.local${path}`, {
method: req.method,
headers: { 'content-type': 'application/json' },
...(write ? { body: JSON.stringify(await readJson(req)) } : {}),
}),
);
return send(res, r.status, await r.json());
}
// The consent page: only a browser session (not another OAuth token) may grant one.
if (path === '/api/oauth/authorize') {
if (claims.via === 'oauth') return send(res, 403, { error: 'Sign in on the website to approve' });
Expand Down
50 changes: 50 additions & 0 deletions test/accounts.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ describe('sign up', () => {
username: 'Viewer_1',
email: 'Viewer@Example.org',
password: 'couch-potato',
first_name: 'Ada',
last_name: 'Lovelace',
company_domain: 'https://www.Acme.com/about',
job_title: 'VP Engineering',
linkedin_url: 'in/ada-lovelace',
});
expect(r.status).toBe(200);
expect(mail).toHaveLength(1);
Expand All @@ -53,6 +58,18 @@ describe('sign up', () => {
expect((await login.json()).code).toBe('unverified');
});

it('needs a first and last name', async () => {
const r = await post('/api/auth/register', {
username: 'nameless',
email: 'n@example.org',
password: 'longenough',
});
expect(r.status).toBe(400);
const body = await r.json();
expect(body.code).toBe('invalid_profile');
expect(Object.keys(body.errors).sort()).toEqual(['first_name', 'last_name']);
});

it('can send the link again', async () => {
await post('/api/auth/resend', { email: 'viewer@example.org' });
expect(mail).toHaveLength(2);
Expand Down Expand Up @@ -88,6 +105,8 @@ describe('sign up', () => {
username: 'viewer_1',
email: 'other@example.org',
password: 'couch-potato',
first_name: 'Grace',
last_name: 'Hopper',
});
expect((await r.json()).error).toContain('taken');
});
Expand All @@ -97,6 +116,8 @@ describe('sign up', () => {
username: 'someone',
email: 'viewer@example.org',
password: 'couch-potato',
first_name: 'Grace',
last_name: 'Hopper',
});
expect(r.status).toBe(200);
});
Expand All @@ -119,6 +140,35 @@ describe('sign in', () => {
});
});

describe('profile', () => {
const me = (init = {}) =>
fetch(at('/api/v1/me/profile'), {
...init,
headers: { 'content-type': 'application/json', authorization: `Bearer ${session.accessToken}` },
});
it('needs a session', async () => {
const r = await fetch(at('/api/v1/me/profile'));
expect(r.status).toBe(401);
expect((await r.json()).error).toBeTruthy();
});
it('kept what the signup form gave, normalized', async () => {
const { profile } = await (await me()).json();
expect(profile).toMatchObject({
email: 'viewer@example.org',
first_name: 'Ada',
last_name: 'Lovelace',
company_domain: 'acme.com',
job_title: 'VP Engineering',
linkedin_url: 'https://www.linkedin.com/in/ada-lovelace',
});
});
it('takes a PATCH from the account sheet', async () => {
const r = await me({ method: 'PATCH', body: JSON.stringify({ job_title: 'CTO' }) });
expect(r.status).toBe(200);
expect((await (await me()).json()).profile).toMatchObject({ job_title: 'CTO', first_name: 'Ada' });
});
});

describe('library', () => {
const get = (token) => fetch(at('/api/library'), { headers: { authorization: `Bearer ${token}` } });
const put = (token, body) =>
Expand Down
8 changes: 7 additions & 1 deletion test/cli-sync.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,13 @@ beforeAll(async () => {
env: { SITE_URL: base, HDTILT_JWT_SECRET: 'cli-secret-cli-secret-cli-secret' },
});
srv = await startServer({ port, public: true, accounts, authPerMinute: 1000 });
await post('/api/auth/register', { username: 'tvroom', email: 'tv@example.org', password: 'remote-control' });
await post('/api/auth/register', {
username: 'tvroom',
email: 'tv@example.org',
password: 'remote-control',
first_name: 'Tv',
last_name: 'Room',
});
const token = new URL(mail[0].text.match(/https?:\/\/\S+/)[0]).searchParams.get('token');
jwt = (await post('/api/auth/verify', { token })).tokens.accessToken;
// What the web app would have synced from another device.
Expand Down
14 changes: 14 additions & 0 deletions web/public/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,20 @@ label {
.field:focus {
border-color: var(--accent);
}
.pfs-profile {
border: 0;
margin: 0;
padding: 0;
min-width: 0;
}
.pfs-profile__optional {
opacity: 0.7;
}
.pfs-profile__error {
color: #fca5a5;
font-size: 0.85rem;
margin: 0.3rem 0 0;
}
.actions {
display: flex;
gap: 0.6rem;
Expand Down
6 changes: 5 additions & 1 deletion web/src/account.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,11 @@ export async function signIn(login, password) {
await renewMediaCookie();
return r.user;
}
export const register = (username, email, password) => call('/api/auth/register', { username, email, password });
export const register = (username, email, password, profile = {}) =>
call('/api/auth/register', { ...profile, username, email, password });
/** The shared profile (name, company, title, LinkedIn) for the signed-in account. */
export const getProfile = () => call('/api/v1/me/profile', undefined, { auth: true });
export const saveProfile = (fields) => call('/api/v1/me/profile', fields, { auth: true, method: 'PATCH' });
export const resend = (email) => call('/api/auth/resend', { email });
export const forgot = (email) => call('/api/auth/forgot', { email });
export const reset = (token, password) => call('/api/auth/reset', { token, password });
Expand Down
33 changes: 31 additions & 2 deletions web/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// overlay that a remote, a keyboard, a mouse or a finger can drive.

import { attachSource, isTvBrowser } from '@profullstack/player';
import { renderProfileFields } from '@profullstack/profile';
import { indexAt, nowNext, progress } from '../../src/core/epg.js';
import { filterByWords } from '../../src/core/match.js';
import { catchupUrl, groupsOf } from '../../src/core/sources.js';
Expand Down Expand Up @@ -1849,6 +1850,15 @@ const busy = async (btn, label, fn) => {
}
};

/** The shared profile fields (@profullstack/profile); the form already has its own email box. */
const PROFILE_FIELDS = ['first_name', 'last_name', 'company_domain', 'job_title', 'linkedin_url'];
const profileFields = (values = {}) =>
renderProfileFields({ fields: PROFILE_FIELDS, idPrefix: 'a-', values }).replaceAll(
'<input ',
'<input class="field" ',
);
const profileValues = (form) => Object.fromEntries(PROFILE_FIELDS.map((n) => [n, form.elements[n].value.trim()]));

/** Sign in, create an account, or (signed in) the account itself. `after` runs after sign-in. */
function openAccount({ tab = 'in', after = null, note = '' } = {}) {
const u = account.current();
Expand All @@ -1859,8 +1869,24 @@ function openAccount({ tab = 'in', after = null, note = '' } = {}) {
<p class="sub">${esc(u.email)}</p>
<p class="sub">Your playlists, favorites and recent channels follow you to every device you sign in on, including <code>hdtilt tui</code> after <code>hdtilt login</code>.</p>
<div class="actions"><button class="btn primary" id="a-sync">Sync now</button><button class="btn" id="a-out">Sign out</button><button class="btn" id="a-close">Close</button></div>
<div class="err" id="a-err"></div>`,
<div class="err" id="a-err"></div>
<form id="pf">${profileFields()}<div class="err" id="p-err"></div>
<div class="actions"><button class="btn" type="submit">Save profile</button></div></form>`,
);
const pf = sh.querySelector('#pf');
account
.getProfile()
.then(({ profile }) => {
for (const n of PROFILE_FIELDS) if (profile?.[n]) pf.elements[n].value = profile[n];
})
.catch(() => {});
pf.onsubmit = (e) => {
e.preventDefault();
$('p-err').textContent = '';
busy(e.submitter, 'Saving…', () => account.saveProfile(profileValues(pf)))
.then(() => toast('Profile saved'))
.catch((x) => ($('p-err').textContent = x.message));
};
sh.querySelector('#a-sync').onclick = (e) =>
busy(e.target, 'Syncing…', () => account.syncNow())
.then(() => toast('Synced'))
Expand All @@ -1884,6 +1910,7 @@ function openAccount({ tab = 'in', after = null, note = '' } = {}) {
tab === 'up'
? `<label for="a-user">Username</label><input class="field" id="a-user" autocomplete="username" autocapitalize="none" spellcheck="false" required>
<label for="a-mail">Email</label><input class="field" id="a-mail" type="email" autocomplete="email" required>
${profileFields()}
<label for="a-pass">Password (8 or more characters)</label><input class="field" id="a-pass" type="password" autocomplete="new-password" minlength="8" required>`
: `<label for="a-login">Username or email</label><input class="field" id="a-login" autocomplete="username" autocapitalize="none" spellcheck="false" required>
<label for="a-pass">Password</label><input class="field" id="a-pass" type="password" autocomplete="current-password" required>`
Expand All @@ -1908,7 +1935,9 @@ function openAccount({ tab = 'in', after = null, note = '' } = {}) {
try {
if (tab === 'up') {
const email = $('a-mail').value.trim();
await busy(btn, 'Creating…', () => account.register($('a-user').value.trim(), email, $('a-pass').value));
await busy(btn, 'Creating…', () =>
account.register($('a-user').value.trim(), email, $('a-pass').value, profileValues($('af'))),
);
return openCheckEmail(email);
}
await busy(btn, 'Signing in…', () => account.signIn($('a-login').value.trim(), $('a-pass').value));
Expand Down
Loading