Skip to main content

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. Traffic with no Origin is let through, with or without an X-App-Id (from release 2026.09.10.1; earlier releases refused an unlisted one). 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 (and https://*.myapp.com for subdomains).
  • Local dev: http://localhost:5173 for that port only, or http://localhost:* for any port.
  • Capacitor Android: https://localhost. Capacitor iOS: capacitor://localhost.
  • Browser extension: chrome-extension://<id> (also moz-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.local already held. Guard on the value (// empty) and use curl -sf for 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 from has, configured_services, values and reveal alike — clean 200s over live credentials. Check GET /api/version before you report a vault as empty, and on a current deploy read unregistered_services and each service's registered flag.
  • The scopes are off by default. can_read_vault, can_reveal_vault and can_write_vault are separate booleans on the token; without them you get 403 TOKEN_PERMISSION_DENIED naming the one you need. Only the owner can enable them, in the admin — so treat a 403 as 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. With can_write_vault you 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 vite and next env blocks contain only values marked client_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}'

See Supabase account tokens.