diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fb12c61 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,54 @@ +# The build stage does `COPY . .`, so anything not excluded here ends up in an +# image layer — and a layer is readable by anyone who can pull the image, even +# if a later stage deletes the file. + +# ---- Secrets. Non-negotiable. ---- +# The platform injects the environment; a baked .env would ship live database +# and Spaces credentials inside the image. +.env +.env.* +!.env.example + +# ---- Build inputs that must be produced inside the image ---- +# A host node_modules is the wrong platform (linux/musl vs win32/darwin) and +# would silently shadow the one `pnpm install` builds in the deps stage. +node_modules +**/node_modules +.next +**/.next +.turbo +**/.turbo +dist +**/dist +out +**/out + +# ---- Never needed at runtime ---- +.git +.gitignore +.github +.vscode +.idea +**/test +**/tests +**/*.test.ts +**/*.test.tsx +**/vitest.config.ts +playwright-report +test-results +coverage +**/*.log +.DS_Store +Thumbs.db + +# Docs and local tooling. Keeping them out is about layer size and churn: a +# README edit should not invalidate the build cache. +*.md +!README.md +docker-compose.yml +docker-compose.*.yml +Dockerfile +.dockerignore + +# NOTE: ca-certificate.crt is deliberately NOT ignored. DATABASE_CA_CERT may +# point at it, and it is a public certificate — no private key. See db/client.ts. diff --git a/.env.example b/.env.example index cf1374a..7073ec9 100644 --- a/.env.example +++ b/.env.example @@ -97,9 +97,19 @@ NEXT_PUBLIC_CITY_LAT=19.4326 NEXT_PUBLIC_CITY_LNG=-99.1332 TWILIO_FROM_NUMBER= -# Dev-only fixed login (+34600000000 / code 000000). MUST stay false/unset in production. +# Dev-only fixed login (+52 55 0000 0000 / code 000000). Ignored entirely when +# NODE_ENV=production, so this cannot leak a bypass into a real deployment. ALLOW_DEV_LOGIN=false +# The SAME fixed login, deliberately allowed in a production build, for the +# client-demo deployment only. Separate from ALLOW_DEV_LOGIN so that copying a +# developer's .env into a real environment cannot switch it on by accident. +# +# Only +52 55 0000 0000 is affected; every other number still goes through +# Twilio. Prints a loud warning on every boot. MUST be unset before this +# platform accepts a real signup — see server/dev-login.ts. +DEMO_LOGIN=false + # Bugsink (Sentry-compatible error tracking). Write-only ingest key, safe in the # client bundle. Leave blank to disable reporting entirely. NEXT_PUBLIC_SENTRY_DSN= diff --git a/DESIGN.md b/DESIGN.md index f9fcc2c..7645bbe 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -458,6 +458,35 @@ A "use my current location" control sits below, and on success **must** fill the reverse-geocoded label — a button that silently sets an invisible pin gives the user nothing to check. +### 6.17 Join as a pro + +The one screen in this product that has to sell something, written under a system that bans +marketing heroes and feature grids (§9). The resolution is that it does not describe the +offer — it **shows the thing being offered**, then asks for an account. + +Order is fixed: + +1. `overline` kicker, `h1`, one sentence. No stat bar, no logo wall. +2. **The preview.** A non-interactive replica of an incoming job request, exactly as a pro + would receive it: trade, title, distance, urgency, budget, and the accept/decline pair. + It is the single raised element on the screen (§5) and it carries `aria-hidden`, because a + sample is not a control — a screen reader offering a fake Accept button is a trap. + Label it in visible text as an example; a mock that reads as live data is a lie. +3. **How it works** — an ordered list, three items, numeral in a `brand-500` pill. An ordered + list, not a grid: these are sequential, and a grid would both break §4 and imply they are not. +4. **What you will need** — the credentials `pro.submitForReview` actually gates on, with the + same required/optional split the wizard uses. Softening it here to raise sign-ups only moves + the drop-off to step four, where the person has already spent their time. +5. The primary action, sticky at the bottom (§9). + +Every claim on this screen is load-bearing and must be traceable to behaviour that exists. +"We verify ID, insurance and licence" is true; a response time or an earnings figure is not, +and neither is a setup duration nobody has measured. Invent nothing here. + +The account note is required, not decorative: `user.setRole` refuses once a job has been +posted, so a customer tapping this is creating a *second* account. A screen that lets them +believe otherwise produces a support ticket at the worst possible moment. + --- ## 7. Motion diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bda559c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,131 @@ +# syntax=docker/dockerfile:1.7 + +############################################################################### +# Linkdr — production image +# +# Four stages so that a code change does not reinstall the dependency tree: +# `deps` is keyed on the lockfile alone, and Docker reuses it until that file +# changes. Installing inside the same layer as the source would rebuild ~1GB of +# node_modules on every commit. +# +# Node 22 rather than 23: 22 is the active LTS line, 23 is not and stops getting +# fixes. package.json says >=20; this pins the version we actually ship. +############################################################################### + +ARG NODE_VERSION=22.12.0-alpine + +# ─────────────────────────────── base ─────────────────────────────── +FROM node:${NODE_VERSION} AS base +# libc6-compat: several native-ish npm packages ship glibc builds and fail on +# musl without it. Cheap, and the failure it prevents is an obscure one. +RUN apk add --no-cache libc6-compat + +# pnpm, pinned to the version in `packageManager` so the image builds with the +# same resolver the lockfile was written by. +# +# `corepack enable` alone is not enough: the corepack bundled with Node 22.12 +# carries expired npm registry signing keys and dies with "Cannot find matching +# keyid" before it ever downloads pnpm. Updating corepack first refreshes those +# keys, and `prepare --activate` fetches the exact version rather than asking +# the registry what "latest" is at build time. +ARG PNPM_VERSION=9.15.4 +RUN npm install -g corepack@latest && corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate +WORKDIR /app + +# ─────────────────────────────── deps ─────────────────────────────── +# Every package.json in the workspace, and nothing else. Adding a source file +# here would defeat the layer cache this stage exists for. +FROM base AS deps +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc* ./ +COPY apps/web/package.json apps/web/ +COPY packages/api/package.json packages/api/ +COPY packages/db/package.json packages/db/ +COPY packages/geocode/package.json packages/geocode/ +COPY packages/notify/package.json packages/notify/ +COPY packages/shared/package.json packages/shared/ +COPY packages/storage/package.json packages/storage/ + +# --frozen-lockfile: a lockfile that does not match package.json is a build +# failure, not something to silently resolve differently than developers did. +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm config set store-dir /pnpm/store && \ + pnpm install --frozen-lockfile + +# ─────────────────────────────── build ────────────────────────────── +FROM base AS build +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules +COPY --from=deps /app/packages ./packages +COPY . . + +# NEXT_PUBLIC_* is inlined into the client bundle at BUILD time — reading it +# from the container's environment at runtime is too late. Anything the browser +# must know therefore has to arrive here as a build argument. +ARG NEXT_PUBLIC_APP_URL +ARG NEXT_PUBLIC_CITY_NAME +ARG NEXT_PUBLIC_CITY_LAT +ARG NEXT_PUBLIC_CITY_LNG +ARG NEXT_PUBLIC_SENTRY_DSN +ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY +ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL \ + NEXT_PUBLIC_CITY_NAME=$NEXT_PUBLIC_CITY_NAME \ + NEXT_PUBLIC_CITY_LAT=$NEXT_PUBLIC_CITY_LAT \ + NEXT_PUBLIC_CITY_LNG=$NEXT_PUBLIC_CITY_LNG \ + NEXT_PUBLIC_SENTRY_DSN=$NEXT_PUBLIC_SENTRY_DSN \ + NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY + +ENV NEXT_TELEMETRY_DISABLED=1 \ + NODE_ENV=production + +# Every page that touches the database is `force-dynamic`, so no database is +# needed to build. If that ever stops being true this line is where it breaks, +# loudly, rather than at deploy time. +RUN pnpm --filter @linkdr/web build + +# ─────────────────────────────── tools ────────────────────────────── +# Migrations, seeding and the asset migration. These need tsx, drizzle-kit and +# the drizzle/ SQL folder, none of which belong in the image that serves +# traffic — so they get their own target rather than bloating the runtime. +# +# Run as a one-shot alongside the app (see docker-compose.dokploy.yml), not as +# a long-lived service: +# docker compose run --rm migrate +FROM base AS tools +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/packages ./packages +COPY . . +ENV NODE_ENV=production +# Idempotent: drizzle records applied migrations, so re-running is a no-op and +# a restarted container cannot double-apply anything. +CMD ["pnpm", "--filter", "@linkdr/db", "migrate"] + +# ────────────────────────────── runtime ───────────────────────────── +FROM base AS runtime +WORKDIR /app + +ENV NODE_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + PORT=3000 \ + HOSTNAME=0.0.0.0 + +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nextjs + +# The standalone bundle ships its own minimal node_modules and server.js. +# `public` and `.next/static` are NOT included in it and must be copied +# separately, or the site renders with no CSS and no images. +COPY --from=build --chown=nextjs:nodejs /app/apps/web/.next/standalone ./ +COPY --from=build --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static +COPY --from=build --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public + +USER nextjs +EXPOSE 3000 + +# Talks to Postgres, so a container that cannot reach its database never enters +# rotation. start-period covers first boot; see app/api/health/route.ts. +HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + +CMD ["node", "apps/web/server.js"] diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index e4cbb09..1dfb61c 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,21 +1,39 @@ +import path from 'node:path'; import { config as loadEnv } from 'dotenv'; import { withSentryConfig } from '@sentry/nextjs'; import type { NextConfig } from 'next'; // The monorepo keeps one .env at the root; Next only looks in the app directory. +// In a container the file does not exist and the platform supplies the +// environment instead — dotenv never overwrites an already-set variable, so +// this line is a no-op there rather than a conflict. loadEnv({ path: '../../.env' }); const config: NextConfig = { reactStrictMode: true, + /** + * Traces the server build and its used dependencies into + * `.next/standalone`, so the runtime image carries a node_modules with only + * what actually runs. Without it a Docker image for this monorepo has to ship + * every workspace's dev dependencies — drizzle-kit, vitest, eslint, the whole + * toolchain — to start one server. + * + * `outputFileTracingRoot` must point at the REPO root, not the app: pnpm + * hoists to a root `node_modules/.pnpm` store, and tracing from apps/web + * silently omits every symlinked workspace package. + */ + output: 'standalone', + outputFileTracingRoot: path.join(__dirname, '../..'), // The workspace packages ship TypeScript source, not build output. transpilePackages: ['@linkdr/api', '@linkdr/db', '@linkdr/shared', '@linkdr/storage'], images: { remotePatterns: [ - // Seed data only — real pros upload to R2. The deck renders a plain , - // so these matter only where next/image is used. - { protocol: 'https', hostname: 'i.pravatar.cc' }, - { protocol: 'https', hostname: 'picsum.photos' }, - { protocol: 'https', hostname: '**.r2.dev' }, + // Where everything is served from once `pnpm assets:migrate` has run. + { protocol: 'https', hostname: '**.digitaloceanspaces.com' }, + { protocol: 'https', hostname: '**.cdn.digitaloceanspaces.com' }, + // The seed writes source urls and the migration rewrites them, so a + // freshly seeded environment points here until that job has run. + { protocol: 'https', hostname: 'images.unsplash.com' }, ], }, // postgres-js opens raw sockets; it must not be bundled into the server chunk. diff --git a/apps/web/src/app/api/health/route.ts b/apps/web/src/app/api/health/route.ts new file mode 100644 index 0000000..10b83e0 --- /dev/null +++ b/apps/web/src/app/api/health/route.ts @@ -0,0 +1,43 @@ +import { sql } from 'drizzle-orm'; +import { db } from '@linkdr/db'; + +/** + * Liveness and readiness for the deployment platform. + * + * Dokploy's health check decides whether a new container replaces the running + * one. A check that only proves Node is listening will happily promote a + * container that cannot reach its database — the deploy goes green and every + * request 500s, which is strictly worse than a failed deploy because nothing + * rolls back. + * + * So this touches Postgres. It is one trivial round trip and it is the single + * dependency without which no page on this site renders. + * + * Not checked here on purpose: + * - Redis. Nothing in the request path needs it yet (see routers/message.ts, + * where the throttle is deliberately in-process until M4). + * - Spaces, Mapbox, Twilio. Third-party outages must not take our own + * container out of rotation and trigger a rollback loop. + */ +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; + +export async function GET() { + const startedAt = Date.now(); + + try { + await db.execute(sql`select 1`); + } catch (error) { + // The message can carry a connection string. Log it, never return it. + console.error('[health] database unreachable', error); + return Response.json( + { status: 'error', database: 'unreachable' }, + { status: 503, headers: { 'cache-control': 'no-store' } }, + ); + } + + return Response.json( + { status: 'ok', database: 'ok', latencyMs: Date.now() - startedAt }, + { status: 200, headers: { 'cache-control': 'no-store' } }, + ); +} diff --git a/apps/web/src/app/pro/join/page.tsx b/apps/web/src/app/pro/join/page.tsx new file mode 100644 index 0000000..aafe447 --- /dev/null +++ b/apps/web/src/app/pro/join/page.tsx @@ -0,0 +1,191 @@ +import Link from 'next/link'; +import { Check, Clock, MapPin, ShieldCheck, X } from 'lucide-react'; +import { BackLink } from '@/components/chrome/back-link'; +import { buttonClasses } from '@/components/ui'; + +export const metadata = { title: 'Join as a pro' }; + +/** + * DESIGN.md §6.17. + * + * The old "Join as a pro" button went straight to /sign-in, which asks a + * tradesperson to create an account before telling them what for. This screen + * is what sits in between: it shows the job request they would receive, states + * what we check and what we will need from them, and only then asks. + * + * A route rather than a panel. The rest of the app is one screen with no routes + * inside it (see showcase-deck.tsx), but this is a one-way door out of the + * customer product into a separate account — the same journey /sign-in already + * takes, and it should be linkable and back-able like one. + * + * Public on purpose: sign-in comes AFTER, and a pitch you have to log in to + * read is not a pitch. + */ + +/** Three sequential steps, not three features. §6.17 — an ordered list. */ +const STEPS = [ + { + title: 'Tell us your trade', + body: 'What you do, where you are based, and how far you are willing to travel.', + }, + { + title: 'We check you out', + body: 'A person reads your ID, insurance and licence by hand. It usually takes a day.', + }, + { + title: 'Jobs start arriving', + body: 'Customers near you send work that matches your trade. Take the ones you want.', + }, +]; + +/** + * What `pro.submitForReview` actually gates on, with the wizard's own + * required/optional split. Softening it here only moves the drop-off to step + * four, after the person has already spent their evening on it. + */ +const NEEDED = [ + { label: 'Photo ID', note: 'Required' }, + { label: 'Public liability insurance', note: 'Required' }, + { label: 'Trade licence', note: 'If your trade needs one' }, +]; + +export default function ProJoinPage() { + return ( +
+ + +

