Feature Description
Introduce a capability system that gates feature availability through a single check: capabilities.has(organizationId, capabilityName). In the open-source distribution, capabilities resolve from a static configuration (default: all enabled). Downstream distributions can plug in alternative resolvers without modifying the core.
Problem/Use Case
Logtide already has features that some operators want to enable conditionally — for example, longer retention windows, stricter audit logging, or limits on the number of saved searches per organization. Today there's no consistent way to express "this feature is available for org X but not org Y". As more such features land, scattering ad-hoc checks (env vars, hardcoded constants, DB columns) creates inconsistency and makes the codebase hard to extend.
A capability system gives every feature gate the same shape, which means:
- Operators can configure their own restriction policies without forking
- The codebase becomes self-documenting about which features are gated
- Downstream platforms (e.g. a hosted service) can resolve capabilities from external sources (subscription state, license file, etc.) without patching the core
Proposed Solution
A capabilities module with a single interface:
interface CapabilityResolver {
has(organizationId: string, capability: CapabilityName): Promise<boolean>
list(organizationId: string): Promise<Record<CapabilityName, boolean>>
}
The default resolver reads from config/capabilities.yaml (or env vars), with all capabilities enabled by default for self-hosted users. The resolver is registered at boot time and is replaceable.
Initial capability namespace:
retention.extended — retention windows beyond the default cap
audit.immutable — append-only audit log storage
auth.sso — SAML/OIDC authentication providers
alerts.unlimited — no cap on the number of active alert rules
dashboards.unlimited — no cap on saved dashboards
These are placeholders — most map to features that will land progressively. The point of v1.0 is to ship the primitive and the first two or three real consumers, not to populate the full list.
Usage at callsites:
if (!(await capabilities.has(ctx.organizationId, 'retention.extended'))) {
throw new CapabilityError('retention.extended')
}
Alternatives Considered
- Boolean columns on the organization table. Works but doesn't scale: every new feature is a migration, and there's no way to centralize the resolution logic (e.g. caching, external lookup). Rejected.
- Environment variables only. Fine for global toggles, but can't express per-organization policy and doesn't compose with multi-tenant deployments. Rejected as the sole mechanism, but env vars remain a valid backend for the default resolver.
- Skip and add gating ad-hoc per feature. Cheaper today, more expensive every time we add a gateable feature. The whole point of doing this at v1.0 is amortizing the cost.
Implementation Details (Optional)
- Capability names are string literals in a TypeScript union type, so callsites get autocomplete and the linter catches typos.
- Resolvers can cache. The default resolver reads config once at boot and serves from memory; no I/O on the hot path.
- The list of all capability names lives in one file (
src/capabilities/registry.ts) so contributors discover what exists before adding new ones.
- A
GET /api/capabilities endpoint returns the resolved set for the current org, which the frontend uses to conditionally render UI (hide buttons for disabled features instead of showing them and erroring on click).
- Reject the temptation to overload this for user-level permissions — that's RBAC, a different problem.
Priority
Target Users
- Self-hosted operators wanting to enforce organization-level policies (e.g. retention caps for free internal tenants)
- Maintainers needing a consistent pattern for shipping features that have tiered availability
- Downstream platforms requiring an extension point to gate features by subscription state
Contribution
Feature Description
Introduce a capability system that gates feature availability through a single check:
capabilities.has(organizationId, capabilityName). In the open-source distribution, capabilities resolve from a static configuration (default: all enabled). Downstream distributions can plug in alternative resolvers without modifying the core.Problem/Use Case
Logtide already has features that some operators want to enable conditionally — for example, longer retention windows, stricter audit logging, or limits on the number of saved searches per organization. Today there's no consistent way to express "this feature is available for org X but not org Y". As more such features land, scattering ad-hoc checks (env vars, hardcoded constants, DB columns) creates inconsistency and makes the codebase hard to extend.
A capability system gives every feature gate the same shape, which means:
Proposed Solution
A
capabilitiesmodule with a single interface:The default resolver reads from
config/capabilities.yaml(or env vars), with all capabilities enabled by default for self-hosted users. The resolver is registered at boot time and is replaceable.Initial capability namespace:
retention.extended— retention windows beyond the default capaudit.immutable— append-only audit log storageauth.sso— SAML/OIDC authentication providersalerts.unlimited— no cap on the number of active alert rulesdashboards.unlimited— no cap on saved dashboardsThese are placeholders — most map to features that will land progressively. The point of v1.0 is to ship the primitive and the first two or three real consumers, not to populate the full list.
Usage at callsites:
Alternatives Considered
Implementation Details (Optional)
src/capabilities/registry.ts) so contributors discover what exists before adding new ones.GET /api/capabilitiesendpoint returns the resolved set for the current org, which the frontend uses to conditionally render UI (hide buttons for disabled features instead of showing them and erroring on click).Priority
Target Users
Contribution