From a8c91cededed2d7117fd83f8196a7191fb26cd79 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 26 Oct 2026 21:38:13 +0000 Subject: [PATCH] feat(pwa): credit codes + scarce invites on app.moshcode.sh Codes (@profullstack/redeem-credits, MC-XXXX-XXXX-XXXX-XXXX) grant moshcode credits INTO the existing credit_ledger: one row per redemption, keyed on (user_id, source) with a unique index, so a grant can never land twice. The module keeps only its code/redemption tables (prefix redeem_, so it does not collide with credit_ledger). Redeem on /settings ("Have a code?"), /redeem?code=, or POST /api/credits/redeem with an API key (never a cookie: /api is outside CSRF). Invites (@profullstack/affiliates): 3 per account at /invites, landing at /i/ (sign-up form carrying the code + a signed cookie). Accepted only when the email sign-up creates the account; one accepted invite per account. The bonus (INVITE_BONUS_CREDITS, default 100 each side) is paid only after the invitee confirms their email through a signed link that works in any browser, and is refused for throwaway domains, the same mailbox (+tags, Gmail dots), an inviter under 24h old, or more than 10 bonuses per inviter in 30 days. Every decision is recorded in invite_bonuses. ?ref= links set the shared referral_code cookie (first touch) and are attributed at sign-up; no commission. Operators mint with scripts/credit-codes.mjs (docker exec) or /api/admin/credit-codes (ADMIN_EMAILS). Nothing touches the Moshpit registry, name sales, ending sales or the CoinPay webhook. Co-Authored-By: Claude Opus 5.5 --- apps/pwa/package-lock.json | 79 +++- apps/pwa/package.json | 5 +- apps/pwa/pnpm-lock.yaml | 76 +++- apps/pwa/scripts/credit-codes.mjs | 60 +++ apps/pwa/src/lib/credit-codes.mjs | 255 +++++++++++++ apps/pwa/src/lib/html.mjs | 3 +- .../027_credit_codes_invites.sql | 16 + .../migrations/027_credit_codes_invites.sql | 22 ++ apps/pwa/src/routes/auth.mjs | 19 + apps/pwa/src/routes/invites.mjs | 344 ++++++++++++++++++ apps/pwa/src/routes/pages.mjs | 10 +- apps/pwa/src/routes/passkey.mjs | 2 + apps/pwa/src/server.mjs | 3 + apps/pwa/test/credit-codes.test.mjs | 255 +++++++++++++ 14 files changed, 1111 insertions(+), 38 deletions(-) create mode 100644 apps/pwa/scripts/credit-codes.mjs create mode 100644 apps/pwa/src/lib/credit-codes.mjs create mode 100644 apps/pwa/src/migrations-pg/027_credit_codes_invites.sql create mode 100644 apps/pwa/src/migrations/027_credit_codes_invites.sql create mode 100644 apps/pwa/src/routes/invites.mjs create mode 100644 apps/pwa/test/credit-codes.test.mjs diff --git a/apps/pwa/package-lock.json b/apps/pwa/package-lock.json index 9d18f13e..94aa6a48 100644 --- a/apps/pwa/package-lock.json +++ b/apps/pwa/package-lock.json @@ -10,16 +10,19 @@ "dependencies": { "@libsql/client": "^0.14.0", "@peculiar/x509": "^1.14.3", + "@profullstack/affiliates": "^0.3.0", "@profullstack/footer": "0.2.6", "@profullstack/libsql-pg": "^0.1.2", "@profullstack/notifications": "^0.1.0", + "@profullstack/redeem-credits": "^0.2.0", "@profullstack/synconfig": "^0.1.1", "@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/server": "^13.1.0", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "cookie-parser": "^1.4.7", - "express": "^4.21.2" + "express": "^4.21.2", + "pg": "^8.23.1" }, "devDependencies": { "@moshcoder/moshpit-name": "^0.4.0" @@ -397,6 +400,21 @@ "node": ">=20.0.0" } }, + "node_modules/@profullstack/affiliates": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@profullstack/affiliates/-/affiliates-0.3.0.tgz", + "integrity": "sha512-bp0iREciWetDWrQYOsa6SJRTzviekKwgJQuATT7vql+4equu7pN76q8tb3SP1oBMczydsv6rYUMvPpS6+QT7yQ==", + "license": "MIT", + "dependencies": { + "@profullstack/referrals": "^0.1.0" + }, + "bin": { + "affiliates": "dist/cli.js" + }, + "engines": { + "node": ">=22.13" + } + }, "node_modules/@profullstack/footer": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/@profullstack/footer/-/footer-0.2.6.tgz", @@ -601,6 +619,35 @@ "node": ">=20.11" } }, + "node_modules/@profullstack/redeem-credits": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@profullstack/redeem-credits/-/redeem-credits-0.2.0.tgz", + "integrity": "sha512-JLowb7mWYfouIK7O522tuPPhvLjQjhdEfm6g3xEYrlNLGzLhrIADDhrYldBlGHfiVBWUFX7wefLuCSLF9fnyog==", + "license": "MIT", + "bin": { + "redeem-credits": "dist/cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@profullstack/referrals": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@profullstack/referrals/-/referrals-0.1.0.tgz", + "integrity": "sha512-u66SdBVpsv3kc0N+NWISPoYD5vjCERyv5wfD07iSkZwQeC2IA+ihX5jNA4e7Xr+Y4AUvxLycG+3b4VaROqzgRg==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": ">=18" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, "node_modules/@profullstack/synconfig": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@profullstack/synconfig/-/synconfig-0.1.3.tgz", @@ -1375,14 +1422,14 @@ "license": "MIT" }, "node_modules/pg": { - "version": "8.23.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", - "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==", "license": "MIT", "dependencies": { - "pg-connection-string": "^2.14.0", + "pg-connection-string": "^2.14.1", "pg-pool": "^3.14.0", - "pg-protocol": "^1.16.0", + "pg-protocol": "^1.16.1", "pg-types": "2.2.0", "pgpass": "1.0.5" }, @@ -1390,7 +1437,7 @@ "node": ">= 16.0.0" }, "optionalDependencies": { - "pg-cloudflare": "^1.4.0" + "pg-cloudflare": "^1.4.1" }, "peerDependencies": { "pg-native": ">=3.0.1" @@ -1402,16 +1449,16 @@ } }, "node_modules/pg-cloudflare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", - "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz", + "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==", "license": "MIT", "optional": true }, "node_modules/pg-connection-string": { - "version": "2.14.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", - "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz", + "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==", "license": "MIT" }, "node_modules/pg-int8": { @@ -1433,9 +1480,9 @@ } }, "node_modules/pg-protocol": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", - "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz", + "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==", "license": "MIT" }, "node_modules/pg-types": { diff --git a/apps/pwa/package.json b/apps/pwa/package.json index 119c2f62..5ee099e5 100644 --- a/apps/pwa/package.json +++ b/apps/pwa/package.json @@ -16,16 +16,19 @@ "dependencies": { "@libsql/client": "^0.14.0", "@peculiar/x509": "^1.14.3", + "@profullstack/affiliates": "^0.3.0", "@profullstack/footer": "0.2.6", "@profullstack/libsql-pg": "^0.1.2", "@profullstack/notifications": "^0.1.0", + "@profullstack/redeem-credits": "^0.2.0", "@profullstack/synconfig": "^0.1.1", "@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/server": "^13.1.0", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "cookie-parser": "^1.4.7", - "express": "^4.21.2" + "express": "^4.21.2", + "pg": "^8.23.1" }, "devDependencies": { "@moshcoder/moshpit-name": "^0.4.0" diff --git a/apps/pwa/pnpm-lock.yaml b/apps/pwa/pnpm-lock.yaml index c908a508..1728d994 100644 --- a/apps/pwa/pnpm-lock.yaml +++ b/apps/pwa/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@peculiar/x509': specifier: ^1.14.3 version: 1.14.3 + '@profullstack/affiliates': + specifier: ^0.3.0 + version: 0.3.0 '@profullstack/footer': specifier: 0.2.6 version: 0.2.6 @@ -23,6 +26,9 @@ importers: '@profullstack/notifications': specifier: ^0.1.0 version: 0.1.0 + '@profullstack/redeem-credits': + specifier: ^0.2.0 + version: 0.2.0 '@profullstack/synconfig': specifier: ^0.1.1 version: 0.1.1 @@ -44,6 +50,9 @@ importers: express: specifier: ^4.21.2 version: 4.22.2 + pg: + specifier: ^8.23.1 + version: 8.23.1 devDependencies: '@moshcoder/moshpit-name': specifier: ^0.4.0 @@ -218,6 +227,11 @@ packages: resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==} engines: {node: '>=20.0.0'} + '@profullstack/affiliates@0.3.0': + resolution: {integrity: sha512-bp0iREciWetDWrQYOsa6SJRTzviekKwgJQuATT7vql+4equu7pN76q8tb3SP1oBMczydsv6rYUMvPpS6+QT7yQ==} + engines: {node: '>=22.13'} + hasBin: true + '@profullstack/footer@0.2.6': resolution: {integrity: sha512-BKwbDszAc7e0mYIBiPjgr/5WS8WyDt8IW718s/zW/8pCWOIB1BGwSNs6fmImRSo5kqIjQKUVbXp22jZQ5GEipA==} engines: {node: '>=18'} @@ -237,6 +251,20 @@ packages: resolution: {integrity: sha512-17jiBCRiZVDcIyWnWKYr5E2eVog/1iHgXaruGbmlw7j7Ob4zd6oOqGjh8BP8wm13iFwikczYObQwSrGadBdnGw==} engines: {node: '>=20.11'} + '@profullstack/redeem-credits@0.2.0': + resolution: {integrity: sha512-JLowb7mWYfouIK7O522tuPPhvLjQjhdEfm6g3xEYrlNLGzLhrIADDhrYldBlGHfiVBWUFX7wefLuCSLF9fnyog==} + engines: {node: '>=20'} + hasBin: true + + '@profullstack/referrals@0.1.0': + resolution: {integrity: sha512-u66SdBVpsv3kc0N+NWISPoYD5vjCERyv5wfD07iSkZwQeC2IA+ihX5jNA4e7Xr+Y4AUvxLycG+3b4VaROqzgRg==} + engines: {node: '>=18'} + peerDependencies: + react: '>=18' + peerDependenciesMeta: + react: + optional: true + '@profullstack/synconfig@0.1.1': resolution: {integrity: sha512-gX/tZ0j0QShtAf2/Mpr40Fev0h7HVoh5jl3tVbFVrSfuQHalthiwVgZ8lQKzhv+0kpxvVex4qkT8xMFHSopklA==} engines: {node: '>=22'} @@ -500,11 +528,11 @@ packages: path-to-regexp@0.1.13: resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} - pg-cloudflare@1.4.0: - resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} + pg-cloudflare@1.4.1: + resolution: {integrity: sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==} - pg-connection-string@2.14.0: - resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==} + pg-connection-string@2.14.1: + resolution: {integrity: sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==} pg-int8@1.0.1: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} @@ -515,15 +543,15 @@ packages: peerDependencies: pg: '>=8.0' - pg-protocol@1.16.0: - resolution: {integrity: sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==} + pg-protocol@1.16.1: + resolution: {integrity: sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==} pg-types@2.2.0: resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} engines: {node: '>=4'} - pg@8.23.0: - resolution: {integrity: sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==} + pg@8.23.1: + resolution: {integrity: sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==} engines: {node: '>= 16.0.0'} peerDependencies: pg-native: '>=3.0.1' @@ -887,11 +915,17 @@ snapshots: tslib: 2.8.1 tsyringe: 4.10.0 + '@profullstack/affiliates@0.3.0': + dependencies: + '@profullstack/referrals': 0.1.0 + transitivePeerDependencies: + - react + '@profullstack/footer@0.2.6': {} '@profullstack/libsql-pg@0.1.2': dependencies: - pg: 8.23.0 + pg: 8.23.1 optionalDependencies: '@libsql/client': 0.15.15 transitivePeerDependencies: @@ -901,6 +935,10 @@ snapshots: '@profullstack/notifications@0.1.0': {} + '@profullstack/redeem-credits@0.2.0': {} + + '@profullstack/referrals@0.1.0': {} + '@profullstack/synconfig@0.1.1': {} '@simplewebauthn/browser@13.3.0': {} @@ -1197,18 +1235,18 @@ snapshots: path-to-regexp@0.1.13: {} - pg-cloudflare@1.4.0: + pg-cloudflare@1.4.1: optional: true - pg-connection-string@2.14.0: {} + pg-connection-string@2.14.1: {} pg-int8@1.0.1: {} - pg-pool@3.14.0(pg@8.23.0): + pg-pool@3.14.0(pg@8.23.1): dependencies: - pg: 8.23.0 + pg: 8.23.1 - pg-protocol@1.16.0: {} + pg-protocol@1.16.1: {} pg-types@2.2.0: dependencies: @@ -1218,15 +1256,15 @@ snapshots: postgres-date: 1.0.7 postgres-interval: 1.2.0 - pg@8.23.0: + pg@8.23.1: dependencies: - pg-connection-string: 2.14.0 - pg-pool: 3.14.0(pg@8.23.0) - pg-protocol: 1.16.0 + pg-connection-string: 2.14.1 + pg-pool: 3.14.0(pg@8.23.1) + pg-protocol: 1.16.1 pg-types: 2.2.0 pgpass: 1.0.5 optionalDependencies: - pg-cloudflare: 1.4.0 + pg-cloudflare: 1.4.1 pgpass@1.0.5: dependencies: diff --git a/apps/pwa/scripts/credit-codes.mjs b/apps/pwa/scripts/credit-codes.mjs new file mode 100644 index 00000000..d0483235 --- /dev/null +++ b/apps/pwa/scripts/credit-codes.mjs @@ -0,0 +1,60 @@ +#!/usr/bin/env bun +// Operator CLI for credit codes and invites, against the app's own DATABASE_URL. +// +// bun apps/pwa/scripts/credit-codes.mjs mint --credits 1000 --label "for Preshy" [--max 1] [--expires-days 90] [--json] +// bun apps/pwa/scripts/credit-codes.mjs list [--json] +// bun apps/pwa/scripts/credit-codes.mjs revoke +// bun apps/pwa/scripts/credit-codes.mjs quota +// +// On dev2: docker exec appmoshcodesh-app-1 bun apps/pwa/scripts/credit-codes.mjs mint --credits 1000 --label "for X" +// The plaintext code is printed once; only its hash is stored. +import { parseArgs } from "node:util"; + +const { values, positionals } = parseArgs({ + allowPositionals: true, + options: { + credits: { type: "string" }, + label: { type: "string", default: "" }, + max: { type: "string", default: "1" }, + "expires-days": { type: "string" }, + by: { type: "string", default: "operator" }, + json: { type: "boolean", default: false }, + }, +}); + +const { mintCode, getCredits, getAffiliates, closeCodeStores } = await import("../src/lib/credit-codes.mjs"); +const { get, db } = await import("../src/db.mjs"); +const { config } = await import("../src/config.mjs"); + +const [cmd, ...rest] = positionals; +const out = (obj, text) => console.log(values.json ? JSON.stringify(obj, null, 2) : text); + +try { + if (cmd === "mint") { + const m = await mintCode({ credits: values.credits, label: values.label, max: values.max, expiresInDays: values["expires-days"], createdBy: values.by }); + const url = `${config.origin}/redeem?code=${encodeURIComponent(m.code)}`; + out({ ...m, url }, `${m.code}\n ${m.grant.amount} credits, ${m.maxRedemptions} use(s)${m.expiresAt ? `, expires ${new Date(m.expiresAt).toISOString().slice(0, 10)}` : ""}\n ${url}\n id ${m.id}`); + } else if (cmd === "list") { + const codes = await (await getCredits()).list(); + out(codes, codes.map((c) => `${c.id} ${c.grant.amount} ${c.grant.unit} ${c.redemptions}/${c.maxRedemptions}${c.revokedAt ? " revoked" : ""} ${c.label}`).join("\n") || "no codes"); + } else if (cmd === "revoke" && rest[0]) { + await (await getCredits()).revoke(rest[0]); + out({ ok: true, id: rest[0] }, `revoked ${rest[0]}`); + } else if (cmd === "quota" && rest[0] && rest[1]) { + const user = await get(`SELECT id FROM users WHERE email = ?`, [rest[0].toLowerCase()]); + if (!user) throw new Error(`no account with email ${rest[0]}`); + const aff = await getAffiliates(); + await aff.setQuota(user.id, Number(rest[1])); + const q = await aff.quota(user.id); + out(q, `${rest[0]}: ${q.left} of ${q.total} invites left`); + } else { + console.error("usage: credit-codes.mjs mint --credits N [--label L] [--max N] [--expires-days N] | list | revoke | quota "); + process.exitCode = 2; + } +} catch (e) { + console.error(e.message); + process.exitCode = 1; +} finally { + await closeCodeStores(); + await db.close?.(); +} diff --git a/apps/pwa/src/lib/credit-codes.mjs b/apps/pwa/src/lib/credit-codes.mjs new file mode 100644 index 00000000..ff3611bf --- /dev/null +++ b/apps/pwa/src/lib/credit-codes.mjs @@ -0,0 +1,255 @@ +// Credit codes and scarce invites, on top of the credit ledger this app already keeps. +// +// @profullstack/redeem-credits mints and redeems hashed MC-XXXX-... codes +// @profullstack/affiliates invites (3 each, single use, 30 days) + ?ref= links +// +// There is one balance: credit_ledger. The modules keep their own tables (codes, +// redemptions, invites), but a code never becomes a second balance. A redemption +// writes one row into credit_ledger keyed on (user_id, source), with a unique index +// behind it, so the same grant can never land twice however often it is retried. +// +// Production is Postgres: the modules get a node-postgres pool on DATABASE_URL +// (sslmode handled the way libsql-pg does it, so dev2's self-signed cert works). +// On a file: database (development, tests) they get a synchronous SQLite handle on +// a sidecar file next to it: @libsql/client is async and cannot be shared. +import path from "node:path"; +import { Credits, postgresStore as creditsPg, sqliteStore as creditsSqlite } from "@profullstack/redeem-credits"; +import { Affiliates, postgresStore as affPg, sqliteStore as affSqlite } from "@profullstack/affiliates"; +import { DB_URL, isPostgres, get, run } from "../db.mjs"; +import { config } from "../config.mjs"; +import { id } from "./crypto.mjs"; +import { balance } from "./credits.mjs"; + +export const UNIT = "credits"; +export const CODE_PREFIX = "MC"; +// redeem-credits' default table prefix is credit_, which would land its ledger on +// top of this app's own credit_ledger. +const CREDITS_TABLES = "redeem_"; + +/** Credits each side gets for an accepted, confirmed invite. Decision for the operator. */ +export const inviteBonus = () => intEnv("INVITE_BONUS_CREDITS", 100); +/** An inviter younger than this earns nothing for an invite (hours). */ +export const inviterMinAgeHours = () => intEnv("INVITE_MIN_INVITER_AGE_HOURS", 24); +/** At most this many invite bonuses per inviter in 30 days. */ +export const inviterMonthlyCap = () => intEnv("INVITE_MONTHLY_CAP", 10); +export const INVITE_QUOTA = 3; + +function intEnv(name, fallback) { + const n = Number(process.env[name]); + return Number.isSafeInteger(n) && n >= 0 ? n : fallback; +} + +let modules = null; + +async function sqliteHandle() { + const file = path.resolve(config.root, DB_URL.slice("file:".length)); + const sidecar = `${file}.codes.sqlite`; + if (globalThis.Bun) { + const { Database } = await import("bun:sqlite"); + const db = new Database(sidecar); + db.exec("PRAGMA busy_timeout = 5000"); + return db; + } + const { DatabaseSync } = await import("node:sqlite"); + const db = new DatabaseSync(sidecar); + db.exec("PRAGMA busy_timeout = 5000"); + return db; +} + +async function load() { + if (isPostgres) { + const { default: pg } = await import("pg"); + const { connectionSettings } = await import("@profullstack/libsql-pg"); + const pool = new pg.Pool({ ...connectionSettings(DB_URL), max: 4, application_name: "moshcode-credit-codes" }); + pool.on("error", () => { /* idle connection dropped; the next query reconnects */ }); + return { + credits: new Credits(creditsPg(pool, { prefix: CREDITS_TABLES })), + affiliates: new Affiliates(affPg(pool), { defaultQuota: INVITE_QUOTA }), + close: () => pool.end(), + }; + } + const db = await sqliteHandle(); + return { + credits: new Credits(creditsSqlite(db, { prefix: CREDITS_TABLES })), + affiliates: new Affiliates(affSqlite(db), { defaultQuota: INVITE_QUOTA }), + close: () => db.close(), + }; +} + +async function mods() { + modules ??= load().catch((error) => { modules = null; throw error; }); + return modules; +} + +export const getCredits = async () => (await mods()).credits; +export const getAffiliates = async () => (await mods()).affiliates; +export async function closeCodeStores() { + if (!modules) return; + const m = await modules.catch(() => null); + modules = null; + await m?.close?.(); +} + +/** + * Add `amount` credits to the ledger exactly once per (user, source). + * Returns true when this call wrote the row, false when it was already there. + */ +export async function grantOnce(userId, amount, reason, source, meta = null) { + const r = await run( + `INSERT INTO credit_ledger (id, user_id, delta, reason, meta, created_at, source) + VALUES (?,?,?,?,?,?,?) ON CONFLICT (user_id, source) DO NOTHING`, + [id(), userId, Math.abs(amount), reason, meta ? JSON.stringify(meta) : null, Date.now(), source], + ); + return r.rowsAffected > 0; +} + +export const REDEEM_MESSAGES = { + invalid: "That code does not exist. Check it and try again.", + expired: "That code has expired.", + used_up: "That code has already been used.", + already_redeemed: "You already redeemed that code.", + revoked: "That code was withdrawn.", + unsupported: "That code is not for moshcode credits.", +}; + +/** Redeem a code into the user's ledger. { ok, credits, balance } or { ok:false, reason }. */ +export async function redeemCode(code, userId) { + const raw = String(code || "").trim(); + if (!raw || raw.length > 64) return { ok: false, reason: "invalid" }; + const credits = await getCredits(); + const r = await credits.redeem(raw, userId); + if (!r.ok) return r; + if (r.grant.unit !== UNIT) return { ok: false, reason: "unsupported" }; + const amount = Math.max(0, Math.floor(Number(r.grant.amount) || 0)); + // The redemption stands even if this write fails; a retry answers already_redeemed, + // so the grant is re-attempted here, idempotently, before giving up. + await grantOnce(userId, amount, "code.redeem", `code:${r.codeId}`, { code_id: r.codeId }); + return { ok: true, credits: amount, codeId: r.codeId, balance: await balance(userId) }; +} + +/** Mint a one-off code worth `credits`. Returns the plaintext once. */ +export async function mintCode({ credits: amount, label = "", max = 1, expiresInDays = null, createdBy = "" }) { + const n = Math.floor(Number(amount)); + if (!Number.isSafeInteger(n) || n <= 0 || n > 10_000_000) throw new Error("credits must be a positive whole number"); + const maxRedemptions = Math.floor(Number(max) || 1); + if (maxRedemptions < 1 || maxRedemptions > 100_000) throw new Error("max must be between 1 and 100000"); + const days = expiresInDays == null || expiresInDays === "" ? null : Number(expiresInDays); + if (days !== null && !(days > 0)) throw new Error("expiresInDays must be positive"); + const credits = await getCredits(); + return credits.mint({ + grant: { unit: UNIT, amount: n }, + label: String(label || "").slice(0, 120), + maxRedemptions, + expiresAt: days ? Date.now() + days * 86_400_000 : null, + prefix: CODE_PREFIX, + createdBy: String(createdBy || "").slice(0, 120), + }); +} + +// ---------------------------------------------------------------- invites -- + +// Throwaway mailboxes. Short on purpose: a stale scraped list refuses real people. +const DISPOSABLE = new Set([ + "mailinator.com", "guerrillamail.com", "guerrillamail.net", "sharklasers.com", "10minutemail.com", + "10minutemail.net", "yopmail.com", "trashmail.com", "dispostable.com", "getnada.com", "maildrop.cc", + "temp-mail.org", "tempmail.com", "throwawaymail.com", "fakeinbox.com", "mailnesia.com", "mytemp.email", + "spam4.me", "byom.de", "discard.email", "example.com", "example.org", "example.net", +]); + +export const emailDomain = (email) => String(email || "").toLowerCase().split("@")[1] || ""; +export const isDisposable = (email) => DISPOSABLE.has(emailDomain(email)); + +/** One key per mailbox: drops +tags everywhere and dots on Gmail. */ +export function emailDedupeKey(email) { + const [local = "", domain = ""] = String(email || "").toLowerCase().split("@"); + const bare = local.split("+")[0]; + const canonical = domain === "gmail.com" || domain === "googlemail.com" ? bare.replace(/\./g, "") : bare; + const dom = domain === "googlemail.com" ? "gmail.com" : domain; + return `${canonical}@${dom}`; +} + +/** The invite code shape: INV-XXXX-XXXX-XXXX (case, spaces and dashes forgiven). */ +export function looksLikeInvite(code) { + return /^INV[-\s]?[0-9A-Z]{4}[-\s]?[0-9A-Z]{4}[-\s]?[0-9A-Z]{4}$/i.test(String(code || "").trim()); +} + +/** + * Accept an invite for an account that was created by this very request. + * + * The invite itself is consumed (one per account, module-enforced). The bonus is + * decided here and recorded in invite_bonuses; when the gates pass it waits for the + * invitee to confirm their email (`confirmInviteBonus`). Returns the bonus row. + */ +export async function acceptInvite(code, invitee) { + const aff = await getAffiliates(); + const r = await aff.redeemInvite(String(code || ""), invitee.id); + if (!r.ok) return { ok: false, reason: r.reason }; + const inviter = await get(`SELECT id, email, created_at FROM users WHERE id = ?`, [r.inviterId]); + let status = "pending"; + let reason = null; + if (!inviter) { status = "refused"; reason = "inviter_gone"; } + else if (!invitee.email) { status = "refused"; reason = "no_email"; } + else if (isDisposable(invitee.email)) { status = "refused"; reason = "disposable_domain"; } + else if (inviter.email && emailDedupeKey(inviter.email) === emailDedupeKey(invitee.email)) { status = "refused"; reason = "same_mailbox"; } + else if (Date.now() - Number(inviter.created_at) < inviterMinAgeHours() * 3_600_000) { status = "refused"; reason = "inviter_too_new"; } + await run( + `INSERT INTO invite_bonuses (invitee_id, inviter_id, invite_id, email, status, reason, credits, created_at, decided_at) + VALUES (?,?,?,?,?,?,?,?,?) ON CONFLICT (invitee_id) DO NOTHING`, + [invitee.id, r.inviterId, r.inviteId, invitee.email || null, status, reason, 0, Date.now(), status === "pending" ? null : Date.now()], + ); + return { ok: true, inviterId: r.inviterId, inviteId: r.inviteId, status, reason }; +} + +/** + * Pay a pending invite bonus once the invitee has confirmed their email. + * Both sides get inviteBonus() credits, exactly once. Returns the outcome. + */ +export async function confirmInviteBonus(inviteeId) { + const row = await get(`SELECT * FROM invite_bonuses WHERE invitee_id = ?`, [inviteeId]); + if (!row) return { ok: false, reason: "no_invite" }; + if (row.status === "paid") return { ok: true, already: true, credits: Number(row.credits) }; + if (row.status !== "pending") return { ok: false, reason: row.reason || row.status }; + const since = Date.now() - 30 * 86_400_000; + const paid = await get( + `SELECT COUNT(*) AS n FROM invite_bonuses WHERE inviter_id = ? AND status = 'paid' AND decided_at >= ?`, + [row.inviter_id, since], + ); + const amount = inviteBonus(); + if (Number(paid?.n || 0) >= inviterMonthlyCap()) { + await run(`UPDATE invite_bonuses SET status = 'refused', reason = 'inviter_monthly_cap', decided_at = ? WHERE invitee_id = ? AND status = 'pending'`, + [Date.now(), inviteeId]); + return { ok: false, reason: "inviter_monthly_cap" }; + } + // Claim the row first, the way the CoinPay webhook claims a purchase: two clicks + // on the same link race here and only one of them pays. + const claimed = await run( + `UPDATE invite_bonuses SET status = 'paid', credits = ?, decided_at = ? WHERE invitee_id = ? AND status = 'pending'`, + [amount, Date.now(), inviteeId], + ); + if (!claimed.rowsAffected) return confirmInviteBonus(inviteeId); + if (amount > 0) { + const meta = { invite_id: row.invite_id }; + await grantOnce(row.invitee_id, amount, "invite.bonus", `invite:${row.invite_id}:invitee`, meta); + await grantOnce(row.inviter_id, amount, "invite.bonus", `invite:${row.invite_id}:inviter`, meta); + } + return { ok: true, credits: amount }; +} + +export const INVITE_REASONS = { + invalid: "That invite code does not exist.", + expired: "That invite has expired.", + used: "That invite has already been used.", + self: "You cannot use your own invite.", + revoked: "That invite was withdrawn.", + already_invited: "This account was already invited by someone else.", +}; + +export const BONUS_REASONS = { + inviter_gone: "the inviting account no longer exists", + no_email: "the account has no email to confirm", + disposable_domain: "throwaway mailboxes do not earn invite credits", + same_mailbox: "the invite came from the same mailbox", + inviter_too_new: "the inviting account was too new", + inviter_monthly_cap: "the inviter has had their invite credits for this month", + no_invite: "there is no invite on this account", +}; diff --git a/apps/pwa/src/lib/html.mjs b/apps/pwa/src/lib/html.mjs index b7b4e9a3..9813ff84 100644 --- a/apps/pwa/src/lib/html.mjs +++ b/apps/pwa/src/lib/html.mjs @@ -113,6 +113,7 @@ export function appBar(user, balance, csrf = "") { The pit ${user ? `◆ ${balance.toLocaleString()} cr Teams + Invites Settings
` : `Sign in`} @@ -122,6 +123,6 @@ export function appBar(user, balance, csrf = "") { export const footer = ``; diff --git a/apps/pwa/src/migrations-pg/027_credit_codes_invites.sql b/apps/pwa/src/migrations-pg/027_credit_codes_invites.sql new file mode 100644 index 00000000..e9b38e47 --- /dev/null +++ b/apps/pwa/src/migrations-pg/027_credit_codes_invites.sql @@ -0,0 +1,16 @@ +-- Postgres twin of migrations/027_credit_codes_invites.sql (hand-converted, reviewed). +ALTER TABLE credit_ledger ADD COLUMN IF NOT EXISTS source text; +CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_user_source ON credit_ledger(user_id, source); + +create table if not exists invite_bonuses ( + invitee_id text PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + inviter_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE, + invite_id text NOT NULL, + email text, + status text NOT NULL DEFAULT 'pending', + reason text, + credits bigint NOT NULL DEFAULT 0, + created_at bigint NOT NULL, + decided_at bigint +); +CREATE INDEX IF NOT EXISTS idx_invite_bonuses_inviter ON invite_bonuses(inviter_id, status, decided_at); diff --git a/apps/pwa/src/migrations/027_credit_codes_invites.sql b/apps/pwa/src/migrations/027_credit_codes_invites.sql new file mode 100644 index 00000000..835e8002 --- /dev/null +++ b/apps/pwa/src/migrations/027_credit_codes_invites.sql @@ -0,0 +1,22 @@ +-- Credit codes + scarce invites (@profullstack/redeem-credits, @profullstack/affiliates). +-- The modules keep their own tables (redeem_*, aff_*) and create them on first use. +-- What lives here is this app's side of it: +-- credit_ledger.source the grant's origin (code:, invite::invitee|inviter), +-- unique per user, so a grant can never land twice. +-- invite_bonuses one row per invited account: whether its bonus was paid, +-- is waiting on the email confirmation, or was refused and why. +ALTER TABLE credit_ledger ADD COLUMN source TEXT; +CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_user_source ON credit_ledger(user_id, source); + +CREATE TABLE IF NOT EXISTS invite_bonuses ( + invitee_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + inviter_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + invite_id TEXT NOT NULL, + email TEXT, + status TEXT NOT NULL DEFAULT 'pending', -- pending (awaiting email confirmation) | paid | refused + reason TEXT, + credits INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + decided_at INTEGER +); +CREATE INDEX IF NOT EXISTS idx_invite_bonuses_inviter ON invite_bonuses(inviter_id, status, decided_at); diff --git a/apps/pwa/src/routes/auth.mjs b/apps/pwa/src/routes/auth.mjs index e2739377..9e06d728 100644 --- a/apps/pwa/src/routes/auth.mjs +++ b/apps/pwa/src/routes/auth.mjs @@ -7,6 +7,8 @@ import { createUserWithPassword, userByEmail } from "../lib/users.mjs"; import { dashboardHandler } from "./pages.mjs"; import { config } from "../config.mjs"; import { ringFooter } from "../lib/ring-footer.mjs"; +import { acceptInvite } from "../lib/credit-codes.mjs"; +import { inviteFromRequest, clearInvite, sendInviteConfirmation, attributeSignup } from "./invites.mjs"; export const authRouter = Router(); @@ -17,6 +19,8 @@ function authPage(req, { error = "", mode = "in" } = {}) { // Reading only req.query blanked the field on each of those, and the visitor // had to retype their email to correct a password. const email = req.body?.email || req.query.email || ""; + // An invite picked up on /i/ rides along with a sign-up made from here. + const invite = mode === "up" ? inviteFromRequest(req) : null; const body = `
MMOSHCODEapp @@ -25,6 +29,7 @@ function authPage(req, { error = "", mode = "in" } = {}) { ${error ? `
${esc(error)}
` : ""}
${csrfInput(req)} + ${invite ? `
Signing up with invite ${esc(invite)}.
` : ""}