For tradespeople

+

Get sent jobs near you

+

+ No bidding, no lead fees, no chasing. Verified customers send you the work directly and + you decide what to take. +

+ + {/* + The preview. The whole reason this screen exists: showing the thing beats + describing it, and it is the honest answer to "what would I actually get?" + + aria-hidden and inert — a screen reader offering a fake Accept button is a + trap, and so is a sighted user tapping one. The caption above it says it is + an example in visible text, because a mock that reads as live data is a lie. + */} +

+ + An example request + +

+ +
+
+ + Plumber + + + + Today if possible + +
+ +

+ Kitchen sink leaking under the cupboard +

+

+ Water pooling under the sink, seems to be the trap. Free most evenings this week. +

+ +

+ + 2.4 km away + · + Budget $80–200 +

+ + {/* + Shown as the shapes of the two buttons, not as buttons. §6.17 — the + point is recognition, and a real control here would be tappable. + */} +
+ + + Decline + + + + Accept + +
+
+ +

+ Accepting opens a private conversation. You agree a fixed price there before any work + starts — we never quote on your behalf. +

+ +

How it works

+
    + {STEPS.map((step, i) => ( +
  1. + + {i + 1} + + + {step.title} + {step.body} + +
  2. + ))} +
+ +

What you will need

+

