Skip to content

Security and data protection

Every answer the application gives, on every host and in every route group (the control panel, the Storefront API, webhooks, the docs and the landing page), carries the same baseline from SecurityHeaders, the one global middleware for it (added 26 October 2026; until then only nginx set three of them):

Header Value Why
Strict-Transport-Security max-age=31536000; includeSubDomains on the platform domain and its subdomains; max-age=31536000 alone on a shop’s own domain Browsers keep using https for a year. Every platform subdomain is ours and on Cloudflare; a shop’s other subdomains are not ours to commit. Sent only over TLS, so local development is unaffected; no preload, because that is an irreversible submission to the browser lists and the owner’s call
Content-Security-Policy default-src 'self'; scripts only from this origin with a per-request nonce, plus Google Maps; styles from this origin, inline, and the two font hosts; images from this origin, data:, blob: and any https host; connections to this origin and Google Maps; no objects; framing by this origin only; forms to this origin only Only on the pages the application renders itself (sign-in, onboarding, the control panel, the fallback storefront page). Vite puts the nonce on every tag it generates and the layout on its one inline script; Google’s loader copies it onto the Maps script it adds. The landing page and the docs are static sites built by Astro with their own inline scripts and get no policy here; the storefront runtime sets its own. Off while Vite’s dev server runs, which injects inline scripts of its own; Laravel Herd injects one too, so a local production build logs one violation per page from Herd’s script, not ours
X-Content-Type-Options nosniff No content sniffing
X-Frame-Options SAMEORIGIN With frame-ancestors for browsers without CSP
Referrer-Policy strict-origin-when-cross-origin, unless a controller chose a stricter one (the order status link sends no-referrer, because its token is in the path) The full URL stays on this site
Permissions-Policy camera=(), microphone=(), geolocation=(), payment=() None of the pages need a device permission

The storefront runtime sends the same HSTS header on every answer of its own (its 404 and redirects included), with the same platform-or-foreign rule; it already set the other five. The policy and the nonce are proven by tests/Feature/SecurityHeadersTest.php, and a Playwright pass of every control-panel page under a production build on 26 October 2026 found no violation of ours.

A password is at least eight characters, and on production not one that appears in a known breach: Password::defaults() in AppServiceProvider, which every form that sets a password shares (sign-up, reset, the settings page). The breach check asks Have I Been Pwned with the first five characters of the password’s SHA-1 only, so the password never leaves the server; an unreachable service counts as not compromised, so an outage locks nobody out. It runs on production only, because tests fake every outbound call and the seeded demo accounts use a plain password. Sign-in is limited to five attempts a minute per address and account, two-factor codes likewise, and two-factor needs the password confirmed first (config/fortify.php).

  • Workspace AI keys are encrypted at rest, hidden from serialization, never flashed back into the session on validation failure, and never included in the Inertia page payload.
  • Provider and transport exceptions are logged with known keys replaced by [redacted]; the browser receives a generic message.
  • Failed provider responses go through ProviderErrors: the browser gets a translated category for the HTTP status (key rejected, request refused, rate limited, or the operation’s generic failure) and the log gets the status and the provider’s short error code or type. Response bodies are never shown or logged, because a provider can echo the key, the prompt or private data back (S2-F05, found 4 October 2026: provider error.message text had reached the browser and full bodies the log). The flower and bouquet forms show that message under the description field; until the same date a failed AI description was silent there.
  • The Google Places key is a browser key by design. It is shared only with signed-in members and should be restricted by HTTP referrer.
  • No provider credentials are needed in the environment file; everything is configured per workspace.

The storefront’s data comes only from the Storefront API:

  • Explicit public resources, checked against the contract, so no undocumented field can leak.
  • Availability as labels, never stock counts.
  • JSON-only writes and no CORS, so the httpOnly cart cookie needs no CSRF token.
  • Hashed cart tokens.
  • Per-client rate limits.

