Files
linkder/apps/web/src/server/dev-login.ts
T
serfaandClaude Opus 5 35d99ce0e2 Containerise for Dokploy, and a demo login that survives production
Everything needed to build and run this on Dokploy at
linkdr.serfaty.site, plus the two things that turned out to be broken
the moment it left a laptop.

The build did not work in a container at all. `lib/auth.ts` throws when
AUTH_SECRET or NEXT_PUBLIC_APP_URL is missing — correct at boot, wrong
during `next build`, which imports every route module with
NODE_ENV=production and none of the runtime secrets. The only way past
it was baking a session key into an image layer, which is worse than
the problem the guard exists to prevent. Both checks now skip
NEXT_PHASE=phase-production-build and still fire on a real boot.

Corepack in node:22.12-alpine ships expired npm registry signing keys
and dies before it can download pnpm, so the image installs corepack
first and prepares the pinned version explicitly.

The image is the standalone trace, which needs outputFileTracingRoot at
the REPO root: pnpm hoists to a root .pnpm store and tracing from
apps/web silently omits every workspace package. 427MB, runs as
non-root, and its healthcheck talks to Postgres — a container that
cannot reach its database must never enter rotation, because a deploy
that goes green and then 500s does not roll back.

DEMO_LOGIN is a login bypass under NODE_ENV=production and there is no
honest way to describe it otherwise. It is a separate variable from
ALLOW_DEV_LOGIN so that copying a dev .env into a real environment
cannot enable it by accident, it still only affects the one seeded
number, and it prints a boot warning every single start so it cannot be
forgotten. That deployment holds nothing but fixtures. It comes out
before the platform sees a real signup.

Also: /api/health, and next/image hosts corrected to the Spaces bucket
rather than the R2 one this stopped using.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 13:17:54 -04:00

95 lines
3.5 KiB
TypeScript

import { eq } from 'drizzle-orm';
import { db, schema } from '@linkdr/db';
/**
* A fixed test account for local development.
*
* Signing in normally needs a real handset to receive a real SMS, which makes
* the whole app untestable without a phone in your hand and Twilio credits. This
* pins one number to one known code so `pnpm dev` is usable.
*
* THREE independent guards, because a login bypass reaching production is the
* worst bug this codebase could ship:
*
* 1. NODE_ENV must not be 'production'.
* 2. ALLOW_DEV_LOGIN must be explicitly 'true' — being in dev is not enough.
* 3. The phone number must match exactly.
*
* Any one of them failing falls straight back to the real OTP path.
*/
const DEV_PHONE = '+525500000000';
const DEV_CODE = '000000';
/**
* DEMO_LOGIN — the same fixed login, deliberately permitted in a production
* BUILD, for the client-demo deployment at linkdr.serfaty.site.
*
* This is a login bypass running under NODE_ENV=production and there is no way
* to dress that up. It is a separate variable from ALLOW_DEV_LOGIN on purpose:
* the two say different things, and someone copying a dev `.env` into a real
* environment must not be able to enable this by accident. Guard 3 still holds
* — only DEV_PHONE is affected, every other number goes through Twilio.
*
* What makes it acceptable HERE and nowhere else: that deployment contains
* nothing but seeded fixtures, and the account it opens is a seeded customer.
* There is no real person's data behind it.
*
* Before this platform takes a real signup, DEMO_LOGIN must be unset and this
* block deleted. The boot warning below exists so that is impossible to
* forget: it prints on every single start.
*/
const demoLogin = process.env.DEMO_LOGIN === 'true';
if (demoLogin && process.env.NODE_ENV === 'production') {
console.warn(
`
############################################################
` +
` # DEMO_LOGIN IS ON IN A PRODUCTION BUILD. #
` +
` # ${DEV_PHONE} signs in with a fixed code and NO SMS. #
` +
` # This is for the client demo only. Unset DEMO_LOGIN #
` +
` # before this platform accepts a real signup. #
` +
` ############################################################
`,
);
}
export function isDevLoginEnabled(): boolean {
// Local development: as before.
if (process.env.NODE_ENV !== 'production') {
return process.env.ALLOW_DEV_LOGIN === 'true';
}
// Production: only the explicit demo flag, never ALLOW_DEV_LOGIN.
return demoLogin;
}
export function isDevLoginPhone(phone: string): boolean {
return isDevLoginEnabled() && phone === DEV_PHONE;
}
/**
* Replace the freshly-generated random code with the fixed one.
*
* better-auth writes the verification row (identifier = the phone number,
* value = "<code>:<attempts>") and only then calls sendOTP, so by the time this
* runs there is a row to overwrite. Rewriting the value rather than intercepting
* the comparison means the real verify path still runs in full — same expiry,
* same attempt cap, same single-use consumption.
*/
export async function pinDevLoginCode(phone: string): Promise<void> {
if (!isDevLoginPhone(phone)) return;
await db
.update(schema.verifications)
.set({ value: `${DEV_CODE}:0` })
.where(eq(schema.verifications.identifier, phone));
console.info(`\n [dev login] ${DEV_PHONE} → code ${DEV_CODE}\n`);
}
export const DEV_LOGIN = { phone: DEV_PHONE, code: DEV_CODE } as const;