| Server IP : 35.80.110.71 / Your IP : 216.73.216.221 Web Server : Apache/2.4.58 (Ubuntu) System : Linux ip-172-31-21-44 6.17.0-1019-aws #19~24.04.1-Ubuntu SMP Tue Jun 23 18:53:06 UTC 2026 x86_64 User : ubuntu ( 1000) PHP Version : 8.3.31 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : OFF Directory : /tmp/ |
Upload File : |
<?php
declare(strict_types=1);
/**
* ScopeForged Websites Product Configuration
*
* Configuration for websites.scopeforged.com. Phase 0 ships a single-page
* landing with a Calendly inline embed and lead form. Later phases extend
* this config (pricing engine, builder, provisioning).
*
* @see docs/plans/website-hosting/270-scopeforged-websites-engineering-plan.md
*/
return [
// Plan 325 §Phase 6 — global kill-switch for the builder's live preview
// patch + persistent-selection behavior. Fleet-wide off (migration-free
// rollback) when false; when true, the per-Website `preview_live_patch`
// feature flag (default off) is the real gate. Defaults true so the
// per-tenant flag governs rollout.
'preview_live_patch' => filter_var(env('WEBSITES_PREVIEW_LIVE_PATCH', true), FILTER_VALIDATE_BOOL),
// Plan 324 §Phase 1 — global kill-switch for on-canvas inline text editing
// (click a heading/subhead/intro in the preview and type). Fleet-wide off
// (migration-free rollback) when false; when true, the per-Website
// `on_canvas_inline_edit` feature flag (default off) is the real gate.
// Defaults true so the per-tenant flag governs rollout.
'on_canvas_inline_edit' => filter_var(env('WEBSITES_ON_CANVAS_INLINE_EDIT', true), FILTER_VALIDATE_BOOL),
// Plan 300 §Phase 300-E-ii — store order PII retention. Terminal orders
// older than this are anonymized by websites:anonymize-expired-store-orders.
'store' => [
'order_pii_retention_days' => (int) env('WEBSITES_STORE_ORDER_PII_RETENTION_DAYS', 365),
],
// Plan 344 §B.4 — DIY self-serve "build free" guardrails.
// A draft is unbilled and never publicly served, so its cost is bounded by
// (a) the per-account concurrent-draft cap and (b) the lowest-tier asset
// quota (AssetQuotaPolicy). Abandoned drafts are reclaimed after the TTL by
// ReclaimAbandonedDraftsJob so the subsidy substrate isn't held hostage.
'diy' => [
'max_drafts_per_account' => (int) env('WEBSITES_DIY_MAX_DRAFTS_PER_ACCOUNT', 3),
'draft_ttl_days' => (int) env('WEBSITES_DIY_DRAFT_TTL_DAYS', 30),
// Plan 344 §F.1 — subsidize-with-cap (Decision #9). DIY sits on CF
// Enterprise; the subsidy is bounded by a HARD cap on concurrent live
// DIY tenants (active/past_due diy-* subscriptions). When the cap is
// reached, new go-lives are waitlisted rather than provisioned. The
// number is PROVISIONAL — sized to the subsidy budget (pricing decision,
// still open) — and overridable per-env.
'max_live_tenants' => (int) env('WEBSITES_DIY_MAX_LIVE_TENANTS', 25),
// Plan 344 §C.2 — metered usage pass-through. Each logical meter maps
// to a Stripe Billing Meter (`event_name` matches the meter configured
// in the Stripe Dashboard; the metered Price feeding that meter is added
// to the subscription as an addon item). Usage is recorded locally then
// pushed to Stripe's Meter Events API and reconciled independently.
//
// `event_name` — Stripe Billing Meter event_name (server → Stripe).
// `label` — human label for portal usage surfaces.
'metered_meters' => [
'sms' => [
'event_name' => env('WEBSITES_METER_SMS_EVENT', 'website_sms_sent'),
'label' => 'SMS messages',
],
'email' => [
'event_name' => env('WEBSITES_METER_EMAIL_EVENT', 'website_email_sent'),
'label' => 'Emails sent',
],
],
// Reconciler: rows unreported this many minutes after occurring are
// considered stuck and retried; rows exceeding max_report_attempts are
// surfaced (never silently dropped — the ledger stays the source of truth).
'usage_stuck_after_minutes' => (int) env('WEBSITES_USAGE_STUCK_AFTER_MINUTES', 15),
'usage_max_report_attempts' => (int) env('WEBSITES_USAGE_MAX_REPORT_ATTEMPTS', 8),
],
// Plan 301 §Phase 301-C — AI chat runtime defaults.
'ai_chat' => [
'model' => env('WEBSITES_AI_CHAT_MODEL', 'claude-haiku-4-5-20251001'),
'temperature' => (float) env('WEBSITES_AI_CHAT_TEMPERATURE', 0.4),
'max_output_tokens' => (int) env('WEBSITES_AI_CHAT_MAX_TOKENS', 600),
// Per-conversation cap on visitor turns — blunts budget runaway
// from a chatty (or abusive) visitor.
'max_visitor_messages' => (int) env('WEBSITES_AI_CHAT_MAX_MESSAGES', 40),
// How many recent messages to include as context.
'history_window' => (int) env('WEBSITES_AI_CHAT_HISTORY', 20),
// Retrieval-augmented grounding (Plan 301-D).
'embeddings' => [
'driver' => env('WEBSITES_AI_CHAT_EMBEDDINGS_DRIVER', 'fake'), // fake | openai
'api_key' => env('WEBSITES_AI_CHAT_EMBEDDINGS_KEY', ''),
'model' => env('WEBSITES_AI_CHAT_EMBEDDINGS_MODEL', 'text-embedding-3-small'),
],
'retrieval_top_k' => (int) env('WEBSITES_AI_CHAT_TOP_K', 4),
'chunk_tokens' => (int) env('WEBSITES_AI_CHAT_CHUNK_TOKENS', 200),
// Visitor-conversation retention: conversations idle longer than this
// are pruned by websites:prune-chat-conversations (falls back to each
// store's ai_chat_settings.retention_days when set).
'conversation_retention_days' => (int) env('WEBSITES_AI_CHAT_RETENTION_DAYS', 90),
],
/*
|--------------------------------------------------------------------------
| Calendly Inline Embed URL
|--------------------------------------------------------------------------
|
| Public Calendly event URL embedded on the landing. Leave empty to hide
| the embed entirely (the "book a call" CTA falls back to a plain mailto
| link). Setting WEBSITES_CALENDLY_URL=https://calendly.com/... enables it.
|
*/
'calendly_url' => env('WEBSITES_CALENDLY_URL', ''),
/*
|--------------------------------------------------------------------------
| Contact Email
|--------------------------------------------------------------------------
|
| Fallback contact address shown on the landing when Calendly is disabled
| and as a "prefer email" option alongside the form.
|
*/
'contact_email' => env('WEBSITES_CONTACT_EMAIL', env('MARKETING_CONTACT_EMAIL', 'hello@scopeforged.com')),
/*
|--------------------------------------------------------------------------
| Marketing Copy
|--------------------------------------------------------------------------
|
| Strings rendered on the landing. Centralized so non-engineering edits
| live in one place and don't require touching the Blade view.
|
*/
/*
|--------------------------------------------------------------------------
| Builder assets
|--------------------------------------------------------------------------
|
| Storage disk for builder-session asset uploads (logos, etc.). Plan 270
| §Phase 2 stores binaries under tenants/anon/{session}/... or
| tenants/{client_id}/builder/{session}/... on S3 in production. The
| `scan_inline_for_dev` flag controls whether the asset row is marked
| clean at upload time (dev/scaffolding) or stays pending until a
| future virus-scan job flips it (production).
|
*/
'assets' => [
'disk' => env('WEBSITES_ASSETS_DISK', 'public'),
// Plan 309 Phase 1a — non-embedded assets (kind = other: downloadable
// files, admin-only previews) land here instead of the public disk and
// are served only through an ownership-scoped signed route. Embedded
// images/logos stay on `disk` so their baked-into-HTML URLs keep working.
// `local` (serve=true) yields working temporaryUrl()s in dev/test; point
// this at `s3` in production for real presigned access.
'private_disk' => env('WEBSITES_ASSETS_PRIVATE_DISK', 'local'),
'signed_url_ttl' => (int) env('WEBSITES_ASSETS_SIGNED_URL_TTL', 15),
'scan_inline_for_dev' => env('WEBSITES_ASSETS_SCAN_INLINE_FOR_DEV', true),
],
/*
|--------------------------------------------------------------------------
| Proposals
|--------------------------------------------------------------------------
|
| Storage disk for rendered proposal PDFs. The PDF is persisted so the
| customer-facing page can stream it without re-rendering on every hit.
| Default validity window applies when admin doesn't specify one.
|
*/
'proposals' => [
'disk' => env('WEBSITES_PROPOSALS_DISK', env('FILESYSTEM_DISK', 'local')),
'default_validity_days' => env('WEBSITES_PROPOSAL_VALIDITY_DAYS', 14),
],
/*
|--------------------------------------------------------------------------
| Data exports (plan 270 §Phase 4)
|--------------------------------------------------------------------------
|
| Per GDPR Article 20 every paying customer can download all data we
| hold about them. The job lands in the `client-portal-default` queue
| per the plan; storage disk defaults to the global filesystem disk so
| S3 in prod / local in dev "just works" without per-feature env vars.
|
*/
'exports' => [
'disk' => env('WEBSITES_EXPORTS_DISK', env('FILESYSTEM_DISK', 'local')),
'queue' => env('WEBSITES_EXPORTS_QUEUE', 'client-portal-default'),
],
/*
|--------------------------------------------------------------------------
| AI content generation (plan 270 §Phase 6)
|--------------------------------------------------------------------------
|
| `provider` chooses the bound ContentGenerator implementation:
| - "fake" (default for dev + CI)
| - "anthropic" primary in production
| - "openai" documented fallback
|
| Setting `WEBSITES_AI_PROVIDER=anthropic` in env switches production.
| `default_monthly_token_budget` is the per-tenant hard cap unless an
| ai_usage_quotas row overrides it for the (client_id, year_month).
|
*/
'ai' => [
'provider' => env('WEBSITES_AI_PROVIDER', 'fake'),
'fallback_provider' => env('WEBSITES_AI_FALLBACK_PROVIDER', 'openai'),
'default_monthly_token_budget' => env('WEBSITES_AI_DEFAULT_MONTHLY_BUDGET', 200_000),
// Plan 323 — AI authoring in the builder (per-field Rewrite/Generate +
// SEO meta). Free to all tenants; the cost model is platform-key +
// per-client token quota (above) + the per-request/rate caps below.
//
// `authoring_enabled` is a fleet-wide kill-switch; the per-Website
// `ai_authoring` feature flag (default off) is the real rollout gate.
'authoring_enabled' => filter_var(env('WEBSITES_AI_AUTHORING_ENABLED', true), FILTER_VALIDATE_BOOL),
'authoring' => [
// Per-request denial-of-wallet caps (Plan 323 §Phase 2.1) — on top
// of the monthly token quota.
'max_output_tokens' => (int) env('WEBSITES_AI_AUTHORING_MAX_TOKENS', 600),
'rate_limit_per_minute' => (int) env('WEBSITES_AI_AUTHORING_RATE_LIMIT', 10),
],
],
/*
|--------------------------------------------------------------------------
| Cost monitoring (plan 272 §A.2)
|--------------------------------------------------------------------------
|
| `ops_alert_email` is the address that receives the 50% rung
| TenantCostGuard alert. Empty disables the ops rung (the alert
| still records in tenant_cost_alerts_sent so duplicate hourly
| runs don't refire).
|
*/
'costs' => [
'ops_alert_email' => env('WEBSITES_COSTS_OPS_ALERT_EMAIL', ''),
],
/*
|--------------------------------------------------------------------------
| Security (plan 272 §B.3, §B.4)
|--------------------------------------------------------------------------
|
| `require_mfa_for_new_customers` (default true) makes
| WebsiteOnboardingCompletionService stamp a `requires_mfa_setup_by`
| deadline on the User row; the `RequireMfaSetup` middleware
| redirects past the deadline.
|
| Session idle-timeout minutes are role-derived; the
| `EnforceSessionIdleTimeout` middleware logs the session out
| after the threshold.
|
*/
'security' => [
'require_mfa_for_new_customers' => env('WEBSITES_REQUIRE_MFA_FOR_NEW_CUSTOMERS', true),
'mfa_setup_grace_days' => env('WEBSITES_MFA_SETUP_GRACE_DAYS', 7),
'session_idle_timeout_customer_minutes' => env('WEBSITES_SESSION_IDLE_CUSTOMER_MIN', 720),
'session_idle_timeout_admin_minutes' => env('WEBSITES_SESSION_IDLE_ADMIN_MIN', 240),
],
/*
|--------------------------------------------------------------------------
| Mail compliance (plan 270 §Phase 7)
|--------------------------------------------------------------------------
|
| Physical address rendered in every customer-facing email's CAN-SPAM
| footer. CanSpamFooterEnforcer asserts the address is present in
| every rendered body.
|
*/
'mail' => [
'physical_address' => env('WEBSITES_MAIL_PHYSICAL_ADDRESS', 'ScopeForged Inc., PO Box change-me, City, ST'),
],
/*
|--------------------------------------------------------------------------
| Legal-process intake (Plan 295 §12)
|--------------------------------------------------------------------------
|
| Where valid subpoenas / court orders / warrants are served. Counsel-
| defined — the placeholder must be replaced before launch.
|
*/
'legal' => [
'process_address' => env('WEBSITES_LEGAL_PROCESS_ADDRESS', 'ScopeForged Inc., c/o Registered Agent, PO Box change-me, City, ST'),
'process_email' => env('WEBSITES_LEGAL_PROCESS_EMAIL', 'legal@scopeforged.com'),
],
/*
|--------------------------------------------------------------------------
| CSP (plan 270 §Phase 7)
|--------------------------------------------------------------------------
|
| Platform-level baseline merged with per-template extensions by
| CspHeaderComposer. Source allowlists prevent templates from
| silently introducing new third-party dependencies.
|
*/
'csp' => [
'baseline' => [
'default-src' => ["'self'"],
'script-src' => ["'self'"],
'frame-src' => ["'none'"],
'img-src' => ["'self'", 'data:', 'https://cdn.scopeforged.com'],
'style-src' => ["'self'", "'unsafe-inline'"],
'connect-src' => ["'self'"],
'font-src' => ["'self'", 'data:'],
'object-src' => ["'none'"],
],
'report_uri' => env('WEBSITES_CSP_REPORT_URI', '/csp-report'),
'report_only_by_default' => env('WEBSITES_CSP_REPORT_ONLY', true),
/*
| Add-on CSP requirements (plan 308 §Phase 1)
|
| An add-on module can declare the CSP sources it needs, keyed by
| `addon_key`. When the add-on is active, CspHeaderComposer merges
| these between the template layer and the tenant-override layer:
|
| baseline ← template extensions ← ACTIVE ADD-ONS ← tenant overrides
|
| Add-on sources are subject to the exact same allowlist +
| forbidden-token guards as every other layer, so an add-on can no
| more introduce 'unsafe-inline' or a non-allowlisted host than a
| template can. Shape:
|
| 'addon_requirements' => [
| 'live-chat' => [
| 'script-src' => ['https://widget.example.com'],
| 'connect-src' => ['https://api.example.com'],
| 'frame-src' => ['https://widget.example.com'],
| ],
| ],
*/
'addon_requirements' => [],
],
/*
|--------------------------------------------------------------------------
| Analytics
|--------------------------------------------------------------------------
|
| Server-side event capture per plan 270 §Phase 1. The salt is used to
| hash IP and User-Agent so the analytics table never stores raw PII;
| rotating the salt invalidates the ability to link future events to
| past hashed identifiers (an intentional privacy property).
|
*/
'analytics' => [
'enabled' => env('WEBSITES_ANALYTICS_ENABLED', true),
'hash_salt' => env('WEBSITES_ANALYTICS_HASH_SALT', env('APP_KEY', 'change-me-in-env')),
'excluded_paths' => [
'sitemap.xml',
'robots.txt',
'leads',
],
// Plan 294 §Phase G — customer-facing analytics (CF Web Analytics +
// GA4 passthrough). `driver` selects the CF stats client: `cloudflare`
// hits the GraphQL API, anything else binds the in-memory fake (which
// returns an empty summary), so CI never touches CF.
'customer' => [
'driver' => env('WEBSITES_CUSTOMER_ANALYTICS_DRIVER', 'fake'), // cloudflare|fake
],
],
'copy' => [
'hero_eyebrow' => 'ScopeForged Websites',
'hero_headline' => 'Done-for-you websites for service businesses.',
'hero_subhead' => 'Conversion-focused sites, transparent pricing, no plugin rabbit holes. We design it, build it, and keep it running so you can stay focused on the work.',
'primary_cta' => 'Book a 20-minute call',
'secondary_cta' => 'Send us a brief',
'response_promise' => 'We reply within one business day.',
],
/*
|--------------------------------------------------------------------------
| DNS + Cache driver wiring (Plan 273 §A)
|--------------------------------------------------------------------------
|
| Production binds the real Cloudflare classes; tests + dev fall back to
| the in-memory fakes. Switching is a one-line env change (`fake` vs
| `cloudflare`) — no code edits.
|
*/
'dns' => [
'driver' => env('WEBSITES_DNS_DRIVER', 'fake'),
],
/*
|--------------------------------------------------------------------------
| Custom-domain self-service (Plan 279)
|--------------------------------------------------------------------------
|
| `self_serve_enabled` kill-switch — flip to false to block new attaches
| without breaking existing Domain rows. Per-Client granularity is a
| follow-up. `public_ipv4`/`public_ipv6` are surfaced to customers in
| the wizard's "Required DNS records" panel. `default_grace_days` is
| the pre-selected option in the remove modal. The reverify ladder
| (30d / 60d / 90d) is enforced by ReverifyDomainOwnershipJob.
|
*/
'custom_domain' => [
'self_serve_enabled' => env('WEBSITES_CUSTOM_DOMAIN_SELF_SERVE', true),
'public_ipv4' => array_values(array_filter(array_map('trim', explode(',', (string) env('WEBSITES_PUBLIC_IPV4', ''))))),
'public_ipv6' => array_values(array_filter(array_map('trim', explode(',', (string) env('WEBSITES_PUBLIC_IPV6', ''))))),
'default_grace_days' => (int) env('WEBSITES_CUSTOM_DOMAIN_DEFAULT_GRACE_DAYS', 30),
'reverify_interval_days' => (int) env('WEBSITES_REVERIFY_INTERVAL_DAYS', 30),
'reverify_failure_threshold_days' => (int) env('WEBSITES_REVERIFY_FAILURE_THRESHOLD_DAYS', 60),
'reverify_stale_threshold_days' => (int) env('WEBSITES_REVERIFY_STALE_THRESHOLD_DAYS', 90),
'restart_cap_per_24h' => (int) env('WEBSITES_DOMAIN_RESTART_CAP', 10),
],
'cache_purge' => [
'driver' => env('WEBSITES_CACHE_PURGE_DRIVER', 'fake'),
'zone_id' => env('WEBSITES_CLOUDFLARE_ZONE_ID', ''),
],
// Plan 294 line 376/769 — Stripe webhook idempotency-table hygiene.
'webhooks' => [
// Retain processed webhook-event rows this long (covers Stripe's 3-day
// retry plus a generous incident-replay window), then prune.
'event_retention_days' => (int) env('WEBSITES_WEBHOOK_EVENT_RETENTION_DAYS', 90),
// Chunk size for the pruning delete so we don't hold long lock waits.
'cleanup_chunk' => (int) env('WEBSITES_WEBHOOK_CLEANUP_CHUNK', 5000),
// Sudden row-count growth ⇒ likely a handler regression backing rows up.
'row_count_alert_threshold' => (int) env('WEBSITES_WEBHOOK_ROW_COUNT_ALERT', 100000),
],
/*
|--------------------------------------------------------------------------
| Apache SNI snippet regeneration (Plan 273 §D.1)
|--------------------------------------------------------------------------
|
| Path the catch-all vhost includes for per-host cert directives. Dry-run
| is on by default so a misconfigured stub can't take Apache down on the
| first deploy; flip via env once the rendered output looks right.
|
*/
'apache' => [
'ssl_domains_conf_path' => env('WEBSITES_APACHE_SSL_DOMAINS_CONF', '/etc/apache2/ssl-domains.conf'),
'reload_command' => env('WEBSITES_APACHE_RELOAD_COMMAND', '/usr/sbin/apachectl graceful'),
'reload_dry_run' => env('WEBSITES_APACHE_RELOAD_DRY_RUN', true),
'queue' => env('WEBSITES_APACHE_QUEUE', 'client-portal-default'),
],
/*
|--------------------------------------------------------------------------
| SMS gateway routing (Plan 273 §B.3)
|--------------------------------------------------------------------------
*/
'sms' => [
'per_tenant_rate_per_minute' => env('WEBSITES_SMS_PER_TENANT_RPM', 60),
],
/*
|--------------------------------------------------------------------------
| Backup integrity (Plan 273 §F.1)
|--------------------------------------------------------------------------
*/
'backup' => [
'inventory_disk' => env('WEBSITES_BACKUP_INVENTORY_DISK', 'backups'),
'asset_disk' => env('WEBSITES_BACKUP_ASSET_DISK', 'assets'),
'queue' => env('WEBSITES_BACKUP_QUEUE', 'client-portal-default'),
],
/*
|--------------------------------------------------------------------------
| Per-tenant content search (Plan 273 §G.2)
|--------------------------------------------------------------------------
|
| MysqlSearchBackend is the primary; Meilisearch is the mirror during
| the FULLTEXT -> Meilisearch transition. Once SearchParityCheckJob
| (§G.3) confirms >= 99% top-K parity for 7 days per tenant, the
| enabled_clients list flips reads to Meilisearch for that client_id.
|
*/
'search' => [
'meilisearch' => [
'url' => env('WEBSITES_MEILISEARCH_URL', 'http://127.0.0.1:7700'),
'master_key' => env('WEBSITES_MEILISEARCH_MASTER_KEY', ''),
'mirror_writes' => (bool) env('WEBSITES_MEILISEARCH_MIRROR_WRITES', false),
'enabled_clients' => array_filter(array_map('intval', explode(',', (string) env('WEBSITES_MEILISEARCH_CLIENTS', '')))),
],
],
/*
|--------------------------------------------------------------------------
| Subdomain provisioner (Plan 294 Phase B-1 / Decision Lock #10)
|--------------------------------------------------------------------------
|
| The driver behind the SubdomainProvisioner contract — writes A records
| on `{slug}.{parent}` against the parent's Route53 hosted zone.
|
| - 'fake' (default): in-memory FakeSubdomainProvisioner for tests
| and local development.
| - 'route53' : production Route53SubdomainProvisioner. Requires
| aws/aws-sdk-php installed (composer require ...).
|
| Defaulting to 'fake' is intentional — an environment without the AWS
| SDK installed should fail loudly at boot if it's misconfigured to
| route53, not silently NXDOMAIN on the first customer signup.
|
*/
'subdomain_provisioner' => [
'driver' => env('WEBSITES_SUBDOMAIN_PROVISIONER_DRIVER', 'fake'),
'aws_region' => env('WEBSITES_SUBDOMAIN_PROVISIONER_AWS_REGION', 'us-east-1'),
],
/*
|--------------------------------------------------------------------------
| Parent domains we own (Plan 294 Phase B-1)
|--------------------------------------------------------------------------
|
| The domains under which we offer subdomains via the zero-touch path.
| Each parent has its own Route53 hosted zone + ACM (Phase A) or Certbot
| (Stage 0) wildcard cert. At Stage 0 we ship with one parent and add
| more once the funnel is proven.
|
| Each entry mirrors the parent_domains table seed. Adding a new parent
| is config + one wildcard cert + one IAM-policy line, not code.
|
*/
'parent_domains' => [
// Note: `scopeforged.com` itself is NOT a tenant parent - it's
// the operational apex (marketing + portal + admin live on it).
// Tenants on the scopeforged.* surface go under the explicit
// `site.scopeforged.com` subdomain instead.
[
'name' => 'site.scopeforged.com',
// site.scopeforged.com lives inside the scopeforged.com Route53
// zone (no separate zone). The provisioner writes A records
// for `{slug}.site.scopeforged.com` into the same hosted zone.
'route53_hosted_zone_id' => env('WEBSITES_PARENT_SITE_SCOPEFORGED_ZONE_ID', env('WEBSITES_PARENT_SCOPEFORGED_ZONE_ID', '')),
'is_active' => true,
'is_default' => true,
],
[
'name' => 'dcsuniverse.com',
'route53_hosted_zone_id' => env('WEBSITES_PARENT_DCSUNIVERSE_ZONE_ID', ''),
'is_active' => true,
'is_default' => false,
],
[
'name' => 'femalelarrybird.com',
'route53_hosted_zone_id' => env('WEBSITES_PARENT_FEMALELARRYBIRD_ZONE_ID', ''),
'is_active' => true,
'is_default' => false,
],
// Staging-only tenant parent — needed so the CSP frame-src allowlist
// in SecurityHeaders includes the staging wildcard, otherwise the
// builder preview iframe on staging is blocked from framing
// `<slug>.staging.websites.scopeforged.com`. Gated on
// WEBSITES_STAGING_TENANTS_ENABLED so it stays inert in prod.
[
'name' => 'staging.websites.scopeforged.com',
'route53_hosted_zone_id' => env('WEBSITES_PARENT_SITE_SCOPEFORGED_ZONE_ID', env('WEBSITES_PARENT_SCOPEFORGED_ZONE_ID', '')),
'is_active' => (bool) env('WEBSITES_STAGING_TENANTS_ENABLED', false),
'is_default' => false,
],
// Customer-friendly staging tenant parent. Same env gate as above.
// Prod does NOT set this flag, so the entry stays inactive on prod
// and doesn't collide with real prod tenants.
[
'name' => 'sites.scopeforged.com',
'route53_hosted_zone_id' => env('WEBSITES_PARENT_SITE_SCOPEFORGED_ZONE_ID', env('WEBSITES_PARENT_SCOPEFORGED_ZONE_ID', '')),
'is_active' => (bool) env('WEBSITES_STAGING_TENANTS_ENABLED', false),
'is_default' => false,
],
],
/*
|--------------------------------------------------------------------------
| Reserved subdomain slugs (Plan 294 Phase B-1)
|--------------------------------------------------------------------------
|
| Slugs a customer can never claim because they collide with operational
| hostnames. Enforced by ReservedSlugValidator at the wizard AND inside
| ProvisionSiteJob (defense in depth - a bypass at one layer doesn't put
| a tenant on a reserved name).
|
| Anything matching the underscore-leading regex below is reserved as a
| CLASS (_dmarc, _domainkey, _acme-challenge, etc. are DNS-convention
| TXT/CNAME hosts; a tenant claiming _dmarc.scopeforged.com would break
| DMARC for the parent). The validator applies both checks.
|
*/
'reserved_slugs' => [
'admin', 'api', 'app', 'apps', 'auth',
'assets', 'beta', 'blog', 'cdn', 'console',
'dashboard', 'dev', 'devops', 'docs', 'download', 'downloads',
'email', 'files', 'forum', 'ftp', 'git', 'go',
'help', 'host', 'images', 'img', 'info', 'js', 'mail',
'media', 'mobile', 'mx', 'news', 'ns', 'ns1', 'ns2',
'pay', 'portal', 'preview', 'public', 'private', 'pro',
'qa', 'release', 'releases', 'review', 'reviews', 'root',
'sale', 'sales', 'shop', 'signup', 'site', 'sites',
'smtp', 'staging', 'static', 'stats', 'status', 'store',
'support', 'system', 'team', 'test', 'tests', 'tools',
'training', 'try', 'uat', 'updates', 'upgrade', 'uploads',
'user', 'users', 'vpn', 'webdav', 'webmail', 'www', 'www2',
// Internal product surface
'scopeforged', 'websites', 'forge',
],
/*
|--------------------------------------------------------------------------
| Reserved slug regex class (Plan 294 Phase B-1)
|--------------------------------------------------------------------------
|
| Anything matching this regex is reserved as a class. Underscore-leading
| hosts are DNS-convention sentinels (_dmarc, _acme-challenge, _domainkey,
| _mta-sts, _smtp._tls, ...) - a tenant claiming `_dmarc.scopeforged.com`
| would break DMARC for the parent.
|
*/
'reserved_slug_pattern' => '/^_/',
/*
|--------------------------------------------------------------------------
| Reputation pre-check on first publish (Plan 294 line 278)
|--------------------------------------------------------------------------
|
| Lightweight content scan that runs on the first publish for subdomain
| tenants — protects the parent-domain reputation from a new customer's
| first page hosting phishing, malware download links, or content in
| the highest-risk verticals.
|
| Pass = saga proceeds and the customer goes live. Fail = the publish
| is held in draft + admin notified. The Cloudflare URL Scanner API
| check is deferred; the v1 implementation uses just the local
| blocklist + keyword screen below.
|
| BYO-domain tenants skip the check — they own their own domain
| reputation.
|
*/
'reputation_check' => [
'enabled' => (bool) env('WEBSITES_REPUTATION_CHECK_ENABLED', true),
// Domains/URLs we never want a tenant linking to. Match is
// substring (lowercased) on each href value in the content.
'url_blocklist' => [
// Known phishing-cluster patterns; expand as we learn.
'login-paypal',
'verify-account',
'secure-bank-login',
'wallet-connect',
],
// Words/phrases that flag the page for admin review. Substring
// match (lowercased, word-boundary not required). Lean toward
// false-positives — admin can dismiss quickly; a single false-
// negative on day 1 can blacklist the parent domain across
// major spam filters.
'keywords' => [
'cashapp giveaway',
'send your seed phrase',
'connect your wallet to claim',
'irs final notice',
'paypal account suspended',
],
],
/*
|--------------------------------------------------------------------------
| Per-tenant origin in-flight budgets (Plan 294 Phase B-1 / Phase A)
|--------------------------------------------------------------------------
|
| OriginTenantRateLimiter middleware uses these per-tier in-flight budgets
| as a Redis token bucket keyed on the tenant id (resolved from Host:).
| Over-quota requests get a 503 with Retry-After: 1; the counter
| `tenant.origin.throttled{tenant_id}` is emitted for dashboards.
|
| These are the PHASE A defaults. Stage 0 halves them via the
| `stage_0.origin_throttle.tier_budgets` override below.
|
*/
'origin_throttle' => [
'tier_budgets' => [
'starter' => 4,
'growth' => 12,
'enterprise' => 0, // 0 = unlimited / contract
],
],
/*
|--------------------------------------------------------------------------
| Stage 0 starting-posture overrides (Plan 294 §Stage 0, ADR-0021)
|--------------------------------------------------------------------------
|
| These knobs make Stage 0 explicit in config. They're consumed by:
| - OriginTenantRateLimiter (tier_budgets override)
| - The wizard host-choice controller (signup cap waitlist)
| - PayingCustomerCountGauge job (alarm + signup cap trigger thresholds)
| - The capacity-ceiling check that fires the Phase A trigger
|
| Stage 0 ends when Phase A cutover completes. At that point this whole
| section is removed (not just set to false) so the production config is
| as honest as Phase A's operating model.
|
*/
'stage_0' => [
'active' => (bool) env('WEBSITES_STAGE_0_ACTIVE', true),
// Halved tier budgets to protect the shared FPM pool from any one
// tenant's burst (the shared pool serves portal + admin + render at
// Stage 0).
'origin_throttle' => [
'tier_budgets' => [
'starter' => 2,
'growth' => 6,
'enterprise' => 12,
],
],
// PhaseAReadinessAlarm fires at this paying-customer count (one
// customer of headroom). Triggers ClickUp task + on-call page; does
// not auto-flip Phase A.
'phase_a_readiness_alarm_count' => (int) env('WEBSITES_STAGE_0_PHASE_A_ALARM_COUNT', 4),
// Hard signup cap. When the paying-customer count reaches this,
// the wizard goes to a waitlist view behind Pennant flag
// `waas_stage_0_capacity_full` until Phase A cutover completes.
'signup_cap_count' => (int) env('WEBSITES_STAGE_0_SIGNUP_CAP_COUNT', 5),
// Plan 294 line 277 - signup-velocity limits at the wizard.
// Per-IP + (Phase B-1.5) per-card-fingerprint daily caps to deter
// bulk-abuse signups. On limit hit, the customer is shown the
// "let's chat" page instead of a 429 so legit edge cases
// (office NAT, small agency signing up multiple sites) get a
// human touchpoint. Card-fingerprint check lands with the Stripe
// direct-checkout wiring (Phase B-1 line 244 completion).
'signup_velocity' => [
'per_ip_per_day' => (int) env('WEBSITES_STAGE_0_SIGNUP_VELOCITY_PER_IP_PER_DAY', 3),
'per_card_fingerprint_per_day' => (int) env('WEBSITES_STAGE_0_SIGNUP_VELOCITY_PER_CARD_PER_DAY', 5),
],
// Measured ceilings on the shared host. Any one breached fires
// the Phase A trigger regardless of paying-customer count.
// Working assumptions from 294-capacity-model.md §Stage 0; replace
// with measured values in Stage 0 week 1.
'capacity_ceiling' => [
'concurrent_renders' => (int) env('WEBSITES_STAGE_0_MAX_CONCURRENT_RENDERS', 20),
'active_byo_certs' => (int) env('WEBSITES_STAGE_0_MAX_BYO_CERTS', 50),
'paying_tenants' => (int) env('WEBSITES_STAGE_0_MAX_PAYING_TENANTS', 5),
],
// Customer-facing SLA banner copy. The 2-4h RTO is the published
// Stage 0 RTO from ADR-0021; honestly stated. Removed on Phase A
// cutover completion.
'sla_banner' => [
'enabled' => (bool) env('WEBSITES_STAGE_0_SLA_BANNER', true),
'published_rto_hours' => '2-4',
],
// The target IP the ProvisionSiteJob saga writes into the Route53
// A record for `{slug}.{parent}` at step 2. At Stage 0 there is no
// ALB — CF origin-pulls direct to the shared EC2's public IP per
// Decision Lock #11's Stage 0 simplification. At Phase A cutover
// this value flips to the ALB DNS name (resolved to ALB anycast).
'subdomain_target_ip' => env('WEBSITES_STAGE_0_SUBDOMAIN_TARGET_IP', '127.0.0.1'),
// CloudWatch region for the Scopeforged/WaaS namespace. The
// EmitPayingCustomerCountGauge command writes the PayingCustomers
// metric here; the PhaseAReadinessAlarm + dashboard widget read
// from the same region. EC2 sits in us-west-2 by default.
'cloudwatch_region' => env('WEBSITES_STAGE_0_CLOUDWATCH_REGION', 'us-west-2'),
],
/*
|--------------------------------------------------------------------------
| Content retention (Plan 333 §Phase 1)
|--------------------------------------------------------------------------
|
| Builder deletes soft-delete the page/section/block (recoverable via the
| Undo toast). `websites:purge-trashed-content` force-deletes rows trashed
| longer than this window through TrashCascadeService (media-ref-safe).
|
*/
'retention' => [
'trashed_builder_content_hours' => (int) env('WEBSITES_TRASHED_CONTENT_RETENTION_HOURS', 24),
],
/*
|--------------------------------------------------------------------------
| Backup retention / paths (Plan 294 §Stage 0)
|--------------------------------------------------------------------------
|
| Used by the backup scripts in scripts/backup/. The bucket itself has
| a 7-day lifecycle policy on the /mysql/ and /letsencrypt/ prefixes
| so retention is enforced server-side, not client-side.
|
*/
'stage_0_backups' => [
'bucket' => env('WEBSITES_STAGE_0_BACKUP_BUCKET', 'scopeforged-websites-prod-backups'),
'mysql_prefix' => 'mysql/',
'letsencrypt_prefix' => 'letsencrypt/',
'retention_days' => 7,
'mysql_cron' => '17 * * * *', // matches /etc/cron.d/scopeforged-stage-0-backups
'letsencrypt_cron' => '23 * * * *',
],
/*
|--------------------------------------------------------------------------
| Dunning (Plan 294 §Phase C)
|--------------------------------------------------------------------------
|
| Branded reminder emails during the past_due grace window. `offsets`
| are days-since-entering-past_due on which a reminder is sent;
| `suspend_after_days` is when an `unpaid` subscription past grace is
| suspended by the daily orchestrator.
*/
'dunning' => [
'offsets' => [0, 3, 7, 10, 13],
'suspend_after_days' => (int) env('WEBSITES_DUNNING_SUSPEND_AFTER_DAYS', 14),
],
/*
|--------------------------------------------------------------------------
| Tenant email — SES/DKIM (Plan 294 §Phase G, Lock #20)
|--------------------------------------------------------------------------
|
| Per-tenant BYO-domain sending identities. `driver` selects the SES
| client: `ses` uses the AWS SDK (production), anything else binds the
| in-memory fake so CI never touches AWS. `dmarc_rua` is the aggregate
| report mailbox we recommend when a tenant has no DMARC record.
*/
'email' => [
'driver' => env('WEBSITES_EMAIL_DRIVER', 'fake'), // ses|fake
'dmarc_rua' => env('WEBSITES_EMAIL_DMARC_RUA', 'dmarc-aggregate@scopeforged.com'),
],
];