Cloudron-native environment variables #55
@@ -16,7 +16,7 @@ Keep new routes within this shape so auth, config, and validation stay uniform.
|
||||
```
|
||||
|
||||
From `lib/server/env` + `lib/server/responses`. Returns a consistent 503
|
||||
when `DATABASE_URL` is missing (local dev, preview builds).
|
||||
when `CLOUDRON_POSTGRESQL_URL` is missing (local dev, preview builds).
|
||||
|
||||
2. **Auth (when the route requires a user).**
|
||||
|
||||
|
||||
+9
-5
@@ -1,17 +1,21 @@
|
||||
# Copy to `.env` for local development (never commit real secrets).
|
||||
|
||||
# PostgreSQL — use `docker compose up -d postgres` and match user/db/password.
|
||||
DATABASE_URL="postgresql://communityrule:communityrule@localhost:5432/communityrule"
|
||||
CLOUDRON_POSTGRESQL_URL="postgresql://communityrule:communityrule@localhost:5432/communityrule"
|
||||
|
||||
# Session signing + secret used when hashing magic-link tokens. Min 16 characters; use a long random string in production.
|
||||
SESSION_SECRET="dev-only-change-me-16chars-min"
|
||||
|
||||
# Optional: Nodemailer transport URL, e.g. `smtp://localhost:1025` with Mailhog from docker-compose.
|
||||
# Leave unset in development to log the magic-link verify URL to the server console instead of sending email.
|
||||
SMTP_URL=
|
||||
# Optional Mailhog (docker compose mailhog service):
|
||||
# CLOUDRON_MAIL_SMTP_SERVER=localhost
|
||||
# CLOUDRON_MAIL_SMTP_PORT=1025
|
||||
# CLOUDRON_MAIL_SMTP_USERNAME=
|
||||
# CLOUDRON_MAIL_SMTP_PASSWORD=
|
||||
|
||||
# Leave mail vars unset in dev to log the magic-link verify URL to the server console instead of sending email.
|
||||
SMTP_FROM="Community Rule <noreply@localhost>"
|
||||
|
||||
# CR-107: inbox for Ask an organizer form submissions (requires SMTP_URL in production).
|
||||
# CR-107: inbox for Ask an organizer form submissions (requires CLOUDRON_MAIL_SMTP_* in production).
|
||||
ORGANIZER_INQUIRY_TO=
|
||||
|
||||
# Set to `true` to sync the create-flow draft with `/api/drafts/me` when the user is signed in.
|
||||
|
||||
+4
-3
@@ -5,7 +5,7 @@
|
||||
1. Copy [`.env.example`](.env.example) to `.env` and set `SESSION_SECRET`
|
||||
(at least 16 characters).
|
||||
2. `docker compose up -d postgres mailhog` — omit `mailhog` if you only
|
||||
need Postgres. Without `SMTP_URL`, the **magic-link verify URL** is
|
||||
need Postgres. Without `CLOUDRON_MAIL_SMTP_*`, the **magic-link verify URL** is
|
||||
printed in the dev server log.
|
||||
3. `npm ci`
|
||||
4. `npx prisma migrate dev`
|
||||
@@ -64,8 +64,9 @@ deployment-pipeline work.
|
||||
|
||||
- Visit **[/login](http://localhost:3000/login)** or use **Log in** in the
|
||||
site header.
|
||||
- Without `SMTP_URL`: copy the verify URL from the dev server terminal.
|
||||
- With Mailhog: set `SMTP_URL=smtp://localhost:1025` and open the message
|
||||
- Without `CLOUDRON_MAIL_SMTP_*`: copy the verify URL from the dev server terminal.
|
||||
- With Mailhog: set `CLOUDRON_MAIL_SMTP_SERVER=localhost` and
|
||||
`CLOUDRON_MAIL_SMTP_PORT=1025` (see `.env.example`) and open the message
|
||||
at [http://localhost:8025](http://localhost:8025).
|
||||
- Open the link in the **same browser** as the app (session cookie).
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ Use this if you **do not** have SSH or hosting access yet. Most engineering tick
|
||||
|
||||
### You do **not** need the server admin for
|
||||
|
||||
- **Tickets 1–8, 10:** Everything runs on your machine: `docker compose up -d postgres mailhog`, `.env`, `npm run dev`, `npx prisma migrate dev`. **Magic-link** sign-in email can use Mailhog or **dev server logs** (verify URL) when `SMTP_URL` is unset—no real SMTP required locally.
|
||||
- **Tickets 1–8, 10:** Everything runs on your machine: `docker compose up -d postgres mailhog`, `.env`, `npm run dev`, `npx prisma migrate dev`. **Magic-link** sign-in email can use Mailhog or **dev server logs** (verify URL) when `CLOUDRON_MAIL_SMTP_*` is unset—no real SMTP required locally.
|
||||
- **Verifying APIs:** Use `localhost` and the same Docker Postgres—no production host.
|
||||
|
||||
### The **first** time you need someone with hosting access
|
||||
@@ -35,10 +35,10 @@ Ask the admin to provide (or do for you) the items below—**Ticket 12** turns t
|
||||
|
||||
| What | Why you need it |
|
||||
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Postgres** | Managed instance or container; a **`DATABASE_URL`** you can plug into the deployed app. |
|
||||
| **Postgres** | Managed instance or container; a **`CLOUDRON_POSTGRESQL_URL`** you can plug into the deployed app. |
|
||||
| **Run migrations** | Someone runs **`npx prisma migrate deploy`** against that database **before** the new app version serves traffic (or gives you a secure way to run it in CI/CD). |
|
||||
| **`SESSION_SECRET`** | Long random string in production env (sessions **+ hashed magic-link tokens**). |
|
||||
| **SMTP** | **`SMTP_URL`** + **`SMTP_FROM`** for real **sign-in link** email; not required on laptop if you use logs/Mailhog. |
|
||||
| **SMTP** | **`CLOUDRON_MAIL_SMTP_*`** + **`SMTP_FROM`** for real **sign-in link** email; not required on laptop if you use logs/Mailhog. |
|
||||
| **DNS for mail** | Often **SPF/DKIM** so **magic-link** messages are not spam—admin or whoever owns DNS. |
|
||||
| **TLS + hostname** | HTTPS URL for the site; reverse proxy (nginx, Caddy, etc.) in front of Node. |
|
||||
| **Health check** | Load balancer or platform should probe **`GET /api/health`** (or your chosen path). |
|
||||
@@ -137,7 +137,7 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi
|
||||
2. Flow: email → “Send link” → user opens link (email, Mailhog, or dev log) → `GET /api/auth/magic-link/verify?token=...` sets session and redirects; optional `next` for post-login path.
|
||||
3. Surface API errors: invalid email, 429 `retryAfterMs`, expired/invalid token, network failure (accessible copy).
|
||||
4. Ensure `fetch` calls use `credentials: "include"` where needed (see [lib/create/api.ts](lib/create/api.ts)).
|
||||
5. **Dev:** without `SMTP_URL`, verify URL is logged; with Mailhog, use [docker-compose.yml](docker-compose.yml) and `SMTP_URL=smtp://localhost:1025`.
|
||||
5. **Dev:** without `CLOUDRON_MAIL_SMTP_*`, verify URL is logged; with Mailhog, use [docker-compose.yml](docker-compose.yml) and `CLOUDRON_MAIL_SMTP_SERVER=localhost` + `CLOUDRON_MAIL_SMTP_PORT=1025`.
|
||||
6. **Marketing header:** When signed in (`fetchAuthSession`), **Log in** becomes **Profile** linking to [`/profile`](app/(app)/profile/page.tsx) (placeholder until Ticket 15 / CR-86). Implemented in [TopWithPathname.tsx](app/components/navigation/Top/TopWithPathname.tsx) + [Top.container.tsx](app/components/navigation/Top/Top.container.tsx).
|
||||
|
||||
**Acceptance criteria:**
|
||||
@@ -676,9 +676,25 @@ All six are titled `[Backend] …`, assigned to Vinod, in the **community-rule**
|
||||
| 6 | [CR-101](https://linear.app/community-rule/issue/CR-101/backend-decommission-legacy-communityrule-lamp-app) | `[Backend] Decommission legacy CommunityRule LAMP app` | CR-99 + sign-off window |
|
||||
| 7 | [CR-102](https://linear.app/community-rule/issue/CR-102/backend-decide-fate-of-legacy-rules-table-read-only-export) | `[Backend] Decide fate of legacy rules table (read-only export?)` | must resolve before CR-99 maintenance window |
|
||||
|
||||
### PR plan (CR-96 – CR-102)
|
||||
|
||||
**One ticket ≠ one mega-branch.** Repo PRs stay small; stack only when there is a hard code dependency (e.g. CR-97 before CR-98 can pull a built image from `main`). Ops steps (Cloudron install, DNS, smoke, cutover, decommission) are tracked in Linear checklists — not bundled into a single git branch.
|
||||
|
||||
| Order | Linear | Repo PR / branch | Kind | Status | Blocked by |
|
||||
| ----- | ------ | ---------------- | ---- | ------ | ---------- |
|
||||
| 1 | [CR-96](https://linear.app/community-rule/issue/CR-96/backend-bridge-cloudron-env-vars-to-canonical-names) | `adilallo/Backend/BridgeCloudronEnv` — *[Backend] Cloudron-native environment variables* | repo | **Open** | — |
|
||||
| 2 | [CR-97](https://linear.app/community-rule/issue/CR-97/backend-container-image-registry-choose-build-push) | TBD — registry choice + build/push (Dockerfile / CI) | repo | **Next** | CR-96 merged + registry decision ([ops-backend-deploy.md](ops-backend-deploy.md) §6) |
|
||||
| — | [CR-102](https://linear.app/community-rule/issue/CR-102/backend-decide-fate-of-legacy-rules-table-read-only-export) | TBD — optional repo PR if export tooling/docs needed | product / repo | **Parallel** | row count from legacy MySQL (pre–CR-99 backup) |
|
||||
| 3 | [CR-98](https://linear.app/community-rule/issue/CR-98/backend-cloudron-staging-install-smoke) | — (ops checklist; doc tweaks only if smoke finds gaps) | ops | Backlog | CR-96 + CR-97 + Cloudron CLI token + staging DNS |
|
||||
| 4 | [CR-100](https://linear.app/community-rule/issue/CR-100/backend-steady-state-operator-runbook) | TBD — `docs/guides/ops-runbook.md` | docs | Backlog | CR-98 (write what we actually did) |
|
||||
| 5 | [CR-99](https://linear.app/community-rule/issue/CR-99/backend-cloudron-production-install-apex-cutover) | — (ops; maintenance window) | ops | Backlog | CR-98 green + CR-102 resolved |
|
||||
| 6 | [CR-101](https://linear.app/community-rule/issue/CR-101/backend-decommission-legacy-communityrule-lamp-app) | — (ops; uninstall LAMP slot) | ops | Backlog | CR-99 + sign-off window |
|
||||
|
||||
**What's next:** merge **CR-96** PR, then open **CR-97** on its own branch. Start **CR-102** product decision in parallel so it is resolved before the CR-99 cutover window.
|
||||
|
||||
**Per-ticket detail:**
|
||||
|
||||
1. **Bridge `CLOUDRON_*` env vars to canonical names.** Cloudron injects `CLOUDRON_POSTGRESQL_URL` and `CLOUDRON_MAIL_SMTP_SERVER/PORT/USERNAME/PASSWORD`; the app reads `DATABASE_URL` / `SMTP_URL`. Recommended approach: read both names in [`lib/server/env.ts`](../../lib/server/env.ts) and assemble `SMTP_URL` from the four parts in [`lib/server/mail.ts`](../../lib/server/mail.ts) when only the Cloudron names are present. Alternative: a `start.sh` shim in the image. Acceptance: with only `CLOUDRON_*` set, app connects to DB and sends mail; with only canonical names set (current behavior), unchanged; unit tests cover both.
|
||||
1. **Cloudron-native env vars (CR-96).** **Shipped in repo** on branch `adilallo/Backend/BridgeCloudronEnv` (PR open). App reads `CLOUDRON_POSTGRESQL_URL` and `CLOUDRON_MAIL_SMTP_*` only (no `DATABASE_URL` / `SMTP_URL` shim). Local dev uses the same names in `.env`. SMTP URL assembled in [`lib/server/env.ts`](../../lib/server/env.ts); mail senders use `getSmtpUrl()`. Acceptance: with only `CLOUDRON_*` set, app connects to DB and sends mail; unit tests in `tests/unit/env.test.ts`.
|
||||
2. **Container image registry: choose, build, push.** Acceptance: `docker pull <registry>/communityrule:<tag>` works from a Cloudron-reachable network. CI builds and pushes on merge to `main` (stretch).
|
||||
3. **Cloudron staging install + smoke.** Acceptance: `curl https://<staging>/api/health` returns `{"ok":true,"database":"connected"}`; magic-link request → click link → `GET /api/auth/session` returns a user; publishing a rule succeeds.
|
||||
4. **Cloudron production install + DNS cutover.** Acceptance: production subdomain resolves to the new app; old subdomain still works during overlap; sign-in + publish succeed against production; backups confirmed.
|
||||
@@ -806,39 +822,39 @@ Tickets **10–11** can be deferred without blocking the core “auth + drafts +
|
||||
|
||||
## Linear (Community-rule team)
|
||||
|
||||
**Main chain (historical):** **CR-72 → CR-83** was the original **strict sequence**; **repo + Linear status today:** **CR-72–CR-79**, **CR-83**, **CR-84**, **CR-85**, **CR-88**, **CR-89** are **Done**; **CR-77** (publish) **Done**; **CR-80–CR-81** remain **Backlog** (web vitals, public rule detail). **CR-82** covered by local `migrate:smoke` (see Ticket 11). **CR-83** (admin handoff) shipped as a narrow handoff sheet; the actual Cloudron deployment pipeline is split into the **`[Backend]` follow-up tickets** filed under it (env-var bridging → image registry → staging → production cutover → operator runbook → legacy decommission). **Parallel (still open):** **CR-86** / Ticket 15 (**Backlog** — publish **not** a blocker); **CR-103** / Ticket 20 (change account email); **CR-93** (**Backlog**); **CR-90** / Ticket 18 (stakeholder invites); **CR-91** / Ticket 19 (`Add` button behavior); **Ticket 21 / [CR-113](https://linear.app/community-rule/issue/CR-113/backend-create-flow-file-uploads-community-photo-custom-method)** (create-flow file uploads).
|
||||
**Main chain (historical):** **CR-72 → CR-83** was the original **strict sequence**; **repo + Linear status today:** **CR-72–CR-79**, **CR-83**, **CR-84**, **CR-85**, **CR-88**, **CR-89** are **Done**; **CR-77** (publish) **Done**; **CR-80–CR-81** remain **Backlog** (web vitals, public rule detail). **CR-82** covered by local `migrate:smoke` (see Ticket 11). **CR-83** (admin handoff) shipped as a narrow handoff sheet; the Cloudron deployment pipeline is split into **`[Backend]` follow-ups CR-96–CR-102** — see [PR plan (CR-96 – CR-102)](#pr-plan-cr-96--cr-102) under Ticket 12 (**CR-96** repo PR open; **CR-97** next). **Parallel (still open):** **CR-86** / Ticket 15 (**Backlog** — publish **not** a blocker); **CR-103** / Ticket 20 (change account email); **CR-93** (**Backlog**); **CR-90** / Ticket 18 (stakeholder invites); **CR-91** / Ticket 19 (`Add` button behavior); **Ticket 21 / [CR-113](https://linear.app/community-rule/issue/CR-113/backend-create-flow-file-uploads-community-photo-custom-method)** (create-flow file uploads).
|
||||
|
||||
| Doc ticket | Linear | Title (short) |
|
||||
| ---------: | --------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
|
||||
| 1 | [CR-72](https://linear.app/community-rule/issue/CR-72/backend-align-docsbackend-roadmapmd-with-current-codebase) | Align backend-roadmap |
|
||||
| 2 | [CR-73](https://linear.app/community-rule/issue/CR-73/backend-formalize-createflowstate-validate-draftpublish-api-payloads) | CreateFlowState + API validation |
|
||||
| 3 | [CR-74](https://linear.app/community-rule/issue/CR-74/backend-magic-link-sign-in-ui-apis-ticket-3-cr-75-done) | Magic-link sign-in UI (Ticket 3; Done) |
|
||||
| 4 | [CR-75](https://linear.app/community-rule/issue/CR-75/backend-create-flow-session-ui-sign-out-ticket-4-done) | Create flow session UI (Ticket 4; Done) |
|
||||
| 5 | [CR-76](https://linear.app/community-rule/issue/CR-76/backend-harden-server-draft-sync-save-and-exit-post-login-transfer) | Draft sync hardening (PUT UX / errors) |
|
||||
| 6 | [CR-77](https://linear.app/community-rule/issue/CR-77/backend-wire-publish-rule-from-create-flow-post-apirules) | Publish wiring **Done** |
|
||||
| 7 | [CR-78](https://linear.app/community-rule/issue/CR-78/backend-prisma-seed-ruletemplate-document) | Template seed **Done** |
|
||||
| 8 | [CR-79](https://linear.app/community-rule/issue/CR-79/backend-load-rule-templates-from-get-apitemplates-in-ui) | Templates in UI |
|
||||
| 9 | [CR-80](https://linear.app/community-rule/issue/CR-80/backend-persist-web-vitals-outside-next-db-or-external-rum) | Web vitals (prefer external) |
|
||||
| 10 | [CR-81](https://linear.app/community-rule/issue/CR-81/backend-public-rule-detail-page-get-apirulesid-optional) | Public rule detail (optional) |
|
||||
| 11 | [CR-82](https://linear.app/community-rule/issue/CR-82/backend-ci-postgres-migration-smoke-optional) | Local migrate smoke (**Done in repo**; optional remote CI) |
|
||||
| 12 | [CR-83](https://linear.app/community-rule/issue/CR-83/backend-stagingproduction-runbook-admin-handoff-docsops-backend) | Ops admin handoff (Cloudron) **Done** |
|
||||
| 12.1 | [CR-96](https://linear.app/community-rule/issue/CR-96/backend-bridge-cloudron-env-vars-to-canonical-names) | `[Backend] Bridge CLOUDRON_* env vars to canonical names` |
|
||||
| 12.2 | [CR-97](https://linear.app/community-rule/issue/CR-97/backend-container-image-registry-choose-build-push) | `[Backend] Container image registry: choose, build, push` |
|
||||
| 12.3 | [CR-98](https://linear.app/community-rule/issue/CR-98/backend-cloudron-staging-install-smoke) | `[Backend] Cloudron staging install + smoke` |
|
||||
| 12.4 | [CR-99](https://linear.app/community-rule/issue/CR-99/backend-cloudron-production-install-apex-cutover) | `[Backend] Cloudron production install + apex cutover` |
|
||||
| 12.5 | [CR-100](https://linear.app/community-rule/issue/CR-100/backend-steady-state-operator-runbook) | `[Backend] Steady-state operator runbook` |
|
||||
| 12.6 | [CR-101](https://linear.app/community-rule/issue/CR-101/backend-decommission-legacy-communityrule-lamp-app) | `[Backend] Decommission legacy CommunityRule LAMP app` |
|
||||
| 12.7 | [CR-102](https://linear.app/community-rule/issue/CR-102/backend-decide-fate-of-legacy-rules-table-read-only-export) | `[Backend] Decide fate of legacy rules table (read-only export?)` |
|
||||
| 13 | [CR-84](https://linear.app/community-rule/issue/CR-84/backend-api-error-contract-request-id-logging) | API errors + request-id logging |
|
||||
| 14 | [CR-85](https://linear.app/community-rule/issue/CR-85/backend-custom-session-lifecycle-cleanup-invalidation-policy) | Session lifecycle + cleanup **Done** |
|
||||
| 15 | [CR-86](https://linear.app/community-rule/issue/CR-86/backend-profile-dashboard-account-figma-profile) | Profile + account (Figma 22143:900069) |
|
||||
| 16 | [CR-88](https://linear.app/community-rule/issue/CR-88/backend-template-recommendation-matrix-facet-data-seed-and-apis-no) | Template matrix (facets; no xlsx) |
|
||||
| 17 | [CR-89](https://linear.app/community-rule/issue/CR-89/product-canon-custom-create-rule-wizard-routes-resume-progress-repo) | Canon create-flow (custom wizard + docs) **Done** |
|
||||
| — | [CR-93](https://linear.app/community-rule/issue/CR-93/product-rank-template-cards-by-community-facets-reuse-get-apitemplates) | Template grid + facet ranking (product) |
|
||||
| 18 | [CR-90](https://linear.app/community-rule/issue/CR-90/productbackend-invite-stakeholders-email-from-confirm-stakeholders) | Stakeholder invites (confirm-stakeholders) |
|
||||
| 19 | [CR-91](https://linear.app/community-rule/issue/CR-91/productdesign-add-button-behavior-on-custom-rule-pages-and-final) | `Add` button behavior (custom-rule + Final Review) |
|
||||
| 20 | [CR-103](https://linear.app/community-rule/issue/CR-103/backend-change-account-email-verify-new-address-conflict-session) | Change account email (verify new address) **Backlog** |
|
||||
| 21 | [CR-113](https://linear.app/community-rule/issue/CR-113/backend-create-flow-file-uploads-community-photo-custom-method) | Create flow file uploads (community + custom blocks) **Backlog** |
|
||||
| Doc ticket | Linear | Title (short) | Deploy PR / tracking |
|
||||
| ---------: | --------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | -------------------- |
|
||||
| 1 | [CR-72](https://linear.app/community-rule/issue/CR-72/backend-align-docsbackend-roadmapmd-with-current-codebase) | Align backend-roadmap | — |
|
||||
| 2 | [CR-73](https://linear.app/community-rule/issue/CR-73/backend-formalize-createflowstate-validate-draftpublish-api-payloads) | CreateFlowState + API validation | — |
|
||||
| 3 | [CR-74](https://linear.app/community-rule/issue/CR-74/backend-magic-link-sign-in-ui-apis-ticket-3-cr-75-done) | Magic-link sign-in UI (Ticket 3; Done) | — |
|
||||
| 4 | [CR-75](https://linear.app/community-rule/issue/CR-75/backend-create-flow-session-ui-sign-out-ticket-4-done) | Create flow session UI (Ticket 4; Done) | — |
|
||||
| 5 | [CR-76](https://linear.app/community-rule/issue/CR-76/backend-harden-server-draft-sync-save-and-exit-post-login-transfer) | Draft sync hardening (PUT UX / errors) | — |
|
||||
| 6 | [CR-77](https://linear.app/community-rule/issue/CR-77/backend-wire-publish-rule-from-create-flow-post-apirules) | Publish wiring **Done** | — |
|
||||
| 7 | [CR-78](https://linear.app/community-rule/issue/CR-78/backend-prisma-seed-ruletemplate-document) | Template seed **Done** | — |
|
||||
| 8 | [CR-79](https://linear.app/community-rule/issue/CR-79/backend-load-rule-templates-from-get-apitemplates-in-ui) | Templates in UI | — |
|
||||
| 9 | [CR-80](https://linear.app/community-rule/issue/CR-80/backend-persist-web-vitals-outside-next-db-or-external-rum) | Web vitals (prefer external) | — |
|
||||
| 10 | [CR-81](https://linear.app/community-rule/issue/CR-81/backend-public-rule-detail-page-get-apirulesid-optional) | Public rule detail (optional) | — |
|
||||
| 11 | [CR-82](https://linear.app/community-rule/issue/CR-82/backend-ci-postgres-migration-smoke-optional) | Local migrate smoke (**Done in repo**; optional remote CI) | — |
|
||||
| 12 | [CR-83](https://linear.app/community-rule/issue/CR-83/backend-stagingproduction-runbook-admin-handoff-docsops-backend) | Ops admin handoff (Cloudron) **Done** | — |
|
||||
| 12.1 | [CR-96](https://linear.app/community-rule/issue/CR-96/backend-bridge-cloudron-env-vars-to-canonical-names) | Cloudron-native env vars | **Open** — `adilallo/Backend/BridgeCloudronEnv` |
|
||||
| 12.2 | [CR-97](https://linear.app/community-rule/issue/CR-97/backend-container-image-registry-choose-build-push) | Container image registry + CI | **Next** — own branch after CR-96 |
|
||||
| 12.3 | [CR-98](https://linear.app/community-rule/issue/CR-98/backend-cloudron-staging-install-smoke) | Cloudron staging install + smoke | Ops — after CR-96 + CR-97 |
|
||||
| 12.4 | [CR-99](https://linear.app/community-rule/issue/CR-99/backend-cloudron-production-install-apex-cutover) | Production install + apex cutover | Ops — after CR-98 + CR-102 |
|
||||
| 12.5 | [CR-100](https://linear.app/community-rule/issue/CR-100/backend-steady-state-operator-runbook) | Steady-state operator runbook | Docs PR — after CR-98 |
|
||||
| 12.6 | [CR-101](https://linear.app/community-rule/issue/CR-101/backend-decommission-legacy-communityrule-lamp-app) | Decommission legacy LAMP app | Ops — after CR-99 + sign-off |
|
||||
| 12.7 | [CR-102](https://linear.app/community-rule/issue/CR-102/backend-decide-fate-of-legacy-rules-table-read-only-export) | Legacy `rules` table fate / export | **Parallel** — before CR-99 |
|
||||
| 13 | [CR-84](https://linear.app/community-rule/issue/CR-84/backend-api-error-contract-request-id-logging) | API errors + request-id logging | — |
|
||||
| 14 | [CR-85](https://linear.app/community-rule/issue/CR-85/backend-custom-session-lifecycle-cleanup-invalidation-policy) | Session lifecycle + cleanup **Done** | — |
|
||||
| 15 | [CR-86](https://linear.app/community-rule/issue/CR-86/backend-profile-dashboard-account-figma-profile) | Profile + account (Figma 22143:900069) | — |
|
||||
| 16 | [CR-88](https://linear.app/community-rule/issue/CR-88/backend-template-recommendation-matrix-facet-data-seed-and-apis-no) | Template matrix (facets; no xlsx) | — |
|
||||
| 17 | [CR-89](https://linear.app/community-rule/issue/CR-89/product-canon-custom-create-rule-wizard-routes-resume-progress-repo) | Canon create-flow (custom wizard + docs) **Done** | — |
|
||||
| — | [CR-93](https://linear.app/community-rule/issue/CR-93/product-rank-template-cards-by-community-facets-reuse-get-apitemplates) | Template grid + facet ranking (product) | — |
|
||||
| 18 | [CR-90](https://linear.app/community-rule/issue/CR-90/productbackend-invite-stakeholders-email-from-confirm-stakeholders) | Stakeholder invites (confirm-stakeholders) | — |
|
||||
| 19 | [CR-91](https://linear.app/community-rule/issue/CR-91/productdesign-add-button-behavior-on-custom-rule-pages-and-final) | `Add` button behavior (custom-rule + Final Review) | — |
|
||||
| 20 | [CR-103](https://linear.app/community-rule/issue/CR-103/backend-change-account-email-verify-new-address-conflict-session) | Change account email (verify new address) **Backlog** | — |
|
||||
| 21 | [CR-113](https://linear.app/community-rule/issue/CR-113/backend-create-flow-file-uploads-community-photo-custom-method) | Create flow file uploads (community + custom blocks) **Backlog** | — |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ Full table: [CONTRIBUTING.md](../CONTRIBUTING.md) **API routes**.
|
||||
|
||||
**Step 2.** Use **Prisma** — `schema.prisma`, `npx prisma migrate dev` / `migrate deploy`.
|
||||
|
||||
**Step 3.** Add **SMTP** (or Mailhog locally) for **magic-link** sign-in email in deployed environments; when `SMTP_URL` is unset in dev, the app can log the **verify URL** to the console (same pattern as [`lib/server/mail.ts`](lib/server/mail.ts)).
|
||||
**Step 3.** Add **SMTP** (or Mailhog locally) for **magic-link** sign-in email in deployed environments; when `CLOUDRON_MAIL_SMTP_*` is unset in dev, the app can log the **verify URL** to the console (same pattern as [`lib/server/mail.ts`](lib/server/mail.ts)).
|
||||
|
||||
**Step 4.** **Redis / queues / Kubernetes** — not required for v1. **Exception:** before running **multiple app instances**, plan a **shared rate-limit store** (often Redis) for **passwordless email (magic-link request)**; the current limiter is in-memory per process ([`lib/server/rateLimit.ts`](lib/server/rateLimit.ts)).
|
||||
|
||||
@@ -157,7 +157,7 @@ Match the current API behavior; tighten as product evolves:
|
||||
|
||||
---
|
||||
|
||||
**Step 1.** Copy `.env.example` to `.env`. Set `DATABASE_URL` and secrets (see file comments).
|
||||
**Step 1.** Copy `.env.example` to `.env`. Set `CLOUDRON_POSTGRESQL_URL` and secrets (see file comments).
|
||||
|
||||
**Step 2.** Start Postgres locally:
|
||||
|
||||
@@ -183,7 +183,7 @@ npm run dev
|
||||
**Step 6.** **Magic-link sign-in** (happy path):
|
||||
|
||||
1. `POST /api/auth/magic-link/request` with `{ "email": "you@example.com" }` (optional `"next"` for redirect after verify).
|
||||
2. Open the link from email, Mailhog, or **server logs** when `SMTP_URL` is unset (dev).
|
||||
2. Open the link from email, Mailhog, or **server logs** when `CLOUDRON_MAIL_SMTP_*` is unset (dev).
|
||||
3. Browser hits `GET /api/auth/magic-link/verify?token=...` (and optional `next=...`); response sets the session cookie and redirects.
|
||||
4. `GET /api/auth/session` should show your user in the same browser.
|
||||
|
||||
@@ -221,7 +221,7 @@ npm run dev
|
||||
|
||||
**Optional QA:** Run automated tests against an **ephemeral** database in CI instead of maintaining a fourth long-lived server.
|
||||
|
||||
**Target platform:** **Cloudron at MEDLab** — same host as the legacy [`CommunityRule/CommunityRuleBackend`](https://git.medlab.host/CommunityRule/CommunityRuleBackend) (Express + MySQL). The new app is packaged as a proper Cloudron app (Docker image + `CloudronManifest.json`, **postgresql + sendmail + localstorage** addons). Cloudron's container supervisor replaces the legacy 30-min `run.sh` watchdog. Admin handoff (access, env vars, platform settings, open decisions): [`docs/guides/ops-backend-deploy.md`](ops-backend-deploy.md). Note: Cloudron injects `CLOUDRON_POSTGRESQL_URL` and `CLOUDRON_MAIL_SMTP_*`; the app reads `DATABASE_URL` / `SMTP_URL`, so a small env-var bridge in [`lib/server/env.ts`](../../lib/server/env.ts) / [`lib/server/mail.ts`](../../lib/server/mail.ts) is needed (tracked in [**CR-96**](https://linear.app/community-rule/issue/CR-96/backend-bridge-cloudron-env-vars-to-canonical-names), filed under CR-83 — see [backend-linear-tickets.md](backend-linear-tickets.md) Ticket 12 follow-ups).
|
||||
**Target platform:** **Cloudron at MEDLab** — same host as the legacy [`CommunityRule/CommunityRuleBackend`](https://git.medlab.host/CommunityRule/CommunityRuleBackend) (Express + MySQL). The new app is packaged as a proper Cloudron app (Docker image + `CloudronManifest.json`, **postgresql + sendmail + localstorage** addons). Cloudron's container supervisor replaces the legacy 30-min `run.sh` watchdog. Admin handoff (access, env vars, platform settings, open decisions): [`docs/guides/ops-backend-deploy.md`](ops-backend-deploy.md). The app reads Cloudron-injected `CLOUDRON_POSTGRESQL_URL` and `CLOUDRON_MAIL_SMTP_*` via [`lib/server/env.ts`](../../lib/server/env.ts) (CR-96).
|
||||
|
||||
**Admin / infra (coordinate with whoever runs the server):**
|
||||
|
||||
|
||||
@@ -59,13 +59,13 @@ Cloudron addons are not "enabled" platform-wide; they are requested
|
||||
per-app in the manifest and provisioned at install time.
|
||||
|
||||
- `CLOUDRON_POSTGRESQL_URL` — from the **postgresql** addon. The app
|
||||
reads `DATABASE_URL`; bridging is a small in-app code change (see
|
||||
§8 [CR-96](https://linear.app/community-rule/issue/CR-96/backend-bridge-cloudron-env-vars-to-canonical-names)).
|
||||
reads this name directly (Prisma + [`lib/server/env.ts`](../../lib/server/env.ts)).
|
||||
- `CLOUDRON_MAIL_SMTP_SERVER` / `_PORT` / `_USERNAME` / `_PASSWORD` —
|
||||
from the **sendmail** addon. The platform Mail server is configured
|
||||
for `communityrule.info` with **Amazon SES relay** + "allow custom
|
||||
from address" on, so `SMTP_FROM` of our choice will deliver. The
|
||||
app reads `SMTP_URL`; bridged the same way.
|
||||
from address" on, so `SMTP_FROM` of our choice will deliver. The app
|
||||
assembles a Nodemailer transport URL from these four vars in
|
||||
[`lib/server/env.ts`](../../lib/server/env.ts).
|
||||
|
||||
### I set manually via `cloudron configure --app <id> --set-env`
|
||||
|
||||
@@ -199,8 +199,8 @@ All filed in Linear, titled `[Backend] …`, assigned to me, in the
|
||||
**Community-rule** team, **Backlog** state.
|
||||
|
||||
1. [**CR-96**](https://linear.app/community-rule/issue/CR-96/backend-bridge-cloudron-env-vars-to-canonical-names)
|
||||
— `[Backend] Bridge CLOUDRON_* env vars to canonical names`. No
|
||||
blockers; can land now.
|
||||
— `[Backend] Cloudron-native env vars` (shipped: app reads
|
||||
`CLOUDRON_POSTGRESQL_URL` and `CLOUDRON_MAIL_SMTP_*` only).
|
||||
2. [**CR-97**](https://linear.app/community-rule/issue/CR-97/backend-container-image-registry-choose-build-push)
|
||||
— `[Backend] Container image registry: choose, build, push`.
|
||||
Registry decided (§6.3); packaging + build/push workflow shipped
|
||||
|
||||
@@ -788,7 +788,7 @@ and return all rows.
|
||||
score-0 templates still present at the end in curated order.
|
||||
- [x] No-facets `GET /api/templates` matches today's curated ordering
|
||||
(no regression for the existing marketing/templates surfaces).
|
||||
- [x] DB-down smoke: with `DATABASE_URL` unset, the four wizard
|
||||
- [x] DB-down smoke: with `CLOUDRON_POSTGRESQL_URL` unset, the four wizard
|
||||
card-deck steps still render the full deck from messages (no
|
||||
5xx, no broken cards).
|
||||
- [x] Editing a `data/create/customRule/<section>.json` entry and
|
||||
|
||||
@@ -26,7 +26,7 @@ Starts a throwaway Postgres on `127.0.0.1:5433`, runs `prisma migrate
|
||||
deploy`, checks the connection, then removes the container. Port **5433**
|
||||
avoids clashing with `docker compose` on **5432**. If you already use
|
||||
Compose on 5432: `docker compose up -d postgres` then
|
||||
`DATABASE_URL=postgresql://communityrule:communityrule@127.0.0.1:5432/communityrule npm run db:deploy`.
|
||||
`CLOUDRON_POSTGRESQL_URL=postgresql://communityrule:communityrule@127.0.0.1:5432/communityrule npm run db:deploy`.
|
||||
|
||||
Do not rewrite migrations already applied to shared DBs — see
|
||||
[CONTRIBUTING.md](../CONTRIBUTING.md) and
|
||||
|
||||
+20
-2
@@ -8,6 +8,24 @@ export function getSessionPepper(): string {
|
||||
return secret;
|
||||
}
|
||||
|
||||
export function isDatabaseConfigured(): boolean {
|
||||
return Boolean(process.env.DATABASE_URL?.trim());
|
||||
export function getDatabaseUrl(): string | undefined {
|
||||
return process.env.CLOUDRON_POSTGRESQL_URL?.trim() || undefined;
|
||||
}
|
||||
|
||||
export function getSmtpUrl(): string | undefined {
|
||||
const server = process.env.CLOUDRON_MAIL_SMTP_SERVER?.trim();
|
||||
const port = process.env.CLOUDRON_MAIL_SMTP_PORT?.trim();
|
||||
if (!server || !port) return undefined;
|
||||
|
||||
const username = process.env.CLOUDRON_MAIL_SMTP_USERNAME?.trim() ?? "";
|
||||
const password = process.env.CLOUDRON_MAIL_SMTP_PASSWORD?.trim() ?? "";
|
||||
if (username || password) {
|
||||
const auth = `${encodeURIComponent(username)}:${encodeURIComponent(password)}@`;
|
||||
return `smtp://${auth}${server}:${port}`;
|
||||
}
|
||||
return `smtp://${server}:${port}`;
|
||||
}
|
||||
|
||||
export function isDatabaseConfigured(): boolean {
|
||||
return Boolean(getDatabaseUrl());
|
||||
}
|
||||
|
||||
+9
-8
@@ -1,18 +1,19 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import { logger } from "../logger";
|
||||
import { getSmtpUrl } from "./env";
|
||||
|
||||
export async function sendMagicLinkEmail(
|
||||
to: string,
|
||||
verifyUrl: string,
|
||||
): Promise<void> {
|
||||
const url = process.env.SMTP_URL;
|
||||
const url = getSmtpUrl();
|
||||
|
||||
if (!url) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
logger.info(`[dev] Magic link for ${to}: ${verifyUrl}`);
|
||||
return;
|
||||
}
|
||||
throw new Error("SMTP_URL is not configured");
|
||||
throw new Error("CLOUDRON_MAIL_SMTP_* is not configured");
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport(url);
|
||||
@@ -33,7 +34,7 @@ export async function sendRuleStakeholderInviteEmail(
|
||||
verifyUrl: string,
|
||||
ruleTitle: string,
|
||||
): Promise<void> {
|
||||
const url = process.env.SMTP_URL;
|
||||
const url = getSmtpUrl();
|
||||
|
||||
if (!url) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
@@ -42,7 +43,7 @@ export async function sendRuleStakeholderInviteEmail(
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("SMTP_URL is not configured");
|
||||
throw new Error("CLOUDRON_MAIL_SMTP_* is not configured");
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport(url);
|
||||
@@ -66,7 +67,7 @@ export async function sendOrganizerInquiryNotification(params: {
|
||||
requestId: string;
|
||||
}): Promise<void> {
|
||||
const { to, fromEmail, visitorEmail, message, requestId } = params;
|
||||
const url = process.env.SMTP_URL;
|
||||
const url = getSmtpUrl();
|
||||
|
||||
if (!url) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
@@ -75,7 +76,7 @@ export async function sendOrganizerInquiryNotification(params: {
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("SMTP_URL is not configured");
|
||||
throw new Error("CLOUDRON_MAIL_SMTP_* is not configured");
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport(url);
|
||||
@@ -93,14 +94,14 @@ export async function sendEmailChangeEmail(
|
||||
to: string,
|
||||
verifyUrl: string,
|
||||
): Promise<void> {
|
||||
const url = process.env.SMTP_URL;
|
||||
const url = getSmtpUrl();
|
||||
|
||||
if (!url) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
logger.info(`[dev] Email change verify for ${to}: ${verifyUrl}`);
|
||||
return;
|
||||
}
|
||||
throw new Error("SMTP_URL is not configured");
|
||||
throw new Error("CLOUDRON_MAIL_SMTP_* is not configured");
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport(url);
|
||||
|
||||
@@ -50,7 +50,7 @@ export function errorJson(
|
||||
export function dbUnavailable(): NextResponse {
|
||||
return errorJson(
|
||||
"db_unavailable",
|
||||
"Database is not configured (DATABASE_URL).",
|
||||
"Database is not configured (CLOUDRON_POSTGRESQL_URL).",
|
||||
503,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ generator client {
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
url = env("CLOUDRON_POSTGRESQL_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
|
||||
@@ -7,7 +7,7 @@ POSTGRES_USER="${POSTGRES_USER:-communityrule}"
|
||||
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-communityrule}"
|
||||
POSTGRES_DB="${POSTGRES_DB:-communityrule}"
|
||||
CONTAINER_NAME="${CONTAINER_NAME:-migrate-smoke-pg}"
|
||||
export DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@127.0.0.1:${PG_HOST_PORT}/${POSTGRES_DB}"
|
||||
export CLOUDRON_POSTGRESQL_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@127.0.0.1:${PG_HOST_PORT}/${POSTGRES_DB}"
|
||||
|
||||
cleanup() {
|
||||
docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
|
||||
@@ -41,6 +41,6 @@ echo "→ prisma migrate deploy"
|
||||
npm run db:deploy
|
||||
|
||||
echo "→ Verifying connection (SELECT 1)"
|
||||
echo "SELECT 1;" | npx --no-install prisma db execute --stdin --url "$DATABASE_URL"
|
||||
echo "SELECT 1;" | npx --no-install prisma db execute --stdin --url "$CLOUDRON_POSTGRESQL_URL"
|
||||
|
||||
echo "→ migrate smoke OK"
|
||||
|
||||
+4
-4
@@ -5,12 +5,12 @@
|
||||
|
||||
set -e
|
||||
|
||||
# Bridge Cloudron's env name to Prisma's expected name so `prisma migrate
|
||||
# deploy` works before CR-96 lands the in-app DATABASE_URL bridging.
|
||||
export DATABASE_URL="${DATABASE_URL:-$CLOUDRON_POSTGRESQL_URL}"
|
||||
|
||||
# /app/data is created at runtime by Cloudron's localstorage addon as
|
||||
# root:root; chown so the node user can write uploads.
|
||||
# Prisma reads `CLOUDRON_POSTGRESQL_URL` directly via `prisma/schema.prisma`
|
||||
# (`datasource db { url = env("CLOUDRON_POSTGRESQL_URL") }`), so no DATABASE_URL
|
||||
# bridging is needed in this script — the app code reads the same var via
|
||||
# `lib/server/env.ts`.
|
||||
chown -R node:node /app/data
|
||||
|
||||
# Next.js ISR cache lives at /app/.next/cache via a symlink baked into the
|
||||
|
||||
@@ -22,7 +22,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("GET /api/health", () => {
|
||||
it("returns not_configured when DATABASE_URL is unset", async () => {
|
||||
it("returns not_configured when database is not configured", async () => {
|
||||
isDatabaseConfiguredMock.mockReturnValue(false);
|
||||
const res = await GET(
|
||||
new NextRequest("https://x.test/api/health"),
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getDatabaseUrl,
|
||||
getSmtpUrl,
|
||||
isDatabaseConfigured,
|
||||
} from "../../lib/server/env";
|
||||
|
||||
const ENV_KEYS = [
|
||||
"CLOUDRON_POSTGRESQL_URL",
|
||||
"CLOUDRON_MAIL_SMTP_SERVER",
|
||||
"CLOUDRON_MAIL_SMTP_PORT",
|
||||
"CLOUDRON_MAIL_SMTP_USERNAME",
|
||||
"CLOUDRON_MAIL_SMTP_PASSWORD",
|
||||
] as const;
|
||||
|
||||
const ORIGINAL_ENV = Object.fromEntries(
|
||||
ENV_KEYS.map((key) => [key, process.env[key]]),
|
||||
) as Record<(typeof ENV_KEYS)[number], string | undefined>;
|
||||
|
||||
function clearEnv(): void {
|
||||
for (const key of ENV_KEYS) {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
|
||||
function restoreEnv(): void {
|
||||
for (const key of ENV_KEYS) {
|
||||
const originalValue = ORIGINAL_ENV[key];
|
||||
if (originalValue === undefined) {
|
||||
delete process.env[key];
|
||||
continue;
|
||||
}
|
||||
process.env[key] = originalValue;
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearEnv();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv();
|
||||
});
|
||||
|
||||
describe("getDatabaseUrl / isDatabaseConfigured", () => {
|
||||
it("returns the URL when CLOUDRON_POSTGRESQL_URL is set", () => {
|
||||
process.env.CLOUDRON_POSTGRESQL_URL =
|
||||
"postgresql://user:pass@localhost:5432/db";
|
||||
expect(getDatabaseUrl()).toBe(
|
||||
"postgresql://user:pass@localhost:5432/db",
|
||||
);
|
||||
expect(isDatabaseConfigured()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns undefined when unset", () => {
|
||||
expect(getDatabaseUrl()).toBeUndefined();
|
||||
expect(isDatabaseConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
it("treats whitespace-only as unset", () => {
|
||||
process.env.CLOUDRON_POSTGRESQL_URL = " ";
|
||||
expect(getDatabaseUrl()).toBeUndefined();
|
||||
expect(isDatabaseConfigured()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSmtpUrl", () => {
|
||||
it("returns undefined when server or port is missing", () => {
|
||||
process.env.CLOUDRON_MAIL_SMTP_SERVER = "localhost";
|
||||
expect(getSmtpUrl()).toBeUndefined();
|
||||
|
||||
clearEnv();
|
||||
process.env.CLOUDRON_MAIL_SMTP_PORT = "1025";
|
||||
expect(getSmtpUrl()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("builds a no-auth URL for Mailhog-style local SMTP", () => {
|
||||
process.env.CLOUDRON_MAIL_SMTP_SERVER = "localhost";
|
||||
process.env.CLOUDRON_MAIL_SMTP_PORT = "1025";
|
||||
expect(getSmtpUrl()).toBe("smtp://localhost:1025");
|
||||
});
|
||||
|
||||
it("builds an authenticated URL with encoded credentials", () => {
|
||||
process.env.CLOUDRON_MAIL_SMTP_SERVER = "smtp.example.com";
|
||||
process.env.CLOUDRON_MAIL_SMTP_PORT = "587";
|
||||
process.env.CLOUDRON_MAIL_SMTP_USERNAME = "user@domain";
|
||||
process.env.CLOUDRON_MAIL_SMTP_PASSWORD = "p@ss:word";
|
||||
expect(getSmtpUrl()).toBe(
|
||||
"smtp://user%40domain:p%40ss%3Aword@smtp.example.com:587",
|
||||
);
|
||||
});
|
||||
|
||||
it("includes auth when only username is set", () => {
|
||||
process.env.CLOUDRON_MAIL_SMTP_SERVER = "smtp.example.com";
|
||||
process.env.CLOUDRON_MAIL_SMTP_PORT = "25";
|
||||
process.env.CLOUDRON_MAIL_SMTP_USERNAME = "apikey";
|
||||
expect(getSmtpUrl()).toBe("smtp://apikey:@smtp.example.com:25");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user