+ We check every pro before a single customer sees them. It is the only thing this + marketplace actually sells, so there is no way around this part. +

+ + +

+ You set your own hourly rate and how far you travel, and you can turn new work off + whenever you are busy. +

+ + {/* + §9 — the primary action sits low and stays in thumb reach. Sticky rather + than placed after the copy, because this screen is longer than the fold + and a CTA below three sections is a CTA nobody sees. + */} +
+ + Create your pro account + + {/* + Not decorative. user.setRole refuses once a job has been posted, so a + customer tapping this is opening a SECOND account — finding that out + later is a support ticket at the worst possible moment. + */} +

+ Working as a pro needs its own account. You keep your customer one for hiring. +

+
+
+ ); +} diff --git a/apps/web/src/app/profile-panel.tsx b/apps/web/src/app/profile-panel.tsx index 6bbb8ba..9c8cc85 100644 --- a/apps/web/src/app/profile-panel.tsx +++ b/apps/web/src/app/profile-panel.tsx @@ -6,7 +6,7 @@ import { Card } from '@/components/deck'; import { SignedOut } from '@/components/chrome/signed-out'; import { SkillsGroup } from '@/components/profile/skills-group'; import { Banner, SettingsGroup, SettingsRow, SettingsToggle, buttonClasses } from '@/components/ui'; -import { api, type RouterOutputs } from '@/lib/trpc'; +import { api } from '@/lib/trpc'; /** * The Profile tab. @@ -35,15 +35,13 @@ export function ProfilePanel() { ); } - return me.data.role === 'pro' ? : ; + return me.data.role === 'pro' ? : ; } function Shell({ children }: { children: React.ReactNode }) { return
{children}
; } -type Me = RouterOutputs['user']['me']; - /* ─────────────────────────────── pro ─────────────────────────────── */ /** What each verification status means commercially — this is the row that decides @@ -79,6 +77,27 @@ const STATUS: Record< }, }; +/** + * §6.11. The banner, the card and a group — in that order and at those sizes, + * so nothing jumps sideways when the two queries land. + */ +function ProSkeleton() { + return ( +
+
+
+
+
+
+
+ ); +} + +/** "Uploaded", or the reason nobody can see you yet. */ +function Required({ present }: { present: boolean }) { + return present ? <>Uploaded : Missing; +} + function ProProfile() { const utils = api.useUtils(); const preview = api.pro.previewCard.useQuery(); @@ -94,7 +113,7 @@ function ProProfile() { if (preview.isLoading || profile.isLoading) { return ( -
+ ); } @@ -124,25 +143,38 @@ function ProProfile() { return (

Your card

-

Exactly what customers see when they swipe.

+

Exactly what customers see when they swipe.

+ + {/* + Above the card, not below it. + Whether the card is on the deck at all outranks what is printed on it — + a rejected pro reading this in order used to meet a polished preview of + something nobody can see, and only then the sentence explaining why. + */} + + {status.body} + {/* The real , not a lookalike — a copy would drift the moment either side changed, and the whole point is that a pro can trust this preview. No onDecide, so it renders static and non-draggable. + + A ratio rather than the 420px it used to be pinned at. Card is + `absolute inset-0`, so it needs a definite height from its parent, and a + fixed one made the preview a different shape from the real thing on + every screen that was not the one it was measured on — which for a + preview sold as "exactly what customers see" is the one thing it must + not do. */} -
+
-

+

The distance shown is an example — customers see how far you are from their own job.

- - {status.body} - - -
+
+ {/* "Card details", not "Your card" — that is the h1 four hundred pixels + above, and two headings with one name meant two different things. */} + {/* + A missing ID or insurance certificate is the whole reason a draft pro + is not earning, and it used to render in the same grey as the optional + licence line. The colour goes on the STATE, not the label: "Photo ID" + is not the problem, "Missing" is. + */} - - + } /> + } /> @@ -210,47 +250,59 @@ function ProProfile() { /* ───────────────────────────── client ────────────────────────────── */ -function ClientProfile({ me }: { me: Me }) { - const jobs = api.job.mine.useQuery(); - +function ClientProfile() { return ( -

Your profile

- - - - - - - {/* - The most valuable thing on an otherwise empty screen. A marketplace with - no pros has no product, so recruiting supply beats decorating a client - profile that has nothing on it. + Full height, with the card taking whatever the title leaves. Three rows + went from here — Name, Phone and Jobs posted — because the first two are + editable in Settings and were dead facts here, and a customer with two + screens showing their name, one of which does nothing when tapped, learns + that tapping things on this screen does nothing. The third is the Jobs + tab's entire subject. + + Removing them left the card stranded at the top above six hundred pixels + of nothing, which reads as a screen that failed to load rather than one + with little to say. Centring it in the space makes the emptiness look + chosen, because it is: a customer profile genuinely has nothing on it, + and the recruitment card is the screen's real job. */} -
- - - For tradespeople - -

Work with us

-

- Get sent local jobs that match your trade. We check every pro’s ID, licence and - insurance, so customers arrive ready to book. +

+

Your profile

+

+ Your name, contact details and notifications live in Settings.

- {/* - user.setRole refuses once a job has been posted, so this must not read - as a switch that flips this account over. - */} -

- Working as a pro needs its own account — you keep this one for hiring. -

- - Join as a pro - + +
+ {/* + A marketplace with no pros has no product, so recruiting supply beats + decorating a client profile that has nothing on it. + */} +
+ + + For tradespeople + +

Work with us

+

+ Get sent local jobs that match your trade. We check every pro’s ID, licence + and insurance, so customers arrive ready to book. +

+ {/* + user.setRole refuses once a job has been posted, so this must not + read as a switch that flips this account over. + */} +

+ Working as a pro needs its own account — you keep this one for hiring. +

+ + Join as a pro + +
+
); diff --git a/apps/web/src/app/settings-panel.tsx b/apps/web/src/app/settings-panel.tsx index 3510ff5..4d51159 100644 --- a/apps/web/src/app/settings-panel.tsx +++ b/apps/web/src/app/settings-panel.tsx @@ -151,11 +151,21 @@ function SignedIn({ me, onNavigate }: { me: Me; onNavigate?: (tab: PhoneTab) => offer, and settings is where somebody goes when they are looking for something they have not found. */ - + + {/* + /pro/join, not /pro/onboarding. Onboarding redirects a client role + straight back out to the role chooser, so the row pointed at a page + this reader can never reach — and it implied their account would + become a pro account, which `setRole` refuses once they have posted + a job. /pro/join is the recruitment page that explains both. + */} )} diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts index d1d6a57..18d634b 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth.ts @@ -29,13 +29,26 @@ import { isDevLoginPhone, pinDevLoginCode } from '@/server/dev-login'; * unvalidated. The only real alternative is delegating the whole flow to Twilio * Verify, which we chose not to do. */ +/** + * `next build` imports this module to collect route metadata, and it does so + * with NODE_ENV=production but none of the runtime secrets — a build machine + * has no business holding a session key. Without this distinction the two + * guards below turn every containerised build into a failure, and the only way + * out is baking AUTH_SECRET into an image layer, which is worse than the + * problem they exist to prevent. + * + * Next sets NEXT_PHASE for the duration of the build and never at runtime, so + * the checks still fire on a real boot. + */ +const isBuildPhase = process.env.NEXT_PHASE === 'phase-production-build'; + /** * Without a secret, better-auth silently falls back to a built-in default — * which would mean every deployment signs sessions with the same publicly known * key. Fail the boot instead. */ const secret = process.env.AUTH_SECRET; -if (!secret && process.env.NODE_ENV === 'production') { +if (!secret && process.env.NODE_ENV === 'production' && !isBuildPhase) { throw new Error('AUTH_SECRET is not set. Generate one with: openssl rand -base64 32'); } @@ -48,7 +61,7 @@ if (!secret && process.env.NODE_ENV === 'production') { */ const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? - (process.env.NODE_ENV === 'production' + (process.env.NODE_ENV === 'production' && !isBuildPhase ? (() => { throw new Error('NEXT_PUBLIC_APP_URL is not set. Set it to the public https origin.'); })() diff --git a/apps/web/src/server/dev-login.ts b/apps/web/src/server/dev-login.ts index a0cb34d..065f973 100644 --- a/apps/web/src/server/dev-login.ts +++ b/apps/web/src/server/dev-login.ts @@ -20,8 +20,51 @@ import { db, schema } from '@linkdr/db'; 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 { - return process.env.NODE_ENV !== 'production' && process.env.ALLOW_DEV_LOGIN === 'true'; + // 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 { diff --git a/docker-compose.dokploy.yml b/docker-compose.dokploy.yml new file mode 100644 index 0000000..7e824dd --- /dev/null +++ b/docker-compose.dokploy.yml @@ -0,0 +1,180 @@ +# Linkdr — Dokploy deployment stack. +# +# Separate from docker-compose.yml, which exists only to give a developer a +# Postgres and a Redis on their laptop. Merging the two would mean one file +# that is wrong in both places. +# +# In Dokploy: create a **Compose** service, point it at this file, paste the +# variables from DEPLOY.md into the Environment tab, then Deploy. +# +# Traefik is Dokploy's ingress. It routes by the labels on `app` below and +# joins containers on the shared `dokploy-network`, which is why that network +# is declared external — Dokploy created it, this stack only attaches to it. + +services: + # ─────────────────────────────── database ─────────────────────────────── + # PostGIS, not plain Postgres. Every distance in this product is + # `ST_Distance` over a `geography(Point,4326)` column, and the first + # migration declares one — vanilla postgres:17 fails on migration 0001. + postgres: + image: postgis/postgis:17-3.5 + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-linkdr} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} + POSTGRES_DB: ${POSTGRES_DB:-linkdr} + volumes: + - pgdata:/var/lib/postgresql/data + # No `ports:` on purpose. The database is reachable on the compose network + # by every service that needs it; publishing 5432 puts it on the public + # internet of the droplet. + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-linkdr} -d ${POSTGRES_DB:-linkdr}'] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + networks: [internal] + + # ──────────────────────────────── redis ───────────────────────────────── + # Not on the request path yet (routers/message.ts throttles in-process until + # M4). Here so the SSE fan-out and BullMQ queues have somewhere to land + # without a second deploy. + redis: + image: redis:7-alpine + restart: unless-stopped + command: redis-server --appendonly yes + volumes: + - redisdata:/data + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 10s + timeout: 3s + retries: 10 + networks: [internal] + + # ────────────────────────────── migrations ────────────────────────────── + # Runs to completion and exits. `app` waits for it, so a container can never + # serve traffic against a schema older than the code inside it. + # + # Idempotent — drizzle records what it has applied, so a redeploy re-runs + # this and it does nothing. + migrate: + build: + context: . + dockerfile: Dockerfile + target: tools + restart: 'no' + environment: + DATABASE_URL: ${DATABASE_URL} + DATABASE_CA_CERT: ${DATABASE_CA_CERT:-} + depends_on: + postgres: + condition: service_healthy + networks: [internal] + + # ──────────────────────────────── the app ─────────────────────────────── + app: + build: + context: . + dockerfile: Dockerfile + target: runtime + # NEXT_PUBLIC_* is inlined into the browser bundle when `next build` + # runs, so these MUST be build args. Setting them only under + # `environment:` below leaves the client bundle holding whatever was + # baked in — usually localhost — and sign-in breaks in a way that looks + # like a cookie bug. + args: + NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL} + NEXT_PUBLIC_CITY_NAME: ${NEXT_PUBLIC_CITY_NAME} + NEXT_PUBLIC_CITY_LAT: ${NEXT_PUBLIC_CITY_LAT} + NEXT_PUBLIC_CITY_LNG: ${NEXT_PUBLIC_CITY_LNG} + NEXT_PUBLIC_SENTRY_DSN: ${NEXT_PUBLIC_SENTRY_DSN:-} + NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY:-} + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + migrate: + condition: service_completed_successfully + environment: + NODE_ENV: production + PORT: '3000' + HOSTNAME: 0.0.0.0 + + DATABASE_URL: ${DATABASE_URL} + DATABASE_CA_CERT: ${DATABASE_CA_CERT:-} + REDIS_URL: ${REDIS_URL:-redis://redis:6379} + + # better-auth derives cookie domain and Secure flag from this. An http:// + # value here on an https:// site produces a login that appears to succeed + # and then has no session — see lib/auth.ts. + AUTH_SECRET: ${AUTH_SECRET:?AUTH_SECRET is required} + AUTH_URL: ${AUTH_URL} + NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL} + + AUTH_GOOGLE_ID: ${AUTH_GOOGLE_ID:-} + AUTH_GOOGLE_SECRET: ${AUTH_GOOGLE_SECRET:-} + AUTH_MICROSOFT_ID: ${AUTH_MICROSOFT_ID:-} + AUTH_MICROSOFT_SECRET: ${AUTH_MICROSOFT_SECRET:-} + AUTH_MICROSOFT_TENANT_ID: ${AUTH_MICROSOFT_TENANT_ID:-common} + AUTH_GITHUB_ID: ${AUTH_GITHUB_ID:-} + AUTH_GITHUB_SECRET: ${AUTH_GITHUB_SECRET:-} + + TWILIO_ACCOUNT_SID: ${TWILIO_ACCOUNT_SID:-} + TWILIO_AUTH_TOKEN: ${TWILIO_AUTH_TOKEN:-} + TWILIO_VERIFY_SERVICE_SID: ${TWILIO_VERIFY_SERVICE_SID:-} + TWILIO_FROM_NUMBER: ${TWILIO_FROM_NUMBER:-} + + MAPBOX_TOKEN: ${MAPBOX_TOKEN:-} + MAPBOX_COUNTRY: ${MAPBOX_COUNTRY:-mx} + + SPACES_REGION: ${SPACES_REGION:-nyc3} + SPACES_BUCKET: ${SPACES_BUCKET:-} + SPACES_KEY: ${SPACES_KEY:-} + SPACES_SECRET: ${SPACES_SECRET:-} + SPACES_CDN_URL: ${SPACES_CDN_URL:-} + + RESEND_API_KEY: ${RESEND_API_KEY:-} + EMAIL_FROM: ${EMAIL_FROM:-noreply@linkdr.serfaty.site} + + STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-} + STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-} + PLATFORM_FEE_BPS: ${PLATFORM_FEE_BPS:-1500} + + NEXT_PUBLIC_SENTRY_DSN: ${NEXT_PUBLIC_SENTRY_DSN:-} + + # Read by dev-login.ts, which ALSO requires NODE_ENV !== 'production'. + # With NODE_ENV=production above, the fixed +52 55 0000 0000 / 000000 + # login is off no matter what this says. See DEPLOY.md → "Signing in". + ALLOW_DEV_LOGIN: 'false' + networks: [internal, dokploy-network] + labels: + - traefik.enable=true + - traefik.docker.network=dokploy-network + # Dokploy's Traefik terminates TLS; the container speaks plain HTTP. + - traefik.http.services.linkdr.loadbalancer.server.port=3000 + - traefik.http.routers.linkdr.rule=Host(`linkdr.serfaty.site`) + - traefik.http.routers.linkdr.entrypoints=websecure + - traefik.http.routers.linkdr.tls=true + - traefik.http.routers.linkdr.tls.certresolver=letsencrypt + # Send :80 to :443 rather than serving the app on both. Auth cookies are + # Secure, so the http origin cannot hold a session anyway. + - traefik.http.routers.linkdr-web.rule=Host(`linkdr.serfaty.site`) + - traefik.http.routers.linkdr-web.entrypoints=web + - traefik.http.routers.linkdr-web.middlewares=linkdr-https + - traefik.http.middlewares.linkdr-https.redirectscheme.scheme=https + - traefik.http.middlewares.linkdr-https.redirectscheme.permanent=true + +volumes: + pgdata: + redisdata: + +networks: + # Private to this stack. Postgres and Redis are reachable here and nowhere else. + internal: + # Created by Dokploy for Traefik. Only `app` joins it. + dokploy-network: + external: true