Skip to content

The submission form

Markdown

/submit/ takes a listing in two steps — the site’s address, then the details the server read from it — and stores a pending row with a signed status link. features.submissions: true in site.config.ts switches it on; it needs a D1 database and the two admin secrets, both of which Go live provides.

  1. Switch the form on.

    site.config.ts
    features: { submissions: true },
    Terminal
    pnpm check:config

    site.config.ts OK — <your site name> (https://your-domain.com).

  2. Give it a database and the two secrets.

    Terminal
    pnpm cf:setup

    It creates the D1 database, applies the migrations, deploys, and sets ADMIN_SECRET and TOKEN_SECRET on the Worker. Already done? pnpm cf:secrets lists both names.

  3. Try it on a fresh clone.

    Terminal
    pnpm dev

    First it writes .env and .dev.vars from their examples and applies the migrations to the local database, one dev: line each. Open http://localhost:4321/submit/, paste an address, press Continue, fill in step 2 and press “Submit for review”. The browser lands on /submit/status/<token>/?new=1 under the heading “In the review queue”. With accounts on, /submit/ first sends you to /login/Submitter accounts.

  4. Read what it wrote.

    Terminal
    pnpm exec wrangler d1 execute DB --local --command "SELECT name, status, tier, payment_status FROM listings ORDER BY created_at DESC LIMIT 1"

    One row: pending, the free tier, payment_status n/a. email_outbox holds a submitted row and an admin-new-submission row. Sign in at /admin/login/ with ADMIN_SECRET from .dev.vars: the queue lists the listing with Approve and Reject.

What to check Where You should see
The form https://<your domain>/submit/ step 1: an address field and Continue. Signed out with accounts on: a 302 to /login/?next=%2Fsubmit%2F
A free submission after “Submit for review” a 303 to /submit/status/<token>/?new=1, “In the review queue”, the banner “Thanks — we have your submission.”
The row listings status pending; payment_status n/a on a free plan, unpaid on a priced one
The queue https://<your domain>/admin/ the “Waiting for review” tile counts it; its row offers Approve
A bad link /submit/status/not-a-real-token/ 404 and “This link no longer works”

Step 1 is a URL. Both steps are server-rendered forms at one address, with no client-side JavaScript. Step 1 posts the address to submit.prefill: the Worker fetches the page, reads its title, description and icons, and renders step 2 filled in. A timeout, an oversized page or a site that is down still reaches step 2, empty, under a note ending “Please fill the fields in yourself.” Only two answers stop at step 1: “Enter a full URL, including https://.” and “That address is not a public website.” Security has the fences around that fetch.

Step 2 is the listing: its fields, the email — or the account’s address when accounts are on — the plan when more than one is offered, and a consent box. A logo can be uploaded; without one, the icons found in step 1 are tried in order. The plans are the enabled tiers; priced ones appear only while features.payments is on.

Validation runs on the server, in a fixed order, and each outcome renders differently:

  1. Honeypot. A hidden field named company, outside the tab order. Filled in, nothing is written, queued or fetched, and the visitor gets a 303 to /submit/?sent=1 — the same “Thanks — we have your submission.” banner a real submission shows. It is on both steps; on step 1 it stops submit.prefill being an open fetch-this-URL endpoint.
  2. Secrets. No TOKEN_SECRET means no status link, so no row.
  3. Schema. Messages appear next to their fields, and the values come back with them.
  4. Duplicate. One host, one live listing. Already pending: a link to that submission’s status page. Already listed: a link to the listing. A rejected row does not block a retry.
  5. Rate limit. limits.submissionsPerEmailPerDay per address over a rolling 24 hours.
  6. Upload. The logo goes to R2 best-effort; a rejected file costs the logo, never the row.
  7. Insert. One listings row, status pending, source submit.

A free plan is redirected to its status page and its receipt is queued at once. A priced plan is redirected to /api/checkout/<id>/?t=<token> and hears nothing until the payment lands — Plans and payments continues there. You are notified either way, at admin.email or site.contactEmail.

The status link is /submit/status/<token>/. The token is <id>.<purpose>.<expiry>.<signature>, an HMAC-SHA256 under TOKEN_SECRET. Purpose and expiry sit inside the signature, so a status link cannot become a checkout link and its expiry cannot be pushed out. Status links last 90 days, checkout links 24 hours. There is no token table, so a link cannot be revoked early. A bad token and a deleted listing produce the same 404 page, so the route cannot be used to probe ids.

The page shows the listing — name, address, plan, dates, payment state, a rejection reason — and nothing about the person: no email address, no admin note. It is noindex and no-store, and /submit/ is disallowed in robots.txt. ?new=1 adds the thank-you banner with “It works for 90 days.”; ?paid=1 adds “Payment received.” A pending free submission also gets its wait estimate and, with paid plans on, a “Skip the queue” section — The review queue.

Configuration What a visitor sees What /admin/ reports Fix
features.submissions: false no Submit button in the header or footer, no mention on /about/, /terms/ or /privacy/ nothing set it to true
TOKEN_SECRET unset step 2 answers “Submissions are temporarily unavailable. Please try again later.”; every status link is the 404 page error: “Missing secret TOKEN_SECRET … Status links, checkout links and the admin cookie cannot be signed.” pnpm cf:secrets TOKEN_SECRET; locally, .dev.vars
TOKEN_SECRET still the placeholder from .dev.vars.example everything works, and every link is forgeable error: “TOKEN_SECRET is still the value from .dev.vars.example…” pnpm cf:setup, which replaces it
no MEDIA bucket bound listings arrive without a logo nothing pnpm cf:setup creates the bucket
providers.email: 'console' no receipt arrives; it is logged as email(console): to=… subject=… warning: providers.email is "console": submitters get no mail, it is only logged. Email
features.payments on, Stripe secrets missing priced plans are offered; checkout answers 503 “Payment is temporarily unavailable…” an error per missing key, ending “Paid tiers cannot complete a checkout.” Plans and payments
Field Default What it changes
features.submissions false the form, the header button, the footer link
features.honeypot true whether the hidden field is checked; it is rendered either way
limits.submissionsPerEmailPerDay 3 submissions per address in a rolling 24 hours
limits.logoMaxBytes 524288 the cap on an uploaded or fetched logo
limits.descriptionMaxChars 2000 the description, and the textarea’s maxlength
limits.unpaidTtlDays 7 how long an unpaid priced submission survives before the hourly job deletes it
limits.screenshotMaxBytes 2097152 declared for screenshots; not read by the form

Which plans are offered comes from tiers; the notification address from admin. Reference: features, limits.

  • site.config.ts features, limits, tiers
  • Directorysrc/
    • Directorypages/submit/
      • index.astro both steps, and the redirects after a POST
      • Directorystatus/
        • [token].astro verifies the token, then reads the row
    • Directorytemplates/submit/
      • SubmitPage.astro the form, the honeypot, the plan cards
      • StatusPage.astro the receipt page
    • actions/submit.ts submit.prefill and submit.create, glue only
    • Directoryserver/
      • submit.ts the validation order and the insert
      • token.ts status and checkout tokens
      • meta-fetcher.ts the step-1 fetch and its SSRF list
      • media.ts logo upload and remote logo fetch
      • secrets.ts configGaps(), the report /admin/ renders
      • email/templates/submitted.ts the receipt
  • tests/e2e/submit.spec.ts the flow, the honeypot and the bad token, in a browser