Runtime configuration
Every platform_config key the code reads, with its accepted values, default, exact effect, blast radius and the risk of setting it wrong.
Last updated 2026-08-06
Summary#
platform_config is the platform's runtime settings store: a single D1 table of key and value strings that the worker reads on every request through a 60-second cache. Fourteen key shapes are read by code. Everything else you can write into the table is inert. This page is the complete registry, with defaults, effects and the risk of each.
Overview#
Storage. One table: platform_config (key TEXT PRIMARY KEY, value TEXT, updated_at INTEGER, updated_by TEXT). updated_at is unix seconds and updated_by is the email of the owner who wrote it. D1 is used rather than KV because a Pages deploy resets KV, and a configuration store that forgets on deploy is worse than no store at all.
Reading. mvConfigAll(env) loads the whole table into a Map and caches it for 60,000 ms. Individual reads go through mvConfig(env, key, fallback). The read fails open: if D1 is unreachable the map comes back empty, every default applies and every tool stays on. Writing or deleting a key resets the cache immediately, so a change is live at once rather than up to a minute later.
Writing. Only an owner can write. POST /api/admin/config/set takes { token, key, value }, or { token, key, delete: true } to remove a key. A blank key returns 400 {"error":"key required"}; a non-owner returns 403 {"error":"Only an owner can change config."}. Values are stored as strings, with null becoming the empty string.
There is no key allow-list and no value length limit. Any key you type is accepted and stored. A typo therefore creates a dead key that looks like a setting and does nothing, which is the single most common configuration mistake here.
Auditing. Writes record config.set with the first 120 characters of the value as meta; deletions record config.delete. Both carry the key as the target. See Audit log.
The key registry#
Fourteen key shapes are read by code. Nothing else in the table has any effect.
| Key | Written by | Accepted values | Default when absent | Blast radius |
|---|---|---|---|---|
tool_off:<toolType> | Tools & Cache, Turn off, or by hand | 1 disables. Any other value is treated as on | on | One AI tool, all customers |
suspended:<email> | Customers screen | 1 suspends | not suspended | One customer |
cache_days_default | Tools & Cache, Apply to all | A positive number of days | each call site's own lifetime | Every tool's provider cache |
cache_days:<toolId> | Tools & Cache, the lifetime select | A positive number of days | none | One tool's provider cache |
ai_reco_min_plan | Config screen, AI Recommendations access | starter, pro, agency, enterprise | pro | Who may press Get Recommendations |
ai_reco_quota:<plan> | Config screen, AI Recommendations access | A whole number of 0 or more | the built-in per-plan default | Monthly recommendation allowance for that plan |
plan_quota:<plan> | Config screen, Plans & limits | A whole number of 0 or more | 0 / 100 / 500 / 2000 / 10000 by plan | That plan's monthly premium credits: the quota gate and every usage display |
seat_limit:<plan> | Config screen, Plans & limits | A whole number of 0 or more | 0 / 4 / 14 / unlimited by plan | How many members that plan can invite |
premium_threshold | Config screen, Plans & limits | A whole number from 1 to 100 | 3 | Which credit costs the monthly quota gates, all plans |
tool_cost:<toolId> | Tools & Cache, the credits picker | A whole number from 0 to 1000 | the built-in credit cost | What one run of that tool charges, all customers |
light_rate_per_hour | Config screen, Plans & limits | A whole number from 10 to 100000 | 100 | The hourly fair-use cap on light tools, signed-in users and anonymous IPs alike |
hero_scans_per_day | Config screen, Plans & limits | A whole number from 0 to 1000 | 2 | How many homepage teaser scans one visitor IP gets per day |
quota_bonus:<email>:<month> | Customers screen, Grant | A positive whole number | 0 | Extra credits for one customer, that calendar month only |
maintenance_message | Config screen, Maintenance mode | Free text | none | A dismissible banner in the admin console and at the top of the customer dashboard |
The email in suspended:<email> is always lower-cased. The plan in ai_reco_quota:<plan>, plan_quota:<plan> and seat_limit:<plan> is a plan id in lower case, and the tool id in tool_cost:<toolId> is lower-cased too.
Key detail#
tool_off:<toolType>#
Set the value to 1 to take one premium AI tool out of service. The tool type is the internal tool id, for example tool_off:ai_visibility.
The check runs at the start of the premium AI route, before quota and metering, and returns 503 {"error":"This tool is temporarily unavailable. Please try again shortly."}. The customer is not charged, because the return happens before usage is incremented.
Risk. Three things catch people out.
- The key is matched against the request's tool type verbatim. A typo disables nothing and produces no warning.
- Requests that can be answered from the shared cross-customer result cache return before this check runs, so a heavily cached tool can keep serving results for a while after you disable it. Clear that tool's cached data if the disable must be immediate. See Tools and cache management.
- Growth Actions is dispatched to its own handler before this check, so
tool_off:growth_actionshas no effect.
Delete the key, or set it to anything other than 1, to re-enable the tool. The switch lives on Tools and cache management: Turn off and Turn on in the Actions column write and delete this key with a confirmation. It can still be typed by hand in the Set a config value card.
suspended:<email>#
Set by the Suspend button on the Customers screen and deleted by Reactivate. A value of 1 blocks that customer at the AI quota gate and at the AI recommendation gate with 403 {"error":"This account is suspended. Please contact support.","code":"account_suspended"}.
The same key drives the suspended badge on the Customers screen, the status column of the subscriber directory, and the bulk suspension scan that the directory runs (SELECT key FROM platform_config WHERE key LIKE 'suspended:%' AND value='1').
Risk. Writing it by hand with the wrong casing or a stray space silently fails to suspend anyone, and it will not show as suspended in the UI either. Use the Customers screen, which lower-cases the address and busts the cache for you. Addresses in the worker's dev-unlimited list return before the suspension check and cannot be suspended at all. See Customer lookup and billing.
cache_days_default#
The global lifetime, in days, for cached provider responses across every tool. Written by Apply to all on the Tools & Cache screen, which also deletes every per-tool override so nothing shadows it. Absent, each tool uses the lifetime its own code passes.
Risk. A long value lowers the provider bill and makes every tool's data staler at once, including tools where freshness matters most, such as rank tracking. A short value raises the bill across the whole platform. The value is parsed defensively: anything unparseable or not greater than zero falls back to the caller's own lifetime, so a bad value degrades freshness rather than correctness.
cache_days:<toolId>#
A per-tool lifetime in days that beats cache_days_default. Written by the lifetime select on the Tools & Cache screen; selecting the default option deletes the key.
Risk. The override only binds for tools whose fetcher passes its tool id into the cache layer. The Tools & Cache screen already knows which those are and shows the rest as Set via Apply to all. Writing cache_days:<toolId> by hand for a tool the screen marks that way produces a key that never applies. Full detail in Tools and cache management.
ai_reco_min_plan#
The lowest plan allowed to press Get Recommendations on any tool result. The built-in default is pro. Accepted values are starter, pro, agency and enterprise; the value is lower-cased on read, and an unrecognized value falls back to the rank of pro.
Below the minimum, the customer gets 403 with the code reco_upgrade_required and a message naming both the required plan and their own. Free is always below every accepted minimum, so Free is always blocked.
Risk. Lowering it to starter opens a paid AI feature to every Starter account, metered only by ai_reco_quota:starter. Raising it takes the feature away from paying customers with no warning and no notification.
ai_reco_quota:<plan>#
The monthly recommendation allowance for one plan. Parsed with parseInt; a blank value or an unparseable one is ignored and the default applies. Zero is a valid value and blocks the plan entirely.
| Plan | Built-in default |
|---|---|
starter | 30 |
pro | 100 |
agency | 400 |
enterprise | 2000 |
unlimited | unlimited, metering skipped |
Free has no entry and therefore resolves to 0, which does not matter in practice because Free is blocked by the minimum-plan gate first. Over quota the customer gets 429 with the code reco_quota_exceeded and a message naming the quota and the reset date. The counter is ai_reco_runs on the current month's usage row and it is incremented before the upstream call, so concurrent requests cannot all pass the check.
Risk. These are the direct control on AI spend for this feature. A large value on a low-priced plan converts straight into provider cost. Setting a value for a plan that does not exist creates a dead key.
plan_quota:<plan> and seat_limit:<plan>#
Set from the Plans & limits card. plan_quota overrides how many premium credits the plan gets per month; seat_limit overrides how many team members the plan may invite (the owner is not counted). Both are read by the quota gate, the dashboard usage numbers, the Customers screen and the team endpoints in the same place, so the enforcement and every display move together.
All four of the new commercial keys fail safe: a missing, blank or unparseable value means the built-in default applies, and the internal unlimited tier is never overridable. A value of 0 is real: plan_quota:starter of 0 stops Starter running premium tools at all.
Risk. The pricing page and the comparison table state the built-in numbers as static copy. If you change a quota or seat limit here, production behaviour changes immediately but the marketing pages keep saying the old numbers until someone edits them. Update Plans and pricing and the pricing page in the same change.
premium_threshold#
The credit cost at or above which a tool is gated by the monthly quota. Below it, a tool is never blocked by the quota (the Free plan still only runs cost-0 tools, and the hourly light-tool rate limit still applies). Raising it makes more tools effectively unlimited for paying customers; lowering it to 1 makes nearly every tool count against the quota. Accepted range 1 to 100.
tool_cost:<toolId>#
Set from the credits picker on Tools and cache management. Overrides what one run of that tool charges, for every customer, at every charge site: the quota gate, the usage increment, the cache-hit charge and the saved-result record all read the same override. 0 makes the tool free to run. The dashboard's run buttons state the built-in costs in their labels, so an override makes those labels stale until the dashboard copy is updated; the actual charge follows the override.
light_rate_per_hour and hero_scans_per_day#
Set from the Fair-use limits row of the Plans & limits card. light_rate_per_hour is the hourly cap the light-tool rate limiter applies per signed-in user, and per IP for the anonymous free-tools endpoints; the 429 it returns states the effective number. hero_scans_per_day caps the anonymous homepage teaser scan per visitor IP and per UTC day; 0 turns the teaser off for anonymous visitors entirely. Both fail safe to their defaults. The customer-facing limits reference states the defaults as fixed numbers, so a lasting change here should update All limits and quotas too.
quota_bonus:<email>:<month>#
Written by Grant on Customer lookup and billing; repeated grants accumulate into the same key. The amount is added on top of the customer's plan quota in the quota gate and in both usage displays, so the dashboard shows the raised total. The month key is part of the config key, so the bonus expires silently when the month rolls over, and granting again next month writes a fresh key. Deleting the key from the Configuration table revokes the bonus immediately.
maintenance_message#
Free text, written by the Maintenance mode card. Ticking the box with an empty text field writes the default string Site is undergoing maintenance. Please check back soon. Unticking it deletes the key.
The dashboard fetches the message from GET /api/status/banner (public, no auth, nothing but this one string) and renders it as a dismissible banner above the app. A customer's dismissal lasts for their browser session and is per message, so changing the text shows the banner again. The config cache means a new message can take up to a minute to appear.
Note: The banner warns; it does not block. Tools keep running while it is up. To actually take a tool out of service, use the availability toggle on Tools and cache management.
Writing and deleting keys#
POST /api/admin/config/set
| Field | Required | Notes |
|---|---|---|
token | yes | Supabase access token, in the JSON body |
key | yes | Trimmed. Blank returns 400 {"error":"key required"} |
value | no | Stored as a string. null becomes "" |
delete | no | true deletes the row and skips value entirely |
POST /api/admin/config lists every row ordered by key. It is admin-gated, unbounded and unpaginated, so the Configuration table on screen is the whole table.
The Config screen#
Admin console → Operations → Config
| Card | Role | What it does |
|---|---|---|
| Social OAuth readiness | any admin | Meant to show which social platforms have OAuth credentials configured |
| Set a config value | owner | The raw key and value editor. Messages: Enter a key. and Saved. |
| Maintenance mode | owner | Writes or deletes maintenance_message |
| AI Recommendations access | owner | Writes ai_reco_min_plan and the four ai_reco_quota:<plan> keys. A blank limit sends a delete. Validation message: Monthly limits must be a whole number of 0 or more. Success: Saved. Changes take effect immediately. |
| Configuration table | any admin to read, owner to delete | Key, Value, Updated, By and a red Delete per row. Empty reads No config set. Defaults apply. |
Note: The Social OAuth readiness card requests a diagnostic endpoint without the internal secret it requires, so it currently always renders No platforms configured in code. regardless of the real state. It is a display defect, not a security problem. Check OAuth credentials from the environment variables instead. See What the admin console cannot do yet.
Risk summary#
| Mistake | What happens | How you notice |
|---|---|---|
| Typo in a key name | A dead row that looks like a setting | Nothing changes; the row sits in the table |
tool_off: on a tool served from the shared cache | The tool keeps answering for cached queries | Runs continue in the Run Log |
Editing cache_days: by hand for a shared-fetcher tool | The override never binds | The Tools & Cache row still reads default |
Large cache_days_default | Stale data across every tool at once | Customers report old numbers |
ai_reco_quota: set high | AI provider spend rises | The provider cost tile on System Health |
ai_reco_min_plan raised | Paying customers lose a feature silently | Support tickets, no notification is sent |
Deleting cache_days_default | Every tool reverts to its built-in lifetime | Provider cost changes |
Nothing in this table notifies anyone. Every write is audited, so the audit log is the only record of who changed what and when.
See also
Was this article helpful?
Thanks — feedback noted for the docs team.