diff --git a/.env.example b/.env.example
index 6e311b7..cf1374a 100644
--- a/.env.example
+++ b/.env.example
@@ -4,6 +4,11 @@ NEXT_PUBLIC_APP_URL=http://localhost:3000
# ---- Database (Postgres 16 + PostGIS) ----
DATABASE_URL=postgresql://linkder:linkder@localhost:5442/linkder
+# Managed Postgres only. Verifies the server's IDENTITY, not merely that the
+# link is encrypted — sslmode=require alone leaves you open to anything that can
+# answer for the hostname. Takes a path to the provider's .crt, or the PEM
+# inline for a platform whose secrets are environment variables.
+DATABASE_CA_CERT=
# ---- Redis (pub/sub for SSE chat + BullMQ queues) ----
REDIS_URL=redis://localhost:6389
@@ -46,7 +51,7 @@ AUTH_MICROSOFT_TENANT_ID=common
# GitHub only returns a primary email if the OAuth app requests `user:email`
# AND the account has a verified one; a user whose email is private signs up
# with no address, so never assume `users.email` is reachable mail — gate
-# outbound on isSyntheticEmail() from @linkder/shared, same as phone signups.
+# outbound on isSyntheticEmail() from @linkdr/shared, same as phone signups.
AUTH_GITHUB_ID=
AUTH_GITHUB_SECRET=
@@ -63,7 +68,7 @@ AUTH_GITHUB_SECRET=
MAPBOX_TOKEN=
# ISO 3166-1 alpha-2. Bounds results to one country: "Carrer de Sants" matches
# in several places and the wrong continent is a worse answer than none.
-MAPBOX_COUNTRY=es
+MAPBOX_COUNTRY=mx
# ---- Phone OTP (Twilio Verify) ----
TWILIO_ACCOUNT_SID=
@@ -82,21 +87,14 @@ DIDIT_API_KEY=
DIDIT_WORKFLOW_ID=
DIDIT_WEBHOOK_SECRET=
-# ---- Cloudflare R2 (S3-compatible object storage) ----
-R2_ACCOUNT_ID=
-R2_ACCESS_KEY_ID=
-R2_SECRET_ACCESS_KEY=
-R2_BUCKET=linkder-uploads
-R2_PUBLIC_URL=
-
# ---- Resend (transactional email) ----
RESEND_API_KEY=
-EMAIL_FROM=noreply@linkder.app
+EMAIL_FROM=noreply@linkdr.app
# ---- Launch market (city-scoped MVP) ----
-NEXT_PUBLIC_CITY_NAME=Barcelona
-NEXT_PUBLIC_CITY_LAT=41.3874
-NEXT_PUBLIC_CITY_LNG=2.1686
+NEXT_PUBLIC_CITY_NAME=Ciudad de México
+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.
@@ -105,3 +103,10 @@ ALLOW_DEV_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=
+
+# ---- Object storage (DigitalOcean Spaces) ----
+SPACES_REGION=nyc3
+SPACES_BUCKET=
+SPACES_KEY=
+SPACES_SECRET=
+SPACES_CDN_URL=
diff --git a/DESIGN.md b/DESIGN.md
index c35be9b..f9fcc2c 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -1,8 +1,8 @@
-# Linkder Design System
+# Linkdr Design System
-**Version 1.0** · derived from the wix.com design language, adapted for Linkder.
+**Version 1.0** · derived from the wix.com design language, adapted for Linkdr.
-This document is the single source of truth for how Linkder looks. Every screen must be
+This document is the single source of truth for how Linkdr looks. Every screen must be
buildable from the tokens and components below. If a screen needs something that is not in
here, add it here first, then build it.
@@ -15,7 +15,7 @@ whitespace rhythm. Values below were read off the live stylesheet, not eyeballed
Adopted: typefaces (Wix Madefor Display / Text, published under the SIL Open Font License via
Google Fonts), colour ramps, radius and spacing conventions, component geometry.
-**Not** adopted: Wix's logo, wordmark, product names, illustrations or photography. Linkder is
+**Not** adopted: Wix's logo, wordmark, product names, illustrations or photography. Linkdr is
not affiliated with Wix and must never present itself as such.
---
@@ -153,7 +153,7 @@ Display sizes use `clamp()` so one token works from 360px to desktop.
## 4. Space and layout
-**Linkder is a mobile-only product.** There is no desktop layout, no marketing site and no
+**Linkdr is a mobile-only product.** There is no desktop layout, no marketing site and no
responsive breakpoint work. Design for a 360–430px viewport and nothing else. On a wider
screen the app renders as a single 480px column centred on `ink-50` — that is a courtesy for
someone who opened it on a laptop, not a layout to design for.
diff --git a/README.md b/README.md
index ba6fc11..08ddf0a 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# Linkder
+# Linkdr
Swipe-to-hire marketplace for local professional services. A client describes a job once, then
swipes through **verified** local pros — plumbers, electricians, handymen. A right swipe sends the
@@ -61,8 +61,8 @@ packages/ui shared components (M1)
```bash
pnpm test # everything
-pnpm --filter @linkder/shared test # 46 unit tests, no database needed
-pnpm --filter @linkder/db test # 18 integration tests, needs a seeded database
+pnpm --filter @linkdr/shared test # 46 unit tests, no database needed
+pnpm --filter @linkdr/db test # 18 integration tests, needs a seeded database
```
The seed is deterministic: every pro sits at a **known** distance and bearing from the city centre,
diff --git a/SECURITY-FINDINGS.md b/SECURITY-FINDINGS.md
index 9555889..51ff5c7 100644
--- a/SECURITY-FINDINGS.md
+++ b/SECURITY-FINDINGS.md
@@ -11,7 +11,7 @@ so nobody re-reports them; the rest are open and are the M1 exit criteria.
| 1 | `pro.upsertProfile` computed `requiresReReview` and never applied it — a verified plumber could silently become a verified electrician 40 km away | Demotes a `verified` profile to `pending` in the same transaction, writes an audit row, and now counts a **trade change** as material (it did not before). Needed a new `verified -> pending` edge in `VERIFICATION_GRAPH` |
| 2 | `phoneNumber()` registers `/phone-number/request-password-reset`, `/phone-number/reset-password` and `/sign-in/phone-number` **unconditionally** — not gated on `emailAndPassword.enabled: false`. Together they mint a password credential with no SMS sent to the owner, then accept it forever with no OTP | All three served as 404 via `disabledPaths` (checked in `onRequest`, before rate limiting) |
| 3 | `bearer()` accepted the raw plaintext `sessions.token` column as an `Authorization: Bearer` credential — one leaked DB row is a replayable login | Plugin removed. The mobile client it was for does not exist yet |
-| 4 | Phone numbers stored exactly as typed, so `+34600111222` and `0034600111222` are two "unique" accounts — defeating the UNIQUE constraint, bans, and duplicate detection | `phoneNumberValidator` pins E.164 on send-otp and sign-in; `toE164`/`isE164` added to `@linkder/shared` |
+| 4 | Phone numbers stored exactly as typed, so `+34600111222` and `0034600111222` are two "unique" accounts — defeating the UNIQUE constraint, bans, and duplicate detection | `phoneNumberValidator` pins E.164 on send-otp and sign-in; `toE164`/`isE164` added to `@linkdr/shared` |
| 5 | `NEXT_PUBLIC_APP_URL` fell back to `http://localhost:3000`, which drops `Secure` and the `__Secure-` prefix from the production session cookie | Throws at boot in production |
## Open — must close before M1 ships
diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts
index a098e94..e4cbb09 100644
--- a/apps/web/next.config.ts
+++ b/apps/web/next.config.ts
@@ -8,7 +8,7 @@ loadEnv({ path: '../../.env' });
const config: NextConfig = {
reactStrictMode: true,
// The workspace packages ship TypeScript source, not build output.
- transpilePackages: ['@linkder/api', '@linkder/db', '@linkder/shared', '@linkder/storage'],
+ transpilePackages: ['@linkdr/api', '@linkdr/db', '@linkdr/shared', '@linkdr/storage'],
images: {
remotePatterns: [
// Seed data only — real pros upload to R2. The deck renders a plain ,
diff --git a/apps/web/package.json b/apps/web/package.json
index 81af4e5..2473bc9 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -1,5 +1,5 @@
{
- "name": "@linkder/web",
+ "name": "@linkdr/web",
"version": "0.0.0",
"private": true,
"type": "module",
@@ -12,11 +12,11 @@
"test": "vitest run"
},
"dependencies": {
- "@linkder/api": "workspace:*",
- "@linkder/db": "workspace:*",
- "@linkder/notify": "workspace:*",
- "@linkder/shared": "workspace:*",
- "@linkder/storage": "workspace:*",
+ "@linkdr/api": "workspace:*",
+ "@linkdr/db": "workspace:*",
+ "@linkdr/notify": "workspace:*",
+ "@linkdr/shared": "workspace:*",
+ "@linkdr/storage": "workspace:*",
"@sentry/nextjs": "^10.70.0",
"@tanstack/react-query": "^5.62.0",
"@trpc/client": "^11.18.0",
diff --git a/apps/web/src/app/admin/[proId]/page.tsx b/apps/web/src/app/admin/[proId]/page.tsx
index 79790fd..ab48e72 100644
--- a/apps/web/src/app/admin/[proId]/page.tsx
+++ b/apps/web/src/app/admin/[proId]/page.tsx
@@ -104,7 +104,7 @@ export default async function AdminProPage({ params }: { params: Promise<{ proId
-
+
- Linkder admin
+ Linkdr admin
Signed in as {me.name ?? me.email ?? 'admin'}
diff --git a/apps/web/src/app/api/trpc/[trpc]/route.ts b/apps/web/src/app/api/trpc/[trpc]/route.ts
index 7078608..732500b 100644
--- a/apps/web/src/app/api/trpc/[trpc]/route.ts
+++ b/apps/web/src/app/api/trpc/[trpc]/route.ts
@@ -1,7 +1,7 @@
import * as Sentry from '@sentry/nextjs';
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
-import { appRouter, createContext } from '@linkder/api';
-import { db } from '@linkder/db';
+import { appRouter, createContext } from '@linkdr/api';
+import { db } from '@linkdr/db';
import { resolveSession } from '@/server/session';
/**
diff --git a/apps/web/src/app/deck/[jobId]/deck-client.tsx b/apps/web/src/app/deck/[jobId]/deck-client.tsx
index 4ec3085..0835551 100644
--- a/apps/web/src/app/deck/[jobId]/deck-client.tsx
+++ b/apps/web/src/app/deck/[jobId]/deck-client.tsx
@@ -1,7 +1,7 @@
'use client';
import { useCallback, useState } from 'react';
-import type { DeckCard } from '@linkder/db';
+import type { DeckCard } from '@linkdr/db';
import { Deck } from '@/components/deck';
import { api } from '@/lib/trpc';
diff --git a/apps/web/src/app/jobs/new/form.tsx b/apps/web/src/app/jobs/new/form.tsx
index 4472ec9..2429cf8 100644
--- a/apps/web/src/app/jobs/new/form.tsx
+++ b/apps/web/src/app/jobs/new/form.tsx
@@ -182,14 +182,14 @@ export function NewJobForm({