The submission form
/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.
-
Switch the form on.
site.config.ts features: { submissions: true },Terminal pnpm check:configsite.config.ts OK — <your site name> (https://your-domain.com). -
Give it a database and the two secrets.
Terminal pnpm cf:setupIt creates the D1 database, applies the migrations, deploys, and sets
ADMIN_SECRETandTOKEN_SECRETon the Worker. Already done?pnpm cf:secretslists both names. -
Try it on a fresh clone.
Terminal pnpm devFirst it writes
.envand.dev.varsfrom their examples and applies the migrations to the local database, onedev:line each. Openhttp://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=1under the heading “In the review queue”. With accounts on,/submit/first sends you to/login/— Submitter accounts. -
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_statusn/a.email_outboxholds asubmittedrow and anadmin-new-submissionrow. Sign in at/admin/login/withADMIN_SECRETfrom.dev.vars: the queue lists the listing with Approve and Reject.
Verify
Section titled “Verify”| 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” |
How it works
Section titled “How it 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:
- 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 stopssubmit.prefillbeing an open fetch-this-URL endpoint. - Secrets. No
TOKEN_SECRETmeans no status link, so no row. - Schema. Messages appear next to their fields, and the values come back with them.
- 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.
- Rate limit.
limits.submissionsPerEmailPerDayper address over a rolling 24 hours. - Upload. The logo goes to R2 best-effort; a rejected file costs the logo, never the row.
- Insert. One
listingsrow,statuspending,sourcesubmit.
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.
When it is off or degraded
Section titled “When it is off or degraded”| 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. |
|
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 |
Configuration
Section titled “Configuration”| 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.prefillandsubmit.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