The Astro storefront holds no database connection, and its only secret is the rate-limit key.

  • Host check. It serves only {slug}.{domain} hosts, so a forged Host header cannot make it call another address.
  • Themes are confined to the SDK. Each theme receives a context object instead of the request. npm run themes:check refuses outside imports, environment or cookie access, raw HTML, inline scripts and network calls of the theme’s own.
  • Content Security Policy. Pages carry a strict CSP: scripts only from the storefront and Astro’s hashed hydration scripts, connections and form posts only to the storefront.
  • Preview tokens move from the URL into an httpOnly cookie and expire with the token.
  • Rate limits per visitor. The rate-limit key lets Laravel count each visitor behind the runtime. Without the key, a forwarded address is ignored.
  • Local stdio only. The internal storefront MCP server has no web endpoint (Mcp::local). Using it requires running artisan on the machine (locally, or over SSH to production), the same trust as a shell there.
  • Same validation and tenant scoping. Every write goes through StorefrontConfig and its validation. Each tool enters the named shop as the tenant, so another shop’s bouquets, categories and images are refused.
  • Draft first. Changes land in the draft and go live only with publish; the one exception is occasion tags.
  • Recorded. Changes are recorded with source mcp and the operator label.
  • Images by URL go through SafeImageDownloader.
  • Plain data only. Storefront configuration is plain data validated against the shop profile rules and the theme manifest’s typed settings. Markup, unknown fields, other shops’ ids and other shops’ uploads are refused, and the whole change is rejected.
  • Preview tokens. These are HMAC-signed, scoped to one shop and expire after an hour. Only members with settings.edit can obtain one.
  • Uploads. Storefront uploads go into the shop’s own folder under random names, and only JPEG, PNG and WebP are accepted. SVG is refused, because it can carry script.
  • Server-side image fetches go through SafeImageDownloader: https only, public addresses only with the connection pinned to the checked address, no redirects, and the body must decode as an image. Found 4 October 2026: FlowerController had fetched any URL it was given, an SSRF route for a signed-in staff member (details).
Field Rule
Flower purchase cost, bouquet cost and margin Managers and above (ADR-001)
Supply delivery price and batch unit cost Managers and above (ADR-001)
Customer phone and email on orders Masked for viewers

Sensitive attributes are hidden on the model by default and revealed explicitly in the responses that need them, so a new endpoint is safe unless it opts in.

Login happens on the root domain; the shop lives on a subdomain. The session cookie is set on the parent domain (.flowery.test locally) so one login covers both. When the cookie domain changes, the cookie name must change too, otherwise browsers holding the old host-only cookie stay logged out (ADR-006).

An Inertia request is an XHR and cannot follow a redirect to another origin. Code that knowingly crosses between the root domain and a shop uses Inertia::location(). As a safety net, HandleInertiaRequests turns any redirect to another host that answers an Inertia request into a full visit. That covers Fortify’s redirect()->intended() after password and two-factor login, which left a signed-in user stuck on the challenge page until 4 October 2026.

A user who is the sole owner of a workspace cannot delete their account, because the deletion would cascade to the workspace and every business record in it (ADR-002).

Sign in with Google (ADR-019):

  • It accepts only addresses Google verified.
  • It finds the account by Google id, then links by email.
  • When it links an account whose address was never confirmed, it replaces that account’s password and remember token and deletes its sessions, so whoever registered the address first keeps no way in.
  • Two-factor still applies.
  • The Google id is hidden from serialization, and no Google token is stored.

Every endpoint that costs money, sends mail, calls a provider or takes a guess at a secret is limited per minute, by the visitor’s address (the real one: nginx takes it from Cloudflare’s CF-Connecting-IP, and the storefront runtime forwards it with the runtime key) or by the signed-in user:

Where Limit
Sign-in, two-factor codes 5 a minute per address and account
Password reset and verification emails, the address correction on the confirm page 6 a minute
Sign in with Google, reminder opt-out links 20 a minute
Invitation links 30 a minute to open, 10 to accept
Team invitations 20 an hour
AI generation 30 a minute per user, the whole /ai group
Domain connection, checks and DNS providers 5 to 10 a minute
Storefront image uploads 30 a minute
Storefront API 120 a minute browsing, 30 cart writes, 30 quotes, 5 order requests
Uklon webhooks 600 a minute

A known advisory in any of the five dependency trees fails CI, and the dependency policy (ADR-023) says when the rest moves: advisories within two working days, in-range updates weekly through Dependabot, majors quarterly one framework at a time, and never past the PHP and Node that production runs.

  • Http::preventStrayRequests() is on in every test: any un-faked outbound call fails the test.
  • Authorization tests cover every role against every protected endpoint, including negative side-effect assertions.
  • Dependency audits for both Composer and npm run in CI and fail on any advisory at moderate or above.
  • CI tests on production’s PHP (8.4) and PostgreSQL (17).
  • A /.well-known/security.txt with a contact for reports: it needs a mailbox the owner reads, which is on the same list as the other public contacts (PROJECT_STATE.md, step 7).
  • A Content Security Policy on the landing page and the docs: Astro can emit one with hashes for its own inline scripts, but the landing page also carries Cloudflare’s analytics beacon, which the policy would have to allow by host.