Agent workflow
This is the recipe an AI coding agent (Claude Code, Codex, …) follows to make sure a local project's
FilesHub API key is correct and properly origin-restricted. It needs one fh_pat_ access token,
provided out-of-band as an environment variable (e.g. FILESHUB_ACCESS_TOKEN) — never hard-coded.
The access token is a server/CLI secret. Read it from the environment, never write it into a repo, a commit, or shell history.
The steps
1. Confirm the token and its scope.
curl -s https://fileshub.zaions.com/api/public/v1/token -H "Authorization: Bearer $FILESHUB_ACCESS_TOKEN"
2. Find the key in the local project. Read the project's .env (or equivalent) for the FilesHub
key — commonly VITE_FILESHUB_API_KEY, FILESHUB_API_KEY, or VITE_FILES_HUB_API_KEY.
3. If a key exists, look it up.
curl -s -X POST https://fileshub.zaions.com/api/public/v1/api-keys/lookup \
-H "Authorization: Bearer $FILESHUB_ACCESS_TOKEN" -H 'Content-Type: application/json' \
-d "{\"api_key\":\"$LOCAL_KEY\"}"
The response tells you the project, whether the key is restricted, and its current origins.
4. Check which origins are already global — before adding any. Some origins are allowed
platform-wide and every restricted key already accepts them. Adding a per-key copy of one is a
duplicate that buys nothing, so ask first and add only what comes back covered: false:
curl -s -X POST .../global-origins/check \
-H "Authorization: Bearer $FILESHUB_ACCESS_TOKEN" -H 'Content-Type: application/json' \
-d '{"origins":[{"type":"domain","value":"https://myapp.com"},
{"type":"domain","value":"http://localhost:5173"},
{"type":"domain","value":"https://localhost"}]}'
Do not compare GET /global-origins yourself. Matching is not string equality, and both
mistakes are easy: https://*.myapp.com already covers https://app.myapp.com, while an apex rule
https://myapp.com covers no subdomain. Guessing either way leaves you with a redundant entry
or a key that 403s in production. The check endpoint runs the same matcher enforcement uses.
5. Fix the restrictions. For a key that ships inside a frontend bundle, it must be restricted to
your own origins. Turn restricted on and add only the origins step 4 reported as not covered:
# restrict the key
curl -s -X PATCH ".../projects/$PROJECT/api-keys/$KEY_ID" \
-H "Authorization: Bearer $FILESHUB_ACCESS_TOKEN" -H 'Content-Type: application/json' \
-d '{"restricted":true}'
# add each origin that came back covered:false — skip the rest
curl -s -X POST ".../projects/$PROJECT/api-keys/$KEY_ID/origins" \
-H "Authorization: Bearer $FILESHUB_ACCESS_TOKEN" -H 'Content-Type: application/json' \
-d '{"type":"domain","value":"https://myapp.com"}'
Adding a globally-covered origin anyway still succeeds — nothing rejects it — which is exactly why the check is on you.
6. If there is no key locally, create one. Find the project (or create it), mint a key, and write
the returned plaintext to the project's .env:
# find by name
curl -s ".../projects?q=my-web-app" -H "Authorization: Bearer $FILESHUB_ACCESS_TOKEN"
# or create the project
curl -s -X POST .../projects -H "Authorization: Bearer $FILESHUB_ACCESS_TOKEN" \
-H 'Content-Type: application/json' -d '{"name":"My Web App"}'
# mint a key (returns plaintext once, flat under data)
curl -s -X POST ".../projects/$PROJECT/api-keys" \
-H "Authorization: Bearer $FILESHUB_ACCESS_TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"web frontend","can_read":true,"can_write":true,"restricted":true}'
# -> { "data": { "id": 7, ..., "plaintext_key": "fh_live_..." } }
Read data.id and data.plaintext_key. Ask for every capability you want — an omitted boolean
defaults to false.
7. Keys used from a server, CLI or Flutter app. Those clients send no Origin, so a restricted
key would refuse them. Set allow_no_origin instead of leaving the key unrestricted:
curl -s -X PATCH ".../projects/$PROJECT/api-keys/$KEY_ID" \
-H "Authorization: Bearer $FILESHUB_ACCESS_TOKEN" -H 'Content-Type: application/json' \
-d '{"restricted":true,"allow_no_origin":true}'
Browser traffic is still matched against the allowlist; header-less traffic is let through. Weaker
than an origin check (so is curl), far better than an unrestricted key.
Browser extensions use a domain origin with the extension scheme —
chrome-extension://<id>, where the host is the id (no port, no wildcard). Only Chrome ids are
stable; Firefox and Safari ids are per-install UUIDs.
Origins are scheme + port sensitive
FilesHub matches origins exactly — scheme and port included. Add each origin in the exact form the browser sends:
- Production web:
https://myapp.com(andhttps://*.myapp.comfor subdomains). - Local dev:
http://localhost:5173for that port only, orhttp://localhost:*for any port. - Capacitor Android:
https://localhost. Capacitor iOS:capacitor://localhost. - Browser extension:
chrome-extension://<id>(alsomoz-extension://,safari-web-extension://).
Some origins are configured platform-wide by the administrator (global origins) and apply to
every restricted key without appearing in GET .../origins — so a key may accept an origin you
never added to it. Read them with GET /global-origins, and test candidates against them with
POST /global-origins/check (step 4) rather than comparing strings: a *. rule covers its subdomains
and its apex, while an apex rule covers neither.
http:// is accepted only for local hosts; a public host must be https://. A duplicate returns
409, so adding the same origin twice is safe to attempt. Full policy:
API key restrictions.
Verify
Remember a key's own list will not show the global origins it also accepts — that is expected, not
a missing entry. After changes, lookup again (or GET .../origins) and confirm restricted is true with the
origins you expect. Then the frontend key is safe to ship: it only works from your own app.
Bootstrapping a whole project from the vault
The workflow above wires up FilesHub itself. The project vault does the same for
everything else a project needs — Firebase config, OAuth client ids, a Sentry DSN, an OneSignal key,
google-services.json, signing fingerprints — so a fresh machine can be brought up without opening a
single console.
BASE=https://fileshub.zaions.com/api/public/v1
# What can this token do?
curl -s $BASE/token -H "Authorization: Bearer $FH_PAT" \
| jq '.data | {can_read_vault, can_reveal_vault, can_write_vault}'
# What does the vault even hold? (schema, not contents — any valid token)
curl -s $BASE/vault/services -H "Authorization: Bearer $FH_PAT" \
| jq -r '.data.services[] | "\(.service): \(.fields | length) fields"'
# Which services is THIS project configured for? (no secret leaves the vault)
# Read `unregistered_services` too — a value stored under a service the registry
# does not declare is real, readable, and named only there.
curl -s $BASE/projects/my-app/vault -H "Authorization: Bearer $FH_PAT" \
| jq '.data | {configured_services, unregistered_services}'
# Write the env file and fetch the Android config
curl -s -X POST $BASE/projects/my-app/vault/reveal -H "Authorization: Bearer $FH_PAT" \
| jq -r '.data.env.vite' > .env.local
curl -s $BASE/projects/my-app/vault-files/firebase.google_services_json \
-H "Authorization: Bearer $FH_PAT" -o android/app/google-services.json
Four things worth knowing before you call it:
- 🔴 Almost nothing is stored in it yet. As of 2026-08-19 one registered project of 54 has a configured
service, so nearly every reveal returns the empty skeleton with
env: {}. The commands above will succeed and write nothing —jq -r '.data.env.vite'on an unconfigured project emits an empty string and>truncates whatever.env.localalready held. Guard on the value (// empty) and usecurl -sffor files. And read an empty service as not entered yet, never as this project does not use that service: the response is identical either way. See Project vault. - 🔴 On a deploy older than
2026.08.19.1, an empty answer could also mean stored and unreportable. Every read path used to iterate the registry rather than the project's own rows, so a credential entered under a service name outside the declared 18 was absent fromhas,configured_services,valuesandrevealalike — clean200s over live credentials. CheckGET /api/versionbefore you report a vault as empty, and on a current deploy readunregistered_servicesand each service'sregisteredflag. - The scopes are off by default.
can_read_vault,can_reveal_vaultandcan_write_vaultare separate booleans on the token; without them you get403 TOKEN_PERMISSION_DENIEDnaming the one you need. Only the owner can enable them, in the admin — so treat a403as a question for them, not something to route around. - 🔴 The vault is writable since
2026.08.20.1, and a missing credential is no longer only a task for the owner. Withcan_write_vaultyou can seed a project from a checkout, a git remote or a console CLI — see Writing to the vault. Note that write implies read but not reveal, so a seeding token need not be able to read back secrets it is not adding. - The
viteandnextenv blocks contain only values markedclient_safe. A server secret is not omitted by luck — it is structurally unable to appear there, which is the point. Use the block you are given rather than filtering one yourself.
Deploying to Supabase without an interactive login
supabase link, supabase db push and supabase functions deploy all read SUPABASE_ACCESS_TOKEN, and
the vault hands you exactly that line:
export SUPABASE_ACCESS_TOKEN=$(
curl -s -X POST $BASE/supabase-accounts/you@example.com/reveal \
-H "Authorization: Bearer $FH_PAT" | jq -r '.data.secrets.personal_access_token'
)
supabase link --project-ref <ref>
This needs can_read_supabase_tokens, which is its own switch — a token holding can_manage_supabase
gets 403 here. Since 2026.08.19.1 a project-scoped token additionally only reaches the accounts and
Supabase projects linked from a FilesHub project it can manage (plus unlinked ones); record the link with
PATCH $BASE/projects/my-app -d '{"supabase_project_id": <id>}' the first time you resolve it. That is deliberate: can_manage_supabase reads one project's credentials, while an
account token can create and delete every project the account owns. Check both before you script anything:
curl -s $BASE/token -H "Authorization: Bearer $FH_PAT" \
| jq '.data | {can_manage_supabase, can_read_supabase_tokens}'