# Overview (/docs/api) Unlike other forum software, Storyden is built around a (fairly) HTTP standards-compliant RESTful API. This enables endless possibilities for developers to build powerful frontends, automations, integrations and more. ## Using the API [#using-the-api] To start experimenting with the API, get a local instance running: ``` docker run -p 8000:8000 ghcr.io/southclaws/storyden ``` Once you've waited roughly 1 millisecond for Storyden to boot up, you can start interacting with it via your favourite HTTP client. The OpenAPI specification is available at: ``` /api/openapi.json ``` You can also access interactive API documentation, powered by Scalar, at: ``` /api/docs ``` Because Storyden uses simple browser cookies, you can use the docs to immediately start playing with the API. ## Authentication [#authentication] Storyden uses secure cookies for authentication. You can register for an account (or log in) either via the browser or via the API itself in order to obtain a session token. You'll find the session cookie under a cookie named: ``` storyden-session ``` Include this with all requests to the API. For example, using curl register for a new account on a fresh local instance: ```bash curl -c cookies.txt http://localhost:8000/api/auth/password/signup \ --request POST \ --header 'Content-Type: application/json' \ --data '{ "identifier": "storyden", "token": "password" }' ``` ```sh curl -c cookies.txt http://localhost:8000/api/auth/password/signup --request POST --header 'Content-Type: application/json' --data '{"identifier":"storyden","token":"password"}' ``` ```sh (curl -c cookies.txt http://localhost:8000/api/auth/password/signup --request POST --header 'Content-Type: application/json' --data '{ "identifier": "storyden", "token": "password" }') ``` Note: we cannot use Nushell's `http` command here as it doesn't support cookies. Now use the `-b cookies.txt` option for cURL to include the session cookie in all requests. For example, to get a list of all members, you will see yourself in the list: ```bash curl -b cookies.txt http://localhost:8000/api/accounts ``` You'll see your account information in the response: ```json { // The first signup is admin by default. "admin": true, // Your bio can be written with rich text formatting. "bio": "", // Accounts may have multiple email addresses if email features are enabled. "email_addresses": [], // The identifier you signed up with "handle": "storyden", "id": "d01oa6i37ros73bk14rg", // Your unique ID "joined": "2025-04-19T11:12:26.873686708Z", // Your display name is the same as your handle by default, you can change this to anything you want. "name": "storyden", // Roles provide permissions, since this would be the first account created, it receives both the Member role with default permissions and the Admin role with all permissions denoted by "ADMINISTRATOR". "roles": [ { "badge": false, "colour": "green", "createdAt": "0001-01-01T00:00:00Z", "default": true, "id": "00000000000000000010", "name": "Member", "permissions": [ "CREATE_POST", "READ_PUBLISHED_THREADS", "CREATE_REACTION", "READ_PUBLISHED_LIBRARY", "SUBMIT_LIBRARY_NODE", "UPLOAD_ASSET", "LIST_PROFILES", "READ_PROFILE", "CREATE_COLLECTION", "LIST_COLLECTIONS", "READ_COLLECTION", "COLLECTION_SUBMIT" ], "updatedAt": "0001-01-01T00:00:00Z" }, { "badge": false, "colour": "red", "createdAt": "0001-01-01T00:00:00Z", "default": true, "id": "00000000000000000020", "name": "Admin", "permissions": ["ADMINISTRATOR"], "updatedAt": "0001-01-01T00:00:00Z" } ] } ``` ## Access Keys [#access-keys] For programmatic access, scripts, and integrations, Storyden provides **access keys** as an alternative to session cookies. Access keys are bearer tokens that authenticate API requests and inherit the permissions of the account that created them. Access keys are ideal for: * Building bots and automation workflows * Integrating with third-party services (Discord, n8n, Zapier, etc.) * Server-to-server communication To create and manage access keys, see the [Access Keys documentation](/docs/operation/access-keys). You'll need the `USE_PERSONAL_ACCESS_KEYS` permission to create keys for your account. Example usage: ```bash curl http://localhost:8000/api/threads \ --header 'Authorization: Bearer your_access_key_here' ``` ```sh curl http://localhost:8000/api/threads --header 'Authorization: Bearer your_access_key_here' ``` ```sh http get http://localhost:8000/api/threads Authorization:'Bearer your_access_key_here' ``` # Access Keys (/docs/reference/access-keys) Access keys provide secure API authentication for Storyden. They allow programmatic access to almost all API endpoints. Keys are provided as `Authorization` header bearer tokens that authenticate API requests. Each key inherits the permissions of the user who created it and provides a secure alternative to session-based authentication for scripts, applications, and integrations. ## Permissions Required [#permissions-required] Not every member can create access keys. Administrators must first grant the `USE_PERSONAL_ACCESS_KEYS` permission via a role before a member can create access keys for their account. Members with the `ADMINISTRATOR` permission inherit the access key usage permission automatically. ## Creating Access Keys [#creating-access-keys] ### Via Web Interface [#via-web-interface] 1. Navigate to your account settings 2. Click on "Access Keys" 3. Click "New" to create a new key 4. Provide a descriptive name (e.g., "Discord Bot", "n8n Agent") 5. Optionally set an expiry date 6. Copy the generated secret - **this is the only time you'll see it** ### Key Properties [#key-properties] * **Name**: Descriptive identifier for the key * **Secret**: The authentication token (shown only once, never stored by Storyden's code) * **Expiry**: Optional expiration date * **Enabled**: Whether the key is active or revoked ## Using Access Keys [#using-access-keys] Include your access key in API requests using the Authorization header: ```bash curl -H "Authorization: Bearer your_access_key_here" \ https://your-storyden-instance.com/api/threads ``` A small number of API operations cannot be invoked with an access key. This includes the `AccessKeyCreate` (`POST /api/auth/access-keys`) operation which means access keys cannot be used to create more access keys. ## Managing Access Keys [#managing-access-keys] ### Member Management [#member-management] Members with the `USE_PERSONAL_ACCESS_KEYS` permission can: * Create new access keys for their own account * View their own keys (name, creation date, expiry, enabled status) * Revoke their own keys ### Admin Management [#admin-management] Administrators can: * View all access keys across the instance * See which user created each key * Revoke any access key ## Security Best Practices [#security-best-practices] ### Key Management [#key-management] * Use descriptive names to identify key purposes * Set expiry dates for temporary access * Revoke unused keys immediately ### Integration Security [#integration-security] * Store keys securely (environment variables, secret managers) * Never commit keys to version control * Use different keys for different applications * Rotate keys periodically ## OAuth 2.0 clients [#oauth-20-clients] If you need an external application to act on behalf of a member, or you want to offer "Sign in with Storyden" to another service then access keys aren't the right tool. For that, see [OAuth 2.0 & OpenID Connect](/docs/introduction/oauth). It supports standard authorization code and device authorisation flows, and issues short-lived JWTs rather than static bearer tokens. # Configuration via Environment Variables (/docs/reference/configuration) "Configuration" throughout the documentation and codebase refers to these variables which are statically set when the process launches. Changing them requires a restart, however they don't need to be changed much once set. The term "Settings" is distinct from these and refers to runtime-configurable values which are stored in the database and not via environment variables. These can be changed at any time via the API or the Admin settings page. ## General [#general] These settings general infrastructure-level configuration settings for managing a Storyden deployment. ### `LOG_LEVEL` [#log_level]
type `debug`, `info`, `warn`, `error`
default `info`
Can be set to either: * `debug` * `info` * `warn` * `error` ### `LOG_FORMAT` [#log_format]
type `string`
default none
Can be set to either: * `(not set)` (default) somewhat human readable "logfmt" format logs for simple setups * `dev` for developer-friendly logs, with colours and attributes on separate lines for readability * `json` for machine-readable logs, mainly for log aggregators, etc. ### `RUN_FRONTEND` [#run_frontend]
type `string`
default (empty string)
Determines whether or not the backend service will also start the frontend Node.js process. When empty, it will not When a path is provided, Storyden will execute `node ` to start the frontend process. This is used by the fullstack Docker image to start the frontend process in the same container as the backend. ### `PROXY_FRONTEND_ADDRESS` [#proxy_frontend_address]
type url (e.g. [http://example.com](http://example.com))
default (empty string)
Used in conjunction with `RUN_FRONTEND`. This is the address that the frontend will be available at. This is used by the fullstack Docker image to proxy requests that don't match any `/api` or other routes to the frontend process. In the default `fullstack` image, this is set to `http://localhost:3000` which is the default port for the Next.js process. ### `SSR_TRUSTED_SOURCE_CIDRS` [#ssr_trusted_source_cidrs]
type `string`
default (empty string)
Comma or newline separated list of CIDR ranges (or plain IPs) that are allowed as direct source addresses for SSR-origin API requests (`X-Storyden-SSR: 1`) when resolving `X-Forwarded-For` in `xff_trusted_proxies` mode. This is useful when your frontend SSR process runs outside the backend host and therefore SSR API calls do not originate from loopback. Storyden also derives trusted SSR source addresses from `PROXY_FRONTEND_ADDRESS` when possible: * `localhost` trusts both `127.0.0.1/32` and `::1/128` * literal IP hosts are trusted automatically ## Development tools [#development-tools] Configuration settings for aiding in development of Storyden clients. ### `DEV_CHAOS_SLOW_MODE` [#dev_chaos_slow_mode]
type duration (e.g. 1h, 1m, 1s)
default none
Simulates slow requests. This will add a random delay between zero and this value to all requests. This is useful for testing how the client handles slow responses. ### `DEV_CHAOS_SLOW_MODE_QUEUE` [#dev_chaos_slow_mode_queue]
type duration (e.g. 1h, 1m, 1s)
default none
Simulates slow message delivery in the internal message queue. This will add a random delay between zero and this value to all messages in the internal message queue. This is useful for testing how the client handles delayed message processing. ### `DEV_CHAOS_FAIL_RATE` [#dev_chaos_fail_rate]
type float (e.g. `1.0`, `1.5`)
default none
A value between 0 and 1 which simulates failed requests. This will add a random failure to all requests. This is useful for testing how the client handles "internal server error" responses. ## Core configuration [#core-configuration] Configuration settings for core functionality, pretty much all of these will need to be configured for production installations, excepting perhaps `LISTEN_ADDR`. ### `DATABASE_URL` [#database_url]
type `string`
default `sqlite://data/data.db?_pragma=foreign_keys(1)`
The database URL to connect to. This can be a SQLite, PostgreSQL, or CockroachDB URL. The accepted schemes for this URL are: * `sqlite://` or `sqlite3://` for SQLite or Litestream. * `postgres://` or `postgresql://` for PostgreSQL, CockroachDB and any other PostgreSQL-compatible database * `libsql://` for Turso/libSQL. * Remote Turso example: `libsql://your-db.turso.io?authToken=...` * Local file-backed libSQL example: `libsql://./relative/path` or `libsql:///absolute/path` ### `LISTEN_ADDR` [#listen_addr]
type `string`
default `0.0.0.0:8000`
The interface on which the API service will for HTTP requests. Typically, in a containerised environment, this should be all interfaces (`0.0.0.0`.) ### `PUBLIC_WEB_ADDRESS` [#public_web_address]
type url (e.g. [http://example.com](http://example.com))
default `http://localhost:3000`
The address at which the web frontend will be hosted. This must be set to the public URL that users of the instance will access the frontend client. It is used to determine things such as cookie domain attributes, CORS policy, WebAuthn attributes and other necessary settings. The scheme may be used by some internal components to determine whether the instance is running in a secure context or not. This is by default `http://localhost:3000` when running locally, or when deploying to production, `https://`. ### `PUBLIC_API_ADDRESS` [#public_api_address]
type url (e.g. [http://example.com](http://example.com))
default `http://localhost:8000`
The address at which the public API will be accessible. This is also used for things such as cookies, CORS, etc. Please note that both the public API address and public web address must share the same root domain name as Storyden cookies are configured to be issued under this assumption. It also makes a lot of cross-origin and cookie configurations easier to make secure. ### `CORS_ALLOWED_ORIGINS` [#cors_allowed_origins]
type `[]string`
default none
Additional browser origins permitted to make credentialed cross-origin requests. The public web and API addresses are always trusted. Set this only when other first-party frontends (such as a separate marketing site or admin console) must call the API from the browser with cookies. Each entry is an origin (`scheme://host[:port]`), comma-separated. Arbitrary third-party origins must not be added here; programmatic clients should authenticate with bearer tokens, which do not depend on CORS credentials. ## Rate limiting [#rate-limiting] Storyden includes a rate limiter to reduce abusive traffic (scraping, brute force, etc.) while staying friendly to normal "bursty" browsing. The limiter uses a sliding-window counter implemented as fixed time buckets (a hash map of `bucket_timestamp` → `count`). Each request increments the current bucket. To decide whether a request is allowed, Storyden sums all buckets within the last `RATE_LIMIT_PERIOD`. Rate limits are keyed by the client’s IP address (respecting proxy-forwarded headers). Rate-limit state is stored in-memory by default. If `CACHE_PROVIDER` is configured, state is stored in the cache provider instead. ### `RATE_LIMIT` [#rate_limit]
type `integer` (number without decimal point)
default `5000`
Maximum number of "units" allowed within the sliding window defined by `RATE_LIMIT_PERIOD`. Most incoming requests will consume `1` unit for authenticated users, and `RATE_LIMIT_GUEST_COST` units for unauthenticated (guest) visitors. Certain endpoints are more expensive by default, such as those that trigger password resets, sending emails, etc. You can also configure custom overrides in the System Settings screen. However, you cannot configure custom overrides via environment variables. ### `RATE_LIMIT_PERIOD` [#rate_limit_period]
type duration (e.g. 1h, 1m, 1s)
default `1h`
Sliding window duration used to enforce `RATE_LIMIT`. On each request, Storyden considers the total number of units consumed in the last `RATE_LIMIT_PERIOD` (not aligned to the hour/minute boundary). ### `RATE_LIMIT_BUCKET` [#rate_limit_bucket]
type duration (e.g. 1h, 1m, 1s)
default `1m`
Bucket size (granularity) used to approximate the sliding window. Requests are counted into discrete time buckets of this size (e.g. 1 minute). When checking the limit, Storyden sums all buckets whose timestamps fall within the last `RATE_LIMIT_PERIOD` and discards older buckets. Smaller buckets = more accurate sliding-window behaviour but more storage/CPU overhead. Larger buckets = cheaper but "chunkier" enforcement. Rule of thumb: set this to \~1/60 of `RATE_LIMIT_PERIOD` (e.g. 1m buckets for a 1h period). ### `RATE_LIMIT_GUEST_COST` [#rate_limit_guest_cost]
type `integer` (number without decimal point)
default `1`
Cost multiplier applied to unauthenticated (guest) requests. Example: if set to 5, each guest request consumes 5 units from the same `RATE_LIMIT` budget, effectively allowing guests \~1/5th the throughput of authenticated users. ## Telemetry and monitoring [#telemetry-and-monitoring] Configuration for monitoring via OpenTelemetry-compatible software. ### `OTEL_PROVIDER` [#otel_provider]
type `string`
default (empty string)
Either: * `otlp` for any standard OpenTelemetry collector, or `file://...` JSONL output when `OTEL_EXPORTER_OTLP_ENDPOINT` is a file URL. * `sentry` for Sentry (which is OpenTelemetry-compatible, however requires its own specific configuration.) * `logger` for local logging to the console. This is only really useful for Storyden developers and is very noisy. ### `OTEL_EXPORTER_OTLP_ENDPOINT` [#otel_exporter_otlp_endpoint]
type url (e.g. [http://example.com](http://example.com))
default (empty string)
The collector endpoint for sending OTEL data. This also supports file output for local profiling when `OTEL_PROVIDER=otlp` by setting a `file://` URL, for example: `file:///tmp/storyden-traces.jsonl` or `file:./otel-logs.jsonl`. ### `SENTRY_DSN` [#sentry_dsn]
type `string`
default (empty string)
When `OTEL_PROVIDER` is set to `sentry`, this is the DSN for the Sentry project. ## Email [#email] Email sending configuration. This must be enabled in order to enable email-based authentication and password reset functionality. When enabling email features, you must also set `JWT_SECRET` as this is used to sign the email tokens for password resets and other features. ### `EMAIL_PROVIDER` [#email_provider]
type `string`
default none
Either: * unset (default) for no email sending. Email sending is not a requirement for a production deployment. * `smtp` for SMTP-based email sending through any SMTP server. * `sendgrid` for SendGrid based email sending. * `mock` for logging emails to the console. Only useful for Storyden developers and testing. ### `SMTP_HOST` [#smtp_host]
type `string`
default none
The hostname of the SMTP server. This is required for sending emails via SMTP. This is typically something like `smtp.gmail.com`, `smtp.outlook.com`, or your organization's SMTP server. ### `SMTP_PORT` [#smtp_port]
type `integer` (number without decimal point)
default none
The port of the SMTP server. This is required for sending emails via SMTP. Common values are: * `587` for STARTTLS (recommended) * `465` for implicit TLS/SSL * `25` for unencrypted SMTP (not recommended for production) ### `SMTP_USERNAME` [#smtp_username]
type `string`
default none
The username for SMTP authentication. This is typically your email address. ### `SMTP_PASSWORD` [#smtp_password]
type `string`
default none
The password for SMTP authentication. For services like Gmail, this may be an "app password" rather than your regular password. ### `SMTP_FROM_NAME` [#smtp_from_name]
type `string`
default none
The name that will be used as the sender name for emails sent via SMTP. This is typically the name of your community or organisation. ### `SMTP_FROM_ADDRESS` [#smtp_from_address]
type `string`
default none
The email address that will be used as the sender address for emails sent via SMTP. This is typically a no-reply address, such as `no-reply@`. ### `SMTP_USE_TLS` [#smtp_use_tls]
type boolean (`true` or `false`, case sensitive)
default `true`
Whether to require TLS encryption for SMTP connections. When enabled, Storyden uses implicit TLS on port `465` and STARTTLS on other ports. Set to `false` only if your SMTP server does not support encryption (not recommended). ### `SENDGRID_FROM_NAME` [#sendgrid_from_name]
type `string`
default none
The name that will be used as the sender name for emails sent via SendGrid. This is typically the name of your community or organisation. ### `SENDGRID_FROM_ADDRESS` [#sendgrid_from_address]
type `string`
default none
The email address that will be used as the sender address for emails sent via SendGrid. This is typically a no-reply address, such as `no-reply@`. ### `SENDGRID_API_KEY` [#sendgrid_api_key]
type `string`
default none
The API key for the SendGrid account. This is required for sending emails via SendGrid. This is typically a long string of characters that you can generate in the SendGrid dashboard. ## Authentication [#authentication] Authentication providers configuration. These are all optional, you can choose to enable any combination of them to allow members of your community to sign up and sign in using a third party provider. In order to enable any of these providers, you must set a JWT secret. This is used to sign the state objects for validating the OAuth2 flow. ### `JWT_SECRET` [#jwt_secret]
type `[]byte`
default none
The secret key used to sign JWT tokens. This is used for authentication and should be kept secret. This is typically a long string of characters that you can generate using a secure random generator such as `openssl rand -hex 12`. The JWT secret is required if you enable any of the OAuth providers or enable email features. This is because JWTs are used to verify callbacks as well as verify password-reset and other tokens. ### `OAUTH_GOOGLE_ENABLED` [#oauth_google_enabled]
type boolean (`true` or `false`, case sensitive)
default none
Enable Google SSO authentication. ### `OAUTH_GOOGLE_CLIENT_ID` [#oauth_google_client_id]
type `string`
default none
The client ID for the Google OAuth2 application. ### `OAUTH_GOOGLE_CLIENT_SECRET` [#oauth_google_client_secret]
type `string`
default none
The client secret for the Google OAuth2 application. ### `OAUTH_GITHUB_ENABLED` [#oauth_github_enabled]
type boolean (`true` or `false`, case sensitive)
default none
Enable GitHub SSO authentication. ### `OAUTH_GITHUB_CLIENT_ID` [#oauth_github_client_id]
type `string`
default none
The client ID for the GitHub OAuth2 application. ### `OAUTH_GITHUB_CLIENT_SECRET` [#oauth_github_client_secret]
type `string`
default none
The client secret for the GitHub OAuth2 application. ### `OAUTH_DISCORD_ENABLED` [#oauth_discord_enabled]
type boolean (`true` or `false`, case sensitive)
default none
Enable Discord SSO authentication. ### `OAUTH_DISCORD_CLIENT_ID` [#oauth_discord_client_id]
type `string`
default none
The client ID for the Discord OAuth2 application. ### `OAUTH_DISCORD_CLIENT_SECRET` [#oauth_discord_client_secret]
type `string`
default none
The client secret for the Discord OAuth2 application. ### `OAUTH_KEYCLOAK_ENABLED` [#oauth_keycloak_enabled]
type boolean (`true` or `false`, case sensitive)
default none
Enable Keycloak OIDC authentication. ### `OAUTH_KEYCLOAK_CLIENT_ID` [#oauth_keycloak_client_id]
type `string`
default none
The client ID for the Keycloak OAuth2 application. ### `OAUTH_KEYCLOAK_CLIENT_SECRET` [#oauth_keycloak_client_secret]
type `string`
default none
The client secret for the Keycloak OAuth2 application. ### `OAUTH_KEYCLOAK_ISSUER_URL` [#oauth_keycloak_issuer_url]
type url (e.g. [http://example.com](http://example.com))
default none
The issuer/discovery URL for the Keycloak realm (e.g. [https://auth.example.com/realms/YourRealm](https://auth.example.com/realms/YourRealm)). ### `OAUTH_KEYCLOAK_DISPLAY_NAME` [#oauth_keycloak_display_name]
type `string`
default `Keycloak`
The display name shown for the Keycloak login button. ### `OAUTH_ENABLED` [#oauth_enabled]
type boolean (`true` or `false`, case sensitive)
default none
Enable Storyden's built-in OAuth2/OIDC authorization server endpoints. ### `OAUTH_ACCESS_TOKEN_TTL` [#oauth_access_token_ttl]
type duration (e.g. 1h, 1m, 1s)
default `15m`
Access token lifetime for Storyden OAuth tokens. ### `OAUTH_REFRESH_TOKEN_TTL` [#oauth_refresh_token_ttl]
type duration (e.g. 1h, 1m, 1s)
default `720h`
Refresh token lifetime for Storyden OAuth tokens. ### `OAUTH_DEVICE_CODE_TTL` [#oauth_device_code_ttl]
type duration (e.g. 1h, 1m, 1s)
default `10m`
Device code lifetime for the OAuth Device Authorization Grant. ### `OAUTH_DEVICE_POLL_EVERY` [#oauth_device_poll_every]
type duration (e.g. 1h, 1m, 1s)
default `5s`
Poll interval used by device flow responses. ### `OAUTH_DEVICE_AUTHORISATION_CONSENT_URL` [#oauth_device_authorisation_consent_url]
type url (e.g. [http://example.com](http://example.com))
default none
Frontend URL used by OAuth Device Authorization Grant users to approve or deny consent. ### `OAUTH_AUTHORISATION_CODE_CONSENT_URL` [#oauth_authorisation_code_consent_url]
type url (e.g. [http://example.com](http://example.com))
default none
Frontend URL used by OAuth Authorization Code Grant users to approve or deny consent. ### `OAUTH_AUTHORISATION_LOGIN_URL` [#oauth_authorisation_login_url]
type url (e.g. [http://example.com](http://example.com))
default `{PUBLIC_WEB_ADDRESS}/login`
Frontend URL to redirect unauthenticated users to when starting the OAuth Authorization Code flow without a session. Defaults to `{PUBLIC_WEB_ADDRESS}/login` when unset. ### `OAUTH_SIGNING_KEY_BASE64` [#oauth_signing_key_base64]
type `string`
default none
Base64-encoded PEM private signing key used for OAuth2/OIDC JWT signing. ### `OAUTH_SIGNING_KEY_ID` [#oauth_signing_key_id]
type `string`
default none
Optional JWT key ID (kid) for OAuth signing keys. ### `OAUTH_DYNAMIC_REGISTRATION_ENABLED` [#oauth_dynamic_registration_enabled]
type boolean (`true` or `false`, case sensitive)
default `false`
Enable RFC 7591 OAuth 2.0 Dynamic Client Registration. ### `OAUTH_CIMD_ENABLED` [#oauth_cimd_enabled]
type boolean (`true` or `false`, case sensitive)
default `false`
Advertise and accept OAuth Client ID Metadata Documents (CIMD), letting clients identify themselves with an https URL client\_id that resolves to a hosted metadata document instead of pre-registering. ### `OAUTH_CIMD_ALLOWED_SCOPES` [#oauth_cimd_allowed_scopes]
type `[]string`
default none
Comma-separated list of permission scopes a CIMD client may request. Empty uses a conservative read-only default. Privileged scopes are still gated by OAUTH\_CIMD\_ALLOW\_PRIVILEGED\_SCOPES. ### `OAUTH_CIMD_ALLOW_PRIVILEGED_SCOPES` [#oauth_cimd_allow_privileged_scopes]
type boolean (`true` or `false`, case sensitive)
default none
Allow privileged/administrative permission scopes to be granted to CIMD clients. Off by default; only enable for trusted, controlled deployments. ### `OAUTH_CIMD_ALLOW_INSECURE_FETCH` [#oauth_cimd_allow_insecure_fetch]
type boolean (`true` or `false`, case sensitive)
default none
Relax CIMD metadata fetching restrictions (allow http, private hosts and skip TLS verification). For local development and testing only; never set in production. ## SMS [#sms] SMS sending configuration. This must be enabled in order to support SMS-based authentication. ### `SMS_PROVIDER` [#sms_provider]
type `string`
default none
Either: * unset (default) for no SMS sending. SMS sending is not a requirement for a production deployment. * `twilio` for Twilio based SMS sending. * `mock` for logging SMS to the console. Only useful for Storyden developers and testing. ### `TWILIO_ACCOUNT_SID` [#twilio_account_sid]
type `string`
default none
The account SID for the Twilio account. This is typically a long string of characters that you can view in the Twilio dashboard. ### `TWILIO_PHONE_NUMBER` [#twilio_phone_number]
type `string`
default none
The phone number that will be used as the sender number for SMS sent via Twilio. ### `TWILIO_AUTH_TOKEN` [#twilio_auth_token]
type `string`
default none
The auth token for the Twilio account. This is required for sending SMS via Twilio. This is typically a long string of characters that you can generate in the Twilio dashboard. ## Assets/file storage [#assetsfile-storage] Configuration for storing files such as avatars, uploaded images, etc. ### `ASSET_STORAGE_TYPE` [#asset_storage_type]
type `string`
default none
Either: * `local` for local file storage. * `s3` for any Amazon S3-compatible storage, such as S3 itself (obviously...), Google Cloud Storage, Cloudflare R2, Minio, etc. ### `ASSET_STORAGE_LOCAL_PATH` [#asset_storage_local_path]
type `string`
default none
When `ASSET_STORAGE_TYPE` is set to `local`, this is the path to the directory where files will be stored. ### `S3_SECURE` [#s3_secure]
type boolean (`true` or `false`, case sensitive)
default `true`
When `ASSET_STORAGE_TYPE` is set to `s3`, this determines whether or not to use HTTPS for the S3 connection. You should always set this to `true` unless your S3-compatible storage provider is internally but not publicly accessible, such as in a Kubernetes cluster or running on the same host. ### `S3_ENDPOINT` [#s3_endpoint]
type `string`
default none
The endpoint for the S3-compatible storage provider. This is typically the base URL of the provider, such as `https://s3.amazonaws.com` for AWS S3, or `https://storage.googleapis.com` for Google Cloud Storage, etc. ### `S3_BUCKET` [#s3_bucket]
type `string`
default none
The bucket name for Storyden assets to be stored in. ### `S3_REGION` [#s3_region]
type `string`
default none
Most S3-compatible storage providers require a region to be specified. This is typically the region in which the bucket is located, such as `us-east-1` for AWS S3. However, some providers do not use regions but S3-compatible clients still require this to be set. In most cases, the provider will give you a value for this, such as `auto` when using Cloudflare R2. ### `S3_ACCESS_KEY` [#s3_access_key]
type `string`
default none
The access key for the S3-compatible storage provider. ### `S3_SECRET_KEY` [#s3_secret_key]
type `string`
default none
The secret key for the S3-compatible storage provider. ## Cache [#cache] Configuration for cachine. Caching is optional in Storyden, but is recommended for larger deployments to reduce process memory usage. ### `CACHE_PROVIDER` [#cache_provider]
type `string`
default (empty string)
When empty, caching will use an efficient in-memory store. This is usually fine for small to medium-sized deployments however it's worth keeping an eye on your deployment's machine memory usage. When set to `redis`, Storyden will use Redis as a cache provider. This is recommended for larger deployments that receive a lot of traffic. The cache provider is also used for the rate limiter so that it can be shared across multiple instances of Storyden. This is necessary for deploying replica instances of Storyden that are backed by the same persistence layers (database, asset storage, etc.) ### `REDIS_URL` [#redis_url]
type url (e.g. [http://example.com](http://example.com))
default (empty string)
The Redis URL to connect to. This is a full URL with `redis://` as the scheme. You can set the username and password using the URL format, for example: `redis://:@:`. ## Search features [#search-features] Configuration for search features. This is not required for Storyden to run, by default search uses a simple database-driven keyword search. However, for larger deployments and better search quality, it is recommended to configure a search provider. ### `SEARCH_PROVIDER` [#search_provider]
type `string`
default `database`
Either: * `database` for the default database-driven search. This is not recommended for larger deployments as it does not scale well and has limited search quality. * `bleve` for Bleve. This is a local full-text search engine that is fast and efficient for small to medium-sized deployments. This is best used when you are using local disk storage, such as SQLite and local asset storage. * `redis` for Redisearch. This is a fast and efficient search provider that is recommended for larger deployments. This is recommended if your environment is ephemeral and you're already using external providers for database and asset storage. ### `SEARCH_INDEX_CHUNK_SIZE` [#search_index_chunk_size]
type `integer` (number without decimal point)
default `1000`
When using `SEARCH_PROVIDER` set to either `bleve` or `redis`, this is the number of items that will be indexed in a single batch. Increasing this value will improve indexing performance, but will also increase memory usage during indexing. ### `BLEVE_PATH` [#bleve_path]
type `string`
default `data/bleve`
The path to the directory where Bleve will store search indexes. Only used when `SEARCH_PROVIDER` is set to `bleve`. ### `REDIS_SEARCH_INDEX_NAME` [#redis_search_index_name]
type `string`
default `storyden`
The name of the Redis search index. Only used when `SEARCH_PROVIDER` is set to `redis`. ## Message queue [#message-queue] Configuration for message/job queue. This is not required for Storyden to run, but it can improve performance, reliability and reduce memory usage in larger deployments. ### `QUEUE_TYPE` [#queue_type]
type `string`
default `internal`
Either: * Default (no value): in-memory Go channels. This is fast and efficient, but not persistent across restarts and will add a bit of memory usage to the process. * `amqp`: RabbitMQ. This is a persistent message queue that is fast and reliable. It is recommended for larger deployments and is necessary for deploying replica instances of Storyden. ### `AMQP_URL` [#amqp_url]
type `string`
default `amqp://guest:guest@localhost:5672/`
The RabbitMQ URL to connect to. This is a full URL with `amqp://` as the scheme. You can set the username and password using the URL format, for example: `amqp://:@:`. The default value is `amqp://guest:guest@localhost:5672/` which is the default RabbitMQ URL. Storyden does not currently support `amqps://` (secure) URLs, but this will be added soon. ### `QUEUE_MAX_RETRIES` [#queue_max_retries]
type `integer` (number without decimal point)
default `5`
The maximum number of times a failed message will be retried before being moved to the dead letter queue. Messages are retried with exponential backoff starting at 1 second and doubling each time up to a maximum of 1 minute between retries. ### `QUEUE_RETRY_INITIAL_INTERVAL` [#queue_retry_initial_interval]
type duration (e.g. 1h, 1m, 1s)
default `1s`
The initial interval to wait before the first retry attempt. ### `QUEUE_RETRY_MAX_INTERVAL` [#queue_retry_max_interval]
type duration (e.g. 1h, 1m, 1s)
default `1m`
The maximum interval to wait between retry attempts. The exponential backoff will not exceed this value. ## Plugins [#plugins] Configuration for the plugin system. Plugins extend Storyden with custom functionality via a process-based runtime. ### `PLUGIN_DATA_PATH` [#plugin_data_path]
type `string`
default `./data/plugins`
The directory where plugin files will be extracted and stored. Each plugin gets its own subdirectory keyed by the plugin installation ID. This directory should be persistent and writable by the Storyden process. ### `ROBOT_WORKSPACE_DATA_PATH` [#robot_workspace_data_path]
type `string`
default `./data/robot-workspaces`
The directory where local Robot workspace instance files will be stored. Each workspace instance gets its own subdirectory keyed by the workspace instance ID. This directory should be persistent and writable by the Storyden process when local Robot workspaces are used. ### `PLUGIN_RUNTIME_PROVIDER` [#plugin_runtime_provider]
type `string`
default `local`
The plugin runtime provider. Different runtime providers offer different security guarantees. The simplest is `local` which just runs the plugin as a child process on the same machine as Storyden. * `none`: disables plugins entirely. Plugin APIs return a permission error and instance capabilities will not include `plugins`. * `local`: runs supervised plugins as local child processes on the same machine as Storyden. * `sprites`: runs supervised plugins in isolated [Sprites](https://docs.sprites.dev/) runtimes. ### `SPRITES_API_KEY` [#sprites_api_key]
type `string`
default none
Required when `PLUGIN_RUNTIME_PROVIDER` is set to `sprites`. This is the API key Storyden uses to create and manage plugin runtimes on Sprites. ### `PLUGIN_MAX_RESTART_ATTEMPTS` [#plugin_max_restart_attempts]
type `integer` (number without decimal point)
default `3`
Maximum number of consecutive restart attempts for a supervised plugin before marking it as errored. When a plugin crashes during startup (before the runtime crash threshold), the restart counter increments. After exceeding this limit, the plugin transitions to an error state and stops attempting restarts. ### `PLUGIN_MAX_BACKOFF_DURATION` [#plugin_max_backoff_duration]
type duration (e.g. 1h, 1m, 1s)
default `60s`
Maximum backoff duration between plugin restart attempts. Restart delays use exponential backoff (1s, 2s, 4s, 8s, ...) capped at this maximum value. ### `PLUGIN_RUNTIME_CRASH_THRESHOLD` [#plugin_runtime_crash_threshold]
type duration (e.g. 1h, 1m, 1s)
default `30s`
Time threshold to distinguish between startup crashes and runtime crashes. If a plugin runs successfully for longer than this duration before crashing, it's considered a "runtime crash" and the restart counter is reset. This allows plugins that crash after running for a while (hours/days) to restart without being penalized by the startup crash limit. ### `PLUGIN_RUNTIME_CRASH_BACKOFF` [#plugin_runtime_crash_backoff]
type duration (e.g. 1h, 1m, 1s)
default `5s`
Backoff duration used specifically for runtime crashes (crashes that occur after the plugin has been running for longer than the runtime crash threshold). This is typically shorter than the exponential backoff used for startup crashes, as runtime crashes are less likely to be configuration issues. ## Artificial intelligence/language models [#artificial-intelligencelanguage-models] Configuration for optional AI features. These can be useful for organising large amounts of library pages and threads, but it can also provide other features such as recommendations and chat-based conversational searching. ### `MCP_ENABLED` [#mcp_enabled]
type boolean (`true` or `false`, case sensitive)
default `false`
Enables the Model Context Provider server, accessible via SSE at `/mcp`. This is used to integrate Storyden into agentic workflow engines and other language model tooling. See [the documentation](https://storyden.org/docs/introduction/mcp) for more information. ### `LANGUAGE_MODEL_PROVIDER` [#language_model_provider]
type `string`
default none
The provider for language model features. `openai` is currently the only supported provider. ### `OPENAI_API_KEY` [#openai_api_key]
type `string`
default none
When `LANGUAGE_MODEL_PROVIDER` is set to `openai`, this is the API key for the OpenAI API. ## Semdex [#semdex] The Semdex is a semantic index that provides vector-based storage of content. This is used for things like recommendations, search, etc. The Semdex works with the language model provider to create embeddings of content. Thus, enabling the Semdex requires a `LANGUAGE_MODEL_PROVIDER` to be set. ### `SEMDEX_PROVIDER` [#semdex_provider]
type `string`
default (empty string)
Either: * `chromem` for an experimental local vector database. This is not recommended for use in large deployments as it's rather slow and memory-hungry. * `pinecone` for Pinecone, a fully managed vector database. ## Local Semdex [#local-semdex] Configuration for when `SEMDEX_PROVIDER` is set to `chromem`. ### `SEMDEX_LOCAL_PATH` [#semdex_local_path]
type `string`
default `data/semdex`
The path to the directory where Chromem will store vector indexes. ## Pinecone Semdex [#pinecone-semdex] Configuration for when `SEMDEX_PROVIDER` is set to `pinecone`. ### `PINECONE_API_KEY` [#pinecone_api_key]
type `string`
default none
Your Pinecone API key. This is required for all Pinecone API requests. ### `PINECONE_INDEX` [#pinecone_index]
type `string`
default none
The index name that Storyden will use in your Pinecone workspace. ### `PINECONE_DIMENSIONS` [#pinecone_dimensions]
type integer (e.g. `1`, `2`, `3`)
default none
This value is dependent on the underlying OpenAI configuration. Currently this is static and set to 3072 dimensions. In future, Storyden will provide more flexible configuration for language model providers. ### `PINECONE_CLOUD` [#pinecone_cloud]
type `string`
default none
Pinecone provides hosting on different cloud providers, see the Pinecone documentation for more information. The cloud provider you choose will be reflected in your Pinecone dashboard. ### `PINECONE_REGION` [#pinecone_region]
type `string`
default none
Same as above, but for the region. As with any third party providers, it's recommended to choose the region closest to both your Storyden deployment and your community members for best performance and experience. # Custom themes (/docs/reference/custom-themes) Custom themes let an administrator apply installation-wide CSS and JavaScript without rebuilding the Storyden frontend. Open **Admin → Appearance → Custom theme**, enable theme editing, and the editor will follow you while you browse the real site. Changes are saved directly to the live site. You can inspect the page you are changing, use your browser developer tools, and save when the result is ready for visitors. Theme JavaScript runs with Storyden's browser privileges for every visitor, including administrators. It can read authenticated pages and make network requests. Only paste code you have written or audited yourself. ## Choosing a selector [#choosing-a-selector] Storyden exposes several kinds of CSS target. Choose the narrowest supported target that describes what you mean. ### Context attributes [#context-attributes] `data-sd-*` attributes describe broad semantic context rather than visual implementation: * `data-sd-layout="default|fullpage"` identifies the application shell. * `data-sd-region="navigation|sidebar|topbar|main|content"` identifies major regions. * `data-sd-page="home|categories|category|thread"` identifies product pages. * `data-sd-block="cover|title|subtitle|content|library|categories|quick-share|threads"` identifies configurable home-page blocks. Use these for rules that should affect an entire page or region. Avoid selectors that depend on the exact nesting between them. ### Storyden BEM classes [#storyden-bem-classes] Product-specific anatomy uses BEM-style classes such as `.category-card__summary`, `.thread-page__replies`, and `.feed-page__block--cover`. These are the preferred hooks when a theme needs to distinguish pieces of a Storyden feature. ### Panda recipe classes [#panda-recipe-classes] Generated recipe classes are supported component hooks because their names reflect a component's declared anatomy and variants. For example: ```css .menu__trigger { border-radius: var(--sd-radius-control); } .button--variant_solid { box-shadow: 0 0.2rem 0.5rem rgb(40 30 90 / 20%); } ``` Panda utility classes such as `.max-w_full`, `.min-w_0`, or hashed/generated implementation selectors are not theme hooks. They may change whenever Storyden's internal layout changes. HTML IDs identify real resources and fragment targets, such as a particular thread or reply. Do not treat dynamic IDs as styling hooks. ## Public variables [#public-variables] Use the public `--sd-*` variables before reaching for individual components. They cover: * canvas, surface, inset, overlay, and control backgrounds; * primary, muted, and subtle text; * normal, muted, and strong borders; * the generated accent ramp, accent foreground, accent text, and focus ring colours; * body and heading fonts; * control, panel, overlay, and pill radii; * content widths, sidebar width, and page gutters. ### Use the administrator's accent colour [#use-the-administrators-accent-colour] Storyden generates a light and dark 12-step accent ramp from **Admin → Appearance → Brand → Accent colour**. Custom themes should normally consume that ramp instead of declaring a separate brand colour. This keeps navigation, controls, focus states, and theme-specific surfaces in sync when an administrator changes the accent. The reference frontend's accent picker changes hue only. The API accepts any CSS colour, but the generated ramp intentionally uses its HSL hue and supplies Storyden's own saturation and lightness progression. | Steps | Intended use | | --------------------------- | ------------------------------------------------- | | `--sd-color-accent-1`–`2` | Tinted page and subtle backgrounds | | `--sd-color-accent-3`–`5` | Normal, hovered, and selected control backgrounds | | `--sd-color-accent-6`–`8` | Separators, borders, and focus rings | | `--sd-color-accent-9`–`10` | Solid and hovered solid backgrounds | | `--sd-color-accent-11`–`12` | Readable accent text | Convenience variables expose the most common roles: * `--sd-color-accent` is the solid step; * `--sd-color-accent-emphasized` is its hover/emphasis step; * `--sd-color-accent-foreground` is readable text on the solid accent; * `--sd-color-accent-text` is readable accent-coloured text; * `--sd-color-focus-ring` is the stronger border step. For example, this theme keeps its own surfaces but derives every branded colour from the installation accent: ```css :root { --sd-color-canvas: #f2f3f7; --sd-color-surface: #ffffff; --sd-color-text: #292b33; --sd-color-border-strong: var(--sd-color-accent-7); --sd-radius-panel: 0.65rem; } .category-card__summary { color: var(--sd-color-accent-foreground); background: linear-gradient( 105deg, var(--sd-color-accent), var(--sd-color-accent-emphasized) ); } @media (prefers-color-scheme: dark) { :root { --sd-color-canvas: #15131d; --sd-color-surface: #211e2b; --sd-color-text: #f4f2f8; } } ``` Storyden follows the operating-system colour preference. The generated accent variables switch automatically. Define both modes for your own canvas, surface, text, and other theme values with `prefers-color-scheme`; do not assume that custom values will be transformed automatically. ## Loading and lifecycle [#loading-and-lifecycle] Storyden serves the active stylesheets through `/theme.css` and scripts through `/theme.js`. The stable URLs are included in the initial HTML, so custom styles can participate in first paint without waiting for a client-side manifest request. The resources use ETags, allowing the browser to avoid downloading unchanged theme code. Theme scripts are classic deferred scripts in their configured order. Storyden dispatches `storyden:ready` after hydration and `storyden:navigate` after client-side navigation. The navigation event includes the current pathname in `event.detail.pathname`. React internals, undocumented DOM nesting, Panda utility classes, and `window.__storyden__` are not public theme APIs. ## Accessibility [#accessibility] Storyden's default interface targets WCAG 2.2 AA. Theme authors are responsible for preserving contrast, visible focus states, readable zoomed layouts, reduced-motion preferences, and keyboard usability in their custom code. The repository includes an [example theme](https://github.com/Southclaws/storyden/blob/main/examples/themes/openmp.css) for the [open.mp project](https://open.mp) demonstrating light and dark palettes, public variables, BEM hooks, and recipe selectors. # Reference (/docs/reference) In this section you'll find various reference documentation for all of Storyden's features and systems. Use it to look up configuration options, system behaviour, supported backends, and other factual details. ## In this section [#in-this-section] ### Configuring Storyden [#configuring-storyden] There are two ways to customize Storyden to your needs: Environment variables passed to the application at startup time. The kinds of things under configuration don't change very often once set, so they require a restart to take effect. How Storyden uses email, running with no email at all, supported providers and setup guides. Runtime settings you can change via the administration menu inside the app itself. These are stored in the database and can be changed at any time without a restart. ### Data Management [#data-management] Pick from a handful of databases based on your needs. Search providers, configuration and comparisons. ## Operation of a Storyden Instance [#operation-of-a-storyden-instance] Storyden is designed to be simple to operate compared to traditional forum software. There's no complex web-based setup wizard, no manual database migrations, and no tangled web of configuration files scattered across the filesystem. Storyden is designed with ephemeral cloud-based infrastructure in mind, while maintaining a sane setup for traditional stateful server environments. Infrastructure configuration follows [12-factor app](https://12factor.net/) principles using environment variables for settings that rarely change after initial setup - things like database URLs, API keys, and service endpoints. This makes Storyden straightforward to deploy anywhere from a single VPS to containerized cloud environments. Meanwhile, community settings you'll want to experiment with - like rate limits, feature toggles, and appearance options - live in the admin UI where you can adjust them without restarting anything. ## Processes [#processes] Storyden runs as a single binary process that can optionally launch the frontend alongside it. This makes deployment dead simple - no complex multi-service orchestration required unless you choose to add optional enhancements like Redis caching or external databases. The platform is built with zero mandatory service dependencies. Everything needed for a production deployment is built in: SQLite for the database, local disk for file storage, in-process memory for caching, and Go channels for message queuing. You can add external services as you scale, but they're not required to get started. # Settings Page (/docs/reference/settings) When logged in as an administrator, you can access the administration settings page, denoted by a crown 👑 icon in the navigation. ## Brand settings [#brand-settings] Here you can change your community's name, this controls a few aspects of the site: * The browser tab title * If the site is installed as a Progress Web App (PWA) it will be the name of the app * The heading in the navigation The Icon section allows you to upload a custom logo which is used in: * The favicon * The PWA app icon used on device home screen, taskbar and dock * The social share image for when the index page is shared The description is used in the meta description tag for SEO and social share embeds. You can read more about how these impact SEO [here](https://ahrefs.com/blog/meta-description/). Finally, the colour wheel allows you to set an accent colour for the site. This will be used as a background tint (in light mode) as well as on buttons and other elements throughout the site. If you want to see more settings for administrators to control, please [open an issue](https://github.com/Southclaws/storyden/issues). ## API [#api] You can configure anything you see on the admin page via the API programmatically. This is useful if you want to set up a new site with a script or change settings based on schedules or other events. ## Client IP and SSR [#client-ip-and-ssr] Client IP mode, trusted CIDRs and related settings on this page affect how Storyden resolves client IPs for API request context. If you use the official Storyden Next.js frontend with SSR, Storyden forwards only `X-Forwarded-For` from the SSR request back to the API, and the backend applies the same client-IP mode logic to both browser and SSR API calls. See [SSR Client IP Forwarding](/docs/reference/ssr-client-ip) for deployment guidance and when each layer should be configured. # SSR Client IP Forwarding (/docs/reference/ssr-client-ip) When you use the official Storyden Next.js frontend, server-side rendering (SSR) makes API requests from the frontend server, not directly from the browser. Without SSR client-IP forwarding, the backend only sees the frontend server address for those requests, which can break: * rate limiting (many users collapse into one IP key), * IP-based moderation controls, * diagnostics for proxy/header configuration. To solve this, Storyden uses an SSR context forwarding flow: 1. The frontend marks SSR API requests with `X-Storyden-SSR: 1`. 2. The frontend forwards `X-Forwarded-For` unchanged. 3. The backend resolves client IP using the configured client-IP mode, exactly as it does for browser API calls. 4. The backend exposes `client.ip_address_ssr` on session info for SSR diagnostics. ## Fullstack Image [#fullstack-image] When you deploy the default Docker image, it bundles the backend and frontend into a single container. The topology and network route path for a regular page load is: `browser -> backend -> frontend SSR -> backend` In this configuration, the backend is already the edge-facing trust boundary. SSR API requests are internal subrequests that carry the existing network header context. The frontend does not derive a separate "SSR client IP" value. It forwards standard headers and the backend resolves IP using your selected mode. ## UI Settings vs Environment Variables [#ui-settings-vs-environment-variables] Client IP settings live in the Admin UI and are applied immediately: * `client_ip_mode`: `remote_addr`, `single_header`, or `xff_trusted_proxies` * `client_ip_header`: trusted header name for `single_header` * `trusted_proxy_cidrs`: trusted proxy ranges for `xff_trusted_proxies` You can use the diagnostic tools in the System settings screen to configure and validate your client IP mode. 2026-03-24-19-35-20.png There is currently no separate setup for SSR. If you run the frontend as a separate process perhaps for load balancing purposes, you will need to configure your reverse proxy to include `X-Forwarded-For`, the frontend will include this with requests to the API and apply the same logic to incoming requests. For `xff_trusted_proxies` mode, SSR-origin API calls can also trust a separate "SSR source" peer address list: * `SSR_TRUSTED_SOURCE_CIDRS` (env): comma/newline-separated CIDRs or IPs. * `PROXY_FRONTEND_ADDRESS` (env): if host is `localhost` or a literal IP, Storyden auto-trusts it as an SSR source. This is used only for SSR-marked requests (`X-Storyden-SSR: 1`) so bundled or externally hosted frontends can forward browser XFF chains without hard-coding loopback trust. The `X-Storyden-SSR` header is not used as a source of trust, it's merely a marker used in combination with trusted proxy source CIDRs (`SSR_TRUSTED_SOURCE_CIDRS`) to apply the client IP logic. This prevents spoofing attacks. ## Reverse Proxy Guidance [#reverse-proxy-guidance] When running behind proxies/CDNs: * ensure your edge proxy strips/overwrites forwarded-IP headers from untrusted clients, * configure Client IP mode in Admin settings to match your infrastructure, * use the Admin header/IP test tools to validate browser-origin and SSR-origin behavior. Misconfigured proxies can cause incorrect rate-limit keys and unreliable audit/abuse signals. Storyden will warn you if it detects a mismatch or potential internal IP addresses in the client IP resolution process. 2026-03-24-19-26-13.png ## Interpreting `ip_address_ssr` [#interpreting-ip_address_ssr] * On SSR-origin API calls (`X-Storyden-SSR: 1`), `client.ip_address_ssr` is populated with the backend-resolved client IP for that SSR request. * On browser-origin API calls, `client.ip_address_ssr` is always empty. Comparing browser and SSR values helps diagnose proxy/header configuration issues. In order for rate limiting to work effectively, these IPs must be the same. Otherwise, you may have a situation where the browser-origin request is rate-limited based on the correct client IP, but the SSR-origin request is rate-limited based on the frontend server IP, which would lead to rate limits being shared between all visitors, which will result in all visitors hitting `429` errors very quickly. ### `xff_trusted_proxies` [#xff_trusted_proxies] Use this mode when Storyden is behind one or more reverse proxies (e.g. Cloudflare, Nginx, Fly.io). Storyden will use the `X-Forwarded-For` header to determine the client IP - but **only if the request actually came through a trusted proxy**. #### How it works [#how-it-works] 1. Storyden checks the request's `RemoteAddr` (the immediate peer). 2. If that IP is **not in your trusted proxy CIDRs**, Storyden only continues for SSR-marked requests when `RemoteAddr` is in SSR trusted sources derived from `PROXY_FRONTEND_ADDRESS` and/or `SSR_TRUSTED_SOURCE_CIDRS`. Otherwise it is treated as direct and `RemoteAddr` is used. 3. If it *is* trusted, Storyden reads the full `X-Forwarded-For` chain. 4. It walks the chain **from right to left**, skipping any IPs that are also trusted proxies or unparseable. 5. The first IP that is **not a trusted proxy** is selected as the client IP. 6. If all IPs in the chain are trusted proxies (or unparseable), it falls back to `RemoteAddr`. For browser-origin requests, only `trusted_proxy_cidrs` is considered. SSR trusted sources are only used when `X-Storyden-SSR: 1` is present. If you host the frontend elsewhere, and it happens to go through a proxy layer for SSR (server-to-server) requests, you must ensure that those proxy IP/CIDR ranges are included in `SSR_TRUSTED_SOURCE_CIDRS` and not `trusted_proxy_cidrs`. #### Why this is safe [#why-this-is-safe] * Prevents attackers from spoofing `X-Forwarded-For` * Only trusts headers added by infrastructure you control (or, at least, trust) * Works with multiple proxy layers automatically #### Example [#example] ``` RemoteAddr: 10.0.0.5 X-Forwarded-For: 1.2.3.4, 203.0.113.10 trusted_proxy_cidrs: - 10.0.0.0/8 - 203.0.113.0/24 ``` Resolution: * `10.0.0.5` → trusted proxy ✅ * `203.0.113.10` → trusted proxy → skip * `1.2.3.4` → not trusted → **selected as client IP** #### When to use this [#when-to-use-this] Use this mode if: * You are behind a reverse proxy or CDN * You control (or trust) the proxy layer * You want accurate per-user rate limiting and logging #### When *not* to use this [#when-not-to-use-this] * Your server is directly exposed to the internet → use `remote_addr` * You don’t know your proxy IP ranges → use `single_header` instead, and select a header you know to be trusted (often a pitfall, be careful, [read this](https://adam-p.ca/blog/2022/03/x-forwarded-for/)). #### Tips [#tips] * Always include **all proxy layers** in `trusted_proxy_cidrs` * Prefer CIDR ranges over individual IPs * Prefer trusted proxy CIDRs mode over `single_header` when possible * If misconfigured, you may end up rate-limiting your proxy instead of users (and then, nobody will have a fun time) # Discord Connector (/docs/cookbook/discord-integration-plugin) Most communities are chat-first now, especially on Discord. That is great for energy, terrible for memory. This recipe is for communities that already live in Discord but want Storyden to be the long-term brain for links, references, and "wait where did we post that?" moments. This recipe is intended to spark a bit of the creative juices. The plugin example code is fairly simple and ripe for stealing, editing and improving! If you're a developer or have one on speed dial, you can have a custom bot for your community set up quickly! ## Chat First, Memory Second [#chat-first-memory-second] The real plugin code lives here: * [Discord connector manifest](https://github.com/Southclaws/storyden/blob/main/plugins/discord-connector/manifest.yaml) * [Discord connector implementation](https://github.com/Southclaws/storyden/blob/main/plugins/discord-connector/main.go) The current implementation is fully usable and serves as a neat example of some Storyden plugin capabilities: * it subscribes to [`EventThreadPublished`](/docs/extending/manifest#events_consumed) * it posts published threads into discord * it registers discord slash commands: `/latest`, `/save`, `/search` * it calls Storyden APIs through plugin-provisioned API access (manifest `access`) If you want the runtime model details, start with [Extending Storyden](/docs/extending), [Plugin Model](/docs/extending/model), [Manifest](/docs/extending/manifest), and [Security](/docs/extending/security). ## The Real Problem It Solves [#the-real-problem-it-solves] If your community is active, this pattern solves boring but annoying problems: * great links disappear in fast-moving channels * the same questions get answered repeatedly * newcomers cannot find old "golden answers" * moderators become human search engines You can see why this matters in [The power of a community-driven knowledgebase](/blog/power-of-community-knowledgebase) and [Links](/docs/introduction/links). ## What Each Command Does [#what-each-command-does] ### `/latest` [#latest] `/latest` fetches the most recent published thread and posts a short summary. * API used: [Thread List](/docs/api/threads/ThreadList) * Code path: [`handleCommand -> fetchLatestThread`](https://github.com/Southclaws/storyden/blob/main/plugins/discord-connector/main.go) ### `/save` [#save] `/save` scans recent messages in the channel where the command was run, finds the most recent URL, and saves it as a Storyden Link. Current behavior in code: 1. Read recent channel messages (newest first). 2. Extract first valid URL from the latest matching message. 3. Submit URL to [Link Create](/docs/api/links/LinkCreate). 4. Return saved link details back to Discord. * API used: [Link Create](/docs/api/links/LinkCreate) * Code path: [`handleSave -> findLatestChannelURL`](https://github.com/Southclaws/storyden/blob/main/plugins/discord-connector/main.go) ### `/search` [#search] `/search` queries Storyden's Link index and returns matching links. Current behavior in code: 1. Accept query string from slash command option. 2. Call [Link List](/docs/api/links/LinkList) with `q`. 3. Return top link results in Discord. * API used: [Link List](/docs/api/links/LinkList) * Code path: [`handleSearch`](https://github.com/Southclaws/storyden/blob/main/plugins/discord-connector/main.go) `/search` intentionally uses Link search, not Datagraph search, because this plugin is focused on community link recall. ## Event Flow: Published Threads [#event-flow-published-threads] When a new thread is published, the plugin receives `EventThreadPublished`, fetches thread details, and posts to the configured Discord channel. * Event contract: [Manifest `events_consumed`](/docs/extending/manifest#events_consumed) * RPC reference: [Host to Plugin `event`](/docs/extending/rpc/host-to-plugin/event) * API used for thread details: [Thread Get](/docs/api/threads/ThreadGet) * Code path: [`handleThreadPublished`](https://github.com/Southclaws/storyden/blob/main/plugins/discord-connector/main.go) ## Plugin Contract You Actually Need [#plugin-contract-you-actually-need] Everything here is grounded in current plugin contracts: * event subscriptions in [`events_consumed`](/docs/extending/manifest#events_consumed) * API identity and permission requests in [`access`](/docs/extending/manifest#access) * operator-provided config fields in [`configuration_schema`](/docs/extending/manifest#configuration_schema) * runtime messaging via [Host-to-Plugin RPC](/docs/extending/rpc/host-to-plugin) and [Plugin-to-Host RPC](/docs/extending/rpc/plugin-to-host) This example plugin currently expects these config fields: * `discord_token` * `channel_id` Source of truth: [plugin manifest](https://github.com/Southclaws/storyden/blob/main/plugins/discord-connector/manifest.yaml) ## Safety and Scope [#safety-and-scope] * request only the API permissions you truly need in manifest `access.permissions` * keep Discord token and RPC token handling aligned with [plugin security guidance](/docs/extending/security#token-handling-guidance) * choose [external mode](/docs/extending/model#external) for fast local development ## Outcome [#outcome] The community keeps chatting where they already are. Storyden quietly accumulates a searchable, durable link memory in the background. No culture change campaign required. Just a good connector. # Cookbook (/docs/cookbook) The Cookbook contains practical guides and patterns for building and running communities with Storyden. These recipes show you how to use Storyden's features together to do neat things. ## What's in the Cookbook? [#whats-in-the-cookbook] Unlike the introduction and reference, cookbook recipes show you compose together Storyden features. Each recipe is designed to be: * **Complete**: shows the full thing from start to finish * **Practical**: based on real-world stuff * **Actionable**: includes specific steps you can follow ## Contributing Recipes [#contributing-recipes] Have a pattern or workflow that works well for your community? We'd love to add it to the cookbook! Open an issue or pull request on [GitHub](https://github.com/Southclaws/storyden). # Capabilities and Limits (/docs/extending/capabilities) This page is intentionally concrete. No roadmap guessing, just current behaviour. ## What plugins can do [#what-plugins-can-do] ### Subscribe to platform events [#subscribe-to-platform-events] Plugins can subscribe to supported Storyden events and react in real time. This covers common automation/integration tasks such as notifications, indexing, downstream sync, and moderation-side workflows. See [Manifest -> `events_consumed`](/docs/extending/manifest#events_consumed) and [RPC -> `event`](/docs/extending/rpc/host-to-plugin/event). ### Receive and apply runtime configuration [#receive-and-apply-runtime-configuration] Plugins can define a simple config schema in their manifest which is used to build a configuration UI in the Admin UI. When a user changes configuration, Storyden validates the new config against the schema and sends it to the plugin via RPC. See [Manifest -> `configuration_schema`](/docs/extending/manifest#configuration_schema), [RPC -> `configure`](/docs/extending/rpc/host-to-plugin/configure), and [RPC -> `get_config`](/docs/extending/rpc/plugin-to-host/get_config). Current schema field types are intentionally small: * string * number * boolean 2026-02-19-19-40-29.png ### Call host RPC methods [#call-host-rpc-methods] Plugins can call host-side RPC methods for operations like: * retrieving plugin configuration * requesting API access credentials (when access is declared) See [RPC Reference](/docs/extending/rpc), especially [Plugin to Host](/docs/extending/rpc/plugin-to-host). ### Use the HTTP API (when access is requested) [#use-the-http-api-when-access-is-requested] If the manifest includes `access`, Storyden can provision an account identity and access key for the plugin. That key is then used for normal API calls under explicit permissions. See [Manifest -> `access`](/docs/extending/manifest#access) and [Security -> API access identity model](/docs/extending/security#api-access-identity-model). This keeps plugin API actions inside the same permission model as the rest of Storyden. The account provisioned for a plugin will be "invited by" the member who installed the plugin. You can at any time tweak this account's permissions, roles, revoke its keys or just ban it if the plugin is misbehaving. When a plugin is uninstalled, its account will remain in case it's linked to any content. You can however just purge its content just like any other account if you don't want it any more. ## What plugins cannot do [#what-plugins-cannot-do] ### Modify requests/responses [#modify-requestsresponses] Plugins cannot hook into API request data or responses. ### Affect the Storyden UI [#affect-the-storyden-ui] Plugins cannot modify the Storyden UI. Users may be using a custom frontend so this is unlikely to be universally possible. We do however plan to allow plugins to provide `