Local development
forculus dev runs your app on your machine with a small copy of our front door in front of it. Every request it passes to your app carries a real, signed identity token, so the code that verifies callers runs as it does in production. You choose who the requests come from: an owner, an editor, a viewer, an agent, a bypass token, a scheduled job, a webhook, someone the app isn’t shared with, or nobody.
It needs no account and no sign-in.
Run it
Section titled “Run it”-
In your project’s directory, start it:
Terminal window forculus devCreating a signing key for this session ...Your app is at http://127.0.0.1:8787, signed in as you@example.com (owner).Switch at http://127.0.0.1:8787/.forculus.Your app’s own output follows.
-
Open
http://127.0.0.1:8787, not the port your app listens on. Requests to your app’s own port skip the front door and carry no token.With the app from the SDK overview, which greets its caller:
Terminal window curl http://127.0.0.1:8787/hello you@example.com -
Stop it with Ctrl-C. Your app stops too, with anything it started.
If forculus dev starts your app the wrong way, give the command after --:
forculus dev -- npm run devOptions
Section titled “Options”| Option | Default | What it does |
|---|---|---|
[DIR] |
. |
The project to run |
--as <WHO> |
owner |
Who requests come from: one of the personas, or an email address |
--role <ROLE> |
The role of an email (owner unless you say), an agent (editor) or a token (viewer) |
|
--provider <PROVIDER> |
stripe |
Who signed the deliveries, with --as webhook: stripe, github, slack, shopify, standard_webhooks or hmac_sha256 |
--pattern <PATTERN> |
/hooks/* |
The public path the deliveries come to, with --as webhook: one path, like /hooks/stripe, or everything under one, like /hooks/* |
--docker |
Run a container app in Docker. See Run a container in Docker | |
--port <PORT> |
8787 |
Where the front door listens |
--app-port <APP_PORT> |
8788 |
Where your app listens. Your app is told it as PORT. |
--app-id <APP_ID> |
00000000-0000-4000-8000-000000000000 |
The app id tokens are made out to, which your app checks as the audience |
--json |
Print one line of JSON on stdout once your app is starting, in place of the two lines that say so. See JSON for scripts | |
-- <COMMAND> |
worked out from the project | What starts your app |
To see the app as a viewer from the start:
forculus dev --as viewerOr as a person of your choosing:
forculus dev --as someone@example.com --role editorJSON for scripts
Section titled “JSON for scripts”With --json, the line on stdout looks like this:
{"url":"http://127.0.0.1:8787","switch":"http://127.0.0.1:8787/.forculus","jwks":"http://127.0.0.1:8787/.forculus/jwks.json","app_port":8788,"app_id":"00000000-0000-4000-8000-000000000000","as":"signed in as you@example.com (owner)","caller":"person","email":"you@example.com","role":"owner"}| Field | What it is |
|---|---|
url |
Where to send requests |
switch |
The page that changes who you are |
jwks |
The keys your app verifies tokens with |
app_port |
Where your app listens |
app_id |
The audience in each token |
as |
Who requests come from, in words, as the terminal says it |
caller |
Who requests come from: person, agent, token, scheduler, webhook, outsider or nobody |
email |
The person’s address, for person and outsider |
role |
owner, editor or viewer, for person, agent, token and scheduler |
agent |
The agent’s name, dev-agent, for agent |
provider |
Who signs the deliveries, for webhook |
pattern |
The path the deliveries come to, for webhook |
A field that doesn’t apply to the caller isn’t there. They say who it was at the start. A change on the switch page isn’t printed.
How it starts your app
Section titled “How it starts your app”With no command after --, it looks at the project the way forculus deploy does, and starts it like this:
| The project is | forculus dev runs |
|---|---|
| A static site | wrangler dev serving the site’s build output as the last build left it |
| A Worker with a wrangler config | wrangler dev with that config |
| A Worker without one | wrangler dev on its entry file (worker.main in forculus.json, else index.js, src/index.ts and the like), with the compatibility date and flags from forculus.json |
A container with container.start in forculus.json |
that command |
| A Node server | npm start |
| FastAPI | uvicorn <module>:app --host 127.0.0.1 --port 8788 |
| Flask | flask --app <module> run --host 127.0.0.1 --port 8788 |
Django with a manage.py |
python manage.py runserver 127.0.0.1:8788 |
| Django without one | gunicorn <project>.wsgi:application --bind 127.0.0.1:8788 |
| Streamlit | streamlit run <script> --server.port 8788 --server.address 127.0.0.1 --server.headless true |
| Gradio, or another Python script | python <module>.py |
Go with a main.go |
go run . |
| Rust | cargo run |
A Dockerfile, and no container.start |
the image, in Docker. See Run a container in Docker |
Django needs a <project>/wsgi.py, with or without manage.py. Without one, forculus dev stops. Name the command after --, or in container.start in forculus.json.
A Go project with no main.go at its root needs the command, for example forculus dev -- go run ./cmd/server.
The ports in the table are the --app-port.
wrangler dev is the project’s own wrangler when it has version 4 or newer installed, and npx --yes wrangler@4.134.0 otherwise. Set FORCULUS_WRANGLER to run a different one.
A static site is served as its last build left it. To see a change, build again, or run the project’s own dev server with forculus dev -- npm run dev.
What your app is told
Section titled “What your app is told”forculus dev starts your app with the same variables we set, pointed at itself:
| Variable | Value |
|---|---|
PORT |
8788 (the --app-port) |
FORCULUS_APP_ID |
00000000-0000-4000-8000-000000000000 (the --app-id) |
FORCULUS_IDENTITY_ISSUER |
http://127.0.0.1:8787 |
FORCULUS_JWKS_URL |
http://127.0.0.1:8787/.forculus/jwks.json |
FORCULUS_DEV |
1 |
A Worker doesn’t read the environment it starts in, so forculus dev passes these to wrangler dev as --var bindings. Your env has them either way.
It doesn’t give your app key-value storage or a queue, and nothing answers at postgres.forculus.internal, so a Postgres connection doesn’t work either. In a Worker, env.KV and env.QUEUE are undefined. Check for them before you use them, and try that code on a preview instead.
FORCULUS_DEV
Section titled “FORCULUS_DEV”Our issuer and key set are always https. The ones forculus dev uses are http on your machine, and the SDKs take those only when FORCULUS_DEV is 1. Without it, every request gets 401 with:
FORCULUS_IDENTITY_ISSUER and FORCULUS_JWKS_URL must use https, except under forculus dev (FORCULUS_DEV=1, on this machine)That is all the variable does. The SDKs still check every signature, and they take an http issuer only at 127.0.0.1, localhost or [::1], with keys from there or from host.docker.internal.
We never set FORCULUS_DEV. You can’t set it on a deployed app either, since forculus env set refuses names starting with FORCULUS_, and a container starts with it empty whatever its image says. So a deployed app that still points at forculus dev refuses everyone, rather than trusting whatever answers on that port. The SDKs from version 0.3.0 work this way. Older ones take an http issuer without it.
forculus dev makes the key that signs the tokens when it starts and discards it when it stops. Nothing else trusts that key.
Personas
Section titled “Personas”--as |
Your app gets |
|---|---|
owner |
you@example.com, an owner |
editor |
editor@example.com, an editor |
viewer |
viewer@example.com, a viewer |
agent |
The workspace’s agent dev-agent: kind is service, sub is agent:ag_dev, and the role is editor, or viewer with --role viewer |
token |
A bypass token: kind is service, sub is service:bt_dev, and the role is viewer, or editor with --role editor |
scheduler |
A scheduled job: kind is service, sub is scheduler:dev, and the role is viewer |
webhook |
A delivery from --provider to the path --pattern opens. requireWebhook takes it, and requireUser refuses it. A request to any other path gets 401 and never reaches your app, as in production, where only an open path takes a webhook |
outsider |
Nothing. We answer for your app as we do for someone it isn’t shared with: a page saying there’s nothing to open for outsider@example.com, or a bare 404 not found to anything that isn’t a page load |
nobody |
A request with no token, as on a public path |
| an email | That person, with --role (owner unless you say) |
With examples/hello from our repository, which greets its caller:
forculus dev examples/hello --as agentcurl http://127.0.0.1:8787/Hello, dev-agent, an agent. You're an editor here.Change who you are
Section titled “Change who you are”Open http://127.0.0.1:8787/.forculus in your browser. It shows who every request comes from, with a button for each persona and a form for any email and role. The change applies from the next request, and your app doesn’t restart.
A script or an agent can switch the same way the page does:
curl -H 'Origin: http://127.0.0.1:8787' -d persona=viewer http://127.0.0.1:8787/.forculus/ascurl -H 'Origin: http://127.0.0.1:8787' -d persona=agent -d role=viewer http://127.0.0.1:8787/.forculus/ascurl -H 'Origin: http://127.0.0.1:8787' -d persona=webhook -d provider=github --data-urlencode 'pattern=/hooks/github' http://127.0.0.1:8787/.forculus/ascurl -H 'Origin: http://127.0.0.1:8787' --data-urlencode email=ada@example.com -d role=editor http://127.0.0.1:8787/.forculus/asThe form accepts a post only with its own page’s Origin. A post from anywhere else gets 403 not from here.
Each request
Section titled “Each request”forculus dev does what the edge does, in the same order:
- It deletes every
X-Forculus-*,Cf-Access-*andCf-Container-*header the client sent, spelled with dashes or underscores. - It adds a new
X-Forculus-Identitytoken that lasts 60 seconds, plusX-Forculus-Emailfor a person andX-Forculus-Rolefor anyone with a role, for display. - It passes the request to your app, and your app’s response back unchanged. Streamed responses, server-sent events and WebSockets work, so hot reload does too.
As in production, a forged header has no effect:
curl -H 'X-Forculus-Email: boss@example.com' -H 'X-Forculus-Identity: forged' http://127.0.0.1:8787/hello you@example.comIt answers only requests addressed to localhost, 127.0.0.1, [::1] or a name ending in .localhost, such as api.localhost. A request for any other name gets 421. This stops a web page from pointing a name it controls at your machine and sending requests your app would treat as yours.
Tokens compared with production
Section titled “Tokens compared with production”The tokens are signed the same way as in production, but some values in them are made up:
subisdev:and the email, likedev:you@example.com, not ausr_…id.- The workspace id is
00000000-0000-4000-8000-0000000000de. - The agent, token, schedule and webhook ids are made up:
agent:ag_dev,service:bt_dev,scheduler:devandwebhook:dev. - A webhook’s
patternis the one you opened with--pattern, as in production. From Stripe, Slack or a Standard Webhooks sender every delivery gets a newdeliveryid and asigned_atof now. From GitHub, Shopify or HMAC-SHA256 it gets neither, since those providers sign neither. Nothing checks a provider’s signature here, since nothing sent one. To test that part, send a test delivery from the provider to a deployed app. See Webhooks and public paths. - Any email you enter is let in.
forculus devdoesn’t know who your app is shared with. Pickoutsiderto see what someone gets when it isn’t shared with them.
Only forculus dev sends X-Forculus-Role. In production, read the role from the verified token.
Run a container in Docker
Section titled “Run a container in Docker”--docker builds a container app’s image the way forculus deploy does, with your Dockerfile or the one we write, and runs it in Docker. A project that has only a Dockerfile runs this way without the flag.
forculus dev --dockerThe image is built for your machine rather than for ours, and isn’t pushed anywhere. The container listens on PORT, 8080 unless forculus.json sets container.port, and Docker publishes it on 127.0.0.1:8788 only. That keeps it off your network, but anything on your machine can still reach it without going through the front door, and so can other containers on Docker’s network. Your app still has to verify X-Forculus-Identity, as it does on the platform, rather than trust that a request came through the front door. The container gets the variables above, except that FORCULUS_JWKS_URL is http://host.docker.internal:8787/.forculus/jwks.json, which is how a container reaches your machine. On Linux, forculus dev adds that name for the container and serves the keys, and nothing else, on Docker’s bridge address too.
When forculus dev stops, the container and the image it built are removed. Docker keeps its build cache, so the next start is quick.
You need Docker, or OrbStack, or another docker named by FORCULUS_DOCKER.
Errors
Section titled “Errors”The front door can’t listen because something else has the port:
Creating a signing key for this session ...error: Couldn't listen on 127.0.0.1:8787: Address already in use (os error 48).Stop whatever holds it, or pick another port with --port.
Something else has the port your app is given. forculus dev starts your app itself, so whatever is there already isn’t your app, and nothing is built or started:
Creating a signing key for this session ...error: 127.0.0.1:8788, where your app is to listen, is taken (something answers on 127.0.0.1:8788). Stop whatever holds it, or pick another port with --app-port.Your app hasn’t started yet, or listens on a different port from the one it’s told:
Nothing is listening on 127.0.0.1:8788 yet (Connection refused (os error 61)).Make your app listen on PORT. Some dev servers ignore it and take their port from a flag of their own. In that case, pass --app-port to match.
A name that is neither a persona nor an email:
error: "someone" isn't a persona (owner, editor, viewer, agent, token, scheduler, webhook, outsider or nobody) or an email address.Every request gets 401 with must use https, except under forculus dev: your app runs with an SDK from 0.3.0 and didn’t get FORCULUS_DEV. forculus dev sets it from CLI 0.1.11, so update the CLI, or start your app in a way that keeps the environment it’s given.
Every request gets 401 from your app. Check that you opened port 8787 and not your app’s own port, and that your app reads the variables above rather than values of its own.
Next steps
Section titled “Next steps”- SDK overview: what your app checks on every request.
- Deploy your first app when it works locally.
- Troubleshooting: when
forculus devwon’t start.