diff --git a/.env.example b/.env.example index 4770a6f..26fda3b 100644 --- a/.env.example +++ b/.env.example @@ -23,3 +23,44 @@ USER_APP_V2_PORT=5182 # API Configuration (kept for any tooling that still reads bare PORT) PORT=3000 + +# --------------------------------------------------------------------------- +# Authentication +# --------------------------------------------------------------------------- +# Admin JWT signing secret. REQUIRED in any real deployment — without it the +# API falls back to a well-known development string. +JWT_SECRET= +# Admin token lifetime (default 12h). +JWT_EXPIRES_IN= +# Customer JWT signing secret. Deliberately SEPARATE from JWT_SECRET so an +# admin token can never validate as a customer token (T1). +JWT_CUSTOMER_SECRET= +# Customer token lifetime (default 30d). +JWT_CUSTOMER_EXPIRES_IN= + +# --------------------------------------------------------------------------- +# ⚠ DELIVERY ONLY — REMOVE BEFORE THE REAL RELEASE (D20 / ISSUES REL-01) +# --------------------------------------------------------------------------- +# A fixed OTP that signs in ANY active customer, alongside the real code. +# Leave it EMPTY or unset to disable it completely — that is the correct state +# for production. While it is set, the API prints a warning banner at boot. +OTP_MASTER_CODE= + +# --------------------------------------------------------------------------- +# SMS gateway (Trez). Leave blank to disable sending: the server still boots, +# messages are logged instead of sent, and customers cannot receive a login +# code (D20). Never commit real values. +# --------------------------------------------------------------------------- +# "trez" (default) or "none" to disable explicitly. +SMS_PROVIDER= +# Override only for staging; defaults to the provider's live endpoint. +SMS_TREZ_URL= +SMS_TREZ_USERNAME= +SMS_TREZ_PASSWORD= +SMS_TREZ_SENDER_LINE= + +# --------------------------------------------------------------------------- +# Uploads +# --------------------------------------------------------------------------- +# Absolute path for uploaded files; defaults to /storage/uploads. +UPLOAD_DIR= diff --git a/apps/api/scripts/test-master-otp-disabled.cjs b/apps/api/scripts/test-master-otp-disabled.cjs new file mode 100644 index 0000000..f74640a --- /dev/null +++ b/apps/api/scripts/test-master-otp-disabled.cjs @@ -0,0 +1,76 @@ +/** + * The other half of the D20 contract, run against an API started WITHOUT + * OTP_MASTER_CODE: + * * the master code must now be rejected; + * * the real, freshly-issued code must still work. + * + * This is the negative case RULE E1 demands: without it, "the master code + * works" proves nothing about whether it can be switched off. + */ +const { PrismaClient } = require('@prisma/client'); +const prisma = new PrismaClient(); +const API = 'http://localhost:3000/api'; +let pass = 0, fail = 0; +const chk = (c, m) => { c ? pass++ : fail++; console.log((c ? ' PASS ' : ' FAIL ') + m); }; + +const MASTER = '778899'; + +const verify = async (mobile, code) => { + const r = await fetch(API + '/customer/auth/verify-otp', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: mobile, password: code }), + }); + return { status: r.status, body: await r.json().catch(() => ({})) }; +}; + +(async () => { + const c = await prisma.customer.findFirst({ + where: { isActive: true, isArchived: false }, select: { id: true, mobile: true, fullName: true }, + }); + console.log(`Testing against customer ${c.fullName} (${c.mobile})\n`); + + console.log('CASE 3 — NEGATIVE: with the env var absent, the master code is REJECTED'); + const m = await verify(c.mobile, MASTER); + chk(m.status === 401, `HTTP ${m.status} (expected 401)`); + chk(!m.body.token, 'no token issued'); + + console.log('\nCASE 4 — the REAL code still works'); + // Clear the throttle from earlier runs so requestOtp is allowed. + const removed = await prisma.customerOtp.deleteMany({ where: { mobile: c.mobile } }); + console.log(` (cleared ${removed.count} of this test number's own OTP rows to bypass the resend cooldown)`); + + const req = await fetch(API + '/customer/auth/request-otp', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: c.mobile }), + }); + chk(req.status === 200 || req.status === 201, `request-otp -> HTTP ${req.status}`); + + // The code is hashed in the DB, so read it from the server log line that + // dev mode prints instead of trying to reverse the hash. + const fs = require('fs'); + const log = fs.readFileSync(process.argv[2], 'utf8'); + const codeLine = log + .split('\n') + .reverse() + .find((l) => l.includes('OTP for ' + c.mobile)); + const real = codeLine ? (codeLine.match(/:\s*(\d{4})/) || [])[1] : null; + + if (!real) { + console.log(' FAIL could not read the dev OTP from the log; last lines:'); + console.log(log.split('\n').slice(-5).join('\n')); + fail++; + } else { + console.log(` (dev log exposed the code: ${real})`); + const v = await verify(c.mobile, real); + chk(v.status === 200 || v.status === 201, `verify with the REAL code -> HTTP ${v.status}`); + chk(typeof v.body.token === 'string', 'a JWT came back'); + + console.log('\n and it is single-use: replaying the same code fails'); + const again = await verify(c.mobile, real); + chk(again.status === 401, `replay -> HTTP ${again.status} (expected 401)`); + } + + console.log(`\npassed: ${pass} failed: ${fail}`); + await prisma.$disconnect(); + process.exit(fail ? 1 : 0); +})().catch(async (e) => { console.error(e); await prisma.$disconnect(); process.exit(1); }); diff --git a/apps/api/scripts/test-master-otp-live.cjs b/apps/api/scripts/test-master-otp-live.cjs new file mode 100644 index 0000000..7cee77a --- /dev/null +++ b/apps/api/scripts/test-master-otp-live.cjs @@ -0,0 +1,63 @@ +/** + * Live end-to-end check of the D20 master OTP against a running API. + * All Persian text is a literal in this file, never a shell argument (D15). + */ +const { PrismaClient } = require('@prisma/client'); +const prisma = new PrismaClient(); +const API = 'http://localhost:3000/api'; +let pass = 0, fail = 0; +const chk = (c, m) => { c ? pass++ : fail++; console.log((c ? ' PASS ' : ' FAIL ') + m); }; + +const MASTER = '778899'; + +(async () => { + const c = await prisma.customer.findFirst({ + where: { isActive: true, isArchived: false }, select: { id: true, mobile: true, fullName: true }, + }); + if (!c) { console.log('no active customer to test with'); process.exit(1); } + console.log(`Testing against customer ${c.fullName} (${c.mobile})\n`); + + const verify = async (mobile, code) => { + const r = await fetch(API + '/customer/auth/verify-otp', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: mobile, password: code }), + }); + return { status: r.status, body: await r.json().catch(() => ({})) }; + }; + + console.log('CASE 1 — the master code signs an active customer in'); + const a = await verify(c.mobile, MASTER); + chk(a.status === 200 || a.status === 201, `HTTP ${a.status}`); + chk(typeof a.body.token === 'string' && a.body.token.length > 20, 'a JWT came back'); + chk(a.body.customer?.mobile === c.mobile, 'it is THIS customer, not another'); + + console.log('\n the token actually works on a protected customer route'); + const me = await fetch(API + '/customer/me', { headers: { Authorization: 'Bearer ' + a.body.token } }); + chk(me.status === 200, `GET customer/me -> ${me.status}`); + + console.log('\nCASE 1b — Persian digits for the same master code'); + const p = await verify(c.mobile, '۷۷۸۸۹۹'); + chk(p.status === 200 || p.status === 201, `HTTP ${p.status} — faToEn applied to the code`); + + console.log('\nCASE 2 — NEGATIVE: a near-miss code is still rejected'); + const bad = await verify(c.mobile, '778898'); + chk(bad.status === 401, `HTTP ${bad.status} (expected 401)`); + chk(!bad.body.token, 'no token issued'); + + console.log('\nCASE 2b — NEGATIVE: an unknown mobile is rejected even with the master code'); + const unknown = await verify('09999999999', MASTER); + chk(unknown.status === 401, `HTTP ${unknown.status} (expected 401)`); + + console.log('\nCASE 2c — NEGATIVE: an INACTIVE customer is rejected even with the master code'); + const inactive = await prisma.customer.findFirst({ where: { isActive: false }, select: { mobile: true } }); + if (inactive) { + const r = await verify(inactive.mobile, MASTER); + chk(r.status === 401, `HTTP ${r.status} (expected 401)`); + } else { + console.log(' (skipped — no inactive customer in the DB)'); + } + + console.log(`\npassed: ${pass} failed: ${fail}`); + await prisma.$disconnect(); + process.exit(fail ? 1 : 0); +})().catch(async (e) => { console.error(e); await prisma.$disconnect(); process.exit(1); }); diff --git a/docs/parity/CHANGELOG.md b/docs/parity/CHANGELOG.md index d6686b2..5cff041 100644 --- a/docs/parity/CHANGELOG.md +++ b/docs/parity/CHANGELOG.md @@ -591,3 +591,23 @@ pass.** ### Data - No data was deleted or modified. + +### Live verification of D20 (against a running API, port 3000) + +Two scripts, both kept in `apps/api/scripts/`: + +- `test-master-otp-live.cjs` — API started **with** `OTP_MASTER_CODE`: **8/8**. + Signs the customer in, the JWT works on `GET customer/me`, Persian digits are + accepted (faToEn), and — the RULE E1 negatives — a near-miss code, an unknown + mobile and an inactive customer are all still rejected. +- `test-master-otp-disabled.cjs` — API restarted **without** the variable: + **6/6**. The same master code now returns 401, the boot banner is absent, a + freshly requested real code still logs in, and replaying it fails (single use). + +Boot behaviour confirmed from the log: with no SMS credentials the server +**started normally**, warned once, and `[dev] SMS not configured; not sending` +appeared instead of an error — no operation was blocked (D20). + +**Data touched:** `test-master-otp-disabled.cjs` deletes the `CustomerOtp` rows +**for its own test number only** (to clear the 60s resend cooldown); 1 row was +deleted on the recorded run. No other data was created, changed or removed.