Configuration Reference
Complete reference for the foir.config.ts file format, all available interfaces, and helper functions.
ApplyConfigInput
The top-level config object passed to defineConfig. This is the shape of your entire config file.
interface ApplyConfigInput {
key: string; // Unique identifier for this config
name: string; // Display name
configType?: string; // Config type identifier
force?: boolean; // Force reinstall (delete and recreate)
/** Base URL prepended to relative operation endpoints at push time. */
operationBaseUrl?: string;
/** Project pin + declarative project-level settings. */
project?: ApplyConfigProjectInput;
models?: ApplyConfigModelInput[]; // Content models
operations?: ApplyConfigOperationInput[]; // Operation endpoints
segments?: ApplyConfigSegmentInput[]; // Customer segments
schedules?: ApplyConfigScheduleInput[]; // Cron schedules
hooks?: ApplyConfigHookInput[]; // Lifecycle hooks
authProviders?: ApplyConfigAuthProviderInput[]; // Auth providers
relyingParties?: ApplyConfigRelyingPartyInput[]; // Login-with-Foir relying parties
placements?: ApplyConfigPlacementInput[]; // Editor placements
apiKeys?: ApplyConfigApiKeyInput[]; // API keys provisioned at push time
customerRoles?: ApplyConfigCustomerRoleInput[]; // Customer RBAC roles (token scopes)
/** Per-project app installations, keyed by app name. See /config/apps. */
apps?: Record<string, AppInput>;
/** W3C design tokens document for this project. See /config/design-tokens. */
designTokens?: ApplyConfigDesignTokensInput;
[key: string]: unknown; // Additional fields accepted at runtime
}All arrays/maps are optional. Include only what your project needs.
Note: The typed interfaces include a
[key: string]: unknownindex signature, so additional fields beyond the ones listed here are accepted at runtime and passed through to the platform API.
Helper Functions
Every helper function provides TypeScript IntelliSense and compile-time validation. They accept and return the same object, so using them is optional but recommended.
defineConfig
Wraps the top-level config object.
import { defineConfig } from '@foir/cli/configs';
export default defineConfig({
key: 'my-app',
name: 'My App',
models: [...],
operations: [...],
});defineModel
Defines a content model with typed fields.
import { defineModel } from '@foir/cli/configs';
const page = defineModel({
key: 'page',
name: 'Page',
fields: [
{ key: 'title', type: 'text', label: 'Title', required: true },
],
lookups: [
{ keyBy: ['slug'] }, // fetch by slug; see /config/models#lookups
],
});defineField
Defines a single field definition. Useful when building fields programmatically or reusing field definitions across models.
import { defineField } from '@foir/cli/configs';
const titleField = defineField({
key: 'title',
type: 'text',
label: 'Title',
required: true,
placeholder: 'Enter a title...',
queryable: true, // allow where/orderBy on this field
access: { write: ['admin', 'service'] }, // field-level access control
});Set queryable: true to allow filtering and sorting on a field (the platform rejects sorts on non-queryable fields). The access object names the principals allowed to read/write the field — a non-empty write is an allow-list ("public" | "self" | "scoped" | "service" | "admin"); omit it for the default (any authenticated principal). See Defining Models.
defineSelectField
Defines a select field. Required for select fields because they need a typed optionModelKey pointing at a model whose records become the options. Plain defineField({ type: 'select' }) is rejected.
import { defineSelectField } from '@foir/cli/configs';
const status = defineSelectField({
key: 'status',
type: 'select',
label: 'Status',
required: true,
config: {
optionModelKey: 'status-option', // a model with records like 'draft', 'published'
multiple: false,
},
});defineEnumField
Defines an enum field — the inline-choice type. Options are declared inline as an { label, value } array. (For options drawn from another model’s records, use defineSelectField instead.)
import { defineEnumField } from '@foir/cli/configs';
const status = defineEnumField({
key: 'status',
type: 'enum',
label: 'Status',
required: true,
config: {
options: [
{ value: 'draft', label: 'Draft' },
{ value: 'published', label: 'Published' },
],
multiple: false,
default: 'draft',
},
});defineOperation
Defines an HTTP endpoint for custom logic.
import { defineOperation } from '@foir/cli/configs';
const syncOp = defineOperation({
key: 'sync-data',
name: 'Sync Data',
description: 'Syncs records to external system',
endpoint: '/sync/all',
});defineHook
Defines a lifecycle hook that triggers an operation on content events.
import { defineHook } from '@foir/cli/configs';
const hook = defineHook({
key: 'sync-on-create',
name: 'Sync on create',
event: 'RECORD_CREATED',
operationKey: 'redirect-sync',
filter: { modelKey: 'redirect' },
});definePlacement
Defines a custom editor panel.
import { definePlacement } from '@foir/cli/configs';
const editorTab = definePlacement({
type: 'main-editor',
url: '/',
modelKeys: ['page'],
tabName: 'Custom Editor',
});defineSchedule
Defines a cron schedule for an operation.
import { defineSchedule } from '@foir/cli/configs';
const nightly = defineSchedule({
operationKey: 'sync-data',
cron: '0 2 * * *',
timezone: 'America/New_York',
enabled: true,
});defineSegment
Defines a customer segment with rules.
import { defineSegment } from '@foir/cli/configs';
const vips = defineSegment({
key: 'vip-customers',
name: 'VIP Customers',
description: 'High-value customers',
rules: {
type: 'condition',
left: { type: 'field', path: 'totalSpend' },
operator: 'greater_than',
right: { type: 'literal', value: 500 },
},
isActive: true,
});defineAuthProvider
Defines an authentication provider.
import { defineAuthProvider } from '@foir/cli/configs';
const auth = defineAuthProvider({
key: 'google-oauth',
name: 'Google OAuth',
type: 'oauth2',
config: {
clientId: process.env.GOOGLE_CLIENT_ID,
allowedDomains: ['example.com'],
},
enabled: true,
isDefault: false,
priority: 10,
});defineRelyingParty
Defines a Login-with-Foir relying party — a storefront or third-party app that can launch the hosted customer login at auth.foir.dev/authorize?client_id=… and exchange the auth code at /customer/oauth/token. foir push is project-scoped, so relying parties declared in config are customer-lane.
import { defineRelyingParty } from '@foir/cli/configs';
const storefront = defineRelyingParty({
clientId: 'eide-clothing-storefront',
name: 'Eide Clothing',
redirectUris: ['https://eide.clothing/auth/callback'],
allowedScopes: ['records:read', 'profile:read'],
loginMethods: {
password: true,
otp: false,
disabledProviders: ['apple'], // hide these project providers on this RP only
},
});interface ApplyConfigRelyingPartyInput {
kind?: 'customer' | 'admin'; // Which lane; config RPs are customer-lane
clientId: string; // Public RP slug, unique per project
name: string; // Display name on the hosted login
redirectUris: string[]; // Allow-listed exact redirect URIs
allowedScopes?: string[]; // OAuth scopes the RP can request
loginMethods?: { // Per-RP login method filter (override-down only)
password?: boolean; // Show password tab (default true)
otp?: boolean; // Show OTP tab (default true)
disabledProviders?: string[]; // Provider keys to hide on this RP
};
customDomains?: string[]; // White-label login domains, e.g. ['login.acme.com']
postLoginUrl?: string; // Where a bare custom-domain login lands (front-door)
allowedOrigins?: string[]; // Origins allowed a browser session, e.g. ['https://app.acme.com']
}customDomains lets you serve the hosted login from your own domain instead of auth.foir.dev. foir push registers each domain (creating a pending row and a DNS challenge) and prunes any you remove from the list; verification is a separate step — add the CNAME and _foir-challenge TXT records the push output prints, then verify. See Custom Domains.
Registering, verifying or removing a domain needs the domains:write permission, which project admins and tenant owners hold by default. Editors and viewers do not, so a push that adds or removes a customDomains entry will fail for them while the rest of the manifest applies normally. Creating the relying party itself needs relyingparties:write, held by the same roles.
allowedOrigins lists the origins your app runs on, and is what lets a browser exchange the first-party session cookie for an access token — the backend-less SPA flow in SPA (no backend). They are exact origins, matched the same way redirectUris are: scheme and host (and port, if it isn’t 443), https only, no wildcards and no paths.
Note: There is no domain-wide default, deliberately. Granting every origin on
acme.comwould also grant a subdomain you no longer control, or one serving user-generated content — either of which could then mint your customers’ access tokens. If you list no origins, the browser session simply doesn’t work; the Login with Foir token flow is unaffected.
Omitting the key leaves the list untouched on push; passing [] clears it.
So relying parties, their login methods, their white-label domains, and the origins allowed a browser session are all declared in foir.config.ts alongside the rest of your project.
defineDesignTokens
Defines a W3C Design Tokens document for the project. The CLI applies it verbatim and references like {font.size.display1} are preserved on disk. See Design Tokens for the full shape.
import { defineDesignTokens } from '@foir/cli/configs';
const tokens = defineDesignTokens({
color: { brand: { primary: { $value: '#2c4433', $type: 'color' } } },
font: { size: { display1: { $value: '48px', $type: 'dimension' } } },
});defineSecrets
Secrets are declared separately in a foir.secrets.ts file (not in foir.config.ts) and reconciled with foir secrets push. defineSecrets is the type-safe identity helper for that file:
import { defineSecrets } from '@foir/cli/configs';
export default defineSecrets({
secrets: [
{ ownerKind: 'project', label: 'deepl_api_key' },
],
});Plaintext lives in a sibling local.foir.secrets.ts (gitignored) keyed by label, or in env vars. Production never runs the reconciler — operators set production secrets through the admin UI.
Field Types
The following field types are available for model field definitions:
| Type | Description |
|---|---|
text | Single- or multi-line text (set config.widget: 'textarea' for multi-line) |
richtext | Translatable rich-text prose |
flexible | Ordered array of structured blocks |
number | Numeric values |
boolean | Yes/no toggle |
date | Date picker (can include time) |
enum | Inline {label, value} choices — see defineEnumField |
select | Record-backed choice (optionModelKey) — see defineSelectField |
reference | Reference to another record |
model | Embedded value of an inline model |
image | Image upload with alt text and focal point |
video | Video upload with poster and HLS streaming |
file | Generic file upload |
list | Ordered list of items (itemType controls the inner type) |
json | Raw JSON |
See Field Types for detailed descriptions of each type.
Complete Example
This is the full config for the Cloudflare KV Redirect extension, showing models, operations, hooks, and placements working together:
import { defineConfig } from '@foir/cli/configs';
export default defineConfig({
key: 'cloudflare-kv',
name: 'Cloudflare KV Redirector',
configType: 'cloudflare-kv',
models: [
{
key: 'redirect',
name: 'Redirect',
fields: [
{
key: 'displayName',
type: 'text',
label: 'Display Name',
required: false,
},
{
key: 'sourcePattern',
type: 'text',
label: 'Source Pattern',
required: true,
},
{
key: 'targetPattern',
type: 'text',
label: 'Target Pattern',
required: true,
},
{
key: 'statusCode',
type: 'enum',
label: 'Status Code',
required: true,
config: {
options: [
{ value: '301', label: '301 - Permanent Redirect' },
{ value: '302', label: '302 - Temporary Redirect' },
{ value: '307', label: '307 - Temporary Redirect (preserve method)' },
{ value: '308', label: '308 - Permanent Redirect (preserve method)' },
],
},
},
{
key: 'priority',
type: 'number',
label: 'Priority',
required: false,
},
{
key: 'activeFrom',
type: 'date',
label: 'Active From',
required: false,
},
{
key: 'activeTo',
type: 'date',
label: 'Active Until',
required: false,
},
{
key: 'reason',
type: 'enum',
label: 'Reason',
required: false,
config: {
options: [
{ value: 'migration', label: 'Site Migration' },
{ value: 'rebrand', label: 'Rebrand / Rename' },
{ value: 'campaign', label: 'Marketing Campaign' },
{ value: 'discontinued', label: 'Discontinued Content' },
{ value: 'seo', label: 'SEO Optimization' },
{ value: 'other', label: 'Other' },
],
},
},
],
},
],
hooks: [
{
key: 'sync-on-create',
name: 'Sync on redirect create',
event: 'RECORD_CREATED',
operationKey: 'redirect-sync',
filter: { modelKey: 'redirect' },
},
{
key: 'sync-on-update',
name: 'Sync on redirect update',
event: 'RECORD_UPDATED',
operationKey: 'redirect-sync',
filter: { modelKey: 'redirect' },
},
{
key: 'sync-on-delete',
name: 'Sync on redirect delete',
event: 'RECORD_DELETED',
operationKey: 'redirect-sync',
filter: { modelKey: 'redirect' },
},
],
operations: [
{
key: 'redirect-sync',
name: 'Sync Redirects to Edge',
description: 'Syncs redirect records to Cloudflare KV',
endpoint: '/sync/all',
},
{
key: 'redirect-sync-status',
name: 'Get Sync Status',
description: 'Returns current sync metadata for display in the editor',
endpoint: '/sync/status',
},
{
key: 'redirect-undeploy',
name: 'Undeploy Edge Redirects',
description: 'Tear down Cloudflare Worker and KV namespace',
endpoint: '/deploy/destroy',
},
],
placements: [
{
type: 'main-editor',
url: '/',
modelKeys: ['redirect'],
hideContentTab: true,
tabName: 'Redirect Editor',
},
],
});ApiKey Provisioning
apiKeys declares API keys the CLI provisions during foir push. The created key value is written into your project’s .env file under the named environment variable so your application can pick it up immediately.
interface ApplyConfigApiKeyInput {
/** Display name (e.g. "Tilly iOS"). */
name: string;
/** 'public' for client apps, 'secret' for server/BFF use. */
keyType: 'public' | 'secret';
/** Env var the CLI writes the key value to (e.g. "FOIR_PUBLIC_KEY"). */
envVar: string;
/** Scopes — use ["*"] for full access. */
scopes?: string[];
/** Restrict to specific model keys. */
allowedModels?: string[];
/** Restrict file uploads to specific MIME types. */
allowedFileTypes?: string[];
}export default defineConfig({
key: 'tilly',
name: 'Tilly',
apiKeys: [
{
name: 'Tilly iOS',
keyType: 'public',
envVar: 'TILLY_PUBLIC_KEY',
scopes: ['records:read', 'records:write:tilly_note'],
allowedModels: ['tilly_note', 'tilly_block'],
},
{
name: 'Tilly BFF',
keyType: 'secret',
envVar: 'TILLY_SECRET_KEY',
scopes: ['*'],
},
],
});Customer Roles
customerRoles declares the customer RBAC roles whose permissions become a customer access token’s scope set at mint time. Reconciled by foir push (matched by key within the project — declared keys are created or updated in place; roles present on the platform but absent from the config are left untouched, never auto-disabled).
Since the two-gate auth change (Model B), a consumer app’s public pk_ key can no longer grant write or execute scopes — the only source of those scopes is the customer’s role. Without a role, every customer holds a read-only token. Declare a single isDefault: true role to grant the baseline write/execute scopes every customer needs:
interface ApplyConfigCustomerRoleInput {
/** Stable key, unique per project (e.g. "default"). */
key: string;
/** Display name (e.g. "Default Customer"). */
name: string;
/** Scopes granted to customers holding this role. */
permissions: string[];
/** When true, every customer inherits this role with no per-customer assignment. */
isDefault?: boolean;
}export default defineConfig({
key: 'tilly',
name: 'Tilly',
customerRoles: [
{
key: 'default',
name: 'Default Customer',
// self:* expands at mint time to records:{read,write}:<model> for every
// customer-writable model in the project. It does NOT include
// operations:execute — list that explicitly when customers run operations.
permissions: ['self:*', 'operations:execute'],
isDefault: true,
},
],
});
self:*depends on field-level access. A model only counts as “customer-writable” — and thus joins theself:*expansion — when one of its fields grants write to theselfprincipal. If your models have no such field access configured, grant explicitrecords:read:<model>/records:write:<model>scopes instead.
Existing customers must log in again. Token refresh re-stamps the old scopes; only a fresh login picks up a newly added or changed role.
The same operations are available imperatively via the foir customer-roles CLI command (list, create, update, disable, assign, revoke, assignments).
Apps
Per-project app installations live under the top-level apps key. Each entry maps a manifest URL to the project’s models and any per-project settings. See Defining Apps for the full reference.
interface AppInput {
source: string; // https URL to the manifest JSON
settings?: Record<string, unknown>;
mappings?: {
sources?: Record<string, AppSourceMappingInput>;
sinks?: Record<string, AppSinkMappingInput>;
placementFields?: Record<string, AppPlacementFieldChoiceInput>;
};
}
interface AppSourceMappingInput {
toModel: string;
naturalKey: string;
fields: Record<string, string>; // app-field-name → model-field-key
}
interface AppSinkMappingInput {
toModel: string;
naturalKey: string;
fields: Record<string, string>;
}
interface AppPlacementFieldChoiceInput {
model: string;
field: string;
}Project Settings
The top-level project key pins the config to a project (guarding against pushing one folder’s config into another) and carries declarative project-level settings. The pin (id / tenantId) is reconciled by foir push; foir pull populates the settings block from live project state. Omit settings to leave the platform settings untouched.
interface ApplyConfigProjectInput {
id: string; // Project id this folder is bound to
tenantId: string; // Tenant id of the bound project
name?: string; // Human-friendly name for diff readability
settings?: ApplyConfigProjectSettingsInput;
}
interface ApplyConfigProjectSettingsInput {
displayName?: string; // Name in customer-facing emails / portal
logoUrl?: string; // Public logo URL (customer emails)
primaryColor?: string; // Accent hex in customer emails
fromName?: string; // "From" name on customer emails
replyTo?: string; // "Reply-To" address
supportEmail?: string; // Support email in email footers
appBaseUrl?: string; // Public base URL; customer-email links built from this
customerWelcomeEmailEnabled?: boolean; // Welcome email on registration (default true)
customerSignupEnabled?: boolean; // Allow public CustomerRegister (default true)
customerPasswordEnabled?: boolean; // Email/password customer login (default true)
customerOtpEnabled?: boolean; // Email one-time-code login (default false)
}Relying Parties
relyingParties registers Login-with-Foir storefronts / external apps. See defineRelyingParty above for the full shape and example.
Design Tokens
designTokens carries a W3C Design Tokens document for the project, applied verbatim by foir push. See Design Tokens and defineDesignTokens.
Secrets
Secrets are not part of foir.config.ts. They are declared in a foir.secrets.ts file with defineSecrets and reconciled with foir secrets push.
Next Steps
- Defining Models — Model and field definition details
- Defining Operations — Registering operation endpoints
- Defining Hooks — Triggering operations on lifecycle events
- Editor Placements — Embedding custom UI in the editor
- Defining Apps — Installing apps from manifest URLs
- Push and Remove — Deploying your config