-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy path.env.example
More file actions
505 lines (465 loc) · 30.8 KB
/
Copy path.env.example
File metadata and controls
505 lines (465 loc) · 30.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
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
# ─────────────────────────────────────────────────────────────────────────
# Taskly.ca — Environment variables
#
# Copy this file to .env.local for local dev. In production set these in
# the Vercel dashboard (Project → Settings → Environment Variables).
# Required at runtime unless explicitly marked optional.
# ─────────────────────────────────────────────────────────────────────────
# ── Site ───────────────────────────────────────────────────────────────────
# Fully-qualified URL used by emails, OG tags, and Stripe redirects.
NEXT_PUBLIC_SITE_URL=https://taskly.ca
# ── Supabase ──────────────────────────────────────────────────────────────
# Public anon key + project URL — used by browser and server clients.
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=ey...
# Service-role key — server-only. Bypasses RLS. NEVER expose to the client.
# Used for admin API routes, webhook handlers, cron jobs.
SUPABASE_SERVICE_ROLE_KEY=ey...
# ── WhatsApp bot bridge ─────────────────────────────────────────────────────
# Shared secret for POST /api/integrations/whatsapp/tasks — the endpoint the
# WhatsApp bot calls to post a task into the marketplace. MUST equal
# TASKLY_BRIDGE_SECRET on the bot side. Generate a long random string.
WHATSAPP_BRIDGE_SECRET=your_long_random_shared_secret
# Base URL of the WhatsApp bot service. Used by lib/marketplace/whatsapp-notify.ts
# to POST task approved / rejected / new_offer events back to the poster.
WHATSAPP_SERVICE_URL=https://your-whatsapp-bot.example.com
# ── Link-import (paste a link → auto-fill tasker onboarding) ────────────────
# Powers POST /api/onboarding/import-link (lib/link-import). It fetches a pasted
# Facebook page/profile/Marketplace link server-side (one authenticated request,
# no browser) and pre-fills the onboarding form. Empty = feature disabled (the
# wizard just falls back to manual entry).
#
# Per-site session cookie strings, copied from a logged-in browser session.
# Server-only — NEVER exposed to the client. Rotate when they expire (days). A
# link whose site has no cookie set just fails soft (wizard falls back to manual).
# FB_COOKIES — Facebook page / profile / Marketplace ("c_user=…; xs=…; datr=…")
# YELP_COOKIES — Yelp business pages ("bse=…; datadome=…; …"); needed for DataDome
FB_COOKIES=
YELP_COOKIES=
# ── Stripe ────────────────────────────────────────────────────────────────
# Use sk_test_* / pk_test_* in preview, sk_live_* / pk_live_* in production.
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
# Required for /api/stripe/webhook to verify event signatures.
# Get from `stripe listen` (local) or Stripe dashboard webhook endpoint.
STRIPE_WEBHOOK_SECRET=whsec_...
# ── Email ─────────────────────────────────────────────────────────────────
# Transport picks Resend first (recommended — reliable transactional email),
# then Gmail SMTP. Auth verification codes + all transactional mail flow through
# whichever is set. Falls back to a no-op + console warn when neither is set.
#
# Resend (preferred): get an API key at resend.com → API Keys.
# Global/default key. Used for any market that has no key of its own below.
RESEND_API_KEY= # re_...
# Per-market keys — set these when each domain is its OWN Resend account (each
# with its own verified sending domain). A market with no key here uses
# RESEND_API_KEY above. <R> ∈ CA | IN | GLOBAL.
RESEND_API_KEY_CA= # re_... (tasklyanything.ca account)
RESEND_API_KEY_IN= # re_... (tasklyanything.in account)
RESEND_API_KEY_GLOBAL= # re_... (tasklyanything.ai account)
# From address. Use onboarding@resend.dev to test instantly (delivers only to
# your own Resend account email); use a verified domain for production. The
# domain here MUST be verified in Resend (Domains → Verified).
RESEND_FROM=Taskly <care@tasklyanything.ai>
# Reply-To for all sends (the global default). We send from the verified
# @tasklyanything.ai domain (no inbox there), so replies default to the mailbox
# we actually read.
EMAIL_REPLY_TO=care@taskly.ca
#
# ── Per-market sender identity (optional; one deployment serves .ca/.in/.ai) ──
# Email region is the RECIPIENT's market (profiles.region), not a host. Each var
# below overrides the global default for one market; UNSET = falls back to the
# global sender above (so a market is never sent from an unverified domain by
# accident). Turn a market ON only after its domain is VERIFIED IN RESEND.
# EMAIL_FROM_<R> full From, e.g. Taskly <noreply@tasklyanything.in>
# EMAIL_REPLY_TO_<R> reply inbox, e.g. care@taskly.in
# <R> ∈ CA | IN | GLOBAL
EMAIL_FROM_CA=
EMAIL_FROM_IN=
EMAIL_FROM_GLOBAL=
EMAIL_REPLY_TO_CA=
EMAIL_REPLY_TO_IN=
EMAIL_REPLY_TO_GLOBAL=
#
# Support address SHOWN on the site + in email copy, per market (NEXT_PUBLIC so
# the browser can pick the right one by the viewer's region). UNSET = care@taskly.ca.
NEXT_PUBLIC_SUPPORT_EMAIL_CA=care@taskly.ca
NEXT_PUBLIC_SUPPORT_EMAIL_IN=care@taskly.in
NEXT_PUBLIC_SUPPORT_EMAIL_GLOBAL=care@taskly.ai
#
# CENTRAL ops inbox — internal notifications (tasker application, task awaiting
# review, feedback) go here for ALL markets (one team manages approvals). This
# is deliberately NOT per-market. Falls back to ADMIN_EMAIL, then care@taskly.ca.
OPS_EMAIL=care@taskly.ca
#
# Gmail (fallback): requires 2-Step Verification + a 16-char App Password.
GMAIL_USER=noreply@taskly.ca
GMAIL_APP_PASSWORD= # 16-char app password, NOT account password
# ── Google Analytics 4 (optional but recommended) ─────────────────────────
# Client-side page-view + event tracking.
NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX
# Admin analytics dashboard pulls report data via the Data API.
# Service-account JSON pasted as a single line.
GA_PROPERTY_ID= # numeric, e.g. 412345678
GA_SERVICE_ACCOUNT_KEY= # {"type":"service_account",...}
# IndexNow (SEO plan 1.4) — instant-indexing pings to the Bing ecosystem (feeds
# ChatGPT Search + Copilot; Google ignores IndexNow and uses our sitemaps).
# Generate any 32+ char hex string (e.g. `openssl rand -hex 16`). Served at
# /indexnow.txt for verification. Unset = pings are a silent no-op.
INDEXNOW_KEY=
# First-party funnel drop-off analytics (/admin/funnels) needs no new keys — it
# logs step events to the public.funnel_events table (migration 079) via
# SUPABASE_SERVICE_ROLE_KEY (set above) and reads them back for the dashboard.
# Works independently of GA4; GA still receives the same events for acquisition.
# (Optional) GA4 Measurement Protocol — lets the Stripe webhook send a
# server-authoritative `purchase` to GA4 so conversions aren't lost when a buyer
# closes the tab before the confirmation page. Create at GA4 Admin → Data Streams
# → (stream) → Measurement Protocol API secrets. No-op if unset.
GA_MP_API_SECRET=
# ── Cloudflare Turnstile (bot protection on signup / password-reset / QuickApply) ──
# DORMANT-UNTIL-CONFIGURED: both unset → the widget renders nothing and the
# server skips verification, so forms still work. Set both to turn it on.
# The site key MUST list your production hostname(s) (taskly.ca AND www.taskly.ca)
# in its allowed-domains list in the Cloudflare dashboard, or the widget throws
# error 600010 / 110200 in the browser. Localhost is auto-bypassed in code.
# When Turnstile is on, keep challenges.cloudflare.com in the CSP (next.config.ts).
NEXT_PUBLIC_TURNSTILE_SITE_KEY= # 0x4AAA… (browser-exposed)
TURNSTILE_SECRET_KEY= # server-only, verifies tokens
# ── Content-Security-Policy enforcement ───────────────────────────────────
# UNSET → CSP ships Report-Only (violations logged, never blocked). Only set to
# true AFTER confirming zero real violations in the browser console — enforcing
# with a missing allowance hard-breaks that feature (e.g. Turnstile, Stripe).
CSP_ENFORCE= # true to enforce; unset = report-only
# ── Cron jobs ─────────────────────────────────────────────────────────────
# Used by /app/api routes invoked from vercel.json `crons` to authenticate
# the request. Vercel sets the matching `Authorization: Bearer $CRON_SECRET`.
CRON_SECRET= # random 32+ char string
# ── Public tasker profile integrations (optional — features degrade gracefully) ──
# LinkedIn "Sign In with OpenID Connect" app → imports verified name + photo
LINKEDIN_CLIENT_ID=
LINKEDIN_CLIENT_SECRET=
# Google Maps Places API key → live Google rating + reviews on /pro profiles
GOOGLE_PLACES_API_KEY=
# Google One Tap (inline "Sign in with Google" prompt) — optional, degrades
# gracefully. Use your existing Google OAuth *Web* Client ID (the same one your
# Supabase Google provider uses). Then: (a) add it to Supabase → Auth → Google →
# "Authorized Client IDs", and (b) add your domains (taskly.ca, .in, .ai,
# http://localhost:3000) to that OAuth client's Authorized JavaScript origins.
NEXT_PUBLIC_GOOGLE_CLIENT_ID=
# ── India KYC — DigiLocker (optional; India region only, degrades gracefully) ──
# Aadhaar identity verification for Indian taskers — the India analog of Stripe
# Identity (Canada). OAuth 2.0 + PKCE against digilocker.meripehchaan.gov.in.
# Credentials come from the DigiLocker Partner Portal (partners.apisetu.gov.in)
# and require a REGISTERED INDIAN ENTITY (no self-serve sandbox) — so these stay
# empty pre-launch and every DigiLocker route degrades to `?digilocker=unconfigured`
# until set. Only shown to taskers when region === 'in'.
DIGILOCKER_CLIENT_ID=
DIGILOCKER_CLIENT_SECRET=
# The redirect URI must EXACTLY match the one registered in the Partner Portal.
# Pin it here (recommended); otherwise it's derived from the request origin, e.g.
# https://tasklyanything.in/api/provider/digilocker/callback
DIGILOCKER_REDIRECT_URI=
# Optional host override (defaults to https://digilocker.meripehchaan.gov.in).
DIGILOCKER_HOST=
# ── Google Maps JS (browse-tasks discovery map) ───────────────────────────
# Uber-grade basemap (real road/POI detail + native dark via colorScheme). BOTH
# vars must be set to switch the /app/browse-tasks map onto Google; otherwise it
# gracefully falls back to the CARTO/Leaflet map. Browser-exposed (NEXT_PUBLIC),
# so use a SEPARATE key from GOOGLE_PLACES_API_KEY — restrict it to your domains
# (HTTP referrers) with only the "Maps JavaScript API" enabled.
# • API key → Google Cloud Console → Credentials → API key (Maps JavaScript API)
# • Map ID → Google Cloud Console → Map Management → new VECTOR map id
# (required for AdvancedMarkers; no custom cloud styling needed —
# colorScheme handles light/dark)
NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=
NEXT_PUBLIC_GOOGLE_MAPS_MAP_ID=
# • Brand-styled Map IDs (OPTIONAL) → two VECTOR map ids cloud-styled to the
# Taskly palette (cream / near-black). See docs/MAP_STYLING.md for the JSON +
# setup. If unset, the map falls back to NEXT_PUBLIC_GOOGLE_MAPS_MAP_ID above
# with Google's built-in light/dark scheme — nothing breaks.
NEXT_PUBLIC_GOOGLE_MAPS_MAP_ID_LIGHT=
NEXT_PUBLIC_GOOGLE_MAPS_MAP_ID_DARK=
# ── Video meetings (booking customer ↔ tasker, brokered by Taskly) ──
# Real Google Meet links + Google Calendar events via the Meet/Calendar APIs.
# Needs a Google Workspace on taskly.ca + a service account with domain-wide
# delegation for BOTH scopes (comma-separated in the Admin console):
# https://www.googleapis.com/auth/meetings.space.created (instant "join now")
# https://www.googleapis.com/auth/calendar.events (scheduled meets → calendar)
# impersonating a workspace user. WITHOUT these three vars, the feature falls back
# to private Jitsi rooms automatically (no calendar event) — no contact details
# are ever shared either way.
GOOGLE_MEET_SA_EMAIL= # svc-meet@<project>.iam.gserviceaccount.com
GOOGLE_MEET_SA_PRIVATE_KEY= # -----BEGIN PRIVATE KEY-----\n... (\n-escaped)
GOOGLE_MEET_IMPERSONATE= # a workspace user, e.g. meetings@taskly.ca
# ── AI task enhancer (marketplace "Post a task" → Review step) ──
# Groq API key (cheap + fast). https://console.groq.com/keys
GROQ_API_KEY=
# Optional override; defaults to llama-3.1-8b-instant
GROQ_MODEL=
# Optional per-purpose overrides (fall back to GROQ_MODEL):
# intent — live category suggestions in the /2 hero composer
GROQ_INTENT_MODEL=
# help — assistant/help surfaces (the Support Assistant chatbot uses this for
# its optional AI replies; falls back to GROQ_MODEL, then llama-3.1-8b-instant).
# Without GROQ_API_KEY the assistant still works fully via the local engine.
GROQ_HELP_MODEL=
# Hard timeout (ms) on the support-assistant AI reply. Default 8000.
GROQ_HELP_TIMEOUT_MS=
# classify — the single source-of-truth task classifier (/api/classify, the
# /2 hero, and the post-task wizard all route through it). MUST be a 70B+ model
# (never the 8B). Defaults to llama-3.3-70b-versatile.
GROQ_CLASSIFY_MODEL=
# Hard timeout (ms) on the classify round-trip; past this we degrade to the
# manual flow. Default 9000.
GROQ_CLASSIFY_TIMEOUT_MS=
# vision — photo-to-task on the hero composer (/api/describe-image): a dropped
# photo is turned into a task title + description. MUST be a multimodal model.
# Defaults to meta-llama/llama-4-scout-17b-16e-instruct.
GROQ_VISION_MODEL=
# Hard timeout (ms) on the vision round-trip. Default 12000.
GROQ_VISION_TIMEOUT_MS=
# voice — speak-your-task on the hero composer (/api/transcribe) AND the support
# assistant mic (/api/support/transcribe): a short voice note is transcribed to
# text via Groq Whisper. Defaults to whisper-large-v3-turbo. Without GROQ_API_KEY
# the support mic falls back to the browser's built-in SpeechRecognition.
GROQ_WHISPER_MODEL=
# Hard timeout (ms) on the support-mic transcription. Default 15000.
GROQ_WHISPER_TIMEOUT_MS=
# Translation model — when the voice note is in another language (Hindi, Tamil…)
# the hero composer asks for English out. Translation needs the full model, not
# turbo. Defaults to whisper-large-v3.
GROQ_WHISPER_TRANSLATE_MODEL=
# Hard timeout (ms) on the transcription round-trip. Default 20000.
GROQ_WHISPER_TIMEOUT_MS=
# ── AI image generation (Post-a-Task → "Generate image") ──
# Google "Nano Banana" (Gemini 2.5 Flash Image). Turns a task title + description
# into an illustrative listing photo. https://aistudio.google.com/apikey
# Without this key the "Generate image" button is hidden (route returns 503) —
# nothing else is affected.
GEMINI_API_KEY=
# Optional override; defaults to gemini-2.5-flash-image
GEMINI_IMAGE_MODEL=
# Hard timeout (ms) on the image-generation round-trip. Default 30000.
GEMINI_IMAGE_TIMEOUT_MS=
# ── Live niche pricer (hybrid pricing) ──
# The SAME GEMINI_API_KEY above also powers the live price lookup for genuine
# niche tasks — a quote_only category with no learned-rate signal yet (laser
# cutting, AI consulting, paper-cut art…). Everything priceable, or a category
# with real tasker-quote data, stays on the deterministic engine. Without the key
# those niche tasks return an honest "set your own budget" (503).
# Optional override; defaults to gemini-2.5-flash.
GEMINI_PRICE_MODEL=
# Hard timeout (ms) on the price round-trip. Default 12000 (cold start ~8-11s).
GEMINI_PRICE_TIMEOUT_MS=
# ── Cloudflare proxy mode (rate-limit client-IP trust) ──
# Set to `true` ONLY when the apex is behind Cloudflare in PROXIED / orange-cloud
# mode (see DEPLOY.md §3). Then the app trusts Cloudflare's `cf-connecting-ip` as
# the real client IP for per-IP rate limits. Leave EMPTY for grey-cloud (DNS-only):
# there Cloudflare isn't in the request path, so that header is client-spoofable and
# the app instead trusts the real peer at the tail of x-forwarded-for.
# TRAP: enabling orange-cloud WITHOUT setting this collapses every user behind a
# Cloudflare PoP into one rate-limit bucket (mass false 429s). Set both together.
CLOUDFLARE_PROXY=
# ── Shared cache (Upstash Redis, REST API) ──
# The SHARED L2 cache for classify results — a task classified on any instance is
# reused everywhere, so common tasks cost ONE Groq call globally (the scaler at
# 10k+ users). Optional: without it, the per-instance in-memory cache still works
# (just lower hit rate). Create a DB at https://console.upstash.com → REST API.
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
# Optional: classify cache TTL (s, default 2592000 = 30d) and Redis call timeout
# (ms, default 1000 — never blocks the request path).
REDIS_CLASSIFY_TTL=
REDIS_TIMEOUT_MS=
# Optional: top-K categories sent to the classifier after the embedding pre-filter
# (default 30). Lower = fewer tokens but higher miss risk.
GROQ_CLASSIFY_TOPK=
# Router thresholds (spec §9/§12) — tune without redeploying code.
# HIGH: confidence to "show directly" (default 75)
# LOW: below this a candidate isn't viable (default 45)
# GAP: min lead over the #2 candidate to auto-assign (default 15)
CLASSIFY_HIGH=
CLASSIFY_LOW=
CLASSIFY_GAP=
# Optional QA test phone (national digits, +1). When set, entering this exact
# number in tasker onboarding auto-verifies with NO SMS/OTP — for testing/demos.
# Real numbers always go through real OTP. Unset to disable. e.g. 4165550100
NEXT_PUBLIC_TEST_PHONE=
# "Make an offer" fast-path (QuickApplyModal) — when the tasker still needs
# Stripe Identity verification, should their offer wait for the real webhook-
# confirmed 'approved' result, or fire as soon as documents are submitted
# ('in_review', not yet reviewed)? true = wait for real approval (safer,
# offer can be delayed / never happen if verification fails). Unset or any
# other value = fire on submission (current default, faster).
NEXT_PUBLIC_QUICK_APPLY_REQUIRE_APPROVAL=
# ── AI feature quota / abuse prevention (lib/ai-quota) ──────────────────────
# Server-side usage limits for the two money-spending AI features on the
# post-task flow: enhance (/api/enhance-task, Groq) and image
# (/api/generate-image, Gemini). ALL enforcement is server-side; the client is
# display-only. Uses the SAME Upstash Redis as above for counters, and writes an
# audit row per call to public.ai_usage_log (migration 152).
#
# MASTER SWITCH. Unset/false → the whole quota layer is a NO-OP (routes behave as
# before; their own burst limits still apply). true → full enforcement.
AI_QUOTA_ENABLED=
# Per-24h limits (defaults shown). Anonymous vs signed-in; image is the pricey
# path. 3 images is the hard ceiling for any user tier.
AI_QUOTA_ANON_IMAGE= # default 1
AI_QUOTA_ANON_ENHANCE= # default 2
AI_QUOTA_USER_IMAGE= # default 3
AI_QUOTA_USER_ENHANCE= # default 6
AI_QUOTA_WINDOW_SEC= # rolling window for all counters (default 86400)
# Per-request guards.
AI_QUOTA_COOLDOWN_SEC= # min gap between two AI calls, same identity (default 6)
AI_QUOTA_MAX_INPUT_CHARS= # reject longer enhance input pre-Groq (default 6000; route accepts up to 6000)
# Global daily image kill switch — total images/day across ALL users. When
# crossed, image gen pauses platform-wide and an alert fires. Tune to ~3-5x
# expected daily volume. Enhance has no kill switch (Groq text is cheap).
AI_QUOTA_IMAGE_KILL_SWITCH= # default 500
AI_QUOTA_ALERT_WEBHOOK_URL= # webhook (e.g. Slack) paged once/day when the switch trips; empty = no alert
# Anti-Sybil / anti-junk toggles.
AI_QUOTA_REQUIRE_EMAIL_VERIFIED= # true → unverified users get image limit 0 (default true)
AI_QUOTA_IMAGE_CATEGORY_GATE= # true → no resolved category ⇒ no image (anti free-Midjourney) (default true)
AI_QUOTA_ANON_IP_MULT= # raw-IP backstop = this × anon image limit; 0 disables (default 5)
AI_QUOTA_DISPOSABLE_DOMAINS= # optional CSV override of the disposable-email blocklist
AI_QUOTA_COOKIE_SECRET= # optional HMAC secret for the anon-id cookie (falls back to CRON_SECRET / service-role key)
# ── API cost tracking (/admin/costs) ────────────────────────────────────────
# Every paid outbound call (Groq, Gemini, Resend, Twilio, Places, Geocode,
# Upstash) is logged to public.api_usage with the market it was spent in, and
# priced by the hand-maintained rate table in lib/usage/pricing.ts. This needs NO
# configuration to work — it's on as soon as migration 178 is applied.
#
# ⚠️ The dollar figures are OUR ESTIMATE (metered quantity × that rate table), not
# the provider invoice. Provider prompt caching, free tiers and minimum billing
# increments all cause drift, and Twilio/Resend are structurally under-reported
# (see the caveats rendered on /admin/costs). Reconcile monthly against each
# provider's own billing page.
#
# Daily spend tripwire — ALERT-ONLY (it never throttles or disables a feature;
# the per-feature kill switches above do that job). Dormant until set. Fires at
# most once per day per breach via alertOps (webhook + admin email).
API_COST_DAILY_ALERT_USD= # e.g. 25 — total across all providers; empty = no budget alerts
# Optional per-provider ceilings. Name = API_COST_DAILY_ALERT_<PROVIDER>, with
# hyphens as underscores (groq-audio → GROQ_AUDIO).
API_COST_DAILY_ALERT_GEMINI= # e.g. 10 — image generation is the priciest per call
API_COST_DAILY_ALERT_GROQ= # e.g. 10
API_COST_DAILY_ALERT_PLACES= # e.g. 5 — priciest per REQUEST (address autocomplete)
# Raw-ledger retention. api_usage_daily (the rollup) is permanent; raw rows are
# pruned past this, and per-user + per-outcome detail only exists for raw rows.
API_USAGE_RAW_RETENTION_DAYS= # default 45
# Display-only FX for the CAD/INR convenience figures. Static on purpose — a cost
# dashboard must not depend on another paid API.
FX_USD_CAD= # default 1.37
FX_USD_INR= # default 87
# UC / Insta-Help (curated fixed-price "vetted services") master switch. HIDDEN
# by default for launch — the app is poster-first (post-a-task). Set to `true`
# to bring back the curated home, the "Services" nav tab, the /app/book +
# /app/services routes, and the Insta-Help FAB (Phase 2). Empty/unset = hidden.
NEXT_PUBLIC_UC_ENABLED=
# ── Admin two-factor (TOTP) enforcement ─────────────────────────────────────
# DEFAULT OFF. Enrollment at /admin/security always works regardless; this only
# controls whether an admin who HAS a verified factor must step up to aal2
# before /admin renders. An admin with no factor is never challenged, so
# flipping this can't lock anyone out — but still enroll + test /admin-verify
# BEFORE setting it to `true`. Recovery snippet in SECURITY.md.
NEXT_PUBLIC_ADMIN_MFA_REQUIRED=
# ── Multi-region (per-domain: .ca = Canada, .ai = India) ────────────────────
# Region is normally resolved from the request Host (three domains: .ca=Canada,
# .in=India, .ai=global gateway), so on the live ccTLDs you get the right region
# automatically. localhost has no host to match and falls to DEFAULT_REGION
# ('ca'). Two dev options:
# 1. NEXT_PUBLIC_FORCE_REGION=in npm run dev → one region on plain localhost
# 2. ca.localhost:3000 / in.localhost:3000 / ai.localhost:3000 → all three
# regions SIDE-BY-SIDE (browsers resolve *.localhost natively; Safari:
# use *.lvh.me). No /etc/hosts editing needed.
# DEV ONLY — hard no-op when NODE_ENV=production, so this can never flip a live
# domain. Values: `ca` | `in` | `global` | empty (Host-driven).
NEXT_PUBLIC_FORCE_REGION=
# MASTER host-routing gate. Default OFF. While OFF, getRegion() returns 'ca' for
# EVERY host and no region-column is read/written — all three live domains keep
# serving the Canadian product, safe even before migrations 155/156 are applied.
# Flip to true ONLY after: (1) migrations 155+156 applied to prod, AND (2) the
# Canadian-member comms for the .ai→.ca move have gone out (go-live sequence in
# status/MULTI_COUNTRY_PLAN.md §8). FORCE_REGION bypasses this in dev.
NEXT_PUBLIC_REGION_ROUTING_LIVE=
# Go-live flags for the region features (default OFF; see lib/flags.ts):
NEXT_PUBLIC_MULTI_REGION_LIVE= # true → emit hreflang cluster + country switcher + geo-suggest banner
NEXT_PUBLIC_IN_MARKETPLACE_LIVE= # true → serve the full app on .in (India); empty → India is waitlist-only
# FORCED cross-domain / geo redirects — THE single "enforce markets" switch.
# DEFAULT ON. Set to false to let the team open .ca / .in / .ai from ANY country
# and use them fully: no geo bounce, no .ai gateway gate, no per-task 301 to its
# home domain, and no India waitlist gate — so log in / log out works on every
# domain from anywhere. (Each domain still shows its own content + currency, and
# email stays per-market: that's profiles.region, not redirects.)
# ⚠ false in PRODUCTION exposes the unfinished India marketplace, because the
# waitlist stops gating .in. Keep it ON (unset or true) in prod; use
# NEXT_PUBLIC_IN_MARKETPLACE_LIVE when India actually launches.
NEXT_PUBLIC_REGION_REDIRECTS= # empty/true → redirects ON (prod); false → free access from anywhere (testing)
# ── Masked calling (Twilio Voice click-to-call bridge) ──────────────────────
# Lets a booking's poster + tasker call each other without sharing real numbers.
# Calling unlocks only on a committed booking (accepted + escrow captured).
# Get these from the Twilio Console. The CALLER_ID must be a Voice-capable Twilio
# number in E.164. The webhook (/api/calls/twiml) must be publicly reachable —
# works on prod (taskly.ca); localhost needs a tunnel. Unset → call button shows
# a graceful "calling unavailable" state, nothing breaks.
TWILIO_ACCOUNT_SID=
TWILIO_AUTH_TOKEN=
TWILIO_CALLER_ID=
# Opt-in call recording (off by default). When "true", calls are recorded and a
# spoken disclaimer plays. Ontario is one-party consent; keep the disclaimer.
TWILIO_RECORD_CALLS=
# ── Web Push (device notifications, VAPID) ──────────────────────────────────
# Device push as an extra channel on createNotification (high-value types only).
# Generate a keypair: node -e "console.log(require('web-push').generateVAPIDKeys())"
# Public key ships to the browser; private key is server-only. Unset → push is a
# no-op (in-app bell unaffected). iOS only delivers push to an installed PWA.
NEXT_PUBLIC_VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:care@taskly.ca
# Fallback recipient for "new task needs review" admin emails when no
# profiles.role='admin' account has an email set. Optional.
ADMIN_EMAIL=
# ── Ops alerting (optional) ─────────────────────────────────────────────────
# When /api/cron/run finds an unhealthy job it emails admins; if set it also
# POSTs a Slack/Discord-style { text } payload here.
OPS_ALERT_WEBHOOK_URL=
# Dead-man's-switch: a healthchecks.io-style ping URL. /api/cron/run POSTs it on
# a healthy tick and `<url>/fail` on failure — if pings STOP, the monitor pages
# you (catches the whole cron silently dying). Optional.
CRON_HEARTBEAT_URL=
# ── Slack review workflow (optional) ────────────────────────────────────────
# Post an interactive Approve/Reject card to a Slack channel whenever a task or a
# tasker application needs review. See docs/SLACK_REVIEW.md for the one-time Slack
# App setup. Every var is optional — with none set, the feature is simply off and
# nothing changes. With only the webhook + signing secret set, Approve works and
# Reject deep-links to /admin; add the bot token to get the in-Slack reject modal.
#
# Incoming Webhook URL for the dedicated #reviews channel (posts the cards).
SLACK_REVIEW_WEBHOOK_URL=
# Slack app Signing Secret — verifies inbound button/modal callbacks.
SLACK_SIGNING_SECRET=
# Bot token (xoxb-…, scope views:open) — opens the reject-reason modal. Optional.
SLACK_BOT_TOKEN=
# Optional: a real profiles.id (role='admin') to record as reviewed_by on Slack-
# driven approvals. Falls back to the first admin, then null.
SLACK_REVIEW_ACTOR_ID=
# Community (native lounge → WS gateway). Same secret as the community/ app + gateway.
COMMUNITY_WS_SECRET=generate-a-long-random-string
NEXT_PUBLIC_COMMUNITY_WS_URL=wss://community.taskly.ca
# ── Founding-Tasker identity-fee waiver ─────────────────────────────────────
# TASKLY_FOUNDING_PROMO_ID: a Stripe promotion_code id ($30 off, duration
# 'once') auto-applied to the $35 identity-fee checkout — founding Taskers
# pay $5 (show-then-discount; the strikethrough renders on Stripe's page).
# Create per environment (test/live promos are separate objects).
# NEXT_PUBLIC_FOUNDING_FEE_WAIVED: drives the "$35 → $5 founding" COPY.
# Set BOTH together or neither — copy must never promise a discount the
# checkout doesn't apply.
TASKLY_FOUNDING_PROMO_ID=
NEXT_PUBLIC_FOUNDING_FEE_WAIVED=false
# ── Browse feed relevance engine (P0, plan artifact ef926bb8) ────────────────
# DEFAULT ON since 2026-07-22 (founder call). Ship-together pair — set BOTH to
# 'false' to fall back to the pure recency feed (emergency opt-out only).
# FEED_RELEVANCE gates server-side ranking; NEXT_PUBLIC_FEED_RELEVANCE gates the
# client picker / "For you" pill / anon+interest params.
FEED_RELEVANCE=
NEXT_PUBLIC_FEED_RELEVANCE=