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>
This commit is contained in:
serfa
2026-08-23 13:17:54 -04:00
co-authored by Claude Opus 5
parent ff1882598c
commit 35d99ce0e2
12 changed files with 838 additions and 64 deletions
+43
View File
@@ -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' } },
);
}
+191
View File
@@ -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 (
<main className="h-full overflow-y-auto bg-page px-5 pb-0 pt-[calc(0.5rem+env(safe-area-inset-top))]">
<BackLink href="/" label="Back" />
<p className="mt-6 text-overline uppercase text-accent">For tradespeople</p>
<h1 className="mt-3 text-balance text-h1">Get sent jobs near you</h1>
<p className="mt-3 max-w-[68ch] text-pretty text-body text-muted">
No bidding, no lead fees, no chasing. Verified customers send you the work directly and
you decide what to take.
</p>
{/*
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.
*/}
<p className="mt-10 flex items-center gap-2 text-meta text-faint">
<span className="h-px flex-1 bg-hairline" aria-hidden />
An example request
<span className="h-px flex-1 bg-hairline" aria-hidden />
</p>
<div
className="mt-4 rounded-card border border-hairline bg-raised p-5 shadow-lg"
aria-hidden
>
<div className="flex items-center justify-between gap-3">
<span className="inline-flex items-center rounded-pill border border-brand-200 bg-brand-100 px-3 py-1 text-meta text-ink-950">
Plumber
</span>
<span className="inline-flex items-center gap-1 text-meta text-sun-600">
<Clock className="h-3.5 w-3.5" aria-hidden />
Today if possible
</span>
</div>
<p className="mt-3 font-display text-h4 text-strong">
Kitchen sink leaking under the cupboard
</p>
<p className="mt-2 text-body-sm text-muted">
Water pooling under the sink, seems to be the trap. Free most evenings this week.
</p>
<p className="mt-3 flex flex-wrap items-center gap-x-2 gap-y-1 text-meta text-faint tabular-nums">
<MapPin className="h-3.5 w-3.5" aria-hidden />
<span>2.4 km away</span>
<span>·</span>
<span>Budget $80200</span>
</p>
{/*
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.
*/}
<div className="mt-5 flex gap-3">
<span className="inline-flex h-11 flex-1 items-center justify-center gap-2 rounded-pill border-[1.5px] border-ink-950 font-display text-body font-semibold text-strong dark:border-hairline">
<X className="h-4 w-4" aria-hidden />
Decline
</span>
<span className="inline-flex h-11 flex-1 items-center justify-center gap-2 rounded-pill bg-go-600 font-display text-body font-semibold text-white">
<Check className="h-4 w-4" aria-hidden />
Accept
</span>
</div>
</div>
<p className="mt-3 text-meta text-faint">
Accepting opens a private conversation. You agree a fixed price there before any work
starts we never quote on your behalf.
</p>
<h2 className="mt-10 text-h3">How it works</h2>
<ol className="mt-4 flex flex-col gap-6">
{STEPS.map((step, i) => (
<li key={step.title} className="flex gap-4">
<span
className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-pill bg-brand-500 font-display text-body-sm font-semibold text-white tabular-nums"
aria-hidden
>
{i + 1}
</span>
<span className="min-w-0">
<span className="block font-display text-h4 text-strong">{step.title}</span>
<span className="mt-1 block text-body-sm text-muted">{step.body}</span>
</span>
</li>
))}
</ol>
<h2 className="mt-10 text-h3">What you will need</h2>
<p className="mt-2 text-body-sm text-muted">
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.
</p>
<ul className="mt-4 flex flex-col gap-3">
{NEEDED.map((item) => (
<li
key={item.label}
className="flex items-center gap-3 rounded-lg border border-hairline px-4 py-3"
>
<ShieldCheck className="h-5 w-5 shrink-0 text-go-600" aria-hidden />
<span className="min-w-0 flex-1 text-body-sm text-strong">{item.label}</span>
<span className="shrink-0 text-meta text-faint">{item.note}</span>
</li>
))}
</ul>
<p className="mt-6 text-body-sm text-muted">
You set your own hourly rate and how far you travel, and you can turn new work off
whenever you are busy.
</p>
{/*
§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.
*/}
<div className="sticky bottom-0 -mx-5 mt-10 border-t border-hairline bg-page/95 px-5 pt-4 pb-[calc(1rem+env(safe-area-inset-bottom))] backdrop-blur-[12px]">
<Link
href="/sign-in?next=/pro/onboarding"
className={buttonClasses({ variant: 'primary', size: 'lg', block: true })}
>
Create your pro account
</Link>
{/*
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.
*/}
<p className="mt-3 text-center text-meta text-faint">
Working as a pro needs its own account. You keep your customer one for hiring.
</p>
</div>
</main>
);
}
+104 -52
View File
@@ -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' ? <ProProfile /> : <ClientProfile me={me.data} />;
return me.data.role === 'pro' ? <ProProfile /> : <ClientProfile />;
}
function Shell({ children }: { children: React.ReactNode }) {
return <div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4 pt-[3.25rem]">{children}</div>;
}
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 (
<div aria-busy>
<div className="mb-1 h-8 w-40 animate-pulse rounded-md bg-inset" />
<div className="mb-4 h-5 w-64 animate-pulse rounded-md bg-inset" />
<div className="mb-5 h-24 animate-pulse rounded-card bg-inset" />
<div className="mb-8 aspect-[3/4] w-full animate-pulse rounded-3xl bg-inset" />
<div className="h-40 animate-pulse rounded-card bg-inset" />
</div>
);
}
/** "Uploaded", or the reason nobody can see you yet. */
function Required({ present }: { present: boolean }) {
return present ? <>Uploaded</> : <span className="text-danger">Missing</span>;
}
function ProProfile() {
const utils = api.useUtils();
const preview = api.pro.previewCard.useQuery();
@@ -94,7 +113,7 @@ function ProProfile() {
if (preview.isLoading || profile.isLoading) {
return (
<Shell>
<div className="h-64 animate-pulse rounded-deck bg-inset" />
<ProSkeleton />
</Shell>
);
}
@@ -124,25 +143,38 @@ function ProProfile() {
return (
<Shell>
<h1 className="mb-1 text-h2">Your card</h1>
<p className="mb-3 text-body-sm text-muted">Exactly what customers see when they swipe.</p>
<p className="mb-4 text-body-sm text-muted">Exactly what customers see when they swipe.</p>
{/*
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.
*/}
<Banner tone={status.tone} title={status.label} className="mb-5">
{status.body}
</Banner>
{/*
The real <Card>, 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.
*/}
<div className="relative mb-2 h-[420px] w-full">
<div className="relative mb-2 aspect-[3/4] w-full">
<Card card={card} />
</div>
<p className="mb-5 text-meta text-faint">
<p className="mb-8 text-meta text-faint">
The distance shown is an example customers see how far you are from their own job.
</p>
<Banner tone={status.tone} title={status.label}>
{status.body}
</Banner>
<div className="mt-5">
<div>
<SettingsGroup title="Availability">
<SettingsToggle
label="Accepting jobs"
@@ -157,8 +189,10 @@ function ProProfile() {
/>
</SettingsGroup>
{/* "Card details", not "Your card" — that is the h1 four hundred pixels
above, and two headings with one name meant two different things. */}
<SettingsGroup
title="Your card"
title="Card details"
/*
* upsertProfile demotes a verified pro to `pending` when trade,
* location or radius changes, which silently drops them off the deck.
@@ -185,9 +219,15 @@ function ProProfile() {
<SkillsGroup skills={p.skills} />
{/*
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.
*/}
<SettingsGroup title="Documents" note="Only our review team ever sees these.">
<SettingsRow label="Photo ID" value={has('id') ? 'Uploaded' : 'Missing'} />
<SettingsRow label="Insurance" value={has('insurance') ? 'Uploaded' : 'Missing'} />
<SettingsRow label="Photo ID" value={<Required present={has('id')} />} />
<SettingsRow label="Insurance" value={<Required present={has('insurance')} />} />
<SettingsRow label="Trade licence" value={has('licence') ? 'Uploaded' : 'Not provided'} />
</SettingsGroup>
@@ -210,47 +250,59 @@ function ProProfile() {
/* ───────────────────────────── client ────────────────────────────── */
function ClientProfile({ me }: { me: Me }) {
const jobs = api.job.mine.useQuery();
function ClientProfile() {
return (
<Shell>
<h1 className="mb-5 text-h2">Your profile</h1>
<SettingsGroup title="Account">
<SettingsRow label="Name" value={me.name ?? 'Not set'} />
<SettingsRow label="Phone" value={me.phoneNumber ?? '—'} />
<SettingsRow label="Jobs posted" value={jobs.data ? String(jobs.data.length) : '…'} />
</SettingsGroup>
{/*
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.
*/}
<div className="rounded-card border border-hairline bg-raised p-5">
<span className="flex items-center gap-2 text-accent">
<Hammer className="h-5 w-5" aria-hidden />
<span className="text-overline uppercase">For tradespeople</span>
</span>
<h2 className="mt-3 text-h3">Work with us</h2>
<p className="mt-2 text-body-sm text-muted">
Get sent local jobs that match your trade. We check every pro&rsquo;s ID, licence and
insurance, so customers arrive ready to book.
<div className="flex min-h-full flex-col">
<h1 className="mb-1 text-h2">Your profile</h1>
<p className="text-body-sm text-muted">
Your name, contact details and notifications live in Settings.
</p>
{/*
user.setRole refuses once a job has been posted, so this must not read
as a switch that flips this account over.
*/}
<p className="mt-2 text-meta text-faint">
Working as a pro needs its own account you keep this one for hiring.
</p>
<Link
href="/sign-in?next=/pro/onboarding"
className={`mt-4 ${buttonClasses({ variant: 'primary', size: 'md', block: true })}`}
>
Join as a pro
</Link>
<div className="flex flex-1 items-center py-8">
{/*
A marketplace with no pros has no product, so recruiting supply beats
decorating a client profile that has nothing on it.
*/}
<div className="w-full rounded-card border border-hairline bg-raised p-5">
<span className="flex items-center gap-2 text-accent">
<Hammer className="h-5 w-5" aria-hidden />
<span className="text-overline uppercase">For tradespeople</span>
</span>
<h2 className="mt-3 text-h3">Work with us</h2>
<p className="mt-2 text-body-sm text-muted">
Get sent local jobs that match your trade. We check every pro&rsquo;s ID, licence
and insurance, so customers arrive ready to book.
</p>
{/*
user.setRole refuses once a job has been posted, so this must not
read as a switch that flips this account over.
*/}
<p className="mt-2 text-meta text-faint">
Working as a pro needs its own account you keep this one for hiring.
</p>
<Link
href="/pro/join"
className={`mt-4 ${buttonClasses({ variant: 'primary', size: 'md', block: true })}`}
>
Join as a pro
</Link>
</div>
</div>
</div>
</Shell>
);
+13 -3
View File
@@ -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.
*/
<SettingsGroup title="Working" note="Verification takes about a day.">
<SettingsGroup
title="Working"
note="A pro account is separate from this one. You keep this for hiring."
>
{/*
/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.
*/}
<SettingsRow
label="Work on Linkdr"
hint="Set up a pro profile and start getting jobs"
href="/pro/onboarding"
hint="Get sent local jobs that match your trade"
href="/pro/join"
/>
</SettingsGroup>
)}
+15 -2
View File
@@ -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.');
})()
+44 -1
View File
@@ -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 {