Production hardening: CSP, rate limit, env validation, health gating, Render blueprint, DEPLOYMENT.md
This commit is contained in:
73
DEPLOYMENT.md
Normal file
73
DEPLOYMENT.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Deployment
|
||||||
|
|
||||||
|
This app is built to deploy to **[Render](https://render.com)** as a Web
|
||||||
|
Service via the included `render.yaml` blueprint.
|
||||||
|
|
||||||
|
## Pre-flight checklist
|
||||||
|
|
||||||
|
Before deploying, confirm:
|
||||||
|
|
||||||
|
- [ ] CiviCRM has a working API key for a service user with sufficient privileges to read/write `Contact`, `Activity`, `Relationship`, and `OptionValue`.
|
||||||
|
- [ ] The `Primary Contact` (or whatever value `FORM_CONTACT_RELATIONSHIP` resolves to in `config/form.ts`) relationship type exists, with Individual on side A and Organization on side B.
|
||||||
|
- [ ] The `Check-in (organizing)` activity type exists with the six custom field groups attached (`Check_in_data__organizing_`, `Stage_1`, `Stage_2`, `Stage_3`, `Stage_4`, `Stage_5`).
|
||||||
|
- [ ] The Framework Stage option group on the Organization contact contains all six in-scope values: `Inquiry`, `Organizing`, `Feasibility`, `Business feasibility`, `Store Implementation`, `Stabilize newly opened co-op`.
|
||||||
|
|
||||||
|
Verify all of the above against your live CiviCRM by hitting `/api/health` (in development; production requires a token — see below).
|
||||||
|
|
||||||
|
## First-time Render setup
|
||||||
|
|
||||||
|
1. **Create a new Web Service** from this repo on Render. Render will detect `render.yaml` and configure the service automatically.
|
||||||
|
|
||||||
|
2. **Set the secrets** in the Render dashboard → Environment. The `render.yaml` declares them but marks them `sync: false` so they're never echoed in the committed file:
|
||||||
|
|
||||||
|
| Variable | Required? | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `CIVI_BASE_URL` | yes | e.g. `https://crm.fci.coop` |
|
||||||
|
| `CIVI_API_KEY` | yes | Per-user API key. Generate via CiviCRM Contact → API Key field. |
|
||||||
|
| `CIVI_SITE_KEY` | yes | From `civicrm.settings.php` (`CIVICRM_SITE_KEY`). |
|
||||||
|
| `CIVI_HTTP_AUTH_USER` | only if Civi sits behind webserver-level Basic Auth | HTTP Basic Auth username. |
|
||||||
|
| `CIVI_HTTP_AUTH_PASS` | only if CIVI_HTTP_AUTH_USER is set | HTTP Basic Auth password. |
|
||||||
|
| `HEALTH_TOKEN` | recommended | Long random string (e.g. `openssl rand -hex 32`). Required to access `/api/health` in production. If unset, that route returns 404. |
|
||||||
|
| `PUBLIC_ORIGIN` | optional | e.g. `https://check-in.fci.coop` — used in absolute self-links if needed later. |
|
||||||
|
|
||||||
|
3. **Trigger the first deploy.** Render will run `npm ci && npm run build` then `npm run start`. The platform health check hits `/healthz` (lightweight, no Civi dependency).
|
||||||
|
|
||||||
|
4. **Verify**:
|
||||||
|
- `https://<your-render-url>/healthz` → `{"ok":true,"service":"coop-checkin"}`
|
||||||
|
- `https://<your-render-url>/api/health?token=<HEALTH_TOKEN>` → all checks should be green
|
||||||
|
- Visit `https://<your-render-url>/?cid=<test-individual>&cs=<their-checksum>` → form loads with prefill
|
||||||
|
|
||||||
|
5. **Custom domain (optional)** — Render dashboard → Custom Domain → add `check-in.fci.coop`. Update DNS to the provided CNAME. Render auto-issues a Let's Encrypt cert.
|
||||||
|
|
||||||
|
## Security defaults
|
||||||
|
|
||||||
|
The app ships with these defaults; review and adjust per your threat model.
|
||||||
|
|
||||||
|
| Concern | Default |
|
||||||
|
|---|---|
|
||||||
|
| HTTPS | Required. Render terminates TLS for you. HSTS header is set with a 2-year max-age + preload. |
|
||||||
|
| CSP | Tight: same-origin scripts/connections; Google Fonts allowed for next/font; `frame-ancestors 'none'`; no third-party scripts. |
|
||||||
|
| Frame embedding | Denied (X-Frame-Options + CSP frame-ancestors). |
|
||||||
|
| Referrer | `same-origin` so the cid+cs query string never leaks via Referer to other origins. |
|
||||||
|
| Health endpoint | Production: gated behind `HEALTH_TOKEN`. Dev: unauthenticated. |
|
||||||
|
| Submit rate limit | 10 submissions per IP per minute (in-memory; resets on restart). |
|
||||||
|
| Error messages | Production responses say "Could not save" without details; full error logged server-side with secrets redacted. |
|
||||||
|
| Stub mode | Refuses to start if `NODE_ENV=production` and any of `CIVI_BASE_URL`/`CIVI_API_KEY`/`CIVI_SITE_KEY` is missing. |
|
||||||
|
|
||||||
|
## What's intentionally NOT done
|
||||||
|
|
||||||
|
- **CSRF tokens.** The submit endpoint is gated by the per-contact CiviCRM checksum, which is not browser-cookie-based, so traditional CSRF doesn't apply. Add CSRF tokens if you ever introduce a session-cookie auth path.
|
||||||
|
- **Multi-instance rate limiting.** The in-memory token bucket is per-process. Acceptable for a single Render instance; switch to Redis or Render's edge rate-limit if you scale horizontally.
|
||||||
|
- **WAF.** None configured. Render's platform provides basic DDoS protection; add a WAF (Cloudflare in front of Render, or similar) if your threat model warrants it.
|
||||||
|
- **i18n.** English-only.
|
||||||
|
|
||||||
|
## Rolling out updates
|
||||||
|
|
||||||
|
1. Push to your default branch.
|
||||||
|
2. Render auto-builds and deploys (per `autoDeploy: true` in `render.yaml`).
|
||||||
|
3. Watch deploy logs for any health-check failures.
|
||||||
|
4. After deploy: hit `/api/health?token=…` to confirm all checks still pass against the live CRM.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Render keeps the previous deploy available. From the Render dashboard → **Deploys** → previous deploy → **Rollback to this deploy**.
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { civi, verifyChecksum } from "@/lib/civicrm";
|
import { civi, verifyChecksum } from "@/lib/civicrm";
|
||||||
import { ACTIVITY_TYPE_NAME, FORM_CONTACT_RELATIONSHIP } from "@/config/form";
|
import { ACTIVITY_TYPE_NAME, FORM_CONTACT_RELATIONSHIP } from "@/config/form";
|
||||||
|
import { appEnv } from "@/lib/env";
|
||||||
|
|
||||||
interface CheckResult {
|
interface CheckResult {
|
||||||
check: string;
|
check: string;
|
||||||
@@ -353,6 +354,20 @@ async function probeContactRelationships(cid: string): Promise<CheckResult> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(req: Request) {
|
export async function GET(req: Request) {
|
||||||
|
// In production: require ?token=<HEALTH_TOKEN>. If HEALTH_TOKEN is unset
|
||||||
|
// in production, the endpoint is disabled entirely (404). In development
|
||||||
|
// / non-prod, anyone can read it.
|
||||||
|
const e = appEnv();
|
||||||
|
const url = new URL(req.url);
|
||||||
|
if (e.isProduction) {
|
||||||
|
if (!e.healthToken) {
|
||||||
|
return new NextResponse("Not Found", { status: 404 });
|
||||||
|
}
|
||||||
|
if (url.searchParams.get("token") !== e.healthToken) {
|
||||||
|
return new NextResponse("Not Found", { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const env = envSummary();
|
const env = envSummary();
|
||||||
const summary = { mode: env.ok ? "live" : "stub" };
|
const summary = { mode: env.ok ? "live" : "stub" };
|
||||||
|
|
||||||
@@ -361,7 +376,6 @@ export async function GET(req: Request) {
|
|||||||
return NextResponse.json({ ...summary, checks: [env] });
|
return NextResponse.json({ ...summary, checks: [env] });
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(req.url);
|
|
||||||
const cid = url.searchParams.get("cid");
|
const cid = url.searchParams.get("cid");
|
||||||
const cs = url.searchParams.get("cs");
|
const cs = url.searchParams.get("cs");
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import { NextResponse } from "next/server";
|
|||||||
import { civi, verifyChecksum } from "@/lib/civicrm";
|
import { civi, verifyChecksum } from "@/lib/civicrm";
|
||||||
import { allFields, ACTIVITY_TYPE_NAME, ACTIVITY_STAGE_FIELD, FORM_CONTACT_RELATIONSHIP } from "@/config/form";
|
import { allFields, ACTIVITY_TYPE_NAME, ACTIVITY_STAGE_FIELD, FORM_CONTACT_RELATIONSHIP } from "@/config/form";
|
||||||
import type { SubmitPayload } from "@/types/form";
|
import type { SubmitPayload } from "@/types/form";
|
||||||
|
import { appEnv, redact } from "@/lib/env";
|
||||||
|
import { rateLimit, clientIp } from "@/lib/rate-limit";
|
||||||
|
|
||||||
function isStubMode(): boolean {
|
function isStubMode(): boolean {
|
||||||
return !(
|
return !(
|
||||||
@@ -28,6 +30,21 @@ function isStubMode(): boolean {
|
|||||||
const FIELD_BY_NAME = new Map(allFields.map((f) => [f.name, f]));
|
const FIELD_BY_NAME = new Map(allFields.map((f) => [f.name, f]));
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
|
// Rate limit per client IP. Generous default — 10 submissions per minute.
|
||||||
|
const ip = clientIp(req);
|
||||||
|
const rl = rateLimit(`submit:${ip}`, { capacity: 10, windowMs: 60_000 });
|
||||||
|
if (!rl.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Too many submissions. Please wait a moment and try again." },
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: {
|
||||||
|
"Retry-After": String(Math.ceil(rl.resetMs / 1000)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let body: SubmitPayload;
|
let body: SubmitPayload;
|
||||||
try {
|
try {
|
||||||
body = (await req.json()) as SubmitPayload;
|
body = (await req.json()) as SubmitPayload;
|
||||||
@@ -44,6 +61,23 @@ export async function POST(req: Request) {
|
|||||||
return NextResponse.json({ ok: true, stub: true });
|
return NextResponse.json({ ok: true, stub: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await runSubmit(cid, cs, values);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error("[submit] failure:", redact(msg));
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: appEnv().isProduction
|
||||||
|
? "Could not save your check-in. Please try again, or contact your engagement coordinator."
|
||||||
|
: `Save failed: ${redact(msg)}`,
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSubmit(cid: string, cs: string, values: Record<string, unknown>): Promise<NextResponse> {
|
||||||
const ok = await verifyChecksum(cid, cs);
|
const ok = await verifyChecksum(cid, cs);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
return NextResponse.json({ error: "Link is invalid or has expired." }, { status: 401 });
|
return NextResponse.json({ error: "Link is invalid or has expired." }, { status: 401 });
|
||||||
|
|||||||
13
app/healthz/route.ts
Normal file
13
app/healthz/route.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* GET /healthz
|
||||||
|
*
|
||||||
|
* Lightweight health check for Render's load-balancer. Returns 200 OK
|
||||||
|
* without making any external calls. Use /api/health for the deeper
|
||||||
|
* Civi-side diagnostic.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json({ ok: true, service: "coop-checkin" }, { status: 200 });
|
||||||
|
}
|
||||||
71
lib/env.ts
Normal file
71
lib/env.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Environment-variable validation. Called at module load time by routes
|
||||||
|
* that need real Civi credentials. In stub mode, no env is required.
|
||||||
|
*
|
||||||
|
* Centralizing here means we can:
|
||||||
|
* - Fail fast at boot in production if a required var is missing
|
||||||
|
* - Avoid leaking secrets to error responses (we redact in error paths)
|
||||||
|
* - Reuse the "is stub mode?" check in one place
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface AppEnv {
|
||||||
|
isStub: boolean;
|
||||||
|
isProduction: boolean;
|
||||||
|
civiBaseUrl?: string;
|
||||||
|
civiApiKey?: string;
|
||||||
|
civiSiteKey?: string;
|
||||||
|
basicAuthUser?: string;
|
||||||
|
basicAuthPass?: string;
|
||||||
|
/**
|
||||||
|
* Optional admin token to gate /api/health in production. If unset in prod,
|
||||||
|
* /api/health is disabled entirely (returns 404). Set this to a long random
|
||||||
|
* string and pass via `?token=<>` to the health endpoint to read diagnostics.
|
||||||
|
*/
|
||||||
|
healthToken?: string;
|
||||||
|
/**
|
||||||
|
* Same-origin host this app runs at. Used to build `frame-ancestors 'self'`
|
||||||
|
* and any absolute self-links. Defaults to inferring from the request.
|
||||||
|
*/
|
||||||
|
publicOrigin?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cached: AppEnv | undefined;
|
||||||
|
|
||||||
|
export function appEnv(): AppEnv {
|
||||||
|
if (cached) return cached;
|
||||||
|
const e: AppEnv = {
|
||||||
|
isStub: !(
|
||||||
|
process.env.CIVI_BASE_URL &&
|
||||||
|
process.env.CIVI_API_KEY &&
|
||||||
|
process.env.CIVI_SITE_KEY
|
||||||
|
),
|
||||||
|
isProduction: process.env.NODE_ENV === "production",
|
||||||
|
civiBaseUrl: process.env.CIVI_BASE_URL,
|
||||||
|
civiApiKey: process.env.CIVI_API_KEY,
|
||||||
|
civiSiteKey: process.env.CIVI_SITE_KEY,
|
||||||
|
basicAuthUser: process.env.CIVI_HTTP_AUTH_USER,
|
||||||
|
basicAuthPass: process.env.CIVI_HTTP_AUTH_PASS,
|
||||||
|
healthToken: process.env.HEALTH_TOKEN,
|
||||||
|
publicOrigin: process.env.PUBLIC_ORIGIN,
|
||||||
|
};
|
||||||
|
// In production, refuse to start in stub mode — that's almost certainly a
|
||||||
|
// misconfiguration. Better to crash loudly than silently serve stub data.
|
||||||
|
if (e.isProduction && e.isStub) {
|
||||||
|
throw new Error(
|
||||||
|
"Refusing to run in production without CIVI_BASE_URL, CIVI_API_KEY, and CIVI_SITE_KEY set. " +
|
||||||
|
"Either provide all three, or set NODE_ENV != 'production'.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
cached = e;
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip secrets from any string before logging or echoing in responses. */
|
||||||
|
export function redact(s: string): string {
|
||||||
|
let out = s;
|
||||||
|
const e = appEnv();
|
||||||
|
for (const v of [e.civiApiKey, e.civiSiteKey, e.basicAuthPass, e.healthToken]) {
|
||||||
|
if (v && v.length >= 6) out = out.split(v).join("«redacted»");
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
55
lib/rate-limit.ts
Normal file
55
lib/rate-limit.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* Tiny in-memory rate limiter. Token-bucket per key (typically client IP).
|
||||||
|
*
|
||||||
|
* Suitable for a single-instance deployment behind Render's load balancer.
|
||||||
|
* State resets on process restart, which is fine for this use case (the
|
||||||
|
* worst that happens is a malicious client gets to retry sooner).
|
||||||
|
*
|
||||||
|
* For multi-instance deployments use Redis or Render's edge rate-limit.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Bucket {
|
||||||
|
tokens: number;
|
||||||
|
refilledAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUCKETS = new Map<string, Bucket>();
|
||||||
|
|
||||||
|
export interface RateLimitOptions {
|
||||||
|
/** Max requests per window. */
|
||||||
|
capacity: number;
|
||||||
|
/** Window length in milliseconds (tokens fully refill each window). */
|
||||||
|
windowMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rateLimit(key: string, opts: RateLimitOptions): { allowed: boolean; remaining: number; resetMs: number } {
|
||||||
|
const now = Date.now();
|
||||||
|
const bucket = BUCKETS.get(key);
|
||||||
|
if (!bucket || now - bucket.refilledAt >= opts.windowMs) {
|
||||||
|
BUCKETS.set(key, { tokens: opts.capacity - 1, refilledAt: now });
|
||||||
|
return { allowed: true, remaining: opts.capacity - 1, resetMs: opts.windowMs };
|
||||||
|
}
|
||||||
|
if (bucket.tokens > 0) {
|
||||||
|
bucket.tokens--;
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
remaining: bucket.tokens,
|
||||||
|
resetMs: opts.windowMs - (now - bucket.refilledAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
remaining: 0,
|
||||||
|
resetMs: opts.windowMs - (now - bucket.refilledAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort client-IP extraction from a Next.js Request. Trusts the
|
||||||
|
* left-most x-forwarded-for entry (set by Render's load balancer).
|
||||||
|
*/
|
||||||
|
export function clientIp(req: Request): string {
|
||||||
|
const xff = req.headers.get("x-forwarded-for");
|
||||||
|
if (xff) return xff.split(",")[0].trim();
|
||||||
|
return req.headers.get("x-real-ip") ?? "unknown";
|
||||||
|
}
|
||||||
@@ -1,7 +1,51 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security headers applied to every response.
|
||||||
|
*
|
||||||
|
* Notes on each:
|
||||||
|
* - CSP: tight default; allows Google Fonts (next/font) and the same-origin
|
||||||
|
* /api routes. No third-party scripts. `frame-ancestors 'none'` prevents
|
||||||
|
* this app being embedded in another site's iframe.
|
||||||
|
* - HSTS: only meaningful behind HTTPS (Render terminates TLS, so this is
|
||||||
|
* correct in production).
|
||||||
|
* - Permissions-Policy: drop everything we don't use.
|
||||||
|
* - Referrer-Policy: same-origin — never leak the cid+cs query string to
|
||||||
|
* other origins via the Referer header.
|
||||||
|
* - X-Content-Type-Options: prevents MIME sniffing.
|
||||||
|
*/
|
||||||
|
const securityHeaders = [
|
||||||
|
{
|
||||||
|
key: "Content-Security-Policy",
|
||||||
|
value: [
|
||||||
|
"default-src 'self'",
|
||||||
|
"script-src 'self' 'unsafe-inline'",
|
||||||
|
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||||
|
"font-src 'self' https://fonts.gstatic.com data:",
|
||||||
|
"img-src 'self' data:",
|
||||||
|
"connect-src 'self'",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
"form-action 'self'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
"object-src 'none'",
|
||||||
|
].join("; "),
|
||||||
|
},
|
||||||
|
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
|
||||||
|
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||||
|
{ key: "Referrer-Policy", value: "same-origin" },
|
||||||
|
{
|
||||||
|
key: "Permissions-Policy",
|
||||||
|
value: "camera=(), microphone=(), geolocation=(), interest-cohort=()",
|
||||||
|
},
|
||||||
|
{ key: "X-Frame-Options", value: "DENY" },
|
||||||
|
];
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
poweredByHeader: false,
|
||||||
|
reactStrictMode: true,
|
||||||
|
async headers() {
|
||||||
|
return [{ source: "/:path*", headers: securityHeaders }];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
37
render.yaml
Normal file
37
render.yaml
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Render Blueprint — https://render.com/docs/blueprint-spec
|
||||||
|
#
|
||||||
|
# Deploy this app as a Web Service on Render. Push to your default branch
|
||||||
|
# triggers a deploy. Set the four CIVI_* env vars in the Render dashboard
|
||||||
|
# (mark them as "Sync: false" so they're not overwritten by this file).
|
||||||
|
|
||||||
|
services:
|
||||||
|
- type: web
|
||||||
|
name: coop-checkin
|
||||||
|
runtime: node
|
||||||
|
plan: starter
|
||||||
|
region: oregon
|
||||||
|
buildCommand: npm ci && npm run build
|
||||||
|
startCommand: npm run start
|
||||||
|
healthCheckPath: /healthz
|
||||||
|
envVars:
|
||||||
|
- key: NODE_ENV
|
||||||
|
value: production
|
||||||
|
- key: NODE_VERSION
|
||||||
|
value: "20"
|
||||||
|
# Set these manually in the Render dashboard (UI → Environment).
|
||||||
|
# Marked sync:false so they're not echoed in this committed file.
|
||||||
|
- key: CIVI_BASE_URL
|
||||||
|
sync: false
|
||||||
|
- key: CIVI_API_KEY
|
||||||
|
sync: false
|
||||||
|
- key: CIVI_SITE_KEY
|
||||||
|
sync: false
|
||||||
|
- key: CIVI_HTTP_AUTH_USER
|
||||||
|
sync: false
|
||||||
|
- key: CIVI_HTTP_AUTH_PASS
|
||||||
|
sync: false
|
||||||
|
- key: HEALTH_TOKEN
|
||||||
|
sync: false
|
||||||
|
- key: PUBLIC_ORIGIN
|
||||||
|
sync: false
|
||||||
|
autoDeploy: true
|
||||||
Reference in New Issue
Block a user