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.
+
+
+ )}
+
+ 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.
+
+
+
+ Send the link
+
+
+ )}
+
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 ?`,