diff --git a/apps/web/package.json b/apps/web/package.json index b267409..aad1578 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,6 +22,7 @@ "@nichedb/premium": "workspace:*", "@nichedb/queue": "workspace:*", "@profullstack/affiliates": "^0.2.0", + "@profullstack/coinpay-onboard": "0.3.1", "@profullstack/footer": "latest", "@profullstack/leaderboard": "^0.3.1", "@profullstack/nichedb": "workspace:*", diff --git a/apps/web/src/app.js b/apps/web/src/app.js index 2ef0218..a03836b 100644 --- a/apps/web/src/app.js +++ b/apps/web/src/app.js @@ -16,6 +16,7 @@ import { registerAutomotive } from './routes/automotive.js'; import { registerBlacklists } from './routes/blacklists.js'; import { registerBooks } from './routes/books.js'; import { registerBusiness } from './routes/business.js'; +import { registerCoinpay } from './routes/coinpay.js'; import { registerCrawlStatus } from './routes/crawlstatus.js'; import { registerCredits } from './routes/credits.js'; import { registerDataDumps } from './routes/data-dumps.js'; @@ -144,6 +145,8 @@ app.get('/healthz', (c) => c.text('ok')); registerStatic(app, gateway); registerAuth(app); +/** Sign in with CoinPay: before registerApi so its /api/v1 meter never sees these redirects. */ +registerCoinpay(app); // Before pages: /i/INV-… is an invite, /i/ (pages) is an item. registerCredits(app); registerPages(app); diff --git a/apps/web/src/lib/coinpay-signin.js b/apps/web/src/lib/coinpay-signin.js new file mode 100644 index 0000000..a4bab91 --- /dev/null +++ b/apps/web/src/lib/coinpay-signin.js @@ -0,0 +1,198 @@ +/** + * Sign in with CoinPay: a confidential OIDC client. Authorization code + PKCE + * S256 + state + nonce, the client secret on the code exchange, and the id_token + * checked (issuer, audience, expiry, nonce) with a userinfo `sub` that must match. + * The protocol half is the house package @profullstack/coinpay-onboard/oauth. + * + * Binding (STANDING rule): an account is found by its CoinPay `sub` only, in + * coinpay_identities. CoinPay never verifies email addresses, so its email is + * never matched against users.email and never becomes one. A first CoinPay + * sign-in gets a new account with an unroutable placeholder email; an existing + * account gets CoinPay only by connecting it while signed in. + */ +import { randomUUID } from 'node:crypto'; +import { + authorizeUrl, + finishSignIn, + pkce, + randomToken, + SIGN_IN_SCOPES, + sameState, +} from '@profullstack/coinpay-onboard/oauth'; + +export const CALLBACK_PATH = '/api/v1/coinpay/oauth/callback'; +export const PENDING_COOKIE = 'ndb_cp'; +export const PENDING_TTL_SECONDS = 600; +export const PLACEHOLDER_DOMAIN = 'coinpay.nichedb.invalid'; + +// Read at request time: the app's env comes from app.env on the box. +const env = (name) => { + const v = process.env[name]; + return typeof v === 'string' ? v.trim() : ''; +}; + +/** Null when the client is not configured, which hides every CoinPay button. */ +export function coinpayConfig(siteUrl) { + const clientId = env('COINPAY_OAUTH_CLIENT_ID'); + const clientSecret = env('COINPAY_OAUTH_CLIENT_SECRET'); + if (!clientId || !clientSecret) return null; + const issuer = (env('COINPAY_OAUTH_ISSUER') || 'https://coinpayportal.com').replace(/\/+$/, ''); + return { + clientId, + clientSecret, + issuer, + redirectUri: siteUrl.replace(/\/+$/, '') + CALLBACK_PATH, + }; +} + +export function placeholderEmail(sub) { + return `${String(sub) + .toLowerCase() + .replace(/[^a-z0-9-]/g, '-')}@${PLACEHOLDER_DOMAIN}`; +} + +export function isPlaceholderEmail(email) { + return String(email ?? '') + .trim() + .toLowerCase() + .endsWith(`@${PLACEHOLDER_DOMAIN}`); +} + +/** Only same-site paths survive the round trip. */ +export function safeNext(raw, fallback = '/following') { + const s = String(raw ?? ''); + return s.startsWith('/') && !s.startsWith('//') && !s.startsWith('/\\') && !/[\t\r\n]/.test(s) + ? s + : fallback; +} + +/** The authorize redirect plus what the callback needs back, kept server side. */ +export function beginSignIn(cfg, { mode = 'login', next, userId = null } = {}) { + const { verifier, challenge } = pkce(); + const pending = { + state: randomToken(24), + nonce: randomToken(24), + verifier, + mode: mode === 'connect' ? 'connect' : 'login', + next: safeNext(next), + userId, + }; + const url = authorizeUrl({ + base: cfg.issuer, + clientId: cfg.clientId, + redirectUri: cfg.redirectUri, + state: pending.state, + challenge, + nonce: pending.nonce, + scopes: SIGN_IN_SCOPES, + }); + return { id: randomUUID(), pending, url }; +} + +export const stateMatches = (expected, received) => sameState(expected, received); + +/** Code -> tokens -> checked id_token -> userinfo. Throws on any mismatch. */ +export function finishCoinpaySignIn(cfg, { code, verifier, nonce }, doFetch) { + return finishSignIn({ + code, + verifier, + nonce, + clientId: cfg.clientId, + clientSecret: cfg.clientSecret, + redirectUri: cfg.redirectUri, + base: cfg.issuer, + fetch: doFetch, + }); +} + +/** + * The identity store over any `run(text, params) => rows` (postgres.js + * `sql.unsafe` in the app, PGlite in tests). + */ +export function identityStore(run) { + const one = async (text, params) => (await run(text, params))[0] ?? null; + return { + async userIdForSub(sub) { + const row = await one('select user_id from coinpay_identities where sub = $1', [sub]); + return row ? String(row.user_id) : null; + }, + + async identityFor(userId) { + return one('select sub, email, created_at from coinpay_identities where user_id = $1', [ + userId, + ]); + }, + + /** Sign-in: the bound account, or a new one bound to this sub. Never by email. */ + async signIn({ sub, email = null, name = null }) { + if (!sub) throw new Error('no sub'); + const bound = await one( + 'update coinpay_identities set last_login_at = now(), email = $2 where sub = $1 returning user_id', + [sub, email], + ); + if (bound) return { userId: String(bound.user_id), created: false }; + // A plain user (never admin: role is fixed here, not ADMIN_EMAILS or + // "first account"). The CTE makes the user and its binding together. + const made = await one( + `with u as ( + insert into users (email, role, display_name) values ($2, 'user', $4) + on conflict (email) do nothing + returning id + ) + insert into coinpay_identities (sub, user_id, email, last_login_at) + select $1, u.id, $3, now() from u + on conflict (sub) do nothing + returning user_id`, + [sub, placeholderEmail(sub), email, name ? String(name).slice(0, 120) : null], + ); + if (made) return { userId: String(made.user_id), created: true }; + // Lost a race with the same sub's first sign-in: use what it bound. + const again = await this.userIdForSub(sub); + if (again) return { userId: again, created: false }; + throw new Error('could not bind the CoinPay identity'); + }, + + /** Connect from a signed-in session. */ + async link(userId, { sub, email = null }) { + const existing = await one('select user_id from coinpay_identities where sub = $1', [sub]); + if (existing) + return String(existing.user_id) === String(userId) + ? { ok: true } + : { ok: false, reason: 'taken' }; + const mine = await one('select sub from coinpay_identities where user_id = $1', [userId]); + if (mine) return { ok: false, reason: 'already_linked' }; + const row = await one( + `insert into coinpay_identities (sub, user_id, email) values ($1, $2, $3) + on conflict do nothing returning sub`, + [sub, userId, email], + ); + return row ? { ok: true } : { ok: false, reason: 'taken' }; + }, + + /** + * Disconnect, unless CoinPay is the only way in: a placeholder-email account + * cannot get a magic link, so it needs a passkey first. + */ + async unlink(userId) { + const user = await one('select email::text as email from users where id = $1', [userId]); + if (!user) return { ok: false, reason: 'no_account' }; + const status = await this.status(userId, user.email); + if (!status.linked) return { ok: false, reason: 'not_linked' }; + if (!status.canDisconnect) return { ok: false, reason: 'only_login' }; + await run('delete from coinpay_identities where user_id = $1', [userId]); + return { ok: true }; + }, + + async status(userId, email) { + const linked = Boolean(await this.identityFor(userId)); + let other = !isPlaceholderEmail(email); + if (!other) { + const pk = await one('select count(*)::int as n from passkeys where user_id = $1', [ + userId, + ]); + other = Number(pk?.n ?? 0) > 0; + } + return { linked, canDisconnect: linked && other }; + }, + }; +} diff --git a/apps/web/src/routes/auth.js b/apps/web/src/routes/auth.js index 891605b..8a73db6 100644 --- a/apps/web/src/routes/auth.js +++ b/apps/web/src/routes/auth.js @@ -7,6 +7,7 @@ import { vapidKeysFromEnv, vapidPublicKeyResponse } from '@profullstack/notifica import { saveSignupProfile, validateProfile } from '@profullstack/profile'; import { getCookie, setCookie } from 'hono/cookie'; import { AUTH, attempt, callerAddress, forgive, VIEW } from '../lib/auth-throttle.js'; +import { isPlaceholderEmail } from '../lib/coinpay-signin.js'; import { acceptInvite, attributeReferral, looksLikeInvite } from '../lib/credits.js'; import { render, requireUser, respond } from '../lib/http.js'; import { profiles } from '../lib/profile.js'; @@ -133,7 +134,8 @@ export function registerAuth(app) { .toLowerCase(); const next = String(body.next ?? '/following'); const invite = looksLikeInvite(body.invite) ? String(body.invite) : inviteFrom(c); - if (/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { + // A CoinPay-only account's placeholder address never gets a link. + if (/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email) && !isPlaceholderEmail(email)) { try { const url = await auth.createLoginLink(email, { next, profile, invite }); await sendLoginLink({ email, url }); diff --git a/apps/web/src/routes/coinpay.js b/apps/web/src/routes/coinpay.js new file mode 100644 index 0000000..d94f7bf --- /dev/null +++ b/apps/web/src/routes/coinpay.js @@ -0,0 +1,148 @@ +import { getCookie, setCookie } from 'hono/cookie'; +import { + beginSignIn, + coinpayConfig, + finishCoinpaySignIn, + identityStore, + PENDING_COOKIE, + PENDING_TTL_SECONDS, + stateMatches, +} from '../lib/coinpay-signin.js'; + +/** + * The app's own wiring. Loaded lazily so tests (and pages that never touch + * CoinPay) do not open Postgres or Redis by importing this module. + */ +async function defaultDeps() { + const [{ config }, { sql }, q, auth, { connection }] = await Promise.all([ + import('@nichedb/config'), + import('@nichedb/db'), + import('@nichedb/db/queries'), + import('@nichedb/auth'), + import('@nichedb/queue'), + ]); + const key = (id) => `cp:pending:${id}`; + return { + siteUrl: config.siteUrl, + secure: config.isProd, + run: (text, params) => sql.unsafe(text, params), + pending: { + save: (id, value) => + connection.set(key(id), JSON.stringify(value), 'EX', PENDING_TTL_SECONDS), + // Single use: read and delete in one step. + take: async (id) => { + const raw = await connection.getdel(key(id)); + return raw ? JSON.parse(raw) : null; + }, + }, + startSession: (userId, userAgent) => + q.startSession({ userId, ttlDays: config.session.ttlDays, userAgent }), + sessionCookie: (sid) => auth.sessionCookie(sid), + fetch: undefined, + }; +} + +/** + * Sign in with CoinPay: + * GET /api/v1/coinpay/oauth/start?next=/x sign in / sign up + * GET /api/v1/coinpay/oauth/start?mode=connect link to the signed-in account + * GET /api/v1/coinpay/oauth/callback + * POST /api/v1/coinpay/disconnect + * Registered before the /api/v1 meter: these are browser redirects, not API calls. + */ +export function registerCoinpay(app, overrides = null) { + let depsPromise = null; + const deps = () => { + depsPromise ??= overrides ? Promise.resolve(overrides) : defaultDeps(); + return depsPromise; + }; + const back = (to, msg, kind = 'error') => `${to}?${kind}=${encodeURIComponent(msg)}`; + + app.get('/api/v1/coinpay/oauth/start', async (c) => { + const d = await deps(); + const cfg = coinpayConfig(d.siteUrl); + if (!cfg) return c.text('Sign in with CoinPay is not configured.', 503); + const mode = c.req.query('mode') === 'connect' ? 'connect' : 'login'; + const user = c.get('user'); + let userId = null; + if (mode === 'connect') { + // A browser session, not an API key: linking changes how the account signs in. + if (!user || c.get('viaKey')) return c.redirect('/login?next=/settings', 303); + userId = String(user.id); + } + const { id, pending, url } = beginSignIn(cfg, { + mode, + next: mode === 'connect' ? '/settings' : c.req.query('next'), + userId, + }); + await d.pending.save(id, pending); + setCookie(c, PENDING_COOKIE, id, { + httpOnly: true, + sameSite: 'Lax', + path: '/', + maxAge: PENDING_TTL_SECONDS, + secure: d.secure, + }); + return c.redirect(url, 302); + }); + + app.get('/api/v1/coinpay/oauth/callback', async (c) => { + const d = await deps(); + const cfg = coinpayConfig(d.siteUrl); + if (!cfg) return c.text('Sign in with CoinPay is not configured.', 503); + const id = getCookie(c, PENDING_COOKIE); + setCookie(c, PENDING_COOKIE, '', { path: '/', maxAge: 0 }); + const pending = id ? await d.pending.take(id) : null; + if (!pending) return c.redirect(back('/login', 'That sign-in expired. Try again.'), 303); + if (c.req.query('error')) + return c.redirect(back('/login', 'CoinPay sign-in was cancelled.'), 303); + if (!stateMatches(pending.state, c.req.query('state'))) + return c.text('CoinPay sign-in failed: state mismatch.', 400); + const code = c.req.query('code'); + if (!code) return c.text('CoinPay sign-in failed: no code.', 400); + + let who; + try { + who = await finishCoinpaySignIn( + cfg, + { code, verifier: pending.verifier, nonce: pending.nonce }, + d.fetch, + ); + } catch (err) { + console.error('[coinpay] sign-in refused:', err.message); + return c.text('CoinPay sign-in could not be verified.', 400); + } + const store = identityStore(d.run); + + if (pending.mode === 'connect') { + const user = c.get('user'); + if (!user || c.get('viaKey') || String(user.id) !== String(pending.userId)) + return c.redirect(back('/login', 'Sign in first, then connect CoinPay.'), 303); + const r = await store.link(user.id, { sub: who.sub, email: who.email }); + if (r.ok) return c.redirect(back('/settings', 'CoinPay connected.', 'notice'), 303); + const why = + r.reason === 'taken' + ? 'That CoinPay account is already linked to another nichedb account.' + : 'This account already has a different CoinPay account connected.'; + return c.redirect(back('/settings', why), 303); + } + + const { userId } = await store.signIn({ sub: who.sub, email: who.email, name: who.name }); + const sid = await d.startSession(userId, c.req.header('user-agent')); + c.header('set-cookie', d.sessionCookie(sid), { append: true }); + return c.redirect(pending.next || '/following', 303); + }); + + app.post('/api/v1/coinpay/disconnect', async (c) => { + const d = await deps(); + const user = c.get('user'); + if (!user || c.get('viaKey')) return c.redirect('/login?next=/settings', 303); + const r = await identityStore(d.run).unlink(user.id); + if (r.ok) return c.redirect(back('/settings', 'CoinPay disconnected.', 'notice'), 303); + const why = + r.reason === 'only_login' + ? 'CoinPay is the only way into this account. Add a passkey first.' + : 'CoinPay is not connected.'; + return c.redirect(back('/settings', why), 303); + }); +} diff --git a/apps/web/src/routes/coinpay.test.js b/apps/web/src/routes/coinpay.test.js new file mode 100644 index 0000000..86cdc19 --- /dev/null +++ b/apps/web/src/routes/coinpay.test.js @@ -0,0 +1,269 @@ +/** + * Sign in with CoinPay: bound by `sub` only. A CoinPay login carrying someone + * else's email never gets their account; linking needs a browser session; a + * forged state or a wrong nonce is refused. Runs every migration on PGlite. + */ +import { beforeAll, describe, expect, test } from 'bun:test'; +import { readdir, readFile } from 'node:fs/promises'; +import { PGlite } from '@electric-sql/pglite'; +import { citext } from '@electric-sql/pglite/contrib/citext'; +import { pg_trgm } from '@electric-sql/pglite/contrib/pg_trgm'; +import { Hono } from 'hono'; +import { identityStore, placeholderEmail } from '../lib/coinpay-signin.js'; +import { registerCoinpay } from './coinpay.js'; + +process.env.COINPAY_OAUTH_CLIENT_ID = 'cp_test_client'; +process.env.COINPAY_OAUTH_CLIENT_SECRET = 'test-client-secret'; + +const ROOT = new URL('../../../../', import.meta.url).pathname; +let db; +let run; +let store; + +beforeAll(async () => { + db = await new PGlite({ extensions: { citext, pg_trgm } }); + const dir = `${ROOT}packages/db/migrations/`; + const files = (await readdir(dir)).filter((f) => f.endsWith('.sql')).sort(); + for (const f of files) { + try { + await db.exec(await readFile(dir + f, 'utf8')); + } catch (err) { + // A few migrations need extensions PGlite lacks (postgis etc.); the auth + // tables come from 0001 and 0042, which must apply. + if (f.startsWith('0001') || f.startsWith('0042')) throw err; + } + } + run = async (text, params) => (await db.query(text, params)).rows; + store = identityStore(run); +}); + +const addUser = async (email) => + (await run(`insert into users (email) values ($1) returning id::text as id`, [email]))[0].id; + +const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url'); +const fakeCoinpay = + ({ sub, email, nonce }) => + async (url) => { + const u = String(url); + if (u.endsWith('/api/oauth/token')) + return Response.json({ + access_token: 'at', + token_type: 'Bearer', + expires_in: 3600, + id_token: `${b64({ alg: 'HS256' })}.${b64({ iss: 'https://coinpayportal.com', aud: 'cp_test_client', sub, nonce, exp: Math.floor(Date.now() / 1000) + 600 })}.sig`, + }); + if (u.endsWith('/api/oauth/userinfo')) + return Response.json({ sub, email, email_verified: false }); + return new Response('{}', { status: 404 }); + }; + +/** The app under test: a fake session (x-test-user header) in front of the routes. */ +function appWith({ coinpay }) { + const pendingMap = new Map(); + const sessions = []; + const app = new Hono(); + app.use('*', async (c, next) => { + const id = c.req.header('x-test-user'); + c.set('user', id ? { id, email: 'x' } : null); + c.set('viaKey', c.req.header('x-test-key') === '1'); + await next(); + }); + registerCoinpay(app, { + siteUrl: 'https://nichedb.test', + secure: true, + run, + pending: { + save: async (id, v) => pendingMap.set(id, v), + take: async (id) => { + const v = pendingMap.get(id) ?? null; + pendingMap.delete(id); + return v; + }, + }, + startSession: async (userId) => { + sessions.push(userId); + return `sid-${sessions.length}`; + }, + sessionCookie: (sid) => `ndb_session=${sid}; Path=/; HttpOnly`, + get fetch() { + return coinpay.fetch; + }, + }); + return { app, sessions }; +} + +async function start(app, { mode, user } = {}) { + const headers = user ? { 'x-test-user': user } : {}; + const res = await app.request(`/api/v1/coinpay/oauth/start${mode ? `?mode=${mode}` : ''}`, { + headers, + }); + const loc = new URL(res.headers.get('location'), 'https://nichedb.test'); + const cookie = (res.headers.get('set-cookie') ?? '').split(';')[0]; + return { res, loc, cookie }; +} + +describe('Sign in with CoinPay', () => { + test('start sends PKCE S256, state and nonce to CoinPay with the exact redirect', async () => { + const { app } = appWith({ coinpay: {} }); + const { res, loc, cookie } = await start(app); + expect(res.status).toBe(302); + expect(loc.origin + loc.pathname).toBe('https://coinpayportal.com/api/oauth/authorize'); + expect(loc.searchParams.get('code_challenge_method')).toBe('S256'); + expect(loc.searchParams.get('state')).toBeTruthy(); + expect(loc.searchParams.get('nonce')).toBeTruthy(); + expect(loc.searchParams.get('redirect_uri')).toBe( + 'https://nichedb.test/api/v1/coinpay/oauth/callback', + ); + expect(loc.searchParams.get('scope')).toBe('openid profile email'); + expect(cookie.startsWith('ndb_cp=')).toBe(true); + }); + + test('a CoinPay login carrying a victim email does not get the victim account', async () => { + const victim = await addUser('victim@example.com'); + const coinpay = {}; + const { app, sessions } = appWith({ coinpay }); + const { loc, cookie } = await start(app); + coinpay.fetch = fakeCoinpay({ + sub: 'attacker-sub', + email: 'victim@example.com', + nonce: loc.searchParams.get('nonce'), + }); + const res = await app.request( + `/api/v1/coinpay/oauth/callback?code=c&state=${loc.searchParams.get('state')}`, + { headers: { cookie } }, + ); + expect(res.status).toBe(303); + expect(sessions.length).toBe(1); + expect(sessions[0]).not.toBe(victim); + const [made] = await run('select email::text as email, role from users where id = $1', [ + sessions[0], + ]); + expect(made.email).toBe(placeholderEmail('attacker-sub')); + expect(made.role).toBe('user'); + expect(await store.identityFor(victim)).toBeNull(); + // the same sub signs into the same account next time, whatever email it sends + const again = await store.signIn({ sub: 'attacker-sub', email: 'other@example.com' }); + expect(again).toEqual({ userId: sessions[0], created: false }); + }); + + test('a forged state is refused and nothing is created', async () => { + const coinpay = { + fetch: fakeCoinpay({ sub: 'forged-sub', email: 'f@example.com', nonce: 'n' }), + }; + const { app, sessions } = appWith({ coinpay }); + const { cookie } = await start(app); + const res = await app.request('/api/v1/coinpay/oauth/callback?code=c&state=forged', { + headers: { cookie }, + }); + expect(res.status).toBe(400); + expect(sessions.length).toBe(0); + expect(await store.userIdForSub('forged-sub')).toBeNull(); + }); + + test('an id_token with the wrong nonce is refused', async () => { + const coinpay = {}; + const { app, sessions } = appWith({ coinpay }); + const { loc, cookie } = await start(app); + coinpay.fetch = fakeCoinpay({ + sub: 'nonce-sub', + email: 'n@example.com', + nonce: 'not-the-nonce', + }); + const res = await app.request( + `/api/v1/coinpay/oauth/callback?code=c&state=${loc.searchParams.get('state')}`, + { headers: { cookie } }, + ); + expect(res.status).toBe(400); + expect(sessions.length).toBe(0); + }); + + test('the pending sign-in is single use', async () => { + const coinpay = {}; + const { app } = appWith({ coinpay }); + const { loc, cookie } = await start(app); + coinpay.fetch = fakeCoinpay({ + sub: 'once-sub', + email: null, + nonce: loc.searchParams.get('nonce'), + }); + const url = `/api/v1/coinpay/oauth/callback?code=c&state=${loc.searchParams.get('state')}`; + expect((await app.request(url, { headers: { cookie } })).status).toBe(303); + const replay = await app.request(url, { headers: { cookie } }); + expect(replay.headers.get('location')).toContain('/login?error='); + }); + + test('connect needs a browser session, and links only to that account', async () => { + const owner = await addUser('owner@example.com'); + const coinpay = {}; + const { app, sessions } = appWith({ coinpay }); + // no session: back to login, nothing pending + const anon = await start(app, { mode: 'connect' }); + expect(anon.loc.pathname).toBe('/login'); + // an API key is not a browser session + const viaKey = await app.request('/api/v1/coinpay/oauth/start?mode=connect', { + headers: { 'x-test-user': owner, 'x-test-key': '1' }, + }); + expect(new URL(viaKey.headers.get('location'), 'https://x').pathname).toBe('/login'); + + const { loc, cookie } = await start(app, { mode: 'connect', user: owner }); + coinpay.fetch = fakeCoinpay({ + sub: 'owner-sub', + email: 'whatever@example.com', + nonce: loc.searchParams.get('nonce'), + }); + const url = `/api/v1/coinpay/oauth/callback?code=c&state=${loc.searchParams.get('state')}`; + // the callback in a browser that is no longer signed in to that account is refused + const other = await addUser('someone-else@example.com'); + const wrong = await app.request(url, { headers: { cookie, 'x-test-user': other } }); + expect(wrong.headers.get('location')).toContain('/login?error='); + expect(await store.identityFor(owner)).toBeNull(); + + const again = await start(app, { mode: 'connect', user: owner }); + coinpay.fetch = fakeCoinpay({ + sub: 'owner-sub', + email: 'whatever@example.com', + nonce: again.loc.searchParams.get('nonce'), + }); + const ok = await app.request( + `/api/v1/coinpay/oauth/callback?code=c&state=${again.loc.searchParams.get('state')}`, + { headers: { cookie: again.cookie, 'x-test-user': owner } }, + ); + expect(ok.headers.get('location')).toContain('/settings?notice='); + expect((await store.identityFor(owner)).sub).toBe('owner-sub'); + expect(sessions.length).toBe(0); + expect(await store.userIdForSub('owner-sub')).toBe(owner); + }); + + test('link and disconnect rules', async () => { + const a = await addUser('a-link@example.com'); + const b = await addUser('b-link@example.com'); + expect(await store.link(a, { sub: 'link-sub' })).toEqual({ ok: true }); + expect(await store.link(b, { sub: 'link-sub' })).toEqual({ ok: false, reason: 'taken' }); + expect(await store.link(a, { sub: 'second-sub' })).toEqual({ + ok: false, + reason: 'already_linked', + }); + expect(await store.status(a, 'a-link@example.com')).toEqual({ + linked: true, + canDisconnect: true, + }); + expect(await store.unlink(a)).toEqual({ ok: true }); + // a CoinPay-only account cannot drop its only way in + const { userId } = await store.signIn({ sub: 'only-sub', email: 'o@example.com' }); + expect(await store.unlink(userId)).toEqual({ ok: false, reason: 'only_login' }); + }); +}); + +describe('CoinPay buttons', () => { + test('sign-in and sign-up offer CoinPay first; settings offers Connect', async () => { + process.env.DATABASE_URL ??= 'postgres://test:test@localhost:5432/test'; + process.env.SITE_URL ??= 'https://nichedb.test'; + const { SignIn } = await import('../views/pages.jsx'); + for (const mode of ['login', 'signup']) { + const out = String(await SignIn({ mode }).toString()); + const button = out.indexOf('/api/v1/coinpay/oauth/start'); + expect(button).toBeGreaterThan(-1); + expect(button).toBeLessThan(out.indexOf('action="/api/auth/magic"')); + } + }); +}); diff --git a/apps/web/src/routes/manage.js b/apps/web/src/routes/manage.js index 1e92f73..0ea5952 100644 --- a/apps/web/src/routes/manage.js +++ b/apps/web/src/routes/manage.js @@ -15,6 +15,7 @@ import { } from '@nichedb/payments/referrals'; import { enqueueRun } from '@nichedb/queue'; import { validateProfile } from '@profullstack/profile'; +import { identityStore } from '../lib/coinpay-signin.js'; import { configFromForm, isProUser, @@ -374,16 +375,20 @@ export function registerManage(app) { app.get('/settings', async (c) => { const user = requireUser(c); - const [passkeys, apiKeys, pro, terms, flash, snapshot, contact, webhooks] = await Promise.all([ - q.listPasskeys(user.id), - auth.listApiKeys(user.id), - isProUser(user), - q.membershipTerms(user.id, { limit: 3 }), - takeFlash(c), - premiumSnapshot(c), - profiles.get(user.id), - hooks.listEndpoints(String(user.id)).catch(() => []), - ]); + const [passkeys, apiKeys, pro, terms, flash, snapshot, contact, webhooks, coinpay] = + await Promise.all([ + q.listPasskeys(user.id), + auth.listApiKeys(user.id), + isProUser(user), + q.membershipTerms(user.id, { limit: 3 }), + takeFlash(c), + premiumSnapshot(c), + profiles.get(user.id), + hooks.listEndpoints(String(user.id)).catch(() => []), + identityStore((text, params) => sql.unsafe(text, params)) + .status(user.id, user.email) + .catch(() => null), + ]); // One flash slot: an API key (ndb_…) or a webhook signing secret (whsec_…). const webhookSecret = flash?.startsWith('whsec_') ? flash : null; const newKey = webhookSecret ? null : flash; @@ -403,6 +408,7 @@ export function registerManage(app) { import { config } from '@nichedb/config'; import { COLUMNS as PROFILE_COLUMNS, renderProfileFields } from '@profullstack/profile'; import { raw } from 'hono/html'; +import { coinpayConfig } from '../lib/coinpay-signin.js'; import { currentModules } from '../lib/modules.js'; import { WhereToBuy } from './books.jsx'; import { @@ -29,6 +30,9 @@ import { Layout } from './Layout.jsx'; import { PremiumUpsell } from './PremiumUpsell.jsx'; import { AwardForm, AwardPills, PlanBadge } from './premium.jsx'; +/** The CoinPay buttons show only when the OAuth client is configured. */ +const coinpayEnabled = () => Boolean(coinpayConfig(config.siteUrl)); + export const Landing = ({ user, stats, collections, latest, feeds }) => (
@@ -677,6 +681,17 @@ export const SignIn = ({ mode, sent, next, error, referral, invite, values, erro

) : null} {error ? : null} + {coinpayEnabled() ? ( + <> + + {mode === 'signup' ? 'Sign up with CoinPay' : 'Sign in with CoinPay'} + +
or
+ + ) : null}
{invite ? : null} @@ -732,6 +747,7 @@ export const SignIn = ({ mode, sent, next, error, referral, invite, values, erro export const Settings = ({ user, contact = null, + coinpay = null, passkeys, apiKeys, newKey, @@ -816,6 +832,38 @@ export const Settings = ({
+ {coinpayEnabled() ? ( +
+

Sign in with CoinPay

+ {coinpay?.linked ? ( + <> +

Connected: you can sign in with your CoinPay account.

+ {coinpay.canDisconnect ? ( +
+ +
+ ) : ( +

+ CoinPay is this account's only way in, so it stays connected. Add a passkey to be + able to disconnect it. +

+ )} + + ) : ( + <> +

+ Connect your CoinPay account to sign in with it as well as by email. +

+ + Connect CoinPay + + + )} +
+ ) : null} +

Passkeys

diff --git a/bun.lock b/bun.lock index ac0bfd1..c123f76 100644 --- a/bun.lock +++ b/bun.lock @@ -36,6 +36,7 @@ "@nichedb/premium": "workspace:*", "@nichedb/queue": "workspace:*", "@profullstack/affiliates": "^0.2.0", + "@profullstack/coinpay-onboard": "0.3.1", "@profullstack/footer": "latest", "@profullstack/leaderboard": "^0.3.1", "@profullstack/nichedb": "workspace:*", @@ -282,6 +283,8 @@ "@logicsrc/openaccess": ["@logicsrc/openaccess@0.3.1", "", { "dependencies": { "@hono/node-server": "^2.1.1", "@profullstack/footer": "^0.2.6", "hono": "^4.13.7" }, "bin": { "openaccess": "bin/openaccess.mjs" } }, "sha512-J92zpd53IlPR1a4ZwC/tg0zSc2WVHCkP2DwO0L0/YRIujGqaxP9eJizauzqdnsEIsGs9GLPRViWBgUH93jDXKw=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.32.2", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-SWsVtH/0vULSQStoZ6xxH2K09Li8fXYTMC64ZeogCdpZ3XSi1eCPOByV1OpuJxuI3OcmHhosDWYyii07tpPsLw=="], + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="], @@ -362,6 +365,8 @@ "@profullstack/coinpay": ["@profullstack/coinpay@0.25.0", "", { "dependencies": { "@inquirer/prompts": "^8.3.0", "@noble/curves": "^1.6.0", "@noble/hashes": "^1.5.0", "@profullstack/hqtui": "^0.6.3", "@scure/bip32": "^1.5.0", "@scure/bip39": "^1.4.0" }, "bin": { "coinpay": "bin/coinpay.js" } }, "sha512-ZoK8COqBCdJXvr0fO5UWw9HPh88CdMBLycc/dU6kXwAyeIrwZ4WkBApisuDgs4leXNN+SMjwex2etgrEvlICTQ=="], + "@profullstack/coinpay-onboard": ["@profullstack/coinpay-onboard@0.3.1", "", { "dependencies": { "@modelcontextprotocol/sdk": "^1.32.2", "zod": "^4.6.7" }, "bin": { "coinpay-onboard": "bin/coinpay-onboard" } }, "sha512-+K7D6uaX10y+0V5sg7To1jKwNG8iCNbN9SSW6q5ACzoiDNswMox3G7gzY/Swxd128klq7ecN2Ni4JVNr50nRIQ=="], + "@profullstack/emailer": ["@profullstack/emailer@1.0.3", "", {}, "sha512-VyAtCcfnSI6LOwJvWogCr3ZNmEsaZw+iuWrqnosLsdcKsbRyCrCmXigMNZJZEjlgYlR+lK//PwNPG+xtCFvWAw=="], "@profullstack/favicon-generator": ["@profullstack/favicon-generator@1.2.1", "", { "dependencies": { "inquirer": "^10.2.2", "sharp": "^0.33.5" }, "bin": { "fav": "bin/cli.js" } }, "sha512-1w+EcoEmi60TXXQd97Brs7kN/BmlaoOcNvE39ZombUWSCBFBNe6fFm2mA7HORXb7TM39mHklNTIoG20glLsgVA=="], @@ -424,6 +429,12 @@ "@types/wrap-ansi": ["@types/wrap-ansi@3.0.0", "", {}, "sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + "ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -434,12 +445,20 @@ "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "body-parser": ["body-parser@2.4.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.1.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.3", "on-finished": "^2.4.1", "qs": "^6.16.1", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ=="], + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], "bullmq": ["bullmq@6.4.2", "", { "dependencies": { "cron-parser": "5.10.2", "msgpackr": "2.1.2", "node-abort-controller": "3.1.1", "semver": "7.8.5", "tslib": "2.8.1" }, "peerDependencies": { "bullmq-otel": ">=2.0.0", "ioredis": ">=5.0.0", "pg": ">=8.0.0", "redis": ">=5.0.0" }, "optionalPeers": ["bullmq-otel", "ioredis", "pg", "redis"] }, "sha512-fCPPJUgJFwbaN7nZiU/JzQVydss+mA3FUYaepvJQnkSUr4jmzobZcqMSJZPZygN/esQZLppE/cTyTYTNniO3bQ=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "chardet": ["chardet@2.2.0", "", {}, "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA=="], "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], @@ -452,8 +471,20 @@ "commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], + "content-disposition": ["content-disposition@2.0.1", "", {}, "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + "cron-parser": ["cron-parser@5.10.2", "", { "dependencies": { "luxon": "^3.7.2" } }, "sha512-JfAYMY0LZaWQTuict8PhtF/ksSnq8xefSDmQUnnoqvkPYkU/y3UP0DTWzJKFBz0P1IXsDG8q0vY3zHkoFZOhvw=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "crypto": ["crypto@1.0.1", "", {}, "sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig=="], "css-select": ["css-select@6.0.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^7.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "nth-check": "^2.1.1" } }, "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw=="], @@ -468,6 +499,8 @@ "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], @@ -478,36 +511,110 @@ "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "encodeurl": ["encodeurl@2.0.1", "", {}, "sha512-Ev/hIyePLZtpL98x562lvPtenrUfGr0lfaf44HFZ+hDEwso7ccV9QAZrEVLg0hxNAzygaAFxy2dYaAKKM0G06A=="], + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.1.1", "", {}, "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ=="], + + "express": ["express@5.3.0", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.3.0", "content-disposition": "^2.0.1", "content-type": "^2.0.0", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.8", "qs": "^6.16.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.1.0", "vary": "^1.1.2" } }, "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g=="], + + "express-rate-limit": ["express-rate-limit@8.7.1", "", { "dependencies": { "debug": "^4.4.3", "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA=="], + "external-editor": ["external-editor@3.1.0", "", { "dependencies": { "chardet": "^0.7.0", "iconv-lite": "^0.4.24", "tmp": "^0.0.33" } }, "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], + "fast-uri": ["fast-uri@3.1.9", "", {}, "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ=="], + "fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="], + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "generic-pool": ["generic-pool@3.9.0", "", {}, "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g=="], + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + "hono": ["hono@4.13.14", "", {}, "sha512-yhRLQ21U6qa9Hsu2N7Qcbh8tE+WKEcoErjIo56LeSGiGXGp9sA7UpE86Ui9uexb61KakiGwuHMn2QdV7w6i6kQ=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "inquirer": ["inquirer@10.2.2", "", { "dependencies": { "@inquirer/core": "^9.1.0", "@inquirer/prompts": "^5.5.0", "@inquirer/type": "^1.5.3", "@types/mute-stream": "^0.0.4", "ansi-escapes": "^4.3.2", "mute-stream": "^1.0.0", "run-async": "^3.0.0", "rxjs": "^7.8.1" } }, "sha512-tyao/4Vo36XnUItZ7DnUXX4f1jVao2mSrleV/5IPtW/XAEA26hRVsbc68nuTEKWcr5vMP/1mVoT2O7u8H4v1Vg=="], "ioredis": ["ioredis@6.0.0", "", { "dependencies": { "@ioredis/commands": "2.0.0", "cluster-key-slot": "1.1.1", "debug": "4.4.3", "denque": "2.1.0", "redis-errors": "1.2.0", "standard-as-callback": "2.1.0" } }, "sha512-f+Dtubxfpf6KYFq7WVXJoOLn0bk4TJrMrN9SzeE+jrWrCWj7XX3fA6vkryafhADX+GMymRxgDJDOI33COkJc0w=="], + "ip-address": ["ip-address@10.7.5", "", {}, "sha512-t2v0rkILYv9WXmSL7TpAaU3HXgaFYOPUcH3QHgwQ++gpJuH3bqyMy4Fvt/DJCTKwzH3Cf+bAp5W/mFL39KPLzA=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jose": ["jose@6.2.13", "", {}, "sha512-7TIg10ZE1x82YediN+lcrPNvGMKge9yFHQD+Qho/b8y2adfugU3AsvvHfxV0fzyFllT6l9xCjf4RWY9g/Z9PJQ=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + "luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], + "media-typer": ["media-typer@1.1.1", "", {}, "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "msgpackr": ["msgpackr@2.1.2", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-JHAdwubWNEadHQsFAib6uXOzKFxY8QOWHu9LCCksOINMC+TjgaKS2Tdrw7dW5SY0LLz8YK2/YqLSi2vigQt05w=="], @@ -516,24 +623,54 @@ "mute-stream": ["mute-stream@1.0.0", "", {}, "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA=="], + "negotiator": ["negotiator@1.1.0", "", { "dependencies": { "content-type": "^2.1.0" } }, "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg=="], + "node-abort-controller": ["node-abort-controller@3.1.1", "", {}, "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ=="], "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "on-finished": ["on-finished@2.4.2", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + + "proxy-addr": ["proxy-addr@2.0.8", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ=="], + "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], "pvutils": ["pvutils@1.2.0", "", {}, "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg=="], + "qs": ["qs@6.17.1", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-jd3COkaKVgGUW+ZHyEHCG9bH7RoaaiWuMgzsIBjUMVjLAaWGWpoUAVwljVvBHxRu/2qEHnadLGZrkutYUBPQJA=="], + + "range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + "redis": ["redis@4.7.1", "", { "dependencies": { "@redis/bloom": "1.2.0", "@redis/client": "1.6.1", "@redis/graph": "1.1.1", "@redis/json": "1.0.7", "@redis/search": "1.2.0", "@redis/time-series": "1.1.0" } }, "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ=="], "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="], "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + "run-async": ["run-async@3.0.0", "", {}, "sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q=="], "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], @@ -544,14 +681,34 @@ "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + "sharp": ["sharp@0.35.5", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.5", "@img/sharp-darwin-x64": "0.35.5", "@img/sharp-freebsd-wasm32": "0.35.5", "@img/sharp-libvips-darwin-arm64": "1.3.4", "@img/sharp-libvips-darwin-x64": "1.3.4", "@img/sharp-libvips-linux-arm": "1.3.4", "@img/sharp-libvips-linux-arm64": "1.3.4", "@img/sharp-libvips-linux-ppc64": "1.3.4", "@img/sharp-libvips-linux-riscv64": "1.3.4", "@img/sharp-libvips-linux-s390x": "1.3.4", "@img/sharp-libvips-linux-x64": "1.3.4", "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", "@img/sharp-libvips-linuxmusl-x64": "1.3.4", "@img/sharp-linux-arm": "0.35.5", "@img/sharp-linux-arm64": "0.35.5", "@img/sharp-linux-ppc64": "0.35.5", "@img/sharp-linux-riscv64": "0.35.5", "@img/sharp-linux-s390x": "0.35.5", "@img/sharp-linux-x64": "0.35.5", "@img/sharp-linuxmusl-arm64": "0.35.5", "@img/sharp-linuxmusl-x64": "0.35.5", "@img/sharp-webcontainers-wasm32": "0.35.5", "@img/sharp-win32-arm64": "0.35.5", "@img/sharp-win32-ia32": "0.35.5", "@img/sharp-win32-x64": "0.35.5" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -562,24 +719,40 @@ "tmp": ["tmp@0.2.7", "", {}, "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw=="], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="], "type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], + "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], + "unbzip2-stream": ["unbzip2-stream@1.4.3", "", { "dependencies": { "buffer": "^5.2.1", "through": "^2.3.8" } }, "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="], + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "yoctocolors-cjs": ["yoctocolors-cjs@2.1.3", "", {}, "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw=="], + "zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + "@inquirer/checkbox/@inquirer/core": ["@inquirer/core@12.0.5", "", { "dependencies": { "@inquirer/ansi": "^2.0.9", "@inquirer/figures": "^2.0.9", "@inquirer/type": "4.2.0", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-c0ZSnO9oVk/IlEGoZK3u8b3IZQeu9I6jUkXJSQnQEVRXsjKvtAq3AGqFL2LgfJhlzMZne08pZHWRWasIcI3Rew=="], "@inquirer/checkbox/@inquirer/figures": ["@inquirer/figures@2.0.9", "", {}, "sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg=="], @@ -630,16 +803,24 @@ "@redis/client/cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], + "body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + "csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="], + "express/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + "external-editor/chardet": ["chardet@0.7.0", "", {}, "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA=="], "external-editor/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], "inquirer/@inquirer/prompts": ["@inquirer/prompts@5.5.0", "", { "dependencies": { "@inquirer/checkbox": "^2.5.0", "@inquirer/confirm": "^3.2.0", "@inquirer/editor": "^2.2.0", "@inquirer/expand": "^2.3.0", "@inquirer/input": "^2.3.0", "@inquirer/number": "^1.1.0", "@inquirer/password": "^2.2.0", "@inquirer/rawlist": "^2.3.0", "@inquirer/search": "^1.1.0", "@inquirer/select": "^2.5.0" } }, "sha512-BHDeL0catgHdcHbSFFUddNzvx/imzJMft+tWDPwTm3hfu8/tApk1HrooNngB2Mb4qY+KaRWF+iZqoVUPeslEog=="], + "negotiator/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], + "type-is/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + "@inquirer/checkbox/@inquirer/core/mute-stream": ["mute-stream@3.0.0", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="], "@inquirer/confirm/@inquirer/core/@inquirer/figures": ["@inquirer/figures@2.0.9", "", {}, "sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg=="], diff --git a/packages/db/migrations/0042_coinpay_identities.sql b/packages/db/migrations/0042_coinpay_identities.sql new file mode 100644 index 0000000..7ba710f --- /dev/null +++ b/packages/db/migrations/0042_coinpay_identities.sql @@ -0,0 +1,13 @@ +-- Sign in with CoinPay. A CoinPay identity (its OIDC `sub`) is bound to exactly +-- one account here and that binding is the only thing a CoinPay sign-in trusts. +-- CoinPay never verifies email addresses, so its email is contact information: +-- it is never matched against users.email. A first CoinPay sign-in makes a new +-- account whose users.email is an unroutable @coinpay.nichedb.invalid; +-- an existing account gets CoinPay only by connecting it from a signed-in session. +create table if not exists coinpay_identities ( + sub text primary key, + user_id uuid not null unique references users(id) on delete cascade, + email text, + created_at timestamptz not null default now(), + last_login_at timestamptz +);