diff --git a/apps/web/package.json b/apps/web/package.json index 98d1c7fd..957454cc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@logicsrc/openaccess": "^0.3.1", + "@profullstack/coinpay-onboard": "0.3.1", "@profullstack/contact-form": "0.3.2", "@profullstack/footer": "latest", "@profullstack/leaderboard": "^0.3.1", diff --git a/apps/web/src/app/SignInPanels.jsx b/apps/web/src/app/SignInPanels.jsx index e1a91603..c0bce38c 100644 --- a/apps/web/src/app/SignInPanels.jsx +++ b/apps/web/src/app/SignInPanels.jsx @@ -1,4 +1,5 @@ import { PasskeySignIn } from './Passkey.jsx'; +import { coinpayEnabled } from '../lib/coinpay.js'; /** * The two ways into an account, as a pair of panels. @@ -30,6 +31,20 @@ export default function SignInPanels({ }) { return ( <> + {/* CoinPay is the fleet's master account, so it is offered first. It + binds to the CoinPay identity, never to a matching address here. */} + {coinpayEnabled() && ( +
+

Sign in with CoinPay

+

Use your CoinPay account. No email round trip.

+
+ + Sign in with CoinPay + +
+
+ )} +

{emailEyebrow}

{/* Which page the form was on, so the redirect afterwards comes back diff --git a/apps/web/src/app/account/page.jsx b/apps/web/src/app/account/page.jsx index 5d977501..7611a23c 100644 --- a/apps/web/src/app/account/page.jsx +++ b/apps/web/src/app/account/page.jsx @@ -1,6 +1,6 @@ import { redirect } from 'next/navigation'; -import { accounts, apikeys, dataset, newsletter, profiles, webrings } from '@rssamplifier/db'; -import { profileStore } from '@rssamplifier/auth'; +import { accounts, apikeys, coinpay, dataset, newsletter, profiles, webrings } from '@rssamplifier/db'; +import { hasOtherSignIn, profileStore } from '@rssamplifier/auth'; import { COLUMNS } from '@profullstack/profile'; import Toolbar from '../Toolbar.jsx'; @@ -13,6 +13,8 @@ import { currentUser } from '../../lib/auth.js'; import { topicLabel } from '../../lib/following.js'; import { ANONYMOUS_HOURLY } from '../../lib/ratelimit.js'; import { latestClosedWindow } from '../../lib/datasetWindow.js'; +import { coinpayEnabled } from '../../lib/coinpay.js'; +import { explainCoinpay } from '../../lib/signInForm.js'; export const dynamic = 'force-dynamic'; @@ -24,7 +26,7 @@ export const metadata = { /** * The account page: what you follow, and how you get back in. * - * @param {{ searchParams: Promise<{ welcome?: string, revoked?: string, created?: string, keyRevoked?: string, keyError?: string, newsletter?: string, ringsite?: string }> }} props + * @param {{ searchParams: Promise<{ coinpay?: string, welcome?: string, revoked?: string, created?: string, keyRevoked?: string, keyError?: string, newsletter?: string, ringsite?: string }> }} props */ export default async function AccountPage({ searchParams }) { const params = await searchParams; @@ -50,6 +52,11 @@ export default async function AccountPage({ searchParams }) { webrings.ringsForAccount(client, userId).catch(() => []), ]); + // Sign in with CoinPay: connected or not, and whether it may be dropped. + const coinpayIdentity = await coinpay.identityForUser(client, userId).catch(() => null); + const placeholder = coinpay.isPlaceholderEmail(user.email); + const canDisconnect = coinpayIdentity ? await hasOtherSignIn(client, userId) : false; + // One row per ring, whether owned or joined through a claimed site. const ringRows = [...new Map(myRings.map((r) => [r.slug, r])).values()]; const ringSite = @@ -60,7 +67,7 @@ export default async function AccountPage({ searchParams }) { const profileValues = Object.fromEntries( COLUMNS.map((c) => [c, mine?.[c] == null ? null : String(mine[c])]), ); - if (!profileValues.email) profileValues.email = String(user.email); + if (!profileValues.email && !placeholder) profileValues.email = String(user.email); // Only for an account that has one, so the overwhelming majority of readers — // who will never license the corpus — do not pay a query to be told they have @@ -73,10 +80,12 @@ export default async function AccountPage({ searchParams }) { return ( <> -

Signed in as {String(user.email)}

+

+ {placeholder ? 'Signed in with CoinPay' : `Signed in as ${String(user.email)}`} +

Your account

- {params.welcome && ( + {params.welcome === '1' && (

Welcome. Add a passkey below and you will not need the email link again — though it will keep working if you ever lose the device. @@ -85,6 +94,57 @@ export default async function AccountPage({ searchParams }) { {params.revoked &&

That passkey has been removed.

} +

CoinPay

+ {params.coinpay &&

{explainCoinpay(params.coinpay)}

} + {params.welcome === 'coinpay' && ( +

+ Welcome. You signed in with CoinPay. Add an email address below if you want the weekly + digest, alerts by email, or the emailed link as a way back in. +

+ )} + {coinpayIdentity ? ( +
+

+ Connected{coinpayIdentity.email ? ` (${String(coinpayIdentity.email)})` : ''}. You can + sign in with CoinPay. +

+ {canDisconnect ? ( + +
+ +
+ + ) : ( +

+ CoinPay is the only way into this account, so it cannot be disconnected. Add an + email address or a passkey first. +

+ )} +
+ ) : coinpayEnabled() ? ( +
+

Connect your CoinPay account to sign in with it.

+
+ + Connect CoinPay + +
+
+ ) : null} + {placeholder && ( +
+

Add an email address

+

+ CoinPay does not confirm addresses, so this account has none yet. We mail a link to the + address; following it adds it. +

+ +
+ +
+
+ )} +

Your details

Your name and, if you like, where you work. Only the email and your name are diff --git a/apps/web/src/app/api/alerts/channels/route.js b/apps/web/src/app/api/alerts/channels/route.js index de21dbb9..c255909d 100644 --- a/apps/web/src/app/api/alerts/channels/route.js +++ b/apps/web/src/app/api/alerts/channels/route.js @@ -1,4 +1,4 @@ -import { alerts } from '@rssamplifier/db'; +import { alerts, coinpay } from '@rssamplifier/db'; import { WebhookError } from '@rssamplifier/notify'; import { db } from '../../../../lib/db.js'; @@ -65,7 +65,8 @@ export async function POST(req) { * @returns {Promise} */ async function addEmail(client, userId, email) { - if (!email) return redirect(`${PAGE}?error=no-address`); + // A CoinPay-only account has a placeholder, not an address it proved. + if (!email || coinpay.isPlaceholderEmail(email)) return redirect(`${PAGE}?error=no-address`); if (!(await room(client, userId))) return redirect(`${PAGE}?error=too-many`); await alerts.addChannel(client, { userId, kind: 'email', target: email, label: 'Email' }); diff --git a/apps/web/src/app/api/auth/passkey/revoke/route.js b/apps/web/src/app/api/auth/passkey/revoke/route.js index df0559df..ec856bd9 100644 --- a/apps/web/src/app/api/auth/passkey/revoke/route.js +++ b/apps/web/src/app/api/auth/passkey/revoke/route.js @@ -1,4 +1,5 @@ import { accounts } from '@rssamplifier/db'; +import { canRemovePasskey } from '@rssamplifier/auth'; import { db } from '../../../../../lib/db.js'; import { currentUser } from '../../../../../lib/auth.js'; @@ -15,6 +16,10 @@ export const dynamic = 'force-dynamic'; * account can never be locked out by this, and refusing would strand anyone * whose only key is on a device they no longer have. * + * The one exception is an account made by Sign in with CoinPay that has since + * disconnected CoinPay and never proved an address: its last passkey is the + * only way in, so it stays. + * * @param {Request} req */ export async function POST(req) { @@ -38,6 +43,12 @@ export async function POST(req) { return json({ error: 'bad-request' }, 400); } + if (!(await canRemovePasskey(db(), String(user.id)))) { + return wantsHtml + ? new Response(null, { status: 303, headers: { location: '/account?coinpay=last-passkey#coinpay' } }) + : json({ error: 'last-way-in' }, 409); + } + const removed = await accounts.deleteCredential(db(), id, String(user.id)); if (wantsHtml) { diff --git a/apps/web/src/app/api/v1/coinpay/disconnect/route.js b/apps/web/src/app/api/v1/coinpay/disconnect/route.js new file mode 100644 index 00000000..1f8d7ed9 --- /dev/null +++ b/apps/web/src/app/api/v1/coinpay/disconnect/route.js @@ -0,0 +1,22 @@ +import { disconnectCoinpay } from '@rssamplifier/auth'; + +import { db } from '../../../../../lib/db.js'; +import { currentUser } from '../../../../../lib/auth.js'; +import { sameOrigin, seeOther } from '../../../../../lib/coinpay.js'; + +export const dynamic = 'force-dynamic'; + +/** + * Disconnect CoinPay from the signed-in account: the account page's form. + * Refused when CoinPay is the only way back in (no proven address, no passkey). + * + * @param {Request} req + */ +export async function POST(req) { + if (!sameOrigin(req)) return new Response('Bad origin', { status: 403 }); + const user = await currentUser(); + if (!user) return seeOther('/login?next=%2Faccount'); + + const result = await disconnectCoinpay(db(), String(user.id)); + return seeOther(`/account?coinpay=${result.ok ? 'disconnected' : result.error}#coinpay`); +} diff --git a/apps/web/src/app/api/v1/coinpay/email/route.js b/apps/web/src/app/api/v1/coinpay/email/route.js new file mode 100644 index 00000000..396a1c0b --- /dev/null +++ b/apps/web/src/app/api/v1/coinpay/email/route.js @@ -0,0 +1,28 @@ +import { requestEmailClaimLink } from '@rssamplifier/auth'; + +import { db, siteUrl } from '../../../../../lib/db.js'; +import { currentUser } from '../../../../../lib/auth.js'; +import { sameOrigin, seeOther } from '../../../../../lib/coinpay.js'; +import { attempt, callerAddress } from '../../../../../lib/authThrottle.js'; + +export const dynamic = 'force-dynamic'; + +/** + * A CoinPay-only account asks to use a real address. Nothing changes until the + * link mailed to that address is followed (see consumeSignInLink). + * + * @param {Request} req + */ +export async function POST(req) { + if (!sameOrigin(req)) return new Response('Bad origin', { status: 403 }); + const user = await currentUser(); + if (!user) return seeOther('/login?next=%2Faccount'); + + const verdict = attempt(`coinpay-email:${callerAddress(req)}`); + if (!verdict.ok) return seeOther('/account?coinpay=too-many#coinpay'); + + const form = await req.formData().catch(() => null); + const email = form?.get('email'); + const result = await requestEmailClaimLink(db(), String(user.id), typeof email === 'string' ? email : '', siteUrl()); + return seeOther(`/account?coinpay=${result.ok ? 'email-sent' : `email-${result.error}`}#coinpay`); +} diff --git a/apps/web/src/app/api/v1/coinpay/oauth/callback/route.js b/apps/web/src/app/api/v1/coinpay/oauth/callback/route.js new file mode 100644 index 00000000..21cf4c58 --- /dev/null +++ b/apps/web/src/app/api/v1/coinpay/oauth/callback/route.js @@ -0,0 +1,75 @@ +import { cookies } from 'next/headers'; +import { finishSignIn } from '@profullstack/coinpay-onboard/oauth'; +import { signInWithCoinpay, startSession, takeCoinpayCallback } from '@rssamplifier/auth'; + +import { db } from '../../../../../../lib/db.js'; +import { currentUser, requestMeta, setSessionCookie } from '../../../../../../lib/auth.js'; +import { STATE_COOKIE, STATE_COOKIE_PATH, coinpayConfig, seeOther } from '../../../../../../lib/coinpay.js'; + +export const dynamic = 'force-dynamic'; + +/** + * Finish "Sign in with CoinPay". + * + * Refused before the code is exchanged when the state is not the one this + * browser started, was already used, or (for Connect CoinPay) the session is + * not the one that asked. The exchange sends the PKCE verifier and the client + * secret; the id_token's issuer, audience, expiry and nonce are checked, and + * userinfo's sub must match it (@profullstack/coinpay-onboard). + * + * @param {Request} req + */ +export async function GET(req) { + const config = coinpayConfig(); + if (!config) return seeOther('/login'); + + const params = new URL(req.url).searchParams; + const store = await cookies(); + const cookieState = store.get(STATE_COOKIE)?.value; + store.set(STATE_COOKIE, '', { path: STATE_COOKIE_PATH, maxAge: 0 }); + + const user = await currentUser(); + const checked = await takeCoinpayCallback(db(), { + state: params.get('state'), + cookieState, + sessionUserId: user ? String(user.id) : null, + }); + if (!checked.ok) return seeOther(`/login?error=coinpay-${checked.error}`); + const { pending } = checked; + const linking = pending.linkUserId !== null; + + const code = params.get('code'); + if (params.get('error') || !code) { + return seeOther(linking ? '/account?coinpay=cancelled#coinpay' : '/login?error=coinpay-cancelled'); + } + + let profile; + try { + const result = await finishSignIn({ + code, + verifier: pending.verifier, + nonce: pending.nonce, + clientId: config.clientId, + clientSecret: config.clientSecret, + redirectUri: config.redirectUri, + }); + profile = { sub: result.sub, email: result.email, name: result.name }; + } catch (err) { + console.error('[coinpay] sign-in failed:', err?.message ?? err); + return seeOther(linking ? '/account?coinpay=failed#coinpay' : '/login?error=coinpay-failed'); + } + + const outcome = await signInWithCoinpay(db(), profile, { linkUserId: pending.linkUserId }); + if (!outcome.ok) { + return seeOther(linking ? `/account?coinpay=${outcome.error}#coinpay` : `/login?error=coinpay-${outcome.error}`); + } + if (linking) return seeOther('/account?coinpay=connected#coinpay'); + + const meta = await requestMeta(); + const { token } = await startSession(db(), outcome.userId, meta); + await setSessionCookie(token); + + if (outcome.created) return seeOther('/account?welcome=coinpay#coinpay'); + return seeOther(pending.next); +} + diff --git a/apps/web/src/app/api/v1/coinpay/oauth/start/route.js b/apps/web/src/app/api/v1/coinpay/oauth/start/route.js new file mode 100644 index 00000000..617ab0ce --- /dev/null +++ b/apps/web/src/app/api/v1/coinpay/oauth/start/route.js @@ -0,0 +1,64 @@ +import { cookies } from 'next/headers'; +import { authorizeUrl, pkce, randomToken, SIGN_IN_SCOPES } from '@profullstack/coinpay-onboard/oauth'; +import { COINPAY_PENDING_MS, saveCoinpayPending } from '@rssamplifier/auth'; + +import { db } from '../../../../../../lib/db.js'; +import { currentUser } from '../../../../../../lib/auth.js'; +import { STATE_COOKIE, STATE_COOKIE_PATH, coinpayConfig, seeOther } from '../../../../../../lib/coinpay.js'; + +export const dynamic = 'force-dynamic'; + +/** + * Start "Sign in with CoinPay" (or, with ?link=1, "Connect CoinPay" from the + * account page). PKCE S256, state and nonce; the verifier and nonce stay in + * coinpay_pending, and state is pinned to this browser by an httpOnly cookie. + * + * @param {Request} req + */ +export async function GET(req) { + const config = coinpayConfig(); + if (!config) return seeOther('/login'); + + const params = new URL(req.url).searchParams; + let linkUserId = null; + if (params.get('link') === '1') { + // Connecting happens only from inside the account it joins. + const user = await currentUser(); + if (!user) return seeOther('/login?next=%2Faccount'); + linkUserId = String(user.id); + } + + const state = randomToken(); + const nonce = randomToken(); + const { verifier, challenge } = pkce(); + await saveCoinpayPending(db(), state, { + verifier, + nonce, + next: params.get('next') ?? '/account', + linkUserId, + }); + + const store = await cookies(); + store.set(STATE_COOKIE, state, { + httpOnly: true, + sameSite: 'lax', + secure: config.redirectUri.startsWith('https:'), + path: STATE_COOKIE_PATH, + maxAge: Math.floor(COINPAY_PENDING_MS / 1000), + }); + + return new Response(null, { + status: 302, + headers: { + location: authorizeUrl({ + clientId: config.clientId, + redirectUri: config.redirectUri, + state, + challenge, + nonce, + scopes: SIGN_IN_SCOPES, + }), + 'cache-control': 'no-store', + }, + }); +} diff --git a/apps/web/src/lib/coinpay.js b/apps/web/src/lib/coinpay.js new file mode 100644 index 00000000..c3e93070 --- /dev/null +++ b/apps/web/src/lib/coinpay.js @@ -0,0 +1,56 @@ +import { siteUrl } from './db.js'; + +/** + * Sign in with CoinPay: the deployment's client, and the cookie that pins a + * login to the browser that started it. + * + * Read through a non-literal property access: Next inlines `process.env.FOO` + * at build time, which would bake an absent value into the image for good. + */ + +export const STATE_COOKIE = 'rssa_cp_state'; +export const STATE_COOKIE_PATH = '/api/v1/coinpay/oauth'; + +/** + * @returns {{ clientId: string, clientSecret: string, redirectUri: string } | null} + */ +export function coinpayConfig() { + const env = process.env; + const clientId = env['COINPAY_OAUTH_CLIENT_ID'] ?? ''; + const clientSecret = env['COINPAY_OAUTH_CLIENT_SECRET'] ?? ''; + if (!clientId || !clientSecret) return null; + return { clientId, clientSecret, redirectUri: `${siteUrl()}/api/v1/coinpay/oauth/callback` }; +} + +/** Whether to offer the button at all. */ +export function coinpayEnabled() { + return coinpayConfig() !== null; +} + +/** + * @param {string} location a path on this site + * @returns {Response} + */ +export function seeOther(location) { + return new Response(null, { + status: 303, + headers: { location: `${siteUrl()}${location}`, 'cache-control': 'no-store' }, + }); +} + +/** + * A form post from another site is refused. The session cookie is SameSite=Lax, + * which already keeps it off a cross-site POST; this is the second lock. + * + * @param {Request} req + * @returns {boolean} + */ +export function sameOrigin(req) { + const origin = req.headers.get('origin'); + if (!origin) return true; + try { + return new URL(origin).host === new URL(siteUrl()).host; + } catch { + return false; + } +} diff --git a/apps/web/src/lib/signInForm.js b/apps/web/src/lib/signInForm.js index ff40ffa2..a908bc7b 100644 --- a/apps/web/src/lib/signInForm.js +++ b/apps/web/src/lib/signInForm.js @@ -83,6 +83,10 @@ export function explainSignInError(code, retry) { } if (code === 'missing-token') return 'That link was incomplete. Ask for a new one.'; if (code === 'invalid-profile') return 'A few of those fields need another look.'; + if (code === 'claim-failed') { + return 'That address now belongs to another account, so it was not added. Sign in to that account and connect CoinPay from its account page.'; + } + if (typeof code === 'string' && code.startsWith('coinpay-')) return explainCoinpay(code.slice(8)); return 'Something went wrong. Try again.'; } @@ -129,3 +133,55 @@ export function signupErrorsFromParams(params) { } return { errors, values }; } + +/** + * What a Sign in with CoinPay outcome means, for /login and the account page. + * + * @param {unknown} code + * @returns {string} + */ +export function explainCoinpay(code) { + switch (code) { + case 'bad-state': + return 'That CoinPay sign-in did not start in this browser. Start again.'; + case 'expired': + return 'That CoinPay sign-in expired or was already used. Start again.'; + case 'wrong-session': + return 'Sign in to the account you want to connect, then try again.'; + case 'cancelled': + return 'CoinPay sign-in was cancelled.'; + case 'failed': + return 'CoinPay sign-in could not be completed. Try again.'; + case 'linked-elsewhere': + return 'That CoinPay account is already connected to a different RSS Amplifier account.'; + case 'already-linked': + return 'This account is already connected to a different CoinPay account.'; + case 'no-account': + return 'That account no longer exists.'; + case 'connected': + return 'CoinPay connected. You can sign in with it from now on.'; + case 'disconnected': + return 'CoinPay disconnected.'; + case 'not-linked': + return 'CoinPay is not connected.'; + case 'only-sign-in': + return 'CoinPay is the only way into this account, so it stays connected. Add an email address or a passkey first.'; + case 'last-passkey': + return 'That passkey is the only way into this account, so it stays. Connect CoinPay or add an email address first.'; + case 'email-sent': + return 'A link is on its way. Follow it to add the address to this account.'; + case 'email-invalid-email': + return 'That does not look like an email address.'; + case 'email-email-taken': + return 'That address belongs to another account. Sign in to it and connect CoinPay from its account page.'; + case 'email-has-email': + return 'This account already has an email address.'; + case 'email-rate-limited': + case 'too-many': + return 'Too many attempts. Try again later.'; + case 'email-email-not-configured': + return 'Email is not configured on this deployment.'; + default: + return 'Something went wrong with CoinPay. Try again.'; + } +} diff --git a/bun.lock b/bun.lock index 99ead77d..ed8d8b4f 100644 --- a/bun.lock +++ b/bun.lock @@ -35,9 +35,10 @@ }, "apps/web": { "name": "@rssamplifier/web", - "version": "0.2.3", + "version": "0.2.4", "dependencies": { "@logicsrc/openaccess": "^0.3.1", + "@profullstack/coinpay-onboard": "0.3.1", "@profullstack/contact-form": "0.3.2", "@profullstack/footer": "latest", "@profullstack/leaderboard": "^0.3.1", @@ -283,6 +284,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=="], + "@mozilla/readability": ["@mozilla/readability@0.6.0", "", {}, "sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ=="], "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], @@ -355,6 +358,8 @@ "@peculiar/x509": ["@peculiar/x509@2.2.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.11.0", "@peculiar/asn1-csr": "^2.11.0", "@peculiar/asn1-ecc": "^2.11.0", "@peculiar/asn1-pkcs9": "^2.11.0", "@peculiar/asn1-rsa": "^2.11.0", "@peculiar/asn1-schema": "^2.11.0", "@peculiar/asn1-x509": "^2.11.0", "@peculiar/asn1-x509-post-quantum": "^2.11.0", "pvtsutils": "^1.3.6", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-adpW58ySPQMOUPv8pTKT3RIyCFqfUFt8X05ukPdtkQToXDVr8dMtxuenik1yx8tNFTbpApWsM/Hi5SzlIx48+Q=="], + "@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/contact-form": ["@profullstack/contact-form@0.3.2", "", { "dependencies": { "@profullstack/emailer": "^1.0.3", "@profullstack/form-guard": "^0.3.0" }, "peerDependencies": { "react": ">=18.0.0" }, "optionalPeers": ["react"] }, "sha512-hXzHgCFj5PAHsov6bnIai++cRaHJsoo8OsSPj1b41qDAP3WfABE87O3A7vlQ44MoRPkqV7pEk5JaSgSAGmB9Lw=="], "@profullstack/emailer": ["@profullstack/emailer@1.0.3", "", {}, "sha512-VyAtCcfnSI6LOwJvWogCr3ZNmEsaZw+iuWrqnosLsdcKsbRyCrCmXigMNZJZEjlgYlR+lK//PwNPG+xtCFvWAw=="], @@ -577,6 +582,12 @@ "@types/ws": ["@types/ws@8.18.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w=="], + "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=="], + "anynum": ["anynum@1.0.1", "", {}, "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A=="], "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], @@ -585,10 +596,18 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.11.33", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-yiVKDX+qCMRKBE084lXvq9EcwFUhKZ2nU8TK1EQQuOgPNR/lmbGxXOeDdlGUqC2Zz11Cs9rk1d/8NfgBmlkt0w=="], + "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@2.0.0", "", {}, "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA=="], "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=="], + "caniuse-lite": ["caniuse-lite@1.0.30001819", "", {}, "sha512-LFUqIIAwpzsIuevnKnsR9qnStBcgzPShTX61lBwN9uFkkf8o8zLiebEjbW8AYtZiF+1YKDWfdt/pWDtMuG8Xwg=="], "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], @@ -599,8 +618,20 @@ "cluster-key-slot": ["cluster-key-slot@1.1.1", "", {}, "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw=="], + "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=="], + "css-select": ["css-select@7.0.0", "", { "dependencies": { "boolbase": "^2.0.0", "css-what": "^8.0.0", "domhandler": "^6.0.1", "domutils": "^4.0.2", "nth-check": "^3.0.1" } }, "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g=="], "css-what": ["css-what@8.0.0", "", {}, "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw=="], @@ -609,6 +640,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.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="], "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], @@ -621,24 +654,70 @@ "domutils": ["domutils@4.0.2", "", { "dependencies": { "dom-serializer": "^3.0.0", "domelementtype": "^3.0.0", "domhandler": "^6.0.0" } }, "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA=="], + "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=="], + + "encodeurl": ["encodeurl@2.0.1", "", {}, "sha512-Ev/hIyePLZtpL98x562lvPtenrUfGr0lfaf44HFZ+hDEwso7ccV9QAZrEVLg0hxNAzygaAFxy2dYaAKKM0G06A=="], + "enhanced-resolve": ["enhanced-resolve@5.26.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw=="], "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + "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=="], + "es6-promise": ["es6-promise@4.2.8", "", {}, "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w=="], + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + "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=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], + "fast-uri": ["fast-uri@3.1.9", "", {}, "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ=="], + "fast-xml-builder": ["fast-xml-builder@1.3.1", "", { "dependencies": { "path-expression-matcher": "^1.6.2", "xml-naming": "^0.3.0" } }, "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug=="], "fast-xml-parser": ["fast-xml-parser@5.11.2", "", { "dependencies": { "@nodable/entities": "^3.0.1", "fast-xml-builder": "^1.2.0", "is-unsafe": "^2.0.0", "path-expression-matcher": "^1.6.2", "strnum": "^2.4.2", "xml-naming": "^0.3.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-R9iDuNrQYeQut46cn2r2wHKn4HYzVDvDm5J1wW+koZewykv0yuO2HChTYeZtrULyHIDM9cj9TUXloCsueCQUog=="], + "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=="], + + "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-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], + "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=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "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=="], + "hls.js": ["hls.js@1.7.3", "", {}, "sha512-MsPlx6yVW4Qv4C7mEVou4/gk/5cN2dVxOTnrNjPSmyH2f0Ln+/UkG10Ax1PE6OvrmTVPKgQYozTtBaPOuCzq7A=="], "hono": ["hono@4.13.14", "", {}, "sha512-yhRLQ21U6qa9Hsu2N7Qcbh8tE+WKEcoErjIo56LeSGiGXGp9sA7UpE86Ui9uexb61KakiGwuHMn2QdV7w6i6kQ=="], @@ -647,16 +726,36 @@ "htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="], + "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=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "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-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + "is-unsafe": ["is-unsafe@2.0.2", "", {}, "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + "jose": ["jose@6.2.13", "", {}, "sha512-7TIg10ZE1x82YediN+lcrPNvGMKge9yFHQD+Qho/b8y2adfugU3AsvvHfxV0fzyFllT6l9xCjf4RWY9g/Z9PJQ=="], + "js-base64": ["js-base64@3.9.4", "", {}, "sha512-PtOMXpEGuP0RRiRXsjzHzl44dMHxSu2CPvAhinupR1tBa88me+1DPqsobl7eupn5UukjLkln1ZnAOAOXOrYG0Q=="], "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], + "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=="], + "libsql": ["libsql@0.5.29", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.5.29", "@libsql/darwin-x64": "0.5.29", "@libsql/linux-arm-gnueabihf": "0.5.29", "@libsql/linux-arm-musleabihf": "0.5.29", "@libsql/linux-arm64-gnu": "0.5.29", "@libsql/linux-arm64-musl": "0.5.29", "@libsql/linux-x64-gnu": "0.5.29", "@libsql/linux-x64-musl": "0.5.29", "@libsql/win32-x64-msvc": "0.5.29" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "arm", "x64", "arm64", ] }, "sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg=="], "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], @@ -691,6 +790,16 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "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=="], + "mpegts.js": ["mpegts.js@1.8.2", "", { "dependencies": { "es6-promise": "^4.2.8", "events": "^3.3.0" } }, "sha512-cZYMa5muASH55wrS6JK5IGkUqnCA96c0f+WdnW91jIK9s/N6NzjmTQDJNYg9A5u/G2Q5nj2b3w1Zwzze/9guxA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -701,6 +810,8 @@ "nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="], + "negotiator": ["negotiator@1.1.0", "", { "dependencies": { "content-type": "^2.1.0" } }, "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg=="], + "next": ["next@16.4.3", "", { "dependencies": { "@next/env": "16.4.3", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.4.3", "@next/swc-darwin-x64": "16.4.3", "@next/swc-linux-arm64-gnu": "16.4.3", "@next/swc-linux-arm64-musl": "16.4.3", "@next/swc-linux-x64-gnu": "16.4.3", "@next/swc-linux-x64-musl": "16.4.3", "@next/swc-win32-arm64-msvc": "16.4.3", "@next/swc-win32-x64-msvc": "16.4.3", "sharp": "^0.35.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-OqxKTGi9u80FydydsMNYNjnuje9Au+kvHlt2GZ//ftdpDHm1dxKqAaENoOOTX5wKzn+9vaKUNIfZeyZUuLhCnA=="], "node-abort-controller": ["node-abort-controller@3.1.1", "", {}, "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ=="], @@ -709,8 +820,22 @@ "nth-check": ["nth-check@3.0.1", "", { "dependencies": { "boolbase": "^2.0.0" } }, "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ=="], + "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-expression-matcher": ["path-expression-matcher@1.6.2", "", {}, "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + "pg": ["pg@8.23.1", "", { "dependencies": { "pg-connection-string": "^2.14.1", "pg-pool": "^3.14.0", "pg-protocol": "^1.16.1", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.1" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ=="], "pg-cloudflare": ["pg-cloudflare@1.4.1", "", {}, "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ=="], @@ -731,6 +856,8 @@ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "postcss": ["postcss@8.5.31", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg=="], "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], @@ -743,12 +870,20 @@ "promise-limit": ["promise-limit@2.7.0", "", {}, "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw=="], + "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=="], + "radix-ui": ["radix-ui@1.8.1", "", { "dependencies": { "@radix-ui/primitive": "1.2.0", "@radix-ui/react-accessible-icon": "1.2.0", "@radix-ui/react-accordion": "1.3.1", "@radix-ui/react-alert-dialog": "1.2.1", "@radix-ui/react-arrow": "1.1.17", "@radix-ui/react-aspect-ratio": "1.2.0", "@radix-ui/react-avatar": "1.3.1", "@radix-ui/react-checkbox": "1.4.1", "@radix-ui/react-collapsible": "1.2.1", "@radix-ui/react-collection": "1.2.0", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.3.0", "@radix-ui/react-context-menu": "2.4.1", "@radix-ui/react-dialog": "1.3.1", "@radix-ui/react-direction": "1.2.0", "@radix-ui/react-dismissable-layer": "1.2.1", "@radix-ui/react-dropdown-menu": "2.2.1", "@radix-ui/react-focus-guards": "1.2.0", "@radix-ui/react-focus-scope": "1.3.1", "@radix-ui/react-form": "0.3.0", "@radix-ui/react-hover-card": "1.2.1", "@radix-ui/react-label": "2.1.17", "@radix-ui/react-menu": "2.2.1", "@radix-ui/react-menubar": "1.2.1", "@radix-ui/react-navigation-menu": "1.4.1", "@radix-ui/react-one-time-password-field": "0.2.1", "@radix-ui/react-password-toggle-field": "0.2.1", "@radix-ui/react-popover": "1.3.1", "@radix-ui/react-popper": "1.4.0", "@radix-ui/react-portal": "1.2.0", "@radix-ui/react-presence": "1.1.11", "@radix-ui/react-primitive": "2.2.0", "@radix-ui/react-progress": "1.2.1", "@radix-ui/react-radio-group": "1.5.1", "@radix-ui/react-roving-focus": "1.2.1", "@radix-ui/react-scroll-area": "1.4.0", "@radix-ui/react-select": "2.4.1", "@radix-ui/react-separator": "1.2.0", "@radix-ui/react-slider": "1.6.1", "@radix-ui/react-slot": "1.5.0", "@radix-ui/react-switch": "1.4.1", "@radix-ui/react-tabs": "1.2.1", "@radix-ui/react-toast": "1.3.1", "@radix-ui/react-toggle": "1.2.1", "@radix-ui/react-toggle-group": "1.2.1", "@radix-ui/react-toolbar": "1.2.1", "@radix-ui/react-tooltip": "1.4.1", "@radix-ui/react-use-callback-ref": "1.1.5", "@radix-ui/react-use-controllable-state": "1.3.0", "@radix-ui/react-use-effect-event": "0.0.5", "@radix-ui/react-use-escape-keydown": "1.1.6", "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-size": "1.1.5", "@radix-ui/react-visually-hidden": "1.2.13" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-HPfpVGizO9xANlsFSauruLhRe/3+iOt3vPX0MXYle/UQi3gorQrSYtBcdiv0BZYewcl2rvL59qrIuLFMGAmUrw=="], + "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=="], + "react": ["react@19.3.1", "", {}, "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw=="], "react-dom": ["react-dom@19.3.1", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.1" } }, "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ=="], @@ -763,12 +898,36 @@ "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=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], "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=="], + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], @@ -777,6 +936,8 @@ "standardwebhooks": ["standardwebhooks@1.1.1", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "strnum": ["strnum@2.5.1", "", { "dependencies": { "anynum": "^1.0.1" } }, "sha512-UM4nTLWzQiDBseVxUGxN0wJzIk0UU89Qi7pKXz9qISo+lhviJikTuikBsK/oFRlpvYHvSdz0TVoLyQ+sEpYwxA=="], "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "@babel/core": "*", "babel-plugin-macros": "*", "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" }, "optionalPeers": ["@babel/core", "babel-plugin-macros"] }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], @@ -787,26 +948,42 @@ "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], "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-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=="], + "uhyphen": ["uhyphen@0.2.0", "", {}, "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA=="], "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + "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=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "ws": ["ws@8.22.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg=="], "xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="], "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + "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=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], @@ -819,6 +996,8 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + "dom-serializer/domelementtype": ["domelementtype@3.0.0", "", {}, "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg=="], "dom-serializer/entities": ["entities@8.1.0", "", {}, "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA=="], @@ -827,12 +1006,16 @@ "domutils/domelementtype": ["domelementtype@3.0.0", "", {}, "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg=="], + "express/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + "htmlparser2/domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], "htmlparser2/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=="], "lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "negotiator/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + "next/postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="], "node-gyp-build-optional-packages/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -841,6 +1024,8 @@ "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], + "type-is/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + "htmlparser2/domutils/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=="], "htmlparser2/domutils/dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], diff --git a/packages/auth/index.js b/packages/auth/index.js index 5bd7e0ca..e4ddb903 100644 --- a/packages/auth/index.js +++ b/packages/auth/index.js @@ -7,7 +7,17 @@ export { endSession, sessionCookieOptions, } from './src/session.js'; -export { requestSignInLink, consumeSignInLink, looksLikeEmail } from './src/magic.js'; +export { requestSignInLink, consumeSignInLink, looksLikeEmail, requestEmailClaimLink } from './src/magic.js'; +export { + PENDING_MS as COINPAY_PENDING_MS, + safeNext as coinpaySafeNext, + savePending as saveCoinpayPending, + takeCallback as takeCoinpayCallback, + signInWithCoinpay, + hasOtherSignIn, + disconnectCoinpay, + canRemovePasskey, +} from './src/coinpay.js'; export { profileStore } from './src/profile.js'; export { beginRegistration, diff --git a/packages/auth/src/coinpay.js b/packages/auth/src/coinpay.js new file mode 100644 index 00000000..f546e9a1 --- /dev/null +++ b/packages/auth/src/coinpay.js @@ -0,0 +1,184 @@ +import { coinpay, accounts, nowIso } from '@rssamplifier/db'; + +import { hashToken, safeEqual } from './tokens.js'; + +/** + * Sign in with CoinPay. CoinPay is the fleet's master account. + * + * The STANDING rule, which every function here exists to keep: + * + * - an account is bound to a CoinPay identity by its `sub`, never by email; + * - a CoinPay login never lands in an existing account because the addresses + * match: CoinPay does not verify them (`email_verified` is always false), and + * an address here is something the reader proved by following a link; + * - the CoinPay email is never written to users.email. A new CoinPay account + * gets a coinpay.invalid placeholder until it proves a real address; + * - CoinPay is connected to an existing account only from inside it. + * + * The HTTP side (redirects, cookies, the token exchange) lives in the web app; + * this is the part that decides, so it can be tested against a database. + */ + +/** Ten minutes from the authorize redirect to the callback. */ +export const PENDING_MS = 10 * 60 * 1000; + +/** + * Only a same-origin path is a place to go after signing in. + * + * @param {unknown} value + * @returns {string} + */ +export function safeNext(value) { + if (typeof value !== 'string' || value.length === 0) return '/account'; + if (!value.startsWith('/') || value.startsWith('//') || value.startsWith('/\\')) return '/account'; + return value; +} + +/** + * @param {string} sub + * @returns {string} + */ +function placeholderFor(sub) { + return `coinpay-${hashToken(sub).slice(0, 20)}@${coinpay.PLACEHOLDER_DOMAIN}`; +} + +/** + * @param {unknown} email + * @returns {string|null} + */ +function contactEmail(email) { + const value = typeof email === 'string' ? email.trim() : ''; + return value && value.length <= 254 && value.includes('@') ? value : null; +} + +/** + * Remember an authorization request until its callback. + * + * @param {import('@rssamplifier/db/src/pg.js').PgClient} db + * @param {string} state + * @param {{ verifier: string, nonce: string, next: string, linkUserId: string|null }} pending + */ +export async function savePending(db, state, pending) { + await coinpay.insertPending(db, { + id: hashToken(state), + verifier: pending.verifier, + nonce: pending.nonce, + next: safeNext(pending.next), + linkUserId: pending.linkUserId, + expiresAt: nowIso(PENDING_MS), + }); +} + +/** + * Check a callback's state and spend its pending request. + * + * The state must match the cookie set in the browser that started the login + * (so a link crafted elsewhere cannot finish in yours), and it is single use. + * When the request was "Connect CoinPay", the session finishing it must be the + * one that started it. + * + * @param {import('@rssamplifier/db/src/pg.js').PgClient} db + * @param {{ state: string|null|undefined, cookieState: string|null|undefined, sessionUserId: string|null }} input + * @returns {Promise<{ ok: true, pending: { verifier: string, nonce: string, next: string, linkUserId: string|null } } | { ok: false, error: 'bad-state'|'expired'|'wrong-session' }>} + */ +export async function takeCallback(db, input) { + const state = typeof input.state === 'string' ? input.state : ''; + const cookie = typeof input.cookieState === 'string' ? input.cookieState : ''; + if (!state || !cookie || !safeEqual(state, cookie)) return { ok: false, error: 'bad-state' }; + + const pending = await coinpay.takePending(db, hashToken(state)); + if (!pending) return { ok: false, error: 'expired' }; + + if (pending.linkUserId !== null && pending.linkUserId !== input.sessionUserId) { + return { ok: false, error: 'wrong-session' }; + } + return { ok: true, pending }; +} + +/** + * Resolve a finished CoinPay authorization to an account. + * + * @param {import('@rssamplifier/db/src/pg.js').PgClient} db + * @param {{ sub: string, email: string|null, name: string|null }} profile + * @param {{ linkUserId: string|null }} options + * @returns {Promise<{ ok: true, userId: string, created: boolean, linked: boolean } | { ok: false, error: 'linked-elsewhere'|'already-linked'|'no-account' }>} + */ +export async function signInWithCoinpay(db, profile, options) { + const identity = { + sub: String(profile.sub), + email: contactEmail(profile.email), + name: profile.name ? String(profile.name).slice(0, 200) : null, + }; + const existing = await coinpay.identityBySub(db, identity.sub); + + if (options.linkUserId !== null) { + const target = await accounts.userById(db, options.linkUserId); + if (!target) return { ok: false, error: 'no-account' }; + if (existing) { + return String(existing.user_id) === String(target.id) + ? { ok: true, userId: String(target.id), created: false, linked: false } + : { ok: false, error: 'linked-elsewhere' }; + } + if (await coinpay.identityForUser(db, String(target.id))) return { ok: false, error: 'already-linked' }; + await coinpay.insertIdentity(db, { ...identity, userId: String(target.id) }); + return { ok: true, userId: String(target.id), created: false, linked: true }; + } + + if (existing) { + const user = await accounts.userById(db, String(existing.user_id)); + if (!user) return { ok: false, error: 'no-account' }; + await coinpay.touchIdentity(db, identity); + return { ok: true, userId: String(user.id), created: false, linked: false }; + } + + // Never an existing account by email: a fresh one, bound to the sub. + const user = await coinpay.insertPlaceholderUser(db, placeholderFor(identity.sub)); + await coinpay.insertIdentity(db, { ...identity, userId: user.id }); + return { ok: true, userId: user.id, created: true, linked: true }; +} + +/** + * Can this account get back in without CoinPay? A proven address (sign-in + * link) counts, and so does a passkey. + * + * @param {import('@rssamplifier/db/src/pg.js').PgClient} db + * @param {string} userId + * @returns {Promise} + */ +export async function hasOtherSignIn(db, userId) { + const user = await accounts.userById(db, userId); + if (!user) return false; + if (!coinpay.isPlaceholderEmail(user.email)) return true; + const credentials = await accounts.credentialsForUser(db, userId); + return credentials.length > 0; +} + +/** + * Disconnect CoinPay, unless it is the only way into the account. + * + * @param {import('@rssamplifier/db/src/pg.js').PgClient} db + * @param {string} userId + * @returns {Promise<{ ok: true } | { ok: false, error: 'not-linked'|'only-sign-in' }>} + */ +export async function disconnectCoinpay(db, userId) { + if (!(await coinpay.identityForUser(db, userId))) return { ok: false, error: 'not-linked' }; + if (!(await hasOtherSignIn(db, userId))) return { ok: false, error: 'only-sign-in' }; + await coinpay.deleteIdentityForUser(db, userId); + return { ok: true }; +} + +/** + * May the account drop a passkey? Always, unless the account has no proven + * address and no CoinPay, and this is its last passkey: then it is the only + * way back in, and removing it would lock the account for good. + * + * @param {import('@rssamplifier/db/src/pg.js').PgClient} db + * @param {string} userId + * @returns {Promise} + */ +export async function canRemovePasskey(db, userId) { + const user = await accounts.userById(db, userId); + if (!user || !coinpay.isPlaceholderEmail(user.email)) return true; + if (await coinpay.identityForUser(db, userId)) return true; + return (await accounts.credentialsForUser(db, userId)).length > 1; +} diff --git a/packages/auth/src/magic.js b/packages/auth/src/magic.js index dbbd70d3..d07ed4d5 100644 --- a/packages/auth/src/magic.js +++ b/packages/auth/src/magic.js @@ -1,4 +1,4 @@ -import { accounts, newsletter, nowIso } from '@rssamplifier/db'; +import { accounts, coinpay, newsletter, nowIso } from '@rssamplifier/db'; import { sendEmail, emailEnabled } from '@rssamplifier/mail'; import { saveSignupProfile } from '@profullstack/profile'; @@ -112,6 +112,14 @@ export async function consumeSignInLink(db, token) { // same situation from the reader's side — ask for a new link. if (!spent) return { ok: false, error: 'invalid-or-expired' }; + // A CoinPay-only account asked to use this address: following the link is + // the proof, and the address becomes that account's, never another's. + if (spent.claimUserId) { + const adopted = await coinpay.adoptEmail(db, spent.claimUserId, spent.email); + if (!adopted) return { ok: false, error: 'claim-failed' }; + return { ok: true, userId: spent.claimUserId, email: spent.email, created: false }; + } + const user = await accounts.findOrCreateUser(db, spent.email); // A link asked for from /signup carries the form's profile. Saving it is not @@ -135,3 +143,57 @@ export async function consumeSignInLink(db, token) { return { ok: true, userId: user.id, email: user.email, created: user.created }; } + +/** + * Mail a link that gives a CoinPay-only account the address it was sent to. + * + * Same token, same table and same limits as a sign-in link; the difference is + * only what following it does (see consumeSignInLink). Refused when the account + * already has a real address or the address belongs to another account. + * + * @param {import('@rssamplifier/db/src/pg.js').PgClient} db + * @param {string} userId + * @param {string} email + * @param {string} siteUrl + * @returns {Promise<{ ok: boolean, error?: string }>} + */ +export async function requestEmailClaimLink(db, userId, email, siteUrl) { + if (!looksLikeEmail(email) || coinpay.isPlaceholderEmail(email)) return { ok: false, error: 'invalid-email' }; + if (!emailEnabled()) return { ok: false, error: 'email-not-configured' }; + + const user = await accounts.userById(db, userId); + if (!user || !coinpay.isPlaceholderEmail(user.email)) return { ok: false, error: 'has-email' }; + + const normalized = accounts.normalizeEmail(email); + if (await accounts.userByEmail(db, normalized)) return { ok: false, error: 'email-taken' }; + + const recent = await accounts.recentLoginTokenCount(db, normalized); + if (recent >= MAX_PER_HOUR) return { ok: false, error: 'rate-limited' }; + + const token = newToken(); + await accounts.insertLoginToken(db, { + tokenHash: hashToken(token), + email: normalized, + expiresAt: nowIso(TOKEN_MS), + claimUserId: userId, + }); + + const link = `${String(siteUrl).replace(/\/+$/, '')}/auth/magic?t=${encodeURIComponent(token)}`; + const sent = await sendEmail({ + to: normalized, + subject: 'Confirm your RSS Amplifier email', + text: [ + 'You signed in to RSS Amplifier with CoinPay and asked to use this address.', + '', + 'Confirm it:', + '', + link, + '', + 'It works once and expires in 20 minutes.', + 'If you did not ask for this, you can ignore it — nothing has changed.', + ].join('\n'), + }); + + if (!sent.ok) return { ok: false, error: sent.error ?? 'send-failed' }; + return { ok: true }; +} diff --git a/packages/auth/test/coinpay.test.js b/packages/auth/test/coinpay.test.js new file mode 100644 index 00000000..5a5b9470 --- /dev/null +++ b/packages/auth/test/coinpay.test.js @@ -0,0 +1,172 @@ +import assert from 'node:assert/strict'; +import { test, before, after } from 'node:test'; +import { accounts, coinpay } from '@rssamplifier/db'; +import { connectTest } from '@rssamplifier/db/src/testdb.js'; + +import { + canRemovePasskey, + disconnectCoinpay, + safeNext, + savePending, + signInWithCoinpay, + takeCallback, +} from '../src/coinpay.js'; +import { consumeSignInLink } from '../src/magic.js'; +import { hashToken, newToken } from '../src/tokens.js'; + +/** + * Sign in with CoinPay. The STANDING rule these hold: an account is bound to + * the CoinPay sub, a matching CoinPay email never takes over an account, + * linking needs the session that asked, and a bad or replayed state is refused. + */ + +let db; +let seq = 0; +const sub = () => `cp-sub-${++seq}-${newToken().slice(0, 6)}`; + +before(async () => { + db = await connectTest({ prefix: 'rssamp_coinpay' }); +}); + +after(() => { + db.close(); +}); + +test('a CoinPay email matching an existing account gets its own new account', async () => { + const victim = await accounts.findOrCreateUser(db, 'victim@example.com'); + + const result = await signInWithCoinpay( + db, + { sub: sub(), email: 'Victim@Example.com', name: 'Mallory' }, + { linkUserId: null }, + ); + + assert.equal(result.ok, true); + assert.notEqual(result.userId, victim.id); + assert.equal(result.created, true); + const made = await accounts.userById(db, result.userId); + assert.ok(coinpay.isPlaceholderEmail(made.email), 'the CoinPay email is not the account address'); + assert.equal((await accounts.userByEmail(db, 'victim@example.com')).id, victim.id); +}); + +test('the same sub signs back into the same account', async () => { + const id = sub(); + const first = await signInWithCoinpay(db, { sub: id, email: null, name: null }, { linkUserId: null }); + const again = await signInWithCoinpay(db, { sub: id, email: 'new@example.com', name: null }, { linkUserId: null }); + assert.equal(again.ok, true); + assert.equal(again.userId, first.userId); + assert.equal(again.created, false); +}); + +test('connect binds to the signed-in account, and a sub belongs to one account', async () => { + const alice = await accounts.findOrCreateUser(db, 'alice@example.com'); + const bob = await accounts.findOrCreateUser(db, 'bob@example.com'); + const id = sub(); + + const linked = await signInWithCoinpay(db, { sub: id, email: null, name: null }, { linkUserId: alice.id }); + assert.deepEqual(linked, { ok: true, userId: alice.id, created: false, linked: true }); + + assert.deepEqual( + await signInWithCoinpay(db, { sub: id, email: null, name: null }, { linkUserId: bob.id }), + { ok: false, error: 'linked-elsewhere' }, + ); + assert.deepEqual( + await signInWithCoinpay(db, { sub: sub(), email: null, name: null }, { linkUserId: alice.id }), + { ok: false, error: 'already-linked' }, + ); + + const login = await signInWithCoinpay(db, { sub: id, email: null, name: null }, { linkUserId: null }); + assert.equal(login.userId, alice.id); +}); + +test('a callback needs the state this browser started, once', async () => { + const state = newToken(); + await savePending(db, state, { verifier: 'v', nonce: 'n', next: '/following', linkUserId: null }); + + assert.deepEqual(await takeCallback(db, { state, cookieState: 'other', sessionUserId: null }), { + ok: false, + error: 'bad-state', + }); + assert.deepEqual(await takeCallback(db, { state, cookieState: undefined, sessionUserId: null }), { + ok: false, + error: 'bad-state', + }); + assert.deepEqual(await takeCallback(db, { state: 'forged', cookieState: 'forged', sessionUserId: null }), { + ok: false, + error: 'expired', + }); + + const ok = await takeCallback(db, { state, cookieState: state, sessionUserId: null }); + assert.equal(ok.ok, true); + assert.deepEqual(ok.pending, { verifier: 'v', nonce: 'n', next: '/following', linkUserId: null }); + + assert.deepEqual(await takeCallback(db, { state, cookieState: state, sessionUserId: null }), { + ok: false, + error: 'expired', + }); +}); + +test('connect must be finished by the session that started it', async () => { + const carol = await accounts.findOrCreateUser(db, 'carol@example.com'); + const state = newToken(); + await savePending(db, state, { verifier: 'v', nonce: 'n', next: '/account', linkUserId: carol.id }); + + assert.deepEqual(await takeCallback(db, { state, cookieState: state, sessionUserId: null }), { + ok: false, + error: 'wrong-session', + }); + + const again = newToken(); + await savePending(db, again, { verifier: 'v', nonce: 'n', next: '/account', linkUserId: carol.id }); + const ok = await takeCallback(db, { state: again, cookieState: again, sessionUserId: carol.id }); + assert.equal(ok.ok, true); +}); + +test('only same-origin paths survive as next', () => { + assert.equal(safeNext('/following'), '/following'); + assert.equal(safeNext('//evil.example'), '/account'); + assert.equal(safeNext('/\\evil.example'), '/account'); + assert.equal(safeNext('https://evil.example'), '/account'); + assert.equal(safeNext(undefined), '/account'); +}); + +test('CoinPay cannot be disconnected when it is the only way in', async () => { + const only = await signInWithCoinpay(db, { sub: sub(), email: null, name: null }, { linkUserId: null }); + assert.deepEqual(await disconnectCoinpay(db, only.userId), { ok: false, error: 'only-sign-in' }); + + const dana = await accounts.findOrCreateUser(db, 'dana@example.com'); + await signInWithCoinpay(db, { sub: sub(), email: null, name: null }, { linkUserId: dana.id }); + assert.deepEqual(await disconnectCoinpay(db, dana.id), { ok: true }); + assert.deepEqual(await disconnectCoinpay(db, dana.id), { ok: false, error: 'not-linked' }); + + // A placeholder account's last passkey is kept once CoinPay is gone. + assert.equal(await canRemovePasskey(db, only.userId), true, 'CoinPay still connected'); + assert.equal(await canRemovePasskey(db, dana.id), true, 'a proven address is a way in'); +}); + +test('a claim link gives a placeholder account the address, and never a taken one', async () => { + const cp = await signInWithCoinpay(db, { sub: sub(), email: null, name: null }, { linkUserId: null }); + + const token = newToken(); + await accounts.insertLoginToken(db, { + tokenHash: hashToken(token), + email: 'mine@example.com', + expiresAt: new Date(Date.now() + 60_000).toISOString(), + claimUserId: cp.userId, + }); + const spent = await consumeSignInLink(db, token); + assert.deepEqual(spent, { ok: true, userId: cp.userId, email: 'mine@example.com', created: false }); + assert.equal((await accounts.userById(db, cp.userId)).email, 'mine@example.com'); + + // Now the address is taken: a second placeholder account cannot claim it. + const other = await signInWithCoinpay(db, { sub: sub(), email: null, name: null }, { linkUserId: null }); + const second = newToken(); + await accounts.insertLoginToken(db, { + tokenHash: hashToken(second), + email: 'mine@example.com', + expiresAt: new Date(Date.now() + 60_000).toISOString(), + claimUserId: other.userId, + }); + assert.deepEqual(await consumeSignInLink(db, second), { ok: false, error: 'claim-failed' }); + assert.ok(coinpay.isPlaceholderEmail((await accounts.userById(db, other.userId)).email)); +}); diff --git a/packages/db/index.js b/packages/db/index.js index 79a21d12..92a07d01 100644 --- a/packages/db/index.js +++ b/packages/db/index.js @@ -26,3 +26,4 @@ export * as removals from './src/removals.js'; export * as crawlSales from './src/crawl-sales.js'; export * as webrings from './src/webrings.js'; export * as newsletter from './src/newsletter.js'; +export * as coinpay from './src/coinpay.js'; diff --git a/packages/db/migrations-pg/20261029060855_coinpay_identities.sql b/packages/db/migrations-pg/20261029060855_coinpay_identities.sql new file mode 100644 index 00000000..7136e2f1 --- /dev/null +++ b/packages/db/migrations-pg/20261029060855_coinpay_identities.sql @@ -0,0 +1,32 @@ +-- Sign in with CoinPay. CoinPay is the fleet's master account. +-- +-- An account is bound to a CoinPay identity by its `sub` and nothing else. +-- CoinPay does not verify addresses, so the email it shares is kept here as +-- contact information only and never written to users.email: an address here +-- is proven by a link the reader followed, and that stays true. +create table if not exists coinpay_identities ( + sub text primary key, + user_id text not null unique references users (id) on delete cascade, + email text, + name text, + created_at text not null, + last_login_at text +); + +-- One row per authorization request in flight, keyed by the hash of `state`. +-- The PKCE verifier and the nonce never leave the server, and the row is +-- spent by the first callback that presents its state. +create table if not exists coinpay_pending ( + id text primary key, + verifier text not null, + nonce text not null, + next_path text not null, + link_user_id text, + expires_at text not null, + created_at text not null +); +create index if not exists coinpay_pending_expiry_idx on coinpay_pending (expires_at); + +-- A sign-in link that, once followed, gives a CoinPay-only account (one still +-- on a coinpay.invalid placeholder) the address the link was mailed to. +alter table login_tokens add column if not exists claim_user_id text; diff --git a/packages/db/src/accounts.js b/packages/db/src/accounts.js index 551a3688..23eb885d 100644 --- a/packages/db/src/accounts.js +++ b/packages/db/src/accounts.js @@ -107,11 +107,11 @@ export async function markUserLoggedIn(db, id) { * then (see consumeSignInLink). A plain sign-in link carries none. * * @param {Client} db - * @param {{ tokenHash: string, email: string, expiresAt: string, profile?: object|null }} token + * @param {{ tokenHash: string, email: string, expiresAt: string, profile?: object|null, newsletter?: boolean, claimUserId?: string|null }} token */ export async function insertLoginToken(db, token) { await db.execute({ - sql: 'insert into login_tokens (id, email, created_at, expires_at, profile, newsletter) values (?, ?, ?, ?, ?, ?)', + sql: 'insert into login_tokens (id, email, created_at, expires_at, profile, newsletter, claim_user_id) values (?, ?, ?, ?, ?, ?, ?)', args: [ token.tokenHash, normalizeEmail(token.email), @@ -120,6 +120,8 @@ export async function insertLoginToken(db, token) { token.profile ? JSON.stringify(token.profile) : null, // What the signup form said about the weekly digest; null when it said nothing. typeof token.newsletter === 'boolean' ? (token.newsletter ? 1 : 0) : null, + // A CoinPay-only account asking to use this address (see coinpay.js). + token.claimUserId ?? null, ], }); } @@ -145,7 +147,7 @@ export async function consumeLoginToken(db, tokenHash) { * * @param {Client} db * @param {string} tokenHash - * @returns {Promise<{ email: string, profile: Record|null, newsletter: boolean|null }|null>} + * @returns {Promise<{ email: string, profile: Record|null, newsletter: boolean|null, claimUserId: string|null }|null>} */ export async function takeLoginToken(db, tokenHash) { const now = nowIso(); @@ -153,13 +155,18 @@ export async function takeLoginToken(db, tokenHash) { const { rows } = await db.execute({ sql: `update login_tokens set consumed_at = ? where id = ? and consumed_at is null and expires_at > ? - returning email, profile, newsletter`, + returning email, profile, newsletter, claim_user_id`, args: [now, tokenHash, now], }); if (!rows[0]?.email) return null; const newsletter = rows[0].newsletter == null ? null : Number(rows[0].newsletter) === 1; - return { email: String(rows[0].email), profile: parseProfile(rows[0].profile), newsletter }; + return { + email: String(rows[0].email), + profile: parseProfile(rows[0].profile), + newsletter, + claimUserId: rows[0].claim_user_id == null ? null : String(rows[0].claim_user_id), + }; } /** diff --git a/packages/db/src/coinpay.js b/packages/db/src/coinpay.js new file mode 100644 index 00000000..cb8ffeee --- /dev/null +++ b/packages/db/src/coinpay.js @@ -0,0 +1,162 @@ +import { newId, nowIso } from './client.js'; +import { normalizeEmail } from './accounts.js'; + +/** + * Sign in with CoinPay: what it reads and writes. + * + * Bound by `sub` only (the STANDING rule): nothing here looks an account up by + * the CoinPay email, and the CoinPay email never reaches users.email. + * + * @typedef {import('./pg.js').PgClient} Client + */ + +/** Accounts made by CoinPay carry an address that cannot receive mail. */ +export const PLACEHOLDER_DOMAIN = 'coinpay.invalid'; + +/** + * @param {unknown} email + * @returns {boolean} + */ +export function isPlaceholderEmail(email) { + return normalizeEmail(email).endsWith(`@${PLACEHOLDER_DOMAIN}`); +} + +/* --------------------------------------------------------- requests in flight */ + +/** + * @param {Client} db + * @param {{ id: string, verifier: string, nonce: string, next: string, linkUserId: string|null, expiresAt: string }} row + */ +export async function insertPending(db, row) { + await db.execute({ sql: 'delete from coinpay_pending where expires_at <= ?', args: [nowIso()] }); + await db.execute({ + sql: `insert into coinpay_pending (id, verifier, nonce, next_path, link_user_id, expires_at, created_at) + values (?, ?, ?, ?, ?, ?, ?)`, + args: [row.id, row.verifier, row.nonce, row.next, row.linkUserId, row.expiresAt, nowIso()], + }); +} + +/** + * Spend a pending request. The delete is the check, so two callbacks with the + * same state race in the database and only one of them gets the row. + * + * @param {Client} db + * @param {string} id + * @returns {Promise<{ verifier: string, nonce: string, next: string, linkUserId: string|null }|null>} + */ +export async function takePending(db, id) { + const { rows } = await db.execute({ + sql: `delete from coinpay_pending where id = ? and expires_at > ? + returning verifier, nonce, next_path, link_user_id`, + args: [id, nowIso()], + }); + const row = rows[0]; + if (!row) return null; + return { + verifier: String(row.verifier), + nonce: String(row.nonce), + next: String(row.next_path), + linkUserId: row.link_user_id == null ? null : String(row.link_user_id), + }; +} + +/* ---------------------------------------------------------------- identities */ + +/** + * @param {Client} db + * @param {string} sub + */ +export async function identityBySub(db, sub) { + const { rows } = await db.execute({ + sql: 'select sub, user_id, email, name, created_at from coinpay_identities where sub = ? limit 1', + args: [sub], + }); + return rows[0] ?? null; +} + +/** + * @param {Client} db + * @param {string} userId + */ +export async function identityForUser(db, userId) { + const { rows } = await db.execute({ + sql: 'select sub, user_id, email, name, created_at from coinpay_identities where user_id = ? limit 1', + args: [userId], + }); + return rows[0] ?? null; +} + +/** + * @param {Client} db + * @param {{ sub: string, userId: string, email: string|null, name: string|null }} identity + */ +export async function insertIdentity(db, identity) { + await db.execute({ + sql: `insert into coinpay_identities (sub, user_id, email, name, created_at, last_login_at) + values (?, ?, ?, ?, ?, ?)`, + args: [identity.sub, identity.userId, identity.email, identity.name, nowIso(), nowIso()], + }); +} + +/** + * @param {Client} db + * @param {{ sub: string, email: string|null, name: string|null }} identity + */ +export async function touchIdentity(db, identity) { + await db.execute({ + sql: 'update coinpay_identities set last_login_at = ?, email = ?, name = ? where sub = ?', + args: [nowIso(), identity.email, identity.name, identity.sub], + }); +} + +/** + * @param {Client} db + * @param {string} userId + */ +export async function deleteIdentityForUser(db, userId) { + await db.execute({ sql: 'delete from coinpay_identities where user_id = ?', args: [userId] }); +} + +/** + * A new account for a CoinPay identity, on a placeholder address and off the + * weekly digest (there is nowhere to send it). + * + * @param {Client} db + * @param {string} placeholder + * @returns {Promise<{ id: string, email: string }>} + */ +export async function insertPlaceholderUser(db, placeholder) { + const id = newId(); + const email = normalizeEmail(placeholder); + await db.execute({ + sql: 'insert into users (id, email, created_at, newsletter) values (?, ?, ?, 0)', + args: [id, email, nowIso()], + }); + return { id, email }; +} + +/** + * Give a placeholder account a real, proven address. Only while it is still on + * the placeholder and the address is free; false otherwise. + * + * @param {Client} db + * @param {string} userId + * @param {string} email + * @returns {Promise} + */ +export async function adoptEmail(db, userId, email) { + const normalized = normalizeEmail(email); + try { + const { rows } = await db.execute({ + sql: `update users set email = ? + where id = ? and email like ? + and not exists (select 1 from users other where other.email = ?) + returning id`, + args: [normalized, userId, `%@${PLACEHOLDER_DOMAIN}`, normalized], + }); + return rows.length === 1; + } catch { + // The unique index lost a race with a sign-in link for the same address. + return false; + } +} diff --git a/packages/db/src/newsletter.js b/packages/db/src/newsletter.js index 0ca6e72c..735be863 100644 --- a/packages/db/src/newsletter.js +++ b/packages/db/src/newsletter.js @@ -109,6 +109,8 @@ export async function pendingFor(db, week, limit) { const { rows } = await db.execute({ sql: `select u.id, u.email from users u where u.newsletter = 1 + -- A CoinPay-only account has no address yet (coinpay.js). + and u.email not like '%@coinpay.invalid' and not exists (select 1 from newsletter_sends s where s.week = ? and s.user_id = u.id) order by u.created_at asc, u.id asc limit ?`,