Architecture
A deep-dive into how this site is built — the request path, the deploy pipeline, the data model and every flow that writes to it — plus the guards that stop it from quietly going wrong.
Executive summary
A storefront for one artist’s paintings and photographs: a filterable gallery, a page per work with print sizes and prices, a map of where each work was made, time-lapse films of the paintings being made, a gift-shop builder, and an admin CMS behind a password. Prints are ordered by enquiry — the order form emails the artist and the sale completes by email, so the site takes no payments and holds no card data.
It runs on fully managed infrastructure — Next.js on Vercel, Postgres on Neon, media on Vercel Blob, email through Resend — and is run by one person with no developer on call. The design leans accordingly: mistakes are made to fail the build rather than reach production, and scheduled jobs email when something goes wrong rather than waiting on a dashboard someone has to watch.
- Framework
- Next.js 16 · App Router
- Runtime
- React 19 · Node
- Data
- Prisma 7 · Neon Postgres
- Host
- Vercel · Crons · Blob
- Resend
- Styling
- Tailwind CSS v4
- Tests
- Vitest · Playwright · axe · ZAP
- Captions
- Claude API
Operations
Tests gate every deploy
Every pull request runs a dependency audit, the full unit suite, a production build and the Playwright suite against a throwaway Postgres. Vercel’s production build runs the unit suite again, so a failing test stops the deploy even if it slipped past review.
Four scheduled jobs
Vercel Crons: a health check every 10 minutes, a catalogue backup daily at 05:00 UTC, a prune of old analytics events and expired rate-limit counters at 05:30 UTC, and Instagram publishing Mon/Wed/Fri at 15:00 UTC. Each route rejects any request without the CRON_SECRET bearer token.
Failures email the owner
Every 10 minutes the health check confirms the database answers and four real pages render on the live domain, and emails through Resend (lib/notify.ts) once when something breaks and once when it recovers — never on every run. The Instagram job emails if its token is invalid, a publish fails, or a queued caption has gone stale.
Two kinds of analytics
Google Analytics 4, plus a first-party tracker writing one row per event to Postgres — no raw IP stored — with its own dashboard. The owner’s browsers are excluded at ingest, not filtered afterwards.
1 · Runtime request path
Every page is a React Server Component rendered on request (dynamic = "force-dynamic"), reading through a single Prisma client. Interactivity is confined to client-component islands — the gallery’s filter and sort, the size selector and order form, the map, the film player — and writes go through route handlers (public forms) or server actions (the admin).
Browser
Server-rendered HTML · client islands: gallery filter/sort, size selector + order form, Leaflet map, click-to-load film player · first-party analytics beacon
Next.js 16 on Vercel (Node runtime)
Proxy gives each page request a fresh CSP nonce · Server Components render every page per request · Route handlers /api/buy /api/contact /api/book /api/track /api/og-image /api/csp-report /api/cron/* · Server actions for the admin (session-checked)
Prisma 7 client (module singleton)
Adapter chosen by URL: PrismaNeonHttp for Neon — one HTTPS request per query, no connection pool to exhaust — or PrismaPg for a plain Postgres (CI and local E2E)
Neon Postgres (serverless)
Product · MakingOf · InstagramPost · AnalyticsEvent · ExcludedVisitor · FormRateLimit · HealthState · CspViolation
Alongside the database
Repository (/public)
catalogue images, served through the next/image optimiser
Vercel Blob
admin uploads, collectors' photos, films, backups
Resend
order, contact and booking emails; failure alerts
Kit
newsletter sign-up, posted from the browser
OpenStreetMap tiles
the Locations map
Meta Graph API · Claude API
Instagram publishing · caption drafts
Rendering on every request is a deliberate trade: an edit in the admin is live on the next page load with no revalidation logic, and the build never needs database access to render. The catalogue is small, so the cost — a database round trip per view — is acceptable.
2 · CI/CD & deployment topology
All work lands through pull requests into main. GitHub Actions (.github/workflows/e2e.yml) runs on every pull request and every push to main; Vercel deploys main to production on merge.
Feature branch
local: unit tests, typecheck, E2E on a throwaway Postgres
GitHub Actions
Postgres 16 service → npm ci → npm audit → npm run build → seed → Playwright + axe → ZAP scan
Merge to main
squash merge
Vercel build
prisma generate → prisma db push → vitest → next build
Production
CDN + Node functions → Neon
- One build script everywhere.
npm run buildisprisma generate && prisma db push && vitest run && next build, in CI and on Vercel alike, so the unit suite gates production and not only pull requests. - No preview deployments.
vercel.jsondisables deploys for thefeature/,fix/,docs/,chore/,perf/,test/andclaude/prefixes, and itsignoreCommandskips any other non-production build. CI is the pre-merge gate; visual checks happen locally against a production build. - Schema changes ship with the code.
prisma db pushruns on every build, so the column exists before the code that reads it is served. There is no migrations directory: changes must be additive or nullable, and anything destructive needs a hand-written plan. - E2E cannot touch production. The Playwright suite creates, edits and deletes rows.
e2e/db-guard.tsrefuses to start unlessDATABASE_URLpoints at a local host — added after a local run once edited the live catalogue.
3 · Data model
Eight tables. Paintings and photographs share Product, distinguished by type. Other tables refer to a work by its id rather than a foreign key: a film can be published before its painting is in the catalogue and links itself up once the product exists, and analytics events outlive a deleted work.
Product
The catalogue — the site's source of truth
- id
- slug, PK — also the URL
- type
- "painting" | "photograph"
- title · category · description
- copy
- image · blurDataURL
- repo path or Blob URL · base64 placeholder
- sizeType · price
- keys into the size/price table in data/products.ts
- inspirationImage · Lat · Lng
- the source photo and where it was taken
- homePhoto · homePhotoCredit
- a collector's photo, stored stripped
- isNew · onSale · soldOut · visible
- merchandising flags
- sortOrder
- drag-to-reorder position
- naturalWidth · naturalHeight
- photographs: panorama detection
InstagramPost
A publishing queue with a state machine
- status
- pending → approved → posting → posted | skipped
- type · productId
- painting, photograph, reel, voting, press
- imageUrl · caption
- media and the approved text
- scheduledAt
- next free Mon/Wed/Fri slot, set on approval
- mediaFingerprint
- etag|content-length when the caption was written
- instagramId · postedAt
- set by the Graph API on publish
MakingOf
Time-lapse films of the paintings
- videoUrl · posterUrl
- Blob — too large to commit
- productId
- optional; links up when the work exists
- description · seconds
- caption and running time
- visible · sortOrder
- publishing and order
AnalyticsEvent
One row per page view or action
- kind · action
- pageview | action (buy_open, product_click, …)
- path · productId · referrer
- what, and from where
- visitorId
- anonymous first-party cookie
- device · browser · os
- parsed server-side from the user agent
- country · city
- coarse, from Vercel edge headers — no IP
- indexes
- createdAt · kind+createdAt · action · productId · visitorId
ExcludedVisitor
Browsers not counted — the owner's
- visitorId
- PK — the gba_vid cookie value
- label
- free text, e.g. "desktop Chrome"
FormRateLimit
Counters that stop the forms being a mail relay
- key
- PK — an HMAC of an IP or email address, never the raw value
- windowStart · count
- fixed window, reset in the same statement
HealthState
The health check's last result — one row
- failing · changedAt
- so it emails on a change, and knows how long it lasted
- checkedAt · detail
- when it last ran, and its full report
CspViolation
Policy violations browsers report — aggregated
- directive · blocked · page
- what, from which origin, on which page
- count · firstSeen · lastSeen
- one row per kind; unseen for 30 days → pruned
In code, not the database
Shared, versioned configuration
- data/siteConfig.ts
- brand, artist, domain, contacts, IDs
- data/products.ts
- SizeType union and the print price table
- data/giftShop.ts
- gift-shop products and image counts
- app/globals.css @theme
- brand colour tokens
4 · Key flows
Order a print, a card or a gift pack
The Buy Now form posts only ids and choices — which work, which size, framed or not, which images in a pack — to /api/buy. The server looks up the title and price itself (lib/order.ts) and refuses a hidden or sold-out work, a size the work isn’t sold in, or a pack with the wrong images, so no posted text can reach an email. It then sends the enquiry to the artist, with Reply-To set to the buyer, and a confirmation to the buyer. No payment is taken on the site. Contact and booking (/api/contact, /api/book) follow the same pattern. All three forms carry a hidden honeypot field; a submission that fills it gets a success response and sends nothing, so a bot learns nothing from being caught.
Add or edit a work (admin)
In the browser, the image is converted from HEIC if needed, downscaled to 2,400px JPEG and given a blur placeholder — which keeps uploads under the 4.5 MB server-action body limit. The server action checks the session, embeds the copyright notice in EXIF and XMP (pure JS; no exiftool in a serverless function), uploads to Blob under paintings/ or photographs/, and writes the row. The work is live on the next request.
Attach a collector’s photo
A photo of a work hanging in a buyer’s home replaces the generated room mockup on that work’s page. It is re-encoded with sharp, which drops all metadata — a phone photo taken at home carries that home’s GPS — and it fails closed: a file that can’t be read is refused, never published as-is. It gets no artist copyright, because it isn’t the artist’s photograph.
Delete a work or replace an image
Before deleting a Blob object, lib/blob-cleanup.ts counts references across products, Instagram posts and films; a file still in use elsewhere is kept.
Publish to Instagram
Captions are drafted by the Claude API into pending; approving one assigns the next free Mon/Wed/Fri slot. The cron checks the token, then re-reads the media’s etag and length and puts the post back to pending if the file changed since the caption was written (a repaint replaces the file behind an unchanged URL). Images outside Instagram’s 0.8–1.91 aspect range are letterboxed to a temporary Blob copy. Publishing is two Graph API calls — create a container, then publish it — with the row held in posting in between. A failure before publishing is safe to retry; one after is not, so a row stuck in posting is left for a person to check rather than retried into a duplicate public post.
Record an analytics event
The tracker posts to /api/track, which drops the event if the host is localhost or a preview deployment, the user agent is a bot or headless browser, or the visitor is excluded. Otherwise it parses the user agent, takes coarse location from Vercel’s headers, sets an anonymous httpOnly gba_vid cookie and writes one row. A daily cron deletes events older than three years with a single deleteMany.
Sell out an edition
soldOut is distinct from visible: a sold-out work keeps its page and stays indexed, its JSON-LD availability becomes SoldOut, prices and the order form give way to an enquiry panel, and it moves to its own section at the end of the gallery and out of the gift shop.
5 · Media pipeline
- Catalogue images live in the repository under
public/paintingsandpublic/photographs, so the artwork is version-controlled. They are served through thenext/imageoptimiser, resized per viewport. A new admin upload lives on Blob until it is committed the same way. - Blur-up placeholders come from
data/blurs.tsfor committed images, or the per-productblurDataURLmade at upload. The generated file is stripped of metadata, and a unit test fails the build if metadata returns — it once took the placeholder file from 46 KB to 200 KB without anyone noticing. - Copyright metadata has two writers that agree: an exiftool script for committed files (EXIF, IPTC, XMP) and a pure-JS writer for uploads (EXIF, XMP). Both change metadata only, never pixel data; the notice itself is defined once, in
lib/copyright.ts. - Films stay on Blob as MP4s with the index at the front (faststart), so playback begins before the file has fully arrived. The page renders a still poster; no
<video>element exists until the visitor presses play, so no film data is downloaded unless asked for. - Share images come from
/api/og-image, which composes a card with sharp from a work that is currently visible — a hard-coded image could name a work that has since been hidden. - Old URLs from the previous Squarespace site (
/home/p/<slug>) redirect permanently to works still for sale; the rest return 404 on purpose rather than landing somewhere misleading.
6 · Tests & build guards
At the time of writing: about 440 unit and component tests (Vitest and Testing Library) and about 85 Playwright tests, one of them opt-in. Beyond ordinary behaviour tests, a set of guards turn whole classes of mistake into build failures. Most exist because the mistake happened once.
| Guard | Fails the build when… |
|---|---|
| Brand colour | a brand-green hex literal appears under app/, components/ or lib/ instead of the @theme token. There were 186, in four different hover greens. |
| Site identity | the brand, domain, email, phone, Instagram handle or analytics id is typed into code instead of read from data/siteConfig.ts. |
| Admin form fields | a server action reads a checkbox its form never renders, or tests one against "off". A browser never sends "off"; both mistakes once shipped at the same time. |
| Collector photo privacy | the home-photo path stops re-encoding, or starts stamping the artist’s copyright on someone else’s photograph. |
| Placeholder metadata | image metadata reappears in the blur placeholders shipped in the bundle. |
| Accessibility | axe finds a WCAG 2.1 A/AA violation on any public page, the open mobile menu or the order form. Two rules the site already failed when this was wired in — colour contrast and colour-only links — are reported but not yet enforced. |
| Content-Security-Policy | any public page, the signed-in admin or a playing film raises a policy violation (e2e/csp.spec.ts), or a page is served without a fresh nonce. |
| Security headers | any response is missing the frame, sniffing, referrer or permissions header. |
| Dependency audit | a production dependency has a critical security advisory (npm audit, the first step in CI). |
| Security scan | OWASP ZAP’s baseline scan of the CI build raises a finding not triaged in .zap/rules.tsv, where each accepted one carries its reason. |
| E2E database | DATABASE_URL is not local — the suite refuses to start. |
| Archive before delete | a destructive script’s archive would overwrite an earlier one; the write fails and nothing is deleted. |
7 · Security & privacy
- Admin authentication is a single password (one user) exchanged for an iron-session cookie: encrypted, httpOnly, SameSite=Lax, Secure in production, eight hours. Every server action that changes data checks that session before doing anything.
- Login attempts are limited to five per fifteen minutes per client IP. The limiter is in memory and per instance, so it slows brute force rather than guaranteeing a global limit.
- Cron routes require the
CRON_SECRETbearer token and return 401 without it. - Public forms validate names and emails server-side, and all three carry a honeypot. The booking form also rejects a submission made faster than a person could type it, and the contact form one with no spaces in it. The site stores no payment details because it takes no payments.
- The forms can’t be used as a mail relay. Each one emails a confirmation to the address typed in, from the site’s own domain, so each is rate-limited in Postgres across every serverless instance: per client IP and form, per recipient address, and a daily total (
lib/form-rate-limit.ts). The confirmations repeat nothing the visitor typed — not even their name — and the stored keys are HMACs, never raw IP or email addresses. Over the IP limit, nothing is sent; over the others, the artist still gets the enquiry and only the confirmation is held back. - Analytics stores no IP address: location is the coarse country and city Vercel supplies, and visitors are counted by a random httpOnly cookie. Headless browsers and bots are dropped at ingest.
- Other people’s photographs are re-encoded to remove location metadata, credited as their owners asked, and removable from the edit form.
- Secrets live in Vercel environment variables. The repository carries only
.env.local.example, with placeholder values.
8 · Security testing & resilience
What is in place, and what is not, stated plainly — including the things that haven’t been done.
Automated security tests
These run in CI on every pull request:
- Admin login rejects a wrong password, and locks out after five failed attempts (
e2e/admin.spec.ts,e2e/zz-rate-limit.spec.ts). - Admin pages redirect to the login page without a session.
- The order endpoint refuses a size the work isn’t sold in and a work that doesn’t exist; every form sends nothing once over its limit; and no confirmation repeats a submitted name or title (
__tests__/form-email-abuse.test.ts). - The order and contact endpoints reject requests with missing fields, accept honeypot submissions without sending anything, and the contact endpoint rejects a message with no spaces (
e2e/api-validation.spec.ts). - Collectors’ photos are re-encoded, never published with their location data, and never stamped with the artist’s copyright (unit tests).
- The E2E suite refuses to run against a database that isn’t local.
- Every form posts, so one sent before JavaScript loads keeps the visitor’s email and message out of the URL, and out of analytics (
e2e/forms.spec.ts).
Automated scanning (DAST)
After the E2E suite passes, CI starts the same production build again and runs the OWASP ZAP baseline scan against it: a spider crawls the site (over a thousand URLs) and ZAP’s passive rules check every response — headers, cookies, the policy, caching, information leaks. It sends no attack payloads and never touches production. The ZAP image is pinned, so a new release can’t fail an unrelated pull request. Any finding fails the job unless .zap/rules.tsv records it as triaged, with the reason; the report is attached to the run.
Its first run found four things, and all four were fixed rather than accepted. The contact form, sent before JavaScript loaded, put the visitor’s email and message in the URL; every form now posts. Responses announced X-Powered-By: Next.js. And the cross-origin opener and resource policies were missing. What it still reports is accepted on the record: the policy is report-only for now, GA4’s script can’t carry a subresource-integrity hash because Google changes it, and Cross-Origin-Embedder-Policy would block the map and analytics for an isolation the site doesn’t need. The public forms carry no anti-CSRF token because they act for no one — there is no session for a forged request to ride — while the admin’s server actions reject a request from another origin, behind a SameSite cookie.
Dependencies
Dependabot opens grouped update PRs weekly for npm packages and monthly for GitHub Actions, and GitHub raises security alerts with automatic fix PRs; each one runs the full CI like any other change. Installs are reproducible — package-lock.json is committed and CI uses npm ci — and CI runs npm audit on production dependencies, failing on any critical advisory.
The audit is held at critical rather than high for one known exception: the Prisma CLI’s own dependencies, which no 7.x release fixes and which run only during the build, never when a visitor loads a page.
Not done yet
| Area | Status |
|---|---|
| Penetration testing | The automated scan is passive. No active scan, which sends attack payloads, and no manual penetration test has been carried out. |
| External uptime monitoring | The health check runs on Vercel, so it can’t see Vercel itself or the domain going down; nothing outside Vercel watches the site yet. |
| Load testing to failure | The load test is capped at 50 visitors on purpose, and the site was comfortable there, so where it stops coping is still unmeasured. |
| Static analysis | TypeScript and ESLint only; no CodeQL, Snyk or secret scanning. |
DDoS and traffic spikes
Vercel applies automatic DDoS mitigation and system-level traffic filtering to every request before it reaches the application, and Attack Mode can be switched on for the project during an attack — it challenges browsers while letting known good bots through. The repository defines no firewall rules of its own. Because pages render per request against a serverless database over HTTP, a spike turns into more function invocations and queries rather than exhausted connections.
Load testing
load/visitors.js is a k6 script that simulates visitors: each one loads the gallery, a painting, a photograph or the photo gallery, then the gift shop, map or films, pausing one to three seconds between pages the way a person does. It reads the works from the sitemap, so it follows the live catalogue. It makes read-only GET requests for pages alone — no forms, no admin, nothing written — and k6 runs no JavaScript, so analytics never sees it. It stops itself if more than 1% of requests fail or the 95th percentile passes three seconds, and it has no default target, so nobody runs it against production by accident.
Against production on 28 September 2026 it ramped to 50 simultaneous visitors over three and a half minutes, about 24 page renders a second at the peak, every one a function invocation with its Neon queries:
| Measure | Result |
|---|---|
| Pages served | 2,849, none failed |
| Median | 135 ms |
| 95th percentile | 194 ms (slowest page type: the films, 251 ms) |
| 99th percentile | 275 ms |
| Slowest request | 852 ms |
Response times stayed flat as the load rose, which says the ceiling is well above 50. How far above was not tested: the run is capped deliberately, because it runs against the live site.
HTTP security headers
Every response — pages, API routes, the admin — carries X-Frame-Options: DENY (no other site may frame the pages), X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, and a Permissions-Policy that switches off the camera, microphone, geolocation, payment and other features the site never uses, and Cross-Origin-Opener-Policy and Cross-Origin-Resource-Policy set to same-origin, so no other site gets a handle on its windows or can embed its images. Nothing announces the framework. They are set once, in lib/security-headers.ts, and checked by a unit test and by an E2E test that reads real responses. Strict-Transport-Security comes from Vercel, with a two-year max-age.
Content-Security-Policy
Nonce-based and strict. proxy.ts gives every page request a fresh nonce; a script runs only if it carries it, or was loaded by one that did ('strict-dynamic') — no 'unsafe-inline' for scripts and no host allowlist. Next.js stamps the nonce on its own scripts, and the GA4 tags take it from the layout. Nonces need every page rendered per request, which this site already does, so they cost nothing extra here. Each outside service is named once: analytics, the newsletter, the map tiles and iframe, stored images and films.
Two deliberate allowances. Styles allow inline, because React renders style attributes that nonces can’t cover. And the admin alone may use blob: workers and eval, for the converter that turns iPhone photos into JPEGs; the public site gets neither.
It is sent as report-only for now: violations are reported to /api/csp-report, aggregated into CspViolation (directive, blocked origin, page, count) and nothing is blocked. An E2E test loads every page, the signed-in admin and a playing film and fails on any violation; once real traffic is quiet too, one constant switches it to enforcing.
9 · Build & runtime notes
- No interactive transactions. The Neon HTTP adapter runs single statements, so the admin avoids batch operations: reordering is a loop of single updates, and the analytics prune is one
deleteMany. - Connection URL lives in
prisma.config.ts(Prisma 7), which loads.env.localover.envthe way Next.js does, and never overrides a variable already set — so an exported localDATABASE_URLalways wins. - Server-action body limit is raised to 4.5 MB, matching Vercel’s function request limit; images are downscaled in the browser to fit.
- Identity and theme are data. Whose site this is lives in
data/siteConfig.tsand the brand colours in Tailwind@themetokens, which is what makes the repository a template: a second artist forks it and followsFORKING.md. - Locally,
.envpoints at the production database, so anything that writes is run against a throwaway Postgres and a production build.
10 · Backups & durability
- Daily catalogue backup. A cron writes a JSON snapshot of the
Producttable to Blob (backups/catalog-YYYY-MM-DD.json) and deletes snapshots older than seven days.npm run db:exporttakes one by hand. - The artwork is in git. Committed catalogue images are versioned with the code; films are uploaded from their local source files by
scripts/publish-films.ts. - Destructive scripts archive first and default to a dry run. The Blob pruner checks every product, film and Instagram post for references before deleting, and archives each file it removes; the analytics purge writes the rows it will delete to a file named to the second, and refuses to overwrite an earlier one.
- Not in the daily backup: films, the Instagram queue and analytics. Films can be re-uploaded from their source files; the other two are operational data.
11 · Decisions & trade-offs
| Decision | Why | Cost |
|---|---|---|
| Render every page per request | Admin edits are live immediately; no cache invalidation to get wrong | A database read per page view — fine at this catalogue size |
| Neon over HTTP | Serverless-friendly: no pool to exhaust, no connection storms | Single statements only; no interactive transactions |
| prisma db push, no migrations | Schema ships with the code in the same build | Additive changes only; destructive ones need a manual plan |
| Catalogue images in the repo | Artwork versioned with the code and served from the CDN | New uploads live on Blob until committed |
| No preview deployments | CI on a throwaway database is the gate; nothing half-built is public | Visual review happens locally, not on a shareable URL |
| First-party analytics beside GA4 | Owns the data; excludes the owner at ingest | Another table to prune and a dashboard to maintain |
| Orders by email, not checkout | No payment data, no PCI scope, a personal sale | Every order is handled by hand |
12 · Repository map
app/
page.tsx · HomeClient.tsx gallery (server page + client filter/sort)
products/[id] photographs/[id] a work: sizes, order form, JSON-LD, room view
locations/ making-of/ map of where works were made · films
gift-shop/ calendar, card and postcard pack builders
about/ faq/ contact/ book/ static and form pages
for-artists/ architecture/ the pitch · this page
admin/ CMS, Instagram queue, analytics dashboard
actions/ server actions: admin, instagram, analytics
api/ buy · contact · book · track · og-image · cron/*
home/p/[slug]/ legacy Squarespace URL redirects
sitemap.ts · robots.ts generated from siteConfig
proxy.ts per-request CSP nonce for every page
components/ Navigation, ProductCard, GalleryGrid,
BuyNowButton, MakingOfVideo, LocationMap, …
lib/
db.ts Prisma singleton; adapter by URL
csp.ts · csp-report.ts the policy · parsing violation reports
session.ts · rate-limit.ts admin session · login limiter
order.ts what an order is: server-side titles and prices
form-rate-limit.ts Postgres-backed limits for the public forms
analytics.ts UA parsing, bot and host filters
instagram.ts caption drafting, price ranges, scheduling
instagram-publish.ts two-step Graph API publish
blob-cleanup.ts reference counting before delete
home-photo.ts re-encode collectors' photos (strips GPS)
image-copyright.ts stamp the artist's notice on uploads
availability.ts · json-ld.ts sold-out rules · structured data
data/ siteConfig, price table, seed catalogue, blurs
prisma/ schema.prisma · seed.ts
scripts/ films, mockups, blurs, copyright, backups,
Blob pruner, analytics purge
__tests__/ Vitest unit, component and source-scan tests
e2e/ Playwright specs, axe scans, db-guard
.github/workflows/e2e.yml audit, build, E2E + ZAP scan on every PR and push to main
.zap/rules.tsv ZAP findings triaged as accepted, with reasons
load/visitors.js k6 load test: simulated visitors, read-only
.github/dependabot.yml weekly npm and monthly Actions updates
vercel.json branch deploy rules · crons