# Resource permission catalog Source: https://engineering.unkey.com/architecture/authorization/resource-permission-catalog Canonical resource hierarchy, paths, and actions The resource permission catalog lists every canonical resource path and its supported actions. GitHub apps are workspace-scoped. Every other catalog resource is project-scoped. Portals remain outside the canonical catalog. Read [Unkey Resource Names](/architecture/resources/unkey-resource-names) for URN format and pattern rules. Read [Resource permissions](/architecture/authorization/resource-permissions) for permission matching and action rules. ## Canonical catalog Each resource shows its actions in brackets. The next line shows its canonical resource path. Add `unkey:v1:{workspace_id}:` before the path and `#{action}` after it to form a permission. ```plaintext theme={"theme":"kanagawa-wave"} Workspace │ ├── GitHub app [read, write, delete] │ github/apps/{github_app_id} │ └── Project [read, write, delete] projects/{project_id} │ ├── App [read, write, delete] │ projects/{project_id}/apps/{app_id} │ │ │ └── Environment [read, write, delete] │ projects/{project_id}/apps/{app_id}/environments/{environment_id} │ │ │ ├── Deployment [read, write, delete] │ │ projects/{project_id}/apps/{app_id}/environments/{environment_id}/deployments/{deployment_id} │ │ │ │ │ └── Logs [read] │ │ projects/{project_id}/apps/{app_id}/environments/{environment_id}/deployments/{deployment_id}/logs │ │ │ ├── Domain [read, write, delete] │ │ projects/{project_id}/apps/{app_id}/environments/{environment_id}/domains/{domain_id} │ │ │ ├── Environment variable [read, write, delete] │ │ projects/{project_id}/apps/{app_id}/environments/{environment_id}/variables/{variable_id} │ │ │ └── Gateway │ projects/{project_id}/apps/{app_id}/environments/{environment_id}/gateway │ │ │ ├── Logs [read] │ │ projects/{project_id}/apps/{app_id}/environments/{environment_id}/gateway/logs │ │ │ └── Policy [read, write, delete] │ projects/{project_id}/apps/{app_id}/environments/{environment_id}/gateway/policies/{policy_id} │ ├── Identity [read, write, delete] │ projects/{project_id}/identities/{identity_id} │ ├── Keyspace [read, write, delete] │ projects/{project_id}/keyspaces/{keyspace_id} │ │ │ ├── Logs [read] │ │ projects/{project_id}/keyspaces/{keyspace_id}/logs │ │ │ └── Key [read, write, delete, decrypt, verify] │ projects/{project_id}/keyspaces/{keyspace_id}/keys/{key_id} │ ├── Rate limit namespace [read, write, delete, limit] │ projects/{project_id}/ratelimits/namespaces/{namespace_id} │ │ │ ├── Logs [read] │ │ projects/{project_id}/ratelimits/namespaces/{namespace_id}/logs │ │ │ └── Override [read, write, delete] │ projects/{project_id}/ratelimits/namespaces/{namespace_id}/overrides/{override_id} │ └── RBAC projects/{project_id}/rbac │ ├── Role [read, write, delete] │ projects/{project_id}/rbac/roles/{role_id} │ └── Permission [read, write, delete] projects/{project_id}/rbac/permissions/{permission_id} ``` `gateway` and `rbac` are path containers. They organize child paths. They are not concrete resources and cannot be permission targets. ## Examples These examples show how concrete IDs and wildcards use the same catalog. ### Create a key Use `write` with a wildcard key ID because the key does not exist yet. ```plaintext theme={"theme":"kanagawa-wave"} unkey:v1:ws_123:projects/proj_123/keyspaces/ks_123/keys/*#write ``` ### Promote or roll back a deployment Promoting or rolling back a deployment changes which deployment serves the environment. Both actions require `write` on the environment. A deployment permission does not grant either action. ```plaintext theme={"theme":"kanagawa-wave"} unkey:v1:ws_123:projects/proj_123/apps/app_123/environments/env_123#write ``` ### Start or stop a deployment Deployment `write` covers creation, updates, starts, and stops. ```plaintext theme={"theme":"kanagawa-wave"} unkey:v1:ws_123:projects/proj_123/apps/app_123/environments/env_123/deployments/dep_123#write ``` ### Read deployment logs Logs are first-class resources. Deployment `read` does not grant access to deployment logs. Grant `read` on the log path. ```plaintext theme={"theme":"kanagawa-wave"} unkey:v1:ws_123:projects/proj_123/apps/app_123/environments/env_123/deployments/dep_123/logs#read ``` ### Read gateway logs Gateway logs belong to an environment's gateway. ```plaintext theme={"theme":"kanagawa-wave"} unkey:v1:ws_123:projects/proj_123/apps/app_123/environments/env_123/gateway/logs#read ``` ### Verify a key Key verification uses `verify`, not `read`. ```plaintext theme={"theme":"kanagawa-wave"} unkey:v1:ws_123:projects/proj_123/keyspaces/ks_123/keys/key_123#verify ``` ## Related pages Use these pages for URN rules, permission rules, and WorkOS role expansion. * [Unkey Resource Names](/architecture/resources/unkey-resource-names) * [Resource permissions](/architecture/authorization/resource-permissions) * [WorkOS roles](/architecture/authorization/workos-permissions) # Resource permissions Source: https://engineering.unkey.com/architecture/authorization/resource-permissions Permission format, matching, and action rules Resource permissions combine a URN pattern with an action. The resource path identifies the resource type and scope. The action identifies the operation. Read [Unkey Resource Names](/architecture/resources/unkey-resource-names) for URN format and pattern rules. Use the [resource permission catalog](/architecture/authorization/resource-permission-catalog) to find each canonical resource path and its supported actions. ## Permission format A permission has two parts separated by `#`. ```plaintext theme={"theme":"kanagawa-wave"} {resource_urn_pattern}#{action} ``` This permission grants read access to one key: ```plaintext theme={"theme":"kanagawa-wave"} unkey:v1:ws_123:projects/proj_123/keyspaces/ks_123/keys/key_456#read ``` Standard grants use an exact canonical resource path. Use `*` only in ID segments when a grant covers multiple resource instances: ```plaintext theme={"theme":"kanagawa-wave"} unkey:v1:ws_123:projects/proj_123/keyspaces/*/keys/*#read ``` The path makes this a key permission. The same `read` action on an environment path grants environment access instead. ## Permission matching A permission grants access when both of these conditions are true: 1. The permission resource pattern matches the request resource URN. 2. The permission action matches the request action. Match resource patterns with the URN rules. Match actions with exact text. The only action wildcard is the global admin permission `unkey:v1:{workspace_id}:**#*`. ```plaintext theme={"theme":"kanagawa-wave"} Permission: unkey:v1:ws_123:projects/proj_123/keyspaces/*/keys/*#read Request: unkey:v1:ws_123:projects/proj_123/keyspaces/ks_123/keys/key_456#read Result: granted ``` An exact app path does not grant access to its descendants: ```plaintext theme={"theme":"kanagawa-wave"} Permission: unkey:v1:ws_123:projects/proj_123/apps/app_123#write Request: unkey:v1:ws_123:projects/proj_123/apps/app_123/environments/env_123#write Result: denied ``` Use a trailing `/**` only for an intentional subtree grant. It covers the base resource and every current or future descendant for the same action: ```plaintext theme={"theme":"kanagawa-wave"} unkey:v1:ws_123:projects/proj_123/apps/app_123/**#write ``` This permission authorizes `write` on the app, its environments, deployments, and other descendants. A `/**` grant also covers descendant resource types added to the catalog later. Use an exact canonical path unless future descendants belong in the grant. ## Action rules Canonical actions use generic operation names. The resource path identifies the target resource type. | Action | Grants access to | | --------- | -------------------------------------------- | | `read` | Get or list the resource | | `write` | Create or update the resource | | `delete` | Delete the resource | | `decrypt` | Decrypt key data | | `verify` | Verify a key | | `limit` | Check or use a rate limit | | `*` | Perform any action as a global administrator | `decrypt`, `verify`, and `limit` are special actions. Use them only on catalog resources that support them. ## Create and update rules Use `write` for create and update operations. Do not define separate create or update actions. Use the concrete resource ID when the resource exists: ```plaintext theme={"theme":"kanagawa-wave"} unkey:v1:ws_123:projects/proj_123/keyspaces/ks_123/keys/key_456#write ``` Use `*` in the target resource ID position when the resource does not exist: ```plaintext theme={"theme":"kanagawa-wave"} unkey:v1:ws_123:projects/proj_123/keyspaces/ks_123/keys/*#write ``` The wildcard applies only to that path segment. It does not grant access to other keyspaces or projects. ## Resource-owned operations Check an operation against the resource that owns the resulting state change. Do not add an action for each API endpoint. | Operation | Resource | Action | | ---------------------------------------------- | ----------- | ------- | | Connect or disconnect a GitHub repository | App | `write` | | Promote or roll back a deployment | Environment | `write` | | Start or stop a deployment | Deployment | `write` | | Restart domain verification | Domain | `write` | | Reroll a key or update its credits | Key | `write` | | Assign or remove a role or permission on a key | Key | `write` | | Assign or remove a permission on a role | Role | `write` | | Create or edit a permission definition | Permission | `write` | ## Log rules Logs are first-class resources. Each log resource supports `read`. Its path identifies the log source. Read access to a parent resource does not grant read access to its logs. Grant the exact log resource path or use an intentional subtree grant. Find all log resource paths in the [resource permission catalog](/architecture/authorization/resource-permission-catalog). ## Global admin This permission grants every action on every resource in a workspace: ```plaintext theme={"theme":"kanagawa-wave"} unkey:v1:{workspace_id}:**#* ``` Use this permission only for workspace administrators. ## Related pages Use these pages for URN rules, the resource catalog, and WorkOS role expansion. * [Unkey Resource Names](/architecture/resources/unkey-resource-names) * [Resource permission catalog](/architecture/authorization/resource-permission-catalog) * [WorkOS roles](/architecture/authorization/workos-permissions) # WorkOS permissions Source: https://engineering.unkey.com/architecture/authorization/workos-permissions How WorkOS permission strings map to Unkey resource permissions WorkOS is authoritative for user roles and permission strings. Unkey uses WorkOS for authentication and role assignment, then translates the WorkOS permission strings from the access token into canonical Unkey resource permissions before the API constructs the principal. This translation exists because WorkOS permission strings cannot represent Unkey resource permissions directly. WorkOS permission slugs are capped at 48 characters and cannot contain `/`, so they cannot encode canonical Unkey resource paths. The WorkOS permission model is intentionally much smaller than Unkey's resource permission model. The exact list of supported WorkOS permissions and their Unkey translations is defined in [`pkg/auth/workos/permissions.go`](https://github.com/unkeyed/unkey/blob/main/pkg/auth/workos/permissions.go). That Go file is the source of truth. ## Permission shape WorkOS recommends clear, concise permission slugs with a resource and action delimiter. Unkey follows that shape and keeps WorkOS permissions broad: ```plaintext theme={"theme":"kanagawa-wave"} {area}:{action} ``` For example: ```plaintext theme={"theme":"kanagawa-wave"} keys:create ``` The WorkOS slug is intentionally not a Unkey resource permission. It is stable input from the identity provider. The API translates it after token verification because only Unkey owns the resource-name contract. ## Translation The WorkOS resolver wraps the generic JWT JWKS resolver. The generic resolver verifies issuer, audience, signature, and time claims, and builds the initial JWT principal. The WorkOS wrapper then replaces the raw WorkOS permission strings with canonical permissions in this format: ```plaintext theme={"theme":"kanagawa-wave"} unkey:v1:{workspace_id}:{resource_path}#{action} ``` A WorkOS permission string such as: ```plaintext theme={"theme":"kanagawa-wave"} deployments:create ``` becomes a resource permission for the principal's workspace: ```plaintext theme={"theme":"kanagawa-wave"} unkey:v1:{workspace_id}:projects/**#create_deployment ``` The translated permission is the only value handlers see. Handler authorization does not depend on WorkOS permission names. ## Unknown permissions Unknown WorkOS permission strings are ignored during translation. They don't grant access, and they don't fall back to legacy wildcard permissions. This makes WorkOS safe to contain permissions that are not yet understood by the API. Adding a new permission requires updating the mapping table in `pkg/auth/workos/permissions.go` and adding tests for the resulting Unkey resource permission. ## Ownership boundaries The generic JWT package must stay provider-neutral. It verifies JWTs and returns claims as-is. WorkOS-specific behavior belongs in `pkg/auth/workos`: * the WorkOS issuer constant, * the WorkOS JWKS resolver wrapper, * the WorkOS permission mapping table, and * tests for WorkOS permission translation. API configuration uses the WorkOS wrapper only for JWKS-backed WorkOS access tokens. HMAC JWT auth continues to use the generic JWT resolver directly. # Architecture Source: https://engineering.unkey.com/architecture/index System architecture and design references This section documents system architecture, service workflows, and RFCs. Content is maintained separately from service-level runbooks and configuration guides. ## Service map ```mermaid theme={"theme":"kanagawa-wave"} flowchart LR Frontline[Frontline] API[API] Vault[Vault] ControlAPI[Control plane API] ControlWorker[Control plane worker] Krane[Krane] Preflight[Preflight] Frontline -->|A| API API -->|B| Vault Frontline -->|B| Vault ControlAPI -->|C| ControlWorker ControlWorker -->|C| Krane ControlWorker -->|B| Vault Preflight -->|A| Krane ``` ### Legend * **A**: HTTP or HTTPS request. * **B**: RPC or service call. * **C**: Asynchronous workflow trigger. ## Sections * [Services](/architecture/services) * [RFCs](/architecture/rfcs) # Consistency model Source: https://engineering.unkey.com/architecture/ratelimiting/consistency-model How rate limit state converges across processes and regions Rate limiting is intentionally not a single synchronous global counter. The system converges in layers so the request path stays fast even when shared dependencies are slow or unavailable. ## Convergence layers Each layer handles a different scope. | Layer | Scope | Purpose | | --------------- | ----------- | -------------------------------------------------------- | | Local memory | One process | Make the immediate decision without a network round trip | | Regional origin | One region | Converge multiple processes serving the same region | | Global counters | All regions | Share meaningful regional usage with other regions | The lower layer always remains useful when a higher layer lags. A process can continue making local decisions if regional convergence is delayed. A region can continue enforcing regional limits if global convergence is delayed. ## Regional convergence After a request is accepted, the process buffers a replay event for the regional origin. Replay merges the regional value back into local memory with `max` and marks the entry fresh. Processes in the same region converge toward the same count without waiting on every request. ```mermaid theme={"theme":"kanagawa-wave"} sequenceDiagram participant Request as Request path participant Local as Local counter participant Replay as Replay buffer participant Origin as Regional origin Request->>Local: Commit accepted increment Request->>Replay: Buffer replay event Replay->>Origin: Increment regional count Origin-->>Replay: Regional total Replay->>Local: Merge max(local, regional total) ``` Cold counters, stale counters, and strict-mode counters read the regional origin synchronously before deciding. This makes the first decision for a key, decisions after stale local state, and decisions after a denial use a fresher regional baseline. Warm entries carry a freshness deadline. Regional-origin reads and successful replays extend that deadline. While the entry is fresh, the request path can use local memory without blocking on origin. After the deadline passes, the next request refreshes from origin before deciding. The freshness interval is intentionally short. Active identifiers normally stay fresh through replay, while idle or lagging identifiers re-read origin before they can keep serving an old local view for the rest of a long window. Concurrent stale requests for the same window cell share one origin read, then continue from the same refreshed value. ## Strict mode Strict mode is regional. When a request is denied, the service records a deadline for the `(workspace, namespace, identifier, duration)` tuple. Until that deadline passes, later requests for the same tuple refresh the current window from the regional origin before evaluating the limit. The strict-mode key excludes the sequence. A denial in one fixed window can still affect the weighted previous-window term in the next fixed window, so strict mode survives the sequence rollover. Strict mode does not publish cross-region state. It refreshes the current window only. Previous windows use the normal cold and stale refresh path because they no longer receive new accepted increments. ## Global convergence Global convergence is eventual. A region publishes its own regional count when the count becomes meaningful for remote decisions. Other regions import the sum of foreign regional counts and include that imported count in future decisions. The publishing region may also import its own published count as a lower bound for local regional state on nodes that have not yet seen the same regional origin value. ```plaintext theme={"theme":"kanagawa-wave"} Region A accepts traffic │ ▼ Region A converges its local nodes │ ▼ Region A publishes its regional count │ ▼ Region B imports Region A's count │ ▼ Region B includes that count in later decisions ``` This model means simultaneous traffic in multiple regions can briefly pass before every region has imported the latest remote usage. The tradeoff is deliberate: request-serving processes do not wait for cross-region coordination on the hot path. Own-region imports are a safety net, not a replacement for the regional origin. They can only raise local regional counts. They do not refresh the local entry's regional-origin freshness deadline, and foreign counts still stay separate so they cannot be published again. Windows shorter than 60 seconds are effectively regional because the global convergence cadence is too coarse to provide useful cross-region accuracy. Longer windows can include global convergence before the window expires. ## Failure behavior Failures degrade toward local decisions and recover when the affected layer becomes available again. | Failure | Behavior | | -------------------- | ----------------------------------------------------------------------------------------------------------- | | Regional read fails | The process continues from its local count and retries soon; failed reads do not make the local entry fresh | | Regional replay lags | Other nodes in the region converge later, or refresh when their local entry becomes stale | | Global publish lags | Other regions do not see the new count yet | | Global import lags | The region continues with its existing imported counts | Correctness does not depend on making every layer synchronous. The invariant is that accepted local work is monotonic within a window cell, so delayed convergence can merge later without subtracting or rewriting history. # Global counters Source: https://engineering.unkey.com/architecture/ratelimiting/global-counters How G-Counters power cross-region rate limit convergence Global counters let regions share rate limit usage without turning every request into a synchronous global write. The model is a grow-only counter, or G-Counter, with one component per region and window cell. ## G-Counter model A G-Counter is a conflict-free replicated data type for counts that only increase. It splits one logical count into independently owned components. The global value is the sum of all components. For rate limiting, each region owns one component for each window cell. The component identity is: ```plaintext theme={"theme":"kanagawa-wave"} (workspace, namespace, identifier, duration, sequence, region) ``` Each region writes only its own component. Region A never writes Region B's component. A component only moves forward inside a sequence. ```mermaid theme={"theme":"kanagawa-wave"} flowchart TB subgraph regionA[Region A] aLocal[Regional count: 8] aComponent[Component A: max(old, 8)] aLocal --> aComponent end subgraph regionB[Region B] bLocal[Regional count: 5] bComponent[Component B: max(old, 5)] bLocal --> bComponent end sum[Global value: A + B] aComponent --> sum bComponent --> sum ``` The merge rule is simple: ```plaintext theme={"theme":"kanagawa-wave"} component = max(component, observedRegionalCount) global = sum(all components) ``` That gives the counter four useful properties. | Property | Effect | | ----------- | --------------------------------------------------------- | | Monotonic | An older delayed publish cannot move a component backward | | Idempotent | Publishing or importing the same value twice is harmless | | Commutative | Regions can publish and import in different orders | | Associative | Components can be aggregated before import | ## Importing without feedback loops A region imports components in two different ways. Its own component is a regional lower bound and can raise the local regional count. Foreign components are summed separately and added to decisions, but they are never published again. ```plaintext theme={"theme":"kanagawa-wave"} Region A decision = A regional count + imported count from B and C Region A publish = A regional count only Region A own-row import = max(A regional count, A published component) ``` This separation prevents double counting. If Region A published `A + B`, then Region B could import that value and count its own traffic again. If Region A folded its own component into imported global count instead of regional count, it would count its own traffic twice during local decisions. Own-row import is only a safety net. The regional origin remains the source of truth for in-region convergence, and importing an own component does not make the local entry fresh against that origin. ## Why it fits rate limiting Usage inside one fixed window cell is grow-only. Requests add cost. The cell does not need to decrement while it is active. Sliding-window behavior comes from reading two cells, weighting the previous one, and letting old cells expire. ```plaintext theme={"theme":"kanagawa-wave"} current = currentRegionalCount + currentGlobalCount previous = previousRegionalCount + previousGlobalCount effective = current + (previous * previousWindowWeight) + cost ``` The G-Counter resets by identity. A new fixed window has a new sequence, so it has a new set of components. Old components stop mattering when they can no longer contribute to the sliding-window calculation. ## Publish threshold The `limit` is not part of the G-Counter merge rule. It only decides when a regional count is worth publishing. Small counts are usually irrelevant to remote decisions, so they stay regional. Once a region uses a meaningful fraction of the limit, the count is published and can affect later decisions elsewhere. This keeps cross-region writes proportional to useful signal rather than total request volume. The request path still makes local decisions immediately, and global convergence catches up for identifiers that are approaching their limit. ## Invariants Global counters rely on these invariants: * Each region owns only its own component. * Component values are merged with `max`, never replacement by an older value. * A region's own imported component can only raise regional count. * Foreign imported count is read by the request path but never republished. * Own components and foreign components stay separate during import. * A sequence is immutable once it ages out. New windows use new component identities. # Rate limiting Source: https://engineering.unkey.com/architecture/ratelimiting/overview Architecture overview for Unkey's distributed rate limiter Rate limiting is a shared runtime subsystem used by API and Frontline policy execution. The implementation lives in [`internal/services/ratelimit`](https://github.com/unkeyed/unkey/tree/main/internal/services/ratelimit). The system is designed to protect request-serving paths first. Accuracy improves as state converges, but no normal request depends on a synchronous global counter. ## Design goals The rate limiter optimizes for these goals, in this order: 1. Keep the hot path local. A process must be able to make a decision from memory without waiting on cross-region state. 2. Preserve availability. Shared dependencies can improve accuracy, but a dependency failure must degrade toward local enforcement instead of taking down requests. 3. Converge where it matters. Nodes in one region converge quickly. Regions share counts when usage is high enough to affect remote decisions. 4. Avoid double counting. A region's own count and imported foreign counts stay separate so remote usage is never republished as local usage. 5. Smooth reset boundaries. Fixed window cells are evaluated as a sliding window so callers cannot spend a full limit on both sides of a boundary. ## Tradeoffs These goals create deliberate tradeoffs: | Choice | Benefit | Cost | | ------------------------------------------ | ------------------------------------------------------ | --------------------------------------------------------------------------------- | | Local-memory decision first | Low latency and high availability | Simultaneous traffic in different processes can briefly see different views | | Async regional convergence | Requests don't wait on the regional origin | A neighboring node may lag until replay, stale refresh, or strict mode catches up | | Async global convergence | No request waits on cross-region coordination | Multi-region bursts can briefly pass before imported counts arrive | | Publish only meaningful regional usage | Cross-region writes stay proportional to useful signal | Low regional usage may remain regional only | | Sliding-window evaluation over fixed cells | Smooths boundary bursts without per-request histories | Requires reading the current and previous window cells | The intended shape is shared-nothing on the hot path, with shared systems used as convergence accelerators. Regional and cross-region state improve accuracy, but they are not critical dependencies for serving the request. ## Core idea The rate limiter stores fixed window cells and evaluates them as a sliding window. Each request updates the current cell if the effective count is still under the limit. State converges in layers: ```mermaid theme={"theme":"kanagawa-wave"} flowchart TB request[Request path] subgraph process[One process] memory[Local atomic counters] originReads[Origin reads] end subgraph region[One region] regional[Regional counter origin] end subgraph global[All regions] counters[Global counter components] end request --> memory memory --> regional regional --> memory memory --> counters counters --> memory originReads --> regional ``` Local counters keep the hot path fast. The regional origin converges nodes inside one region through asynchronous replay, stale-entry refreshes, and strict-mode reads after denials. Global counters converge regional observations across regions for longer windows and can also raise same-region local counts as a safety net when a node has not yet seen the latest regional-origin value. ## Pages * [Request path](/architecture/ratelimiting/request-path) explains how one request is evaluated, including sliding-window math and batch semantics. * [Consistency model](/architecture/ratelimiting/consistency-model) explains what converges locally, regionally, and globally. * [Global counters](/architecture/ratelimiting/global-counters) explains the G-Counter model used for cross-region convergence. ## Invariants These invariants shape the implementation: * The request path must not wait on cross-region state. * A local count represents only this region's own observations. * Imported global count represents other regions and must not be pushed back out. * Own-region global-counter imports may raise local count but must not mark regional origin state fresh. * A fixed window cell is grow-only while it is active. * Sliding-window behavior comes from weighting the previous cell, not from decrementing the current cell. * Batch requests must preserve all-or-nothing semantics. ## Scope boundaries This subsystem owns counting, convergence, and the rate limit decision. It does not own how callers choose identifiers, configure limits, or translate denial responses into protocol-specific errors. API uses the subsystem for standalone rate limits, key verification limits, and workspace API throttling. Frontline uses it for policy execution. Sharing the subsystem keeps these paths on the same counter semantics instead of creating service-specific rate limit behavior. # Request path Source: https://engineering.unkey.com/architecture/ratelimiting/request-path How one rate limit request is evaluated The rate limit request path turns a `(workspace, namespace, identifier, duration)` tuple into a sliding-window decision. The normal path is local-memory first and reaches regional state when a counter is cold, stale, or in strict mode. ## Single request flow Each request builds two counter keys: the current fixed window cell and the previous fixed window cell. The previous cell is needed because the public behavior is a sliding window. ```mermaid theme={"theme":"kanagawa-wave"} flowchart TD start[Ratelimit request] --> validate[Validate request fields] validate --> keys[Build current and previous keys] keys --> hydrate[Hydrate cold or stale counters] hydrate --> strict{Strict mode active?} strict -->|Yes| fetch[Refresh from regional origin] strict -->|No| read[Read local and imported counts] fetch --> read read --> math[Compute sliding-window count] math --> decision{Effective count exceeds limit?} decision -->|Yes| deny[Deny and set strict deadline] decision -->|No| commit[CAS increment current counter] commit -->|Retry| read commit -->|Success| replay[Queue regional replay] replay --> allow[Return success] ``` The compare-and-swap loop protects the local counter from concurrent accepted requests in the same process. If another goroutine changes the current counter between the read and the commit, the request recomputes the effective count before trying again. ## Sliding-window math The implementation stores fixed window cells, but the decision behaves like a sliding window. For a request at time `t`, the current sequence is: ```plaintext theme={"theme":"kanagawa-wave"} currentSequence = floor(t / duration) previousSequence = currentSequence - 1 ``` The current cell contributes its full count. The previous cell contributes only the fraction that still overlaps the sliding window. ```plaintext theme={"theme":"kanagawa-wave"} current = currentRegionalCount + currentGlobalCount previous = previousRegionalCount + previousGlobalCount effective = current + (previous * previousWindowWeight) + cost ``` The weight starts near `1` at the beginning of a new fixed window and moves toward `0` as the current fixed window advances. ```plaintext theme={"theme":"kanagawa-wave"} previous window current window ┌────────────────┐┌────────────────┐ ▲ request near boundary Most of the previous window still overlaps the sliding window. previous window current window ┌────────────────┐┌────────────────┐ ▲ request near end Very little of the previous window still overlaps the sliding window. ``` This prevents a caller from using the full limit at the end of one fixed window and immediately using the full limit again at the start of the next one. ## Counter entries `counterEntry` is the in-memory state for one window cell. It is intentionally small because it sits on the request path. | State | Purpose | | --------------------- | ------------------------------------------------------------ | | Regional count | This region's count for the window cell | | Speculative count | In-flight `RatelimitMany` increments that may roll back | | Hydration state | Cold-start coordination for regional origin reads | | Origin freshness | Last regional-origin read or replay time for stale detection | | Stale refresh mutex | Single-flight guard for concurrent stale origin reads | | Global count | Imported count from other regions | | Global push threshold | Minimum regional count worth sharing globally | | Last pushed count | Last regional count published for global convergence | The key invariant is that regional count and global count stay separate. Regional count can be published outward. Global count is imported from other regions and must not be published again. ## Batch requests `RatelimitMany` evaluates multiple limits with all-or-nothing semantics. The method temporarily increments each requested counter, evaluates the full batch, then either keeps every increment or rolls every increment back. ```mermaid theme={"theme":"kanagawa-wave"} sequenceDiagram participant Batch as RatelimitMany participant Entry as counterEntry Batch->>Entry: speculative += cost Batch->>Entry: regional += cost Batch->>Batch: Evaluate every requested limit alt batch passes Batch->>Entry: speculative -= cost else batch fails Batch->>Entry: regional -= cost Batch->>Entry: speculative -= cost end ``` Global publishing reads regional count minus speculative count. That prevents temporary batch state from leaking into cross-region convergence before the batch is committed. # Unkey Resource Names Source: https://engineering.unkey.com/architecture/resources/unkey-resource-names Canonical resource names for public Unkey resources Unkey Resource Names, or URNs, identify public Unkey resources in a stable, parseable format. URNs are used anywhere Unkey needs to refer to the same resource across product surfaces, audit logs, permission checks, traces, and internal events. This document defines the `v1` resource-name contract. Future versions can add path shapes or change parsing rules, but `v1` URNs must keep the behavior defined here. A permission attaches an action to a URN, but the URN itself names only the resource. The Go implementation lives in [`pkg/urn`](https://github.com/unkeyed/unkey/tree/main/pkg/urn). ## Format A URN has four colon-separated fields. ```plaintext theme={"theme":"kanagawa-wave"} unkey:v1:{workspace_id}:{resource_path} ``` Each field has a fixed meaning. | Field | Description | | ----------------- | ---------------------------------------------------- | | `unkey` | Fixed prefix for every Unkey Resource Name. | | `v1` | Resource-name grammar version. | | `{workspace_id}` | Workspace that owns the resource. | | `{resource_path}` | Canonical path to the resource inside the workspace. | For example: ```plaintext theme={"theme":"kanagawa-wave"} unkey:v1:ws_123:projects/proj_123 unkey:v1:ws_123:projects/proj_123/apps/app_456 unkey:v1:ws_123:projects/proj_123/apps/app_456/environments/env_789/deployments/d_abc ``` The workspace ID is part of the URN even when the caller already has workspace context. Audit logs, background jobs, and support tools must be able to copy a URN and identify the owning workspace without extra state. ## Path rules Resource paths are part of the contract. Code that creates, parses, or matches URNs must follow these rules. * Concrete resource URNs must use the full canonical path from the catalog. * Collection segments are plural, for example `keyspaces`, `keys`, and `projects`. * ID segments use the existing public Unkey ID for that resource. * `:` is reserved for top-level URN fields and must not appear in a resource path. * `#` is reserved for permissions and must not appear in a URN. * A resource path must not start or end with `/`. Concrete URNs identify one resource. Resource-name patterns identify a set of resources and are part of the `v1` URN grammar. Callers that need one exact resource, such as audit logs and authorization requests, must use concrete URNs. Stored authorization grants can use patterns. ## Resource-name patterns Resource-name patterns use the same four-field URN format as concrete resource names. The difference is in the resource path. | Operator | Meaning | | -------- | ---------------------------------------------------- | | `*` | Matches exactly one complete path segment. | | `/**` | Matches the base path and every descendant below it. | The `*` operator must be the whole path segment. A pattern such as `key_*` is invalid because it would make prefix matching ambiguous. The `/**` operator must be the final path segment. A pattern such as `projects/**/deployments/*` is invalid because descendant matching has to stop at the end of the path. After a path uses `*` for an ID selector, descendant ID selectors must also use `*`. The path can still name child collections, but it can't narrow back to a specific child. This keeps wildcard paths canonical and avoids permissions that pretend to select a child without selecting the parent that owns it. Valid: ```plaintext theme={"theme":"kanagawa-wave"} unkey:v1:ws_123:projects/*/apps/* unkey:v1:ws_123:projects/*/apps/*/environments/*/deployments/* unkey:v1:ws_123:projects/proj_123/apps/*/environments/* ``` Invalid: ```plaintext theme={"theme":"kanagawa-wave"} unkey:v1:ws_123:projects/*/apps/app_123 unkey:v1:ws_123:projects/proj_123/apps/*/environments/env_123 unkey:v1:ws_123:projects/proj_123/apps/*/environments/*/deployments/dep_123 ``` For example, this pattern: ```plaintext theme={"theme":"kanagawa-wave"} unkey:v1:ws_123:keyspaces/*/keys/* ``` matches this concrete URN: ```plaintext theme={"theme":"kanagawa-wave"} unkey:v1:ws_123:keyspaces/ks_123/keys/key_456 ``` This descendant pattern: ```plaintext theme={"theme":"kanagawa-wave"} unkey:v1:ws_123:projects/proj_123/** ``` matches the project itself and every public descendant below that project. Patterns never cross workspace boundaries, including the global workspace pattern: ```plaintext theme={"theme":"kanagawa-wave"} unkey:v1:ws_123:** ``` `pkg/urn` parses concrete names and patterns. It also decides whether one URN covers another. The permission system adds the action suffix and decides what a covered resource authorizes. It doesn't define its own path matching. ## Resource catalog The public catalog defines every concrete resource path that can appear in a `v1` URN. Implementation code must reject concrete URNs that don't match one of these path shapes. Pattern grants must still be built from these path shapes, with `*` replacing complete ID segments or trailing `/**` covering descendants. ### Team Team resources are rooted under `team`. | Resource | Path | | ---------- | ---------------------------------- | | Membership | `team/memberships/{membership_id}` | | Invitation | `team/invitations/{invitation_id}` | Examples: ```plaintext theme={"theme":"kanagawa-wave"} unkey:v1:ws_123:team/memberships/mbr_123 unkey:v1:ws_123:team/invitations/inv_456 ``` ### Billing Billing resources are rooted under `billing`. Workspace quota is a singleton resource because quota applies to the workspace billing state. | Resource | Path | | ------------- | ------------------------------- | | Billing state | `billing` | | Invoice | `billing/invoices/{invoice_id}` | | Quota | `billing/quotas` | Examples: ```plaintext theme={"theme":"kanagawa-wave"} unkey:v1:ws_123:billing unkey:v1:ws_123:billing/invoices/inv_123 unkey:v1:ws_123:billing/quotas ``` ### Keyspaces Key resources are rooted under the keyspace that owns the key. | Resource | Path | | -------- | --------------------------------------- | | Keyspace | `keyspaces/{keyspace_id}` | | Key | `keyspaces/{keyspace_id}/keys/{key_id}` | Examples: ```plaintext theme={"theme":"kanagawa-wave"} unkey:v1:ws_123:keyspaces/ks_123 unkey:v1:ws_123:keyspaces/ks_123/keys/key_456 ``` ### Identities Identity resources are rooted under `identities`. | Resource | Path | | -------- | -------------------------- | | Identity | `identities/{identity_id}` | Example: ```plaintext theme={"theme":"kanagawa-wave"} unkey:v1:ws_123:identities/id_123 ``` ### Rate limits Standalone rate limiting resources are rooted under `ratelimits`. Overrides belong to the namespace they modify. | Resource | Path | | --------- | -------------------------------------------------------------- | | Namespace | `ratelimits/namespaces/{namespace_id}` | | Override | `ratelimits/namespaces/{namespace_id}/overrides/{override_id}` | Examples: ```plaintext theme={"theme":"kanagawa-wave"} unkey:v1:ws_123:ratelimits/namespaces/rlns_123 unkey:v1:ws_123:ratelimits/namespaces/rlns_123/overrides/rlor_456 ``` ### RBAC RBAC resources are rooted under `rbac`. Relationship changes, such as adding a role to a key, are audited against both affected resources rather than by creating a separate join-table URN. | Resource | Path | | ---------- | ---------------------------------- | | Role | `rbac/roles/{role_id}` | | Permission | `rbac/permissions/{permission_id}` | Examples: ```plaintext theme={"theme":"kanagawa-wave"} unkey:v1:ws_123:rbac/roles/role_123 unkey:v1:ws_123:rbac/permissions/perm_456 ``` ### Deploy Deploy resources use the full product hierarchy. A deployment belongs to one environment, which belongs to one app, which belongs to one project. | Resource | Path | | ------------------- | ----------------------------------------------------------------------------------------------------------------------- | | Project | `projects/{project_id}` | | App | `projects/{project_id}/apps/{app_id}` | | Environment | `projects/{project_id}/apps/{app_id}/environments/{environment_id}` | | Deployment | `projects/{project_id}/apps/{app_id}/environments/{environment_id}/deployments/{deployment_id}` | | Deployment instance | `projects/{project_id}/apps/{app_id}/environments/{environment_id}/deployments/{deployment_id}/instances/{instance_id}` | | Domain | `projects/{project_id}/apps/{app_id}/environments/{environment_id}/domains/{domain_id}` | | Variable | `projects/{project_id}/apps/{app_id}/environments/{environment_id}/variables/{variable_id}` | | Gateway policy | `projects/{project_id}/apps/{app_id}/environments/{environment_id}/gateway/policies/{policy_id}` | Examples: ```plaintext theme={"theme":"kanagawa-wave"} unkey:v1:ws_123:projects/proj_123 unkey:v1:ws_123:projects/proj_123/apps/app_456 unkey:v1:ws_123:projects/proj_123/apps/app_456/environments/env_789 unkey:v1:ws_123:projects/proj_123/apps/app_456/environments/env_789/deployments/d_abc ``` ### Portal Portal resources are rooted under `portals`. Session tokens and sessions belong to the portal that created them. | Resource | Path | | -------------------- | -------------------------------------------------------------- | | Portal | `portals/{portal_id}` | | Portal session token | `portals/{portal_id}/session_tokens/{portal_session_token_id}` | | Portal session | `portals/{portal_id}/sessions/{portal_session_id}` | | Portal branding | `portals/{portal_id}/branding` | Examples: ```plaintext theme={"theme":"kanagawa-wave"} unkey:v1:ws_123:portals/portal_123 unkey:v1:ws_123:portals/portal_123/sessions/ps_456 ``` ## Internal resources Runtime and implementation resources are not part of the public `v1` catalog unless a product feature explicitly promotes them. This includes: * Frontline routes * Regional counters * ClickHouse outbox rows * Cache entries * Join-table rows Internal systems can still log implementation IDs in metadata. They must not mint public URNs for these resources unless the catalog is updated first. ## Invalid examples These strings are invalid URNs or invalid concrete URNs. | Value | Reason | | ------------------------------------------------ | ------------------------------------------------- | | `urn:unkey:v1:ws_123:keyspaces/ks_123` | Uses the wrong prefix. | | `unkey:v1:ws_123` | Missing the resource path. | | `unkey:v1:ws_123:keyspaces/ks_123#read_keyspace` | Contains a permission action. | | `unkey:v1:ws_123:keyspaces/ks_*` | Uses `*` inside a path segment. | | `unkey:v1:ws_123:projects/**/deployments/*` | Uses `**` before the end of the path. | | `unkey:v1:ws_123:projects/*/apps/app_123` | Selects a specific child under a wildcard parent. | | `unkey:v1:ws_123:keyspace/ks_123` | Uses an unknown path shape. | ## Related pages * [Resource permissions](/architecture/authorization/resource-permissions) defines how actions attach to URNs for authorization. # 0000 Template Source: https://engineering.unkey.com/architecture/rfcs/0000-template You may copy this as a starting point, but it's not required ## Summary One paragraph explanation of the feature. ## Motivation Why are we doing this? What use cases does it support? What is the expected outcome? ## Detailed design This is the bulk of the RFC. Explain the design in enough detail for somebody familiar with the network to understand, and for somebody familiar with the code practices to implement. This should get into specifics and corner-cases, and include examples of how the feature is used. ## Drawbacks I Why should we not do this? ## Alternatives What other designs have been considered? What is the impact of not doing this? ## Unresolved questions What parts of the design are still to be done? # 0001 RBAC Source: https://engineering.unkey.com/architecture/rfcs/0001-rbac To reduce the scope and time to implementation, we will be reducing our initial permission model to RBAC instead of ReBAC. This has fewer moving parts and can be implemented with just 1 new table. There are many ways to store this data. Initially I had a 2 table setup, one for roles and one for an M:N relation between roles and keys, but there are some operational issues with that, mainly around planetscale’s foreignkeys and the lack of an easy “upsert” method. This requires us to query all roles of a workspace, then figuring out which ones are missing and creating them. For every key creation… That’s not amazing. Here’s a much simpler proposal using a single table: ### Table Schema * `id` unique id for this row * `workspaceId` Every role is scoped to a workspace, no role sharing between tenants * `keyId` The key holding this role * `role: string` the actual name of a role, ie: `finance` (or more elaborate, see below) This is completely up to the user, the only limitation is a length ≤ 512chars for our own roles, we'll likely do some schema for roles, like `api::\{id\}::create_key` This single table design is the simplest form of doing roles. By adding indices for these queries, it should scale far enough: * roles by key * roles by workspace * keys by role ### Unkey internal role schema `*` denotes either an id or a wildcard ```tsx theme={"theme":"kanagawa-wave"} root_key::*::read_root_key root_key::*::create_root_key // a root key MUST NOT be allowed to create another key with more permissions than itself root_key::*::delete_root_key root_key::*::update_root_key api::*::create_api api::*::delete_api // either wildcard or a specific id -> api::api_123::delete_api api::*::read_api api::*::update_api api::*::read_key api::*::create_key api::*::update_key api::*::delete_key ``` Some of these internal roles (`api::*::create_api`) seem overly complicated, because the wildcard will always be present, since it’s impossible to write this role in advance without knowing the api\_id that will get generated later, but by sticking with this schema, we stay consistent and can build our types and tooling more easily. We could go deeper like `api::*::keys::*::read_key` but I’m not convinced anyone needs this and it just adds complexity for now. It’s trivial to add more roles later, let’s wait it out. ### Examples 1. A key should be allowed to create new apis, modify them and be able to perform all actions on keys. ```tsx theme={"theme":"kanagawa-wave"} api::*::create_api api::*::update_api api::*::read_key api::*::create_key api::*::update_key api::*::delete_key ``` 2. Update access to one api and its keys, read access to all apis and their keys ```tsx theme={"theme":"kanagawa-wave"} api::api_123::update api::api_123::update_key api::*::read_api api::*::read_key ``` # 0002 Secret Scanning Source: https://engineering.unkey.com/architecture/rfcs/0002-github-secret-scanning ## Changelog * Unkey now offers automated alerts when root keys are checked in to Github. * Unkey has partnered with Github to offer secret scanning for root keys. * Root keys all take the shape `unkey_xxx`. Whenever you check in code to a public Github repository, Github will scan for this pattern and notify us. * Unkey will automatically invalidate the key, notify you that this has taken place, and ask you to generate a new key to replace it. * Currently, this is only offered for root keys – not for API keys that you generate in Unkey for your users. Stay tuned as we look to expand our partnership with Github in future. ## Context Github offer a secret scanning program. Whenever code is checked in to a public repository, Github will scan the code for secrets that match a list of patterns published by official Github partners. If they detect that a secret matches that pattern, they notify the partner. The provider validates the string and decides if they should revoke the secret, issue a new secret, or contact the customer that published the secret. secret-scanning-flow.webp *Example flow* Unkey has registered as a partner with Github, meaning that we can enable this protection for our customers’ root keys. Currently we can’t offer the same protection to non-root keys: * This conflicts with GitHub's terms of service when Unkey doesn't own the gateway infrastructure that the API key belongs to ([source: Slack thread](https://unkey.slack.com/archives/C05SYU6P2DC/p1707492833527049)). * Currently there is no identifier that marks an Unkey API key as having been created by Unkey. This could change in the future: [MRFC: Key shape](https://www.notion.so/MRFC-Key-shape-0dc89f8cfa60422a9830cfeff94efd47?pvs=21) So only root keys are in scope for this RFC. However, we should avoid making assumptions that keys are root keys in our implementation to make future refactors easier. ## Implementation ### Steps * [x] Contact Github to register as a partner * [x] Identify the secrets we want to scan for, and create regular expressions to capture them * [ ] Create a secret alert service which accepts webhooks containing the secret scanning payload * [ ] Implement signature verification in the secret scanning service * [ ] Implement secret revocation and user notification in the secret scanning service ### Secret identification As per above, only root keys are in scope for now. ```json theme={"theme":"kanagawa-wave"} { "name" : "Unkey", "regex": "^unkey_[a-zA-Z0-9]+$", "webhook_endpoint": "https://secrets.unkey.dev/api/v1/github_secrets" } ``` ### Create secret alert service and implement signature verification The secret alert service will receive payloads from Github with the following shape: ```jsx theme={"theme":"kanagawa-wave"} [ { "token":"NMIfyYncKcRALEXAMPLE", "type":"mycompany_api_token", "url":"https://github.com/octocat/Hello-World/blob/12345600b9cbe38a219f39a9941c9319b600c002/foo/bar.txt", "source":"content" // where it was found on Github: code, PR title, etc } ] ``` We should verify the signature of requests from Github: ```tsx theme={"theme":"kanagawa-wave"} const crypto = require("crypto"); const GITHUB_KEYS_URI = 'https://api.github.com/meta/public_keys/secret_scanning'; async function verifySignature(payload: string, signature: string, keyID: string): Promise { if (!payload) { throw new Error('Invalid payload'); } if (!signature) { throw new Error('Invalid signature'); } if (!keyID) { throw new Error('Invalid keyID'); } const response = await fetch(GITHUB_KEYS_URI); const data = await response.json(); const keys = data.public_keys; if (!(keys instanceof Array) || keys.length === 0) { throw new Error('No public keys found'); } const publicKey = keys.find((k: { key_identifier: string }) => k.key_identifier === keyID) ?? null; if (!publicKey) { throw new Error('No public key found matching key identifier'); } const verifier = crypto.createVerify('SHA256').update(payload); if (!verifier.verify(publicKey.key, Buffer.from(signature, 'base64'))) { throw new Error('Signature does not match payload'); } } ``` Sample POST request from Github: ```tsx theme={"theme":"kanagawa-wave"} POST / HTTP/2 Host: HOST Accept: */* Content-Length: 104 Content-Type: application/json Github-Public-Key-Identifier: bcb53661c06b4728e59d897fb6165d5c9cda0fd9cdf9d09ead458168deb7518c Github-Public-Key-Signature: MEQCIQDaMKqrGnE27S0kgMrEK0eYBmyG0LeZismAEz/BgZyt7AIfXt9fErtRS4XaeSt/AO1RtBY66YcAdjxji410VQV4xg== [{"source":"commit","token":"some_token","type":"some_type","url":"https://example.com/base-repo-url/"}] ``` ### Implement key revocation and user notification in the service ```tsx theme={"theme":"kanagawa-wave"} // /api/v1/github_secrets.ts export async function handler(request: Request) { try { const payload = await extractPayload(request); log("Github secret scanning webhook", payload); await verifySignature(payload); const hash = await hash(payload.token); const user = await getUserByKeyHash(hash); const team = await getTeam(user); await deleteKey(hash); await notifyTeam(team); log("User automatic key revocation event", user); } catch (error) { log("Github webhook verification error", error) } } ``` ## Questions * Should this run in a [trigger.dev](http://trigger.dev) background job? Github say that it needs to be able to “handle requests with a large number of matches without timing out” # 0003 Key shape Source: https://engineering.unkey.com/architecture/rfcs/0003-key-shape [https://unkey.slack.com/archives/C05SYU6P2DC/p1705420411688469](https://unkey.slack.com/archives/C05SYU6P2DC/p1705420411688469) This is the current shape of our keys:`\{prefix?\}_\{version\}\{length\}\{base58_randomness\}` I chose this for backwards compatibility if we ever wanted to encode something into the key, for example a semi-public identifier, think `client_id` and `client_secret` in OAuth or other implementations. If the version would match, we knew it was a key with separate client\_id and client\_secret and could split and parse it correctly. However there is a problem with this. We are encoding the version as well as the byte-length at the *beginning* of the key, this makes all of our keys look very similar when you’re only seeing the first few characters. Other complications come from the fact that we want to be able to onboard other keys without the end user noticing, which means we can not force them to reroll their key, but need to accept it as is, we can only change the hash function to our own (sha256). This means, we can never enforce a strict schema on keys, we can only try to shift the key landscape, by asking users to reroll. Example: Resend have keys in the shape of `re_\{user_id\}_\{secret\}_\{checksum\}` When we want to win them as a customer, we do not want to ask all of their users to create new keys, existing keys must keep working without our customer having to maintain their own system as well as unkey. We do this by adding their hashes to our db and marking them as well as noting how we derive the hash from the key (see [https://linear.app/unkey/issue/ENG-119/migrating-keys-to-unkey](https://linear.app/unkey/issue/ENG-119/migrating-keys-to-unkey)). When they now create new keys through unkey, they’ll have our own keyshape, but old keys do not change. At this point when we receive a key, we do not know what shape it’ll be, it could be a resend shape, it could be one of our own, or it could be something entirely different from another customer. ## Possible Solution Having a version encoded is still a good idea I think, but there’s nothing stopping us from moving it to the end, instead of parsing a key from the start, we can just start reading from the back. ## Other thoughts * We should also think of adding a checksum, which would allow us to clearly identify “this key was created by unkey”, as well as it would help with github secret scanning stuff. * A problem with github secret scanning is, that we allow our users to choose their prefix, so it’s not as simple as matching `/^unkey_.{16}$/`, we’ll have to look a bit deeper, but a checksum may help. * our future key shape could look like this: ```tsx theme={"theme":"kanagawa-wave"} {prefix}_{base58_randomness}{meta}{version}_{checksum} ``` I don’t really like the second delimiter, but I haven’t thought deeply enough how we can match that in a way that doesn’t accidentally match a migrated key # 0004 COSS Starter Source: https://engineering.unkey.com/architecture/rfcs/0004-coss-starter ### **Problem**: One of our biggest hurdles is penetrating the market because you need to be building either an API or you have one and need something we offer. This is fine but what if you don’t know if you need one? Or if you are building an application for the first time and have never considered it. ### **Proposed solution**: Open Source starter that is highly opinionated and includes everything you need to build a new product. 1. Web app with internal API 2. Public API with open API spec protected by Unkey 3. Docs 4. Stripe integrations Imagine typing `npx create-coss-app`, and there you have it. All you need to do is sign up for 1 or 2 services, including Unkey. We want it to be DX-driven and super easy to extend and modify, but keep it so easy that an FE engineer can do it. We can get a domain with a landing page that points to the npx command and some documentation for how it works. ## Tech stack proposed We can create a monorepo using turbo repo for the entire stack to make it as easy as possible to use. ### Web application: * Next.js * tRPC * Lucia auth * Drizzle * Planetscale * Stripe * Unkey ### Public API * Hono v4 * Unkey ## Docs * Mintlify using the open API spec built using Hono v4. The proposed solution uses the smallest amount of 3rd party services (Planetscale, Mintlify, and Unkey), allowing maintenance and contributions a small hurdle. ## Work to be done Come up with a simplified project to show how everything works. An example could be a to-do list or something similar. 1. Web app has a UI interface that allows for creating, updating, and deleting todos, but you can only have a maximum of 5 on the free tier. The web app also allows for the creation of API keys to provide API access (The free tier gets limited uses or rate limiting or whatever we pick) and a way for the user to upgrade to the paid tier with unlimited. 2. API has keys from Unkey passed in and can be used to CRUD the data via the API. 3. Document everything as a real product to show how documentation works. 4. Basic landing page with the command and link to docs and a demo 5. The demo is just a deployed version of this. Inspiration : [https://create.t3.gg/](https://create.t3.gg/) # 0005 Analytics API Source: https://engineering.unkey.com/architecture/rfcs/0005-analytics-api Unkey exposes APIs to retrieve all required data to build end-user facing dashboards and drive our customer's usage-based billing. ## Motivation Consumption based billing for APIs is getting more and more popular, but it's tedious to build in house. For low frequency events, it's quite possible to emit usage events directly to Stripe or similar, but this becomes very noisy quickly. Furthermore if you want to build end-user facing or internal analytics, you need to be able to query the events from Stripe, which often does not provide the granularity required. Most teams end up without end-user facing analytics, or build their own system to store and query usage metrics. Since Unkey already stores and aggregates verification events by time, outcome and identity, we can offer this data via an API. ## Detailed design In order to charge for usage, our users need information of **who** used their API **when** and **how often**. For end-user facing analytics dashboards, it would also be relevant to differentiate between different outcomes (`VALID`, `RATE_LIMITED`, `USAGE_EXCEEDED`, `INSUFFICIENT_PERMiSSIONS` etc.) ### Available data We already store events for every verification in ClickHouse and have materialized views for aggregations. ```sql theme={"theme":"kanagawa-wave"} `request_id` String, `time` Int64, `workspace_id` String, `key_space_id` String, `key_id` String, `region` LowCardinality(String), `outcome` LowCardinality(String), `identity_id` String ``` We can return this data in different granularities: * hourly * daily * monthly In order be scalable, we will not expose individual events in the beginning, nor allow you to filter by exact timestamps. If we can't query a materialized view, it would be too compute-intensive to query. If needed, a per-minute granularity materialized view could be created, but is not currently planned. And filtered by: * identity\_id * key\_space\_id (which we can derive from the api\_id) * key\_id * outcome * start and end time ### Request We will create a new endpoint `GET /v1/analytics.getVerifications`, protected by a root key in the `Authorization` header. The root key will require specific permissions tbd. Calling the endpoint will return an array of verification counts, aggregated by time and provided filters. All required and optional arguments are passed via query parameters. Some parameters may be specified multiple times, either as You may specify multiple ids such as `?param=value_1,value_2` or `?param=value_1¶m=value_2` #### start (integer, required) Unix timestamp in milliseconds to specify the start of the interval to retrieve. We will return all datapoints with a timestamp greater or equal to `start`. There may be restrictions depending on the granularity chosen and the retention quota of the customer #### end (integer, required) Unix timestamp in milliseconds to specify the end of the interval to retrieve. We will return all datapoints with a timestamp less than or equal to `end`. There may be restrictions depending on the granularity chosen and the retention quota of the customer #### granularity (enum \["hour", "day", "month"], required) Selects the granularity of data. For example selecting `hour` will return one datapoint per hour. #### apiId (string, optional, may be provided multiple times) Select the API for which to return data. When you are providing zero or more than one API ids, all usage counts are aggregated and summed up. Send multiple requests with one apiId each if you need counts per API. #### externalId (string, optional, may be provided multiple times) Filtering by externalId allows you to narrow down the search to a specific user or organisation. When you are providing zero or more than one external ids, all usage counts are aggregated and summed up. Send multiple requests with one externalId each if you need counts per identity. #### keyId (string, optional, may be provided multiple times) Only include data for a speciifc key or keys. When you are providing zero or more than one key ids, all usage counts are aggregated and summed up. Send multiple requests with one keyId each if you need counts per key. #### groupBy (enum \["key", "identity"], optional) By default, all datapoints are aggregated by time alone, summing up all verifications across identities and keys. However in certain scenarios you want to get a breakdown per key. For example finding out the usage spread across all keys for a specific user. ### limit (integer, optional) Limit the number of returned datapoints. This may become useful for querying the top 10 identities based on usage. #### orderBy (enum \["total", "valid", ..], optional) This is a rough idea. We're leaning towards `?orderBy=valid&order=asc`, but have not decided what this API should look like. #### order (enum \["asc", "desc"], optional, default="asc", only allowed in combination with `orderBy`) See above. ### Example Access Patterns > A chart of an enduser's usage over the past 24h, showing the outcomes ```bash theme={"theme":"kanagawa-wave"} ?start={timestamp_24h_ago}&end={timestamp_now}&externalId=user_123&granularity=hour [ // 24 elements, one per hour { time: 123, valid: 10, ratelimited: 2, ..., total: 30 }, ] ``` > A daily usage breakdown for a user per key in the current month ```bash theme={"theme":"kanagawa-wave"} ?start={timestamp_start_of_month}&end={timestamp_now}&granularity=day&externalId={user_123}&groupBy=key [ // One row per keyId and time { keyId: "key_1", time: 123, valid: 10, ..., total: 30 }, { keyId: "key_1", time: 456, valid: 20, ..., total: 52 }, ... { keyId: "key_2", time: 123, valid: 0, ..., total: 10 }, { keyId: "key_2", time: 456, valid: 1, ..., total: 2 }, ... ] ``` > A monthly cron job creates invoices for each identity: ```bash theme={"theme":"kanagawa-wave"} ?start={timestamp_start_of_month}&end={timestamp_end_of_month}&granularity=month&externalId={user_123} [ // one element for the single month { time: 123, valid: 10, ..., total: 30 } ] ``` > A user sees a gauge with their quota, showing they used X out of Y API calls in the current billing period: ```bash theme={"theme":"kanagawa-wave"} ?start={timestamp_start_of_billing_cycle}&end={timestamp_end_of_billing_cycle}&granularity=day&externalId={user_123} [ { time: 123, valid: 10, ..., total: 30 } ] ``` Sum up the `valid` or `total`, however you want to count, and display it to the user. > An internal dashboard shows the top 10 users by API usage over the past 30 days ```bash theme={"theme":"kanagawa-wave"} ?start={timestamp_30_days_ago}&end={timestamp_now}&granularity=day&groupBy=identity&limit=10&orderBy=total&order=desc ``` ### Response Successful responses will always return an array of datapoints. One datapoint per granular slice, ie: hourly granularity means you receive one element per hour within the queried interval. ```json title="200 OK Body" theme={"theme":"kanagawa-wave"} [ Datapoint, Datapoint, Datapoint ] ``` ```ts title="Datapoint" theme={"theme":"kanagawa-wave"} type Datapoint = { /** * Unix timestamp in milliseconds of the start of the current time slice. */ time: number /** * For brevity, I will not explain every outcome here. * There will be one key and count for every possible outcome, so you may * choose what to display or not. */ valid: number rateLimited: number usageExceeded: number // ... /** * Total number of verifications in the current time slice, regardless of outcome. */ total: number /** * Only available when specifying groupBy in the query. * In this case there would be one datapoint per time and groupBy target. */ keyId?: string apiId?: string identity?: { id: string externalId: string } } ``` ## Drawbacks Our current serverless architecture costs money per invocation. Our customer's users could generate a decent amount of requests. ## Alternatives Offering a prometheus `/metrics` endpoint would be interesting, however I believe most of our users don't have the infra in place to adopt this easily. *** Instead of aggregating multiple keyIds together, we could disallow specifying them multiple times and instead ask the user to create one request per id and then merge them together on their side. ## Unresolved questions * What cache times are acceptable? We probably don't want to hit ClickHouse for every single query, especially for fetching monthly aggregations. * When we return keyIds as part of groupBy queries, the user needs to make another call to our API in order to fetch details such as the name for each key. That doesn't feel great. * What are the retention quotas tier and granularity? # 0006 Auth Migration Source: https://engineering.unkey.com/architecture/rfcs/0006-auth-migration Migrate everything to WorkOS, despite their bad APIs.. ## Motivation We need to migrate our users and organisations from Clerk to WorkOS. ## Detailed design In order to apply the changes safely and, more importantly, roll back in case we need to, we'll need to do it in 3 steps. Each step should be its own PR, so we can roll back safely. ### 1. Migrate Data 1. Create a new non-nullable varchar column in our `workspaces` table with default `""`. 2. Migrate all users from Clerk to WorkOS. They will receive a new `user_id`, which is fine. 3. Migrate all organisations and personal workspaces from Clerk to WorkOS. These will also have a new `org_id`, which we will store in the `workspaces.organisation_id` column. ### 2. Switch Reads (Meg's PR) We deploy the dashboard changes to production, which will now receive an `orgId` from the WorkOS SDK and change our db queries to match against the `organisation_id` field, instead of `tenant_id`. If we really need to, we can roll this back at any time and use clerk again. The only problem here is that users that were created after the switch, would not be in Clerk. ### 3. Remove old columns After we're happy with everything and it's been running smoothly for a few weeks, we can remove the old `tenant_id` column. # 0007 Client-side file structure Source: https://engineering.unkey.com/architecture/rfcs/0007-client-file-structure File structure for our client apps ## Executive Summary This RFC proposes restructuring our client components from their current flat organization into a feature-based architecture, grouping related components, hooks, and utilities within feature-specific directories. Each Next.js page will be treated as a distinct feature module, ensuring clear boundaries and colocation of related code. The migration can be implemented incrementally, with each feature module being refactored independently without disrupting ongoing development. Key benefits include: * Improved developer onboarding through intuitive code organization * Reduced coupling between features * Faster feature development through clear patterns and conventions * Better code maintainability through consistent structure * Easier code reviews through predictable file locations * **Standardized contribution patterns for our open source community** ## Problem Statement ### Current Situation Our Next.js application's flat directory structure has led to several challenges: 1. Related code is scattered across different directories, making it difficult to understand feature boundaries 2. New team members spend excessive time locating relevant components and understanding relationships 3. Lack of consistent patterns leads to inconsistent implementations 4. Code reuse is hindered by poor discoverability of existing components 5. Utilities often end up far from the components they support A critical issue in our open-source project is the lack of standardized patterns. Currently: * Different contributors implement features using their own organizational preferences because they don't know our pattern. * This creates inconsistency across the codebase * Code reviews take longer as reviewers need to understand each contributor's unique approach * New contributors lack clear examples to follow * Integration of community contributions requires significant refactoring For example, our `/authorization` page demonstrates these issues... ```bash theme={"theme":"kanagawa-wave"} ├── authorization/ │ ├── permissions/ │ │ ├── [permissionId]/ │ │ │ ├── client.tsx │ │ │ ├── delete-permission.tsx │ │ │ └── page.tsx │ │ ├── create-new-permission.tsx │ │ └── page.tsx │ └── roles/ │ ├── [roleId]/ │ │ ├── delete-role.tsx │ │ ├── page.tsx │ │ ├── permission-toggle.tsx │ │ ├── tree.tsx │ │ └── update-role.tsx │ ├── create-new-role.tsx │ └── page.tsx ├── constants.ts └── layout.tsx ``` We could turn this into this: ```bash theme={"theme":"kanagawa-wave"} ├── authorization/ │ ├── permissions/ │ │ ├── [permissionId]/ │ │ │ ├── components/ │ │ │ │ └── permission-details.tsx │ │ │ ├── actions/ │ │ │ │ └── delete-permission.ts │ │ │ ├── hooks/ # Page-specific query hooks │ │ │ │ └── use-permission.ts # Single permission queries │ │ │ └── page.tsx │ │ ├── components/ │ │ │ ├── create-new-permission/ │ │ │ │ ├── index.tsx │ │ │ │ └── permission-form.tsx │ │ ├── schemas/ # New validation schemas folder │ │ │ ├── permission-form.schema.ts # .schema or -schema suffix are both fine. │ │ │ └── permission.schema.ts │ │ ├── types/ │ │ │ └── permission.ts │ │ ├── utils/ │ │ │ └── permission-validator.ts │ │ ├── hooks/ │ │ │ ├── use-permission-form.ts │ │ │ └── queries/ # Shared permission query hooks │ │ │ ├── use-permissions-list.ts │ │ │ ├── use-create-permission.ts │ │ │ └── use-update-permission.ts │ │ ├── constants.ts # Permission wide constants │ │ └── page.tsx ├── constants/ │ └── shared.ts # Authorization wide constants ``` And, actual page files will look like this. Note this is audit component refactored from this [Old Audit Page](https://github.com/unkeyed/unkey/blob/46878c232b3e57372f43141816e508f63c6570fd/apps/dashboard/app/\(app\)/audit/%5Bbucket%5D/page.tsx) to this: ```ts theme={"theme":"kanagawa-wave"} import { Navbar } from "@/components/navbar"; import { PageContent } from "@/components/page-content"; import { getOrgId } from "@/lib/auth"; import { InputSearch } from "@unkey/icons"; import { type SearchParams, getWorkspace, parseFilterParams } from "./actions"; import { Filters } from "./components/filters"; import { AuditLogTableClient } from "./components/table/audit-log-table-client"; type Props = { params: { bucket: string; }; searchParams: SearchParams; }; export default async function AuditPage(props: Props) { const orgId = await getOrgId(); const workspace = await getWorkspace(orgId); const parsedParams = parseFilterParams({ ...props.searchParams, bucket: props.params.bucket, }); return (
}> Audit {workspace.ratelimitNamespaces.find((ratelimit) => ratelimit.id === props.params.bucket) ?.name ?? props.params.bucket}
); } ``` Contributors and our team will be able to easily locate functions and components, and get a general feel for the component immediately. ### Impact This problem affects multiple stakeholders in our ecosystem: Developer Community: * Open source contributors face a learning curve when trying to understand where to place new code * Community developers spend extra time in code review discussions about file organization rather than functionality * First-time contributors often need multiple revision cycles just to match project structure Core Team: * Maintainers spend significant time providing structural guidance in PRs * Code review efficiency is reduced by inconsistent file organization * Integration of community contributions requires extra refactoring effort End Users: * Feature delivery is slowed by organizational overhead * Bug fixes take longer as developers navigate inconsistent structures * New features may be delayed due to time spent on structural debates ### Motivation Solving this organizational challenge is critical for several reasons: Project Scalability: * As our project grows, the cost of inconsistent structure compounds * More contributors means more potential for divergent patterns * Larger features become increasingly difficult to maintain without clear boundaries Community Growth: * Clear conventions lower the barrier to entry for new contributors * Standardized patterns help contributors focus on value-add features rather than structure * Predictable organization improves documentation and knowledge sharing Development Velocity: * Consistent patterns reduce cognitive load during development * Feature implementation time decreases as conventions become second nature * Code reviews can focus on logic and functionality rather than organization * Faster onboarding for new contributors who can follow established patterns Code Quality: * Well-organized code is easier to test and maintain * Clear boundaries prevent unwanted coupling between features * Consistent structure makes it easier to identify and fix architectural issues ## Proposed Solution ### Overview We propose implementing a feature-based architecture where each distinct feature (Next.js page) is treated as a self-contained module with its own component hierarchy. The structure follows these key principles: 1. Feature Isolation * Each feature (page) gets its own directory * All related components, hooks, and utilities live within the feature directory * Shared code is clearly separated from feature-specific code 2. Consistent Internal Structure Each feature directory follows a standard organization: * `/components`: Feature-specific React components * `/hooks`: Custom hooks for the feature * `/actions`: Server actions and API calls * `/types`: Types and interfaces * `/schemas`: Zod schemas * `/utils`: Helper functions and utilities * `/constants`: Feature-specific constants 3. Clear Dependencies * Shared components live in a global `@components` or `/components` directory * Feature-specific components shouldn't be imported by other features * Common utilities, types and components are placed in root-level shared directories As demonstrated in the example of the `/authorization` feature above. ## Alternatives Considered ### Alternative 1: Features folders #### Description A simpler feature-based structure where all features are in a `/features` directory: ```bash theme={"theme":"kanagawa-wave"} ├── features/ │ ├── authorization/ │ │ ├── components/ │ │ ├── hooks/ │ │ └── utils/ │ ├── audit/ │ └── billing/ ├── shared/ │ ├── components/ │ └── utils/ └── pages/ ``` #### Pros * Clear separation between features and shared code * Simpler top-level organization * Common pattern in React applications * Less nesting compared to proposed solution * Easier to colocate tRPC and page related code #### Cons * More difficult to colocate route-specific code * Less granular organization within features * Harder to implement incrementally * Mixing of page-specific and feature-wide code #### Why we didn't choose this Going from what we have to this one is really hard to do incrementally. ### Alternative 2: Flat structure (current) #### Description Our current flat structure where files are organized by type: #### Pros * Simple to understand * No complicated nesting * Easier to move components between features #### Cons * Related code is scattered * No clear feature boundaries * Poor scalability as app grows * Difficult to understand feature scope * Hard for new contributors to know where to put things * Mixed responsibilities in directories * No clear ownership of code * Harder to refactor single features * OSS contributors tend to put files in random places #### Why we didn't choose this The flat structure has proven problematic as our project grows and receives more open source contributions. The lack of clear conventions leads to inconsistent implementations and makes it harder for new contributors to understand where their code should go. The proposed solution provides clearer boundaries and better guides contributors toward consistent patterns. ## Future Work After implementing the initial structure, we can gradually move towards a more framework-agnostic `/features` organization: * Move framework-independent code (components, hooks, utils) into `/features` * Keep Next.js specific files (page.tsx, loading.tsx, error.tsx) in the App Router structure * This separation will make our codebase more portable ## Questions and Discussion Topics * Are there too many levels of nesting in the proposed structure? * Maybe we should adopt Remix.js-style suffixes for better clarity? Examples: * `.type.ts` for type definitions * `.schema.ts` for validation schemas * `.client.tsx` for client-specific components * `.server.ts` for server-only code * `.action.ts` for server actions *** ## Document History | Version | Date | Description | Author | | ------- | ---------- | ------------- | ------ | | 0.1 | 2024-12-20 | Initial draft | @Oz | # 0008 Dataplane Source: https://engineering.unkey.com/architecture/rfcs/0008-dataplane Global Unkey Deployment Architecture We need to design a globally distributed architecture for Unkey where the dataplane can operate independently of the primary database for improved availability. ## Goals * Achieve 100% dataplane availability independent of primary database * Provide fast access to dynamic data across global regions * Propagate data across the system quickly * Minimize load on expensive storage * Enable efficient cache invalidation * Can run on any cloud or on premise ## Options ### 1. Direct S3 + In-Memory Cache with SWR ```ascii theme={"theme":"kanagawa-wave"} ┌─────────────────────┐ ┌─────────┐ │ Frontline │ │ │ │ ┌───────────────┐ │────►│ S3 │ │ │ Memory Cache │ │ │ │ │ └───────────────┘ │ │ │ └─────────────────────┘ └─────────┘ ``` #### Pros * Simple, straightforward design * Low architectural complexity #### Cons * Cache invalidation requires communication with all machines or really low TTLs (\<10s) * Inefficient cache refresh patterns * High load on S3 due to concurrent SWR requests from multiple machines ### 2. S3 + In-Memory Cache with Gossip Protocol ```ascii theme={"theme":"kanagawa-wave"} ┌─────────────────────┐ │ Frontline │ │ ┌───────────────┐ │──┐ │ │ Memory Cache │ │ │ │ └───────────────┘ │ │ └─────────────────────┘ │ ▲ │ │ Gossip │ ┌─────────┐ ▼ ├───►│ │ ┌─────────────────────┐ │ │ S3 │ │ Frontline │ │ │ │ │ ┌───────────────┐ │──┘ └─────────┘ │ │ Memory Cache │ │ │ └───────────────┘ │ └─────────────────────┘ ``` If we had global fast and efficient eviction, we could set much higher TTLs. #### Pros * Efficient cache invalidation through gossip, allowing higher TTLs * Reduced load on primary storage * Only need to notify one node for changes #### Cons * Need to implement ordering mechanism (timestamps/Lamport clocks) * More complex system architecture * Global gossip cluster management overhead ### 3. S3 + Dedicated Cache Layer ```ascii theme={"theme":"kanagawa-wave"} ┌─────────────────┐ │ Frontline 1 │───┐ └─────────────────┘ │ │ ┌─────────────────┐ │ ┌────────────┐ │ Frontline 2 │───┼───►│ Load │ ┌────────────┐ └─────────────────┘ │ │ Balancer │───►│ Cache │──┐ │ │ │ │ Node 1 │ │ ┌─────────────────┐ │ │ │ └────────────┘ │ │ Frontline 3 │───┤ │ │ │ ┌─────────┐ └─────────────────┘ │ │ │ ├───►│ S3 │ ├───►│ │ ┌────────────┐ │ └─────────┘ ┌─────────────────┐ │ │ │───►│ Cache │──┘ │ Frontline 4 │───┤ │ │ │ Node 2 │ └─────────────────┘ │ └────────────┘ └────────────┘ │ ┌─────────────────┐ │ │ Frontline n │───┘ └─────────────────┘ ``` Frontline instances still use an in-memory SWR cache. The cache nodes help reduce the cost and latency of S3, but will likely have the same freshness/staleness as the Frontline instances. TBD.. Everything here, except the S3 bucket, would be duplicated per region. #### Pros * Better cache retention due to less frequent reboots * Optional global eviction via gossip/kafka later * Maybe we only need 1 S3 region now instead of replicating it #### Cons * Additional infrastructure to manage ### 4. DynamoDB Global Tables + Caching Option 4A: Direct DynamoDB + Frontline Memory Cache * Each Frontline instance maintains a local memory SWR cache with a TTL of 10s * DynamoDB serves as source of truth * Automatic multi-region replication handled by AWS ```ascii theme={"theme":"kanagawa-wave"} ┌─────────────────────┐ ┌──────────────────┐ │ Frontline (US) │ │ DynamoDB │ │ ┌───────────────┐ │────►│ (US-WEST-1) │ │ │ Memory Cache │ │ │ │ │ └───────────────┘ │ └──────────────────┘ └─────────────────────┘ ▲ │ │ Replication │ ┌─────────────────────┐ ▼ │ Frontline (EU) │ ┌──────────────────┐ │ ┌───────────────┐ │────►│ DynamoDB │ │ │ Memory Cache │ │ │ (EU-WEST-1) │ │ └───────────────┘ │ │ │ └─────────────────────┘ └──────────────────┘ ``` Option 4B: With Dedicated Cache Layer A dedicated cache layer could be added to reduce the load on DynamoDB and improve read performance as well as cost. Whether this actually saves money is debatable, we'll have to try. These cache nodes would be dumb, they only cache reads for 10s and don't have any manual eviction possibilities. ```ascii theme={"theme":"kanagawa-wave"} ┌─────────────────┐ │ Frontline 1 │───┐ └─────────────────┘ │ │ ┌────────────┐ ┌─────────────────┐ │ │ Load │ ┌────────────┐ │ Frontline 2 │───┼───►│ Balancer │───►│ Cache │──┐ └─────────────────┘ │ │ │ │ Node 1 │ │ ┌──────────────┐ │ │ │ └────────────┘ ├───►│ DynamoDB │ │ │ │ │ │ Global │ │ │ │ ┌────────────┐ │ │ Tables │ ┌─────────────────┐ │ │ │───►│ Cache │──┘ └──────────────┘ │ Frontline n │───┘ └────────────┘ │ Node 2 │ └─────────────────┘ └────────────┘ ``` #### Pros * Built-in multi-region replication with strong consistency * No need to manage complex replication logic * Lower latency reads from local region * Automatic conflict resolution * Serverless and fully managed by AWS * Cheaper for small reads than S3 * 99.999% availability (s3 only has 99.99%) #### Cons * Vendor lock-in to AWS -> we need to have an abstraction * Higher storage cost compared to S3 due to replication * Cost of replication * Replication lag is controlled by AWS, not us * More expensive for large >13kb reads than S3 # 0009 Pricing refresh for 2025 Source: https://engineering.unkey.com/architecture/rfcs/0009-pricing-updates We need to update our pricing for 2025 ## Summary Our pricing has been the same for the past 12 months, while we want stability we also want to ensure we are competitive in the market. We are proposing a refresh of our pricing and free tier to ensure we are competitive and can continue to grow our business. ## Motivation Our whole ethos has always been "Making developers lives easier" and we want to continue to do that. We want to ensure that we are competitive in the market and that we are providing value to our customers, and currently our pricing has two main issues: 1. Unexpected bills due to the way we charge for requests, can be scary. 2. Confusing pricing structure that is only getting more confusing as we add more features a. Ratelimiting b. API key verification c. What consisitutes a valid request? We have seen a lot of growth in the past 12 months and we want to ensure that we can continue to grow by making it easier for developers to adopt, and scale with us. ## Details of the pricing refresh Firstly we are moving away from "verifications" and moving towards using the "requests", this will make it easier to transition into our new features we rely directly on requests. #### For reference our current pricing is: | Free | Pro | Enterprise | | ------------------------ | ------------------------ | ---------------------- | | 2.5k Valid Verifications | 150k Valid Verifications | XM Valid Verifications | | 100k Valid Ratelimits | 2.5M Valid Ratelimits | XM Valid Ratelimits | | 7 days Logs | 90 Days Logs | X Days Logs | | 30 days Audit | 90 Days Audit | X Days Audit | | 1k API keys | 1M API Keys | 1M API Keys | | Unlimited APIs | Unlimited APIs | Unlimited APIs | | \$0 | \$25 | \$? | #### Charge for additional requests: * \$1 per 10k * \$1 per 100k (Ratelimits) ### Proposal for 2025 When we met in October 2024, we discussed moving to buckets of requests, where a developer can have a certain number of requests per month. This is similar to other SaaS products like `Resend` or `Dub`. | Free | Pro Tier 1 | Pro Tier 2 | Pro Tier 3 | Pro Tier 4 | Pro Tier 5 | Pro Tier 6 | Pro Tier 7 | Enterprise | | -------------- | ---------------------- | ---------------------- | ----------------- | ------------------------ | ------------------ | ------------------ | ---------------- | -------------- | | 150k Requests | Up To 250,000 Requests | Up To 500,000 Requests | UP To 1M Requests | Up To 2,000,000 Requests | Up to 10M Requests | Up to 50M Requests | Up 100M Requests | XM requests | | 7 Days Logs | 90 Days Logs | 90 Days Logs | 90 Days Logs | 90 Days Logs | 90 Days Logs | 90 Days Logs | 90 Days Logs | X Days Logs | | 30 days Audit | 90 Days Audit | 90 Days Audit | 90 Days Audit | 90 Days Audit | 90 Days Audit | 90 Days Audit | 90 Days Audit | X Days Audit | | 1k API keys | 1M API Keys | 1M API Keys | 1M API Keys | 1M API Keys | 1M API Keys | 1M API Keys | 1M API Keys | 1M API Keys | | Unlimited APIs | Unlimited APIs | Unlimited APIs | Unlimited APIs | Unlimited APIs | Unlimited APIs | Unlimited APIs | Unlimited APIs | Unlimited APIs | | \$0 | \$25 | \$50 | \$75 | \$100 | \$250 | \$500 | \$1000 | \$? | To provide context 100M API Key verifications on the old pricing would cost $10,010.00 but on the new pricing would cost $1,000.00. #### What is counted as a successful request? A successful request means everything is fine and you should grant access to the user. Requests may be unsuccessful due to exceeding limits, keys being expired or disabled, or other factors. To protect your business from abuse, we do not charge for unsuccessful requests. #### What happens if I exceed my tier? If you exceed your tier, we will continue to allow traffic to flow and won’t charge you for the additional usage. If you API continues to exceed the tier we will advise you to upgrade to the next tier up. ## Drawbacks Standalone ratelimiting becomes more expensive as a SaaS scales however adding more requests to the free tier will help offset this. This seems like a win for scaling into the product, and getting more eyes on it, rather than a negative. ## Alternatives The only alternative considered so far: 1. Keeping the pro tier as Pay as you go, while also adding bucket pricing. This could cause additional confusion, or even more support requests as they realize bucketing is much cheaper. # 0010 Splitting the monorepo Source: https://engineering.unkey.com/architecture/rfcs/0010-split-monos Splitting the monorepo into multiple smaller, more focused repositories / monorepos. ## Summary As our codebase continues to grow, the need for scalability, maintainability, and improved development workflows has become more critical. This RFC proposes splitting our current monorepo into multiple smaller, more focused repositories / monorepos. ## Motivation We have on several occasions encountered issues with our current Monorepo structure, such as: * Increased build times due to large codebases * Complex dependency management across multiple projects * Difficulty onboarding new contributors due to the size and complexity of the monorepo * Conflicts between different projects There is nothing worse than pushing a change that affects a single project only to have a separate project affected as well. While monorepos make sense, splitting the monorepo into smaller, more focused repositories can help address these issues. ## Detailed design The proposal is to transition from a monorepo to a multi-repo / monorepo architecture, breaking up our codebase into several repositories based on functionality and project scope. Each repository would encapsulate its dependencies, tools, and build processes. ### Key Repositories 1. **Core**: The Core API code, dashboard, and engineering documentation is contained. 2. **SDKs**: Houses all our SDKs, this makes autogeneration of SDKs easier. 3. **Infra**: This includes all infra codes in its repo today. 4. **Documentation**: Dedicated documentation for Unkey. 5. **Marketing site**: It's not dependent on anything else, so we should just keep it separate. This includes the playground project which should be maintained by the same developers. ## Benefits * **Improved Build Performance**: Smaller repositories lead to faster incremental builds and no more waiting for unrelated builds to fire off. * **Improved GH Actions**: Only run relevant actions for each repository. * **Simplified Dependency Management**: Each repository will manage its dependencies, making upgrading and maintaining easier. * **Easier Onboarding**: New developers can clone and work on a smaller codebase relevant to their tasks. ## Drawbacks The drawback is that there are more repositories to clone and maintain. However, grouping core product functionality into a single repository can lead to a more cohesive and unified codebase, making it easier to maintain and develop. ## Alternatives The alternative is to keep the mono repo structure as is and continue to bloat it with unrelated projects. ## Unresolved questions * **Migration Plan**: A clear, step-by-step plan will need to be established for moving code and setting up each new repository. * **Communication**: Ensure all team members are updated on changes and can adapt to the new structure, including when we should pull the main and checkout codes. * **Tooling**: Set up the necessary tools for managing multiple repositories if needed. # 0008 URNs Source: https://engineering.unkey.com/architecture/rfcs/0011-unkey-resource-names Implementing Uniform Resource Names (URNs) and Structured Error Codes at Unkey This RFC records the original proposal. The implemented `v1` resource-name contract is documented in [Unkey Resource Names](/architecture/resources/unkey-resource-names) and [Resource permissions](/architecture/authorization/resource-permissions). The implemented contract uses `unkey:v1:{workspace_id}:{resource_path}` and includes resource-name patterns with `*` and trailing `/**` for authorization grants. ## 1. Background As we grow Unkey from a single API authentication product into a comprehensive API infrastructure platform with multiple services, we face increasing complexity in resource identification and error handling. Our current approach lacks a consistent, scalable system for uniquely identifying resources across services and providing structured error information to developers. I'm proposing two complementary systems: 1. A hierarchical URN schema for all Unkey resources 2. A structured error code system that provides detailed, actionable error information Both systems are designed to scale with our expanding product surface while providing developers with a consistent, intuitive experience. ## 2. Resource Identification: URN Structure ### 2.1 Format I propose adopting a URN structure for all Unkey resources: ``` urn:unkey:{service}:{workspace_id}:{environment}:{resource-type}/{resource-id} ``` Where: * `urn:unkey` - Fixed prefix indicating this is a Unkey resource identifier * `service` - The Unkey service (auth, ratelimit, deploy, etc.) * `workspace_id` - ID of the workspace containing the resource * `environment` - Environment within the workspace (production, staging, etc.) * `resource-type` - Type of resource (key, api, identity, etc.) * `resource-id` - Unique identifier for the specific resource For customer cloud deployments, we can extend this pattern: ``` urn:{customer-cloud-id}:{service}:{workspace_id}:{environment}:{resource-type}/{resource-id} ``` ### 2.2 Examples ``` # API resource urn:unkey:auth:ws_123456:production:api/api_abcdef # Key resource urn:unkey:auth:ws_123456:staging:key/key_xyz123 # Rate limit namespace urn:unkey:ratelimit:ws_123456:production:namespace/ns_abc123 # Customer cloud deployment urn:customer_abc:auth:ws_123456:production:key/key_xyz123 ``` ### 2.3 Service Namespace Based on our product roadmap, I propose the following service namespaces: * `auth` - Authentication and authorization * `ratelimit` - Rate limiting service * `identity` - Identity management * `deploy` - Deployment service * `observe` - Observability platform * `audit` - Audit logging service * `secrets` - Secrets management * `billing` - API monetization/billing ## 3. Error Identification: Error Code Structure ### 3.1 Format For error codes, I propose a separate namespace with a more concise format: ``` err:{service}:{category}:{specific-error} ``` Where: * `err` - Fixed prefix indicating this is an error code * `service` - The Unkey service where the error occurred * `category` - Broad category of the error * `specific-error` - Specific error type ### 3.2 Error Categories I recommend standardizing on the following error categories across all services: 1. `state` - Existence, status, and lifecycle issues * Resource not found * Resource already exists * Resource disabled/expired 2. `validation` - Format and content issues * Invalid formats * Schema violations * Constraint violations 3. `permissions` - Access control issues * Insufficient permissions * Unauthorized access * Role requirements 4. `limits` - Quota and capacity issues * Rate limits exceeded * Storage limits exceeded * Quota limits exceeded 5. `configuration` - Setup problems * Invalid settings * Incompatible configurations * Missing required settings ### 3.3 Error Code Examples ``` # Authentication error - key not found err:auth:state:key_not_found # Rate limiting error err:ratelimit:limits:exceeded # Deployment error err:deploy:validation:schema_violation ``` ## 4. Implementation Strategy ### 4.1 API Version Approach I propose implementing these systems using a clean API versioning strategy: * **V1 API (Current)**: Maintains existing ID formats and error responses for complete backward compatibility * **V2 API (New)**: Fully implements the URN and error code systems as the standard approach This creates a clean separation between versions and avoids complex migration paths for existing clients. Developers can choose when to adopt the new systems by migrating to the V2 API. ### 4.2 Implementation Focus For the V2 API implementation: * All resources will be identified using the URN schema * All errors will follow the structured error code format * Documentation will be built around these new systems * Client libraries will support the new formats natively ### 4.3 Documentation and Tooling To support this approach: * Clear migration guides for moving from V1 to V2 * Automatic URN generation for resources created via the V2 API * Comprehensive documentation of the new URN and error systems * Developer tools to help work with and parse URNs ## 5. API Response Examples ### 5.1 Resource Response with URN ```json theme={"theme":"kanagawa-wave"} { "id": "key_xyz123", "urn": "urn:unkey:auth:ws_123456:production:key/key_xyz123", "name": "Production API Key", "enabled": true, "created_at": "2023-01-01T00:00:00Z" } ``` ### 5.2 Error Response with Error Code ```json theme={"theme":"kanagawa-wave"} { "error": { "code": "RATE_LIMITED", "err": "err:ratelimit:limits:exceeded", "message": "You have exceeded your rate limit of 100 requests per minute", "docs": "https://unkey.dev/docs/errors/ratelimit/limits/exceeded", "requestId": "req_1234567890" } } ``` ## 6. Benefits ### 6.1 Technical Benefits * **Consistency**: Uniform identification across all services * **Self-documenting**: Resource identifiers and error codes are descriptive and hierarchical * **Future-proof**: Structure scales to accommodate new services and resource types * **Improved Debugging**: Detailed error information speeds troubleshooting * **Better Logging**: Structured identifiers improve log searchability and correlation ### 6.2 Developer Experience Benefits * **Intuitive Navigation**: Hierarchical structure makes relationships clear * **Better Documentation**: Documentation can be organized to match URN structure * **Error Actionability**: Structured errors guide developers to solutions * **Consistent Patterns**: Same patterns work across all Unkey services * **Familiar Model**: Developers familiar with similar systems will recognize the approach ## 7. Migration Considerations ### 7.1 Database Impact No schema changes are required to existing resources. URNs will be computed dynamically based on existing IDs, services, workspaces, and environments. ### 7.2 API Impact All V2 APIs will need to: * Include URNs in responses * Support the new error code format * Be thoroughly documented with examples ### 7.3 Documentation Impact Documentation will need to be reorganized to: * Explain the URN and error code systems * Provide examples of both systems in use * Possibly reorganize API reference documentation to align with the URN hierarchy ## 8. Conclusion Implementing standardized URNs and error codes across Unkey's growing product surface will significantly improve both our internal systems and developer experience. These systems provide a foundation for scale as we expand our product offerings while maintaining a consistent, intuitive interface for developers. This approach balances immediate needs with long-term scalability, ensuring we can grow our platform without introducing inconsistencies or complexity for developers. ## Appendix A: Common Error Codes Below is an initial set of error codes for key services. This list will expand as new services and features are added. ### Authentication Service (`auth`) * `err:auth:credentials:invalid_key` - API key is invalid or malformed * `err:auth:credentials:missing_key` - Required API key is not provided * `err:auth:permissions:insufficient_scope` - Key lacks required permissions * `err:auth:state:key_disabled` - API key exists but is disabled * `err:auth:state:key_not_found` - Referenced key doesn't exist * `err:auth:state:already_exists` - Resource already exists (conflict) ### Rate Limiting Service (`ratelimit`) * `err:ratelimit:limits:exceeded` - Rate limit has been exceeded * `err:ratelimit:limits:quota_exceeded` - Monthly quota has been exceeded * `err:ratelimit:configuration:invalid_limit` - Invalid rate limit configuration * `err:ratelimit:state:namespace_not_found` - Rate limit namespace doesn't exist ### Identity Service (`identity`) * `err:identity:validation:invalid_external_id` - External ID format is invalid * `err:identity:state:disabled` - Identity is disabled * `err:identity:state:not_found` - Identity doesn't exist * `err:identity:state:already_exists` - Identity already exists with this external ID ## Appendix B: Related Work This proposal draws inspiration from several established systems: * RFC 8141 (URN Syntax) * Amazon Web Services ARNs * Azure Resource IDs * Google Cloud Resource Names # 0012 Stricter Linter Source: https://engineering.unkey.com/architecture/rfcs/0012-stricter-linter Adding more strict lint rules to minimize issues in our codebase. ## Summary Our current linter rules are a bit loose, allowing us to make mistakes like using `as any`, non-null assertions (`shouldntBeNull!`), and redundant conditionals (`true ? true : false`). Although we are careful when reviewing PRs, these things can still slip through. Therefore, we need to introduce a few more rules to make our configuration stricter. ## Motivation To improve code predictability and maintainability using stricter, automated lint rules. This will also make PR reviews more efficient by automatically catching issues like `as any`, saving developers from repeatedly providing the same feedback manually. ## Solution To achieve the goals, we need some new rules. Warnings will highlight potential issues without blocking commits initially, allowing for gradual adoption. ```json theme={"theme":"kanagawa-wave"} "linter": { "enabled": true, "rules": { "recommended": true, "a11y": { "noSvgWithoutTitle": "off", "useSemanticElements": "warn", "useFocusableInteractive": "warn" }, "correctness": { "noUnusedVariables": "error", "useExhaustiveDependencies": "warn", "noUnusedImports": "warn", "noChildrenProp": "off", "useJsxKeyInIterable": "warn", "noUnsafeOptionalChaining": "warn" }, "security": { "noDangerouslySetInnerHtml": "warn" // -> Changed from "off" to "warn" }, "style": { "useConst": "warn", "useBlockStatements": "error", // -> "Old" "noNonNullAssertion": "warn", "noUselessElse": "warn", "useImportType": "warn", "useFragmentSyntax": "warn", "useDefaultSwitchClause": "warn", "useAsConstAssertion": "warn", "useTemplate": "warn", "useNamingConvention": "warn", "noYodaExpression": "warn", "noUnusedTemplateLiteral": "warn", "noNegationElse": "warn", "useSelfClosingElements": "warn", "useShorthandAssign": "warn" }, "performance": { "noDelete": "off" // -> "Old" }, "suspicious": { "recommended": true, "noDoubleEquals": "warn", "useIsArray": "warn", "useAwait": "warn", "noFallthroughSwitchClause": "warn", "noExplicitAny": "warn", "noConsoleLog": "warn" }, "complexity": { "noForEach": "off", // -> "Old" "noUselessTernary": "warn", "noUselessTypeConstraint": "warn", "useSimplifiedLogicExpression": "warn", "noUselessStringConcat": "warn", "useOptionalChain": "warn", "useDateNow": "warn", "noExtraBooleanCast": "warn" } } } ``` ### Accessibility Rules (Making Web Content Usable for Everyone) * **`useSemanticElements`** (`warn`) * **Why:** Enforces the use of HTML elements that convey meaning (like `