-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy path.env.example
More file actions
241 lines (225 loc) · 13.8 KB
/
Copy path.env.example
File metadata and controls
241 lines (225 loc) · 13.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
# Public site URL (baked at build time for sitemap/robots/OG)
NEXT_PUBLIC_SITE_URL=https://sofrapiwas.com
# Waitlist intake (server-side; without these the API returns 503 and the
# form shows the mailto fallback)
RESEND_API_KEY=
WAITLIST_TO=
# Must be a sender on a Resend-VERIFIED domain. Do NOT use onboarding@resend.dev:
# it reaches ONLY the Resend account owner's address and 403s for everyone else.
WAITLIST_FROM="Sofra <sofra@send.sofrapiwas.com>"
# The monitored mailbox a recipient reaches by pressing Reply. The From domain
# sends but does not receive (sofrapiwas.com has no MX), so without this a reply
# to an invite, a password reset or an INVOICE hard-bounces. Does not have to be
# on the sending domain. Unset = no Reply-To header at all.
SUPPORT_REPLY_TO="sofra@piwas.nl"
# --- Control plane (partner program) ---
# Postgres for the control-plane DB (Prisma). Local dev: docker run postgres:16.
DATABASE_URL=postgresql://sofra:dev@localhost:5434/sofra
# Auth.js session signing secret: openssl rand -base64 32
AUTH_SECRET=
# Absolute base URL used in emails/links (prod: https://sofrapiwas.com)
NEXTAUTH_URL=http://localhost:3000
# --- Mollie subscription billing (S9 — ADR-005/ADR-011 Job A) ---
# API key from the sofrapiwas.com Mollie profile: test_... in dev/verification,
# live_... in production. Unset -> billing UI is disabled and the webhook 503s.
# NOTE: Mollie needs a publicly reachable webhook URL (derived from
# NEXTAUTH_URL) — webhooks don't fire into localhost dev.
# Mollie: test_ or live_ prefix selects the mode. Billing QA is local dev with a
# test_ key ONLY (CLAUDE.md §9) — never exercise billing against the live key.
MOLLIE_API_KEY=
# --- Stripe platform webhooks (POST /api/webhooks/stripe) ---
# ADR-011 amendment. This app never creates Stripe charges (that's the backend,
# Job B); it returns an application fee when a connected account refunds a
# charge, it records the fees it earns, and since the Express migration it MINTS
# each tenant's connected account (lib/stripe-connect-accounts.ts).
# Platform secret key (sk_test_/sk_live_). Unset -> the webhook 503s.
#
# SCOPES: `Application fees -> write` (the refund rail) plus, for minting,
# `Connect -> write`. That second scope is the reason this key stays in the
# CONTROL PLANE and is never the box's: `provision-tenant.sh` reads
# STRIPE_PLATFORM_API_KEY on the box, and a key that can mint connected accounts
# is a higher-value target than any box. Do NOT add `Connect -> write` there.
STRIPE_API_KEY=
# ONE url, TWO Stripe endpoints, one handler — because the two event scopes are
# ORTHOGONAL and Stripe will not merge them. Each endpoint has its own whsec_;
# either one alone is a working configuration, and the route 503s only when
# BOTH are unset.
#
# Signing secret for the platform's CONNECT (`connect: true`) endpoint —
# `charge.refunded` for every connected account. Stripe refuses to let a
# platform register a webhook ON a connected account, so these arrive here.
STRIPE_CONNECT_WEBHOOK_SECRET=
# Signing secret for the ACCOUNT-scoped (non-Connect) endpoint at the SAME url —
# `application_fee.created`. An ApplicationFee is a PLATFORM-owned object, so its
# event carries `account: null` and a Connect endpoint NEVER receives it
# (measured 2026-09-04, with a control). Stripe accepts a `connect: true`
# endpoint that lists this event with HTTP 200 and then never fires it, so the
# wrong configuration reads exactly like "no commission earned yet".
STRIPE_ACCOUNT_WEBHOOK_SECRET=
# --- Fleet telemetry roll-up (POST /api/telemetry/fleet) ---
# Shared bearer secret each tenant backend's FleetSummaryPushService must present
# (openssl rand -hex 32). Must match the same secret on the backend side. Unset -> the route 503s.
PRINTER_TELEMETRY_SECRET=
# --- Scheduled sweeps (POST /api/cron/{retention,go-live,trial-warnings,backup-alerts}) ---
# ONE shared bearer secret for all four cron endpoints (openssl rand -hex 32). Must
# match the CRON_SECRET GitHub secret on piwas-21/sofra. Unset -> the routes 503.
# Three of the four SEND MAIL, so an unset-or-guessable value here is an open relay
# for send.sofrapiwas.com, not merely a sweep that does not run.
CRON_SECRET=
# DATA-LOSS: the sweep purges nothing unless this is exactly "true". Windows below
# default to the privacy pack's recommendations (days / months); override to tune.
RETENTION_ENABLED=false
RETENTION_INVITE_TOKEN_DAYS=30
RETENTION_AUDIT_LOG_MONTHS=18
RETENTION_REJECTED_APPLICATION_MONTHS=12
# --- Error tracking (Sentry — DEV-PHASES W3) ---
# Empty = disabled (the SDK is a no-op, build stays green). The provided DSN is
# Sentry SaaS EU (ingest.de.sentry.io = EU data residency); "self-host" later is
# just swapping these values — no code change.
# SENTRY_DSN : server-side (Node/edge), read at RUNTIME on the box.
# NEXT_PUBLIC_SENTRY_DSN: browser SDK, baked into the client bundle at BUILD time
# (also drives the CSP connect-src allow — next.config.ts).
SENTRY_DSN=
NEXT_PUBLIC_SENTRY_DSN=
# Optional: label events by environment (defaults to NODE_ENV).
SENTRY_ENVIRONMENT=
NEXT_PUBLIC_SENTRY_ENVIRONMENT=
# The public IP a tenant's domain must point at, shown to a partner as the A record
# to publish when they put a client on their OWN zone or on the restaurant's own
# domain (SOFRA-PARTNER-FLEXIBILITY-PLAN D2). NOT a secret — it is public DNS — but
# deployment configuration rather than a constant: hardcoding it would be wrong the
# day the box moves, and every already-published record would then point at nothing.
# Unset is safe and honest: the UI says "we'll send you the address to point it at"
# instead of a plausible-looking placeholder, because an A record aimed at the wrong
# IP fails in a way that looks like OUR fault and takes a day to find.
TENANT_BOX_IP=
# ADR-012 git-native provisioning trigger (control plane → deploy-repo registry PR).
# A fine-grained GitHub token scoped to piwas-21/restaurant-app-deploy with
# Contents + Pull-requests: write. Unset → /admin/provision shows a "not configured"
# banner. The token opens a REVIEWABLE PR only; it never touches the box (the box
# SSH key stays in Actions secrets — ADR-012 invariant 2).
PROVISION_GITHUB_TOKEN=
# TEST-ONLY. Overrides where Mollie is told to POST payment updates. Exists
# because Mollie validates webhook reachability at payment creation and rejects
# localhost with a 422, which would otherwise make a real (unmocked) billing E2E
# impossible. Set by scripts/e2e-suite.sh to an inert public sink; the suite then
# POSTs the real payment id to the local handler itself.
# LEAVE UNSET IN PRODUCTION — if set there, Mollie's retries never reach the app
# and a paid first payment would sit with its subscription stuck PENDING.
MOLLIE_WEBHOOK_URL=
# Test-mode Mollie key, read by scripts/e2e-suite.sh for the unmocked billing E2E
# (never by the app, which reads MOLLIE_API_KEY). The suite hard-refuses anything
# without a test_ prefix.
MOLLIE_API_KEY_TEST=
# Test-mode Stripe platform key, read by scripts/e2e-suite.sh for the unmocked MINT
# E2E (tests/e2e/connect-mint.spec.ts), which creates REAL Stripe connected accounts
# and deletes every one of them afterwards. Never read by the app, which reads
# STRIPE_API_KEY. The suite hard-refuses anything without an sk_test_ prefix, and
# falls back to STRIPE_SECRET_KEY only when that is itself an sk_test_ key. Unset ->
# the mint spec skips with a stated reason; it never passes quietly.
#
# Needs `Connect -> write`, like the control plane's own key. NEVER a live key here:
# a live connected account is a real business's KYC record, not a fixture.
STRIPE_API_KEY_TEST=
# --- Sofra's own registration details, printed on every invoice (B0/B4) ---
# Owner inputs (plan §8.1). Until ALL of these are set, NO invoice is issued —
# deliberately: a placeholder KVK number on a real invoice is worse than no
# invoice, because it looks finished. /admin/invoices names exactly what is missing.
#
# Not secrets — a KVK and a VAT number are public registry data and must appear on
# the website too. They live in env because they differ per environment (staging
# must never issue a document carrying the real company's identity) and because the
# box .env is where the owner can set them without a deploy.
SOFRA_LEGAL_NAME=
SOFRA_LEGAL_ADDRESS=
SOFRA_LEGAL_ADDRESS_2=
SOFRA_LEGAL_POSTAL=
SOFRA_LEGAL_CITY=
SOFRA_LEGAL_COUNTRY=NL
# The address a customer can actually reach a human at. Three obligations run
# through it — the imprint contact, the GDPR data-subject route, and "ask us for
# the terms" — so it must be a mailbox that RECEIVES. Check the domain has an MX
# record before setting it.
SOFRA_LEGAL_EMAIL=
# Chamber of Commerce (KVK) number.
SOFRA_KVK=
SOFRA_IBAN=
# Invoice number prefix: SP -> SP-2026-0001. Changing it starts a NEW series at
# 0001; it does not renumber anything. Only change it at a year boundary, and only
# with the accountant's agreement.
SOFRA_INVOICE_SERIES=SP
# What to do about an EU buyer whose VAT number we cannot verify — a business
# decision, not a code one, which is why it is here.
# nlVat (default) — issue the invoice at 21% Dutch VAT. The CONSERVATIVE
# reading: VAT is collected and remitted, so the sale can never turn out to
# have been under-declared. The invoice invites them to supply a VAT number
# so FUTURE invoices are reverse-charged (this one cannot be corrected —
# invoices are immutable and credit notes are not built).
# hold — refuse to invoice and wait for a human. Use when the accountant wants
# each such sale decided individually (Dutch VAT vs the buyer's own rate
# under OSS are both defensible).
# Anything unrecognised means nlVat: a typo must not silently stop all billing.
SOFRA_EU_NO_VAT_FALLBACK=nlVat
# --- Sofra's own VAT identification number (SOFRA-BILLING-IDENTITY-PLAN B2) ---
# Sent to VIES as the REQUESTER when checking a customer's VAT number, e.g.
# NL123456789B01. Measured 2026-08-11: a check carrying requester details returns a
# `requestIdentifier` (the consultation reference); the identical check without them
# returns "". That reference is the evidence the check happened, on that date, by us.
#
# Unset is NOT neutral: every check still answers VALID/INVALID, but with ref=null —
# successful-LOOKING checks that substantiate nothing, and the gap is discovered at
# audit rather than at the time. A malformed value is refused outright (logged, no
# requester block sent) rather than passed on, because VIES answers a bad requester
# with INVALID_REQUESTER_INFO and that must never be read as a verdict on a customer.
SOFRA_VAT_NUMBER=
# QA admin for the DEPLOYED staging control plane, read by tests/e2e/staging-live.spec.ts
# (`npm run test:e2e:staging`). Not used by the app or the local suite.
#
# The single quotes are REQUIRED. scripts/e2e-suite.sh sources this file's real counterpart
# with `set -a && . ./.env`, and bash reads an unquoted {email: a@b.com, password: x} as an
# assignment followed by the command `a@b.com,` — under the script's `set -euo pipefail`
# that kills the entire unmocked E2E suite before it starts Postgres, and reports the
# failure as an email address, which names nothing you would think to look at.
STAGING_ADMIN='{email: , password: }'
# --- Tenant backups (POST /api/telemetry/backups, GET /api/backups/jobs,
# POST /api/backups/jobs/<id>/result) ---
# The bearer the box-side backup agent presents on all three endpoints
# (openssl rand -hex 32), named PER BOX below. None set -> all three answer 503.
#
# Every credential in this feature points BOX -> SOFRA and this is the only one:
# the control plane holds NOTHING that can reach a box (ADR-012 invariant 2). An
# unauthenticated ingest would leak which restaurants exist, how large each one's
# database is and which ones have left — a competitor's research budget, answered
# by a curl.
# ONE CREDENTIAL PER BOX. `BACKUP_AGENT_SECRET_<BOX>` (uppercased, `-` -> `_`:
# prod -> BACKUP_AGENT_SECRET_PROD) binds a bearer to the box it claims to be. It
# matters because the inventory push PRUNES what it stops listing: with one shared
# value, a compromise of the STAGING box yielded a bearer that could erase the
# control plane's whole record of PROD's backups and lease away its jobs — the very
# privilege-escalation path the box SSH keys are arranged to prevent.
#
# THE BOX SIDE IS UNCHANGED: each box keeps its own value under the plain
# `BACKUP_AGENT_SECRET` in its own .env, which is what its agent reads. Only the
# CONTROL PLANE names them per box — and since 2026-08-21 it accepts NOTHING else,
# the shared fallback having been retired the moment both agents were observed
# pushing with their own. A box whose value is not configured here gets 401, goes
# quiet, and the backup alarm says so.
BACKUP_AGENT_SECRET_PROD=
BACKUP_AGENT_SECRET_STAGING=
# How long a departed tenant's data is kept, in days (default 180). This is a
# DISPLAY of the box's `restic forget` policy, not the policy itself — the box
# enforces it and this container cannot see it, so set both to the same number.
# If they disagree, the box wins and this page is the one that is wrong.
BACKUP_RETENTION_DAYS=180
# DESTRUCTIVE, and off unless this is exactly "true". Deleting a backup destroys
# the only copy of a departed customer's data and there is no undo anywhere in
# the system. Retention on the box already removes copies — declaratively,
# reviewably, and without a button that turns any other bug in this app into
# permanent data loss. See lib/backup-job-policy.ts for the full argument.
BACKUP_DELETE_ENABLED=false
# The backup ALARM (POST /api/cron/backup-alerts) mails WAITLIST_TO — the founder
# inbox set at the top of this file. There is no separate address on purpose: a
# second one would be a second thing to keep working, and this alert is read by the
# same person who reads every other founder notice. With WAITLIST_TO unset the cron
# workflow FAILS its run rather than passing quietly, because an alarm with no
# recipient is worse than no alarm.