From 436a1b662e5c646c553342f082364e9eb7bc5a46 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Sun, 25 Oct 2026 16:01:24 -0700 Subject: [PATCH 1/6] Enforce plan registration caps for devices, agents and humans `PlanLimits` gains `devices` and `humans` (null = unlimited), `UsageMetric` gains the matching registration metrics, and `PLAN_LIMITS` gains the hosted billing tiers: intro 5/15/2, pro 15/60/10, enterprise unlimited. Legacy `free`/`connect` carry no registration caps. Every path that creates a node or agent runs a registration-capacity guard before inserting: `POST /v1/nodes`, `POST /v1/agents`, `POST /v1/a2a/register` and the `agent.register` node frame. A refusal is HTTP 429 `plan_limit_exceeded` with top-level `error_code`, `limit`, `max`, `current` and `plan`; the node-control error frame carries the same fields. Re-enrolling or rotating an existing node/agent is answered by its usual conflict, never by the cap, and an agent's implicit direct node is not a device. `workspaces.plan_limits` (migration 0076) stores per-workspace overrides a control plane pushes; `StaticEntitlementsProvider` applies them over the plan table and counts registrations live from the database. Providers may report the effective plan via the optional `getPlan`. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 7 +- README.md | 1 + openapi.yaml | 53 ++++ packages/engine/CHANGELOG.md | 10 +- .../conformance/inboxArchivedMentions.test.ts | 2 +- .../conformance/planCapacity.test.ts | 244 ++++++++++++++++++ .../conformance/rateLimitContract.test.ts | 2 + .../__tests__/conformance/usageQuota.test.ts | 2 + packages/engine/src/adapters/node/index.ts | 3 +- packages/engine/src/adapters/node/realtime.ts | 11 + .../db/__tests__/compactMigrations.test.ts | 16 +- .../src/db/__tests__/taskMigration.test.ts | 1 + .../migrations/0076_workspace_plan_limits.sql | 6 + packages/engine/src/db/schema.ts | 5 + packages/engine/src/engine.ts | 3 + packages/engine/src/engine/a2a.ts | 4 + packages/engine/src/engine/agent.ts | 13 + packages/engine/src/engine/node.ts | 34 ++- packages/engine/src/engine/planCapacity.ts | 184 +++++++++++++ packages/engine/src/index.ts | 12 + packages/engine/src/lib/httpError.ts | 5 + packages/engine/src/middleware/planLimits.ts | 15 ++ packages/engine/src/ports/entitlements.ts | 51 +++- packages/engine/src/ports/index.ts | 2 + .../__tests__/static-entitlements.test.ts | 58 ++++- .../src/providers/static-entitlements.ts | 47 +++- packages/engine/src/routes/a2a.ts | 2 + packages/engine/src/routes/agent.ts | 2 + packages/engine/src/routes/node.ts | 3 +- 29 files changed, 757 insertions(+), 41 deletions(-) create mode 100644 packages/engine/src/__tests__/conformance/planCapacity.test.ts create mode 100644 packages/engine/src/db/migrations/0076_workspace_plan_limits.sql create mode 100644 packages/engine/src/engine/planCapacity.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e786ac58..43645a3d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,12 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Packages without a separate changelog are covered by the cross-package notes below. -## [Unreleased] +## [Unreleased - Minor] + +### Added + +- Plans cap how many devices (nodes), agents and human members a workspace can register. New `intro` (5/15/2), `pro` (15/60/10) and `enterprise` (unlimited) tiers; a new registration past the cap is refused with HTTP 429 `plan_limit_exceeded` carrying `limit`, `max`, `current` and `plan`, on HTTP and on the node `agent.register` frame. Existing nodes and agents re-register freely. +- A hosting control plane can store per-workspace cap overrides on the workspace row (`workspaces.plan_limits`), which the entitlements provider applies over the plan table. ## [11.0.0] - 2026-10-22 diff --git a/README.md b/README.md index aee41832e..2548effa4 100644 --- a/README.md +++ b/README.md @@ -336,6 +336,7 @@ Two different conditions return HTTP 429, and the error code tells you which: | --- | --- | --- | | `rate_limit_exceeded` | Per-minute request ceiling | Always clears inside the window; `Retry-After` is at most 60 | | `plan_limit_exceeded` | Plan API-call quota for the current billing period | Clears only at the period boundary; `Retry-After` reports it | +| `plan_limit_exceeded` with `limit` | Plan registration cap (`devices`, `agents` or `humans`) hit by a NEW node/agent registration; body carries `max`, `current`, `plan` | Clears when a registration is removed or the plan changes; no `Retry-After` | Both carry `Retry-After` and `X-RateLimit-Reset`; successful responses carry `X-RateLimit-Limit` and `X-RateLimit-Remaining`. `Retry-After` is authoritative diff --git a/openapi.yaml b/openapi.yaml index 79f3809af..3d37aa608 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -19,6 +19,16 @@ info: credential validation and launch preflight stays reachable when the workspace's data-plane traffic is at its ceiling. + Plans also cap how many devices (nodes), agents and human members a + workspace can register. Enrolling a NEW node (`POST /nodes`), registering a + NEW agent (`POST /agents`, `POST /a2a/register`, or an `agent.register` + node frame) beyond the cap returns HTTP 429 `plan_limit_exceeded` with the + top-level fields `error_code`, `limit` (`devices`|`agents`|`humans`), + `max`, `current` and `plan` so a client can explain the refusal and offer + an upgrade. Re-enrolling or rotating an existing node or agent is never + capped. No `Retry-After` is sent: the cap clears only when the workspace + removes a registration or changes plan. + Workspace delivery capacity applies to all local message producers. A write that would exceed it returns HTTP 429 with error code `workspace_delivery_depth_exceeded` and `Retry-After: 30`. A required @@ -182,8 +192,51 @@ components: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + PlanLimitExceeded: + description: >- + The workspace is at its plan's registration cap for the requested kind. + Only a NEW registration is refused; re-enrolling or rotating an existing + node or agent never is. The cap clears when a registration is removed or + the plan changes, so no `Retry-After` is sent. + content: + application/json: + schema: + $ref: '#/components/schemas/PlanLimitExceededResponse' schemas: + PlanLimitExceededResponse: + type: object + required: [ok, error, error_code, limit, max, current, plan] + properties: + ok: + type: boolean + enum: [false] + error: + type: object + required: [code, message] + properties: + code: + type: string + enum: [plan_limit_exceeded] + message: + type: string + error_code: + type: string + enum: [plan_limit_exceeded] + limit: + type: string + enum: [devices, agents, humans] + description: Which registration cap refused the request. + max: + type: integer + description: The plan's cap for `limit`. + current: + type: integer + description: How many are registered now. + plan: + type: string + description: The workspace's effective plan key (for example `intro`, `pro`). + WorkspaceProvenanceInput: type: object description: >- diff --git a/packages/engine/CHANGELOG.md b/packages/engine/CHANGELOG.md index 644e050d5..47d189f7c 100644 --- a/packages/engine/CHANGELOG.md +++ b/packages/engine/CHANGELOG.md @@ -8,7 +8,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [Unreleased - Minor] + +### Added + +- `PlanLimits` gains `devices` and `humans` (`number | null`, null = unlimited) and `UsageMetric` gains `devices`/`humans`; `PLAN_LIMITS` gains `intro` and `pro` takes the billing tiers' caps (intro 5/15/2, pro 15/60/10, enterprise unlimited). Legacy `free`/`connect` carry no registration caps. +- Registration caps are enforced at every path that creates a node or agent: `POST /v1/nodes`, `POST /v1/agents`, `POST /v1/a2a/register` and the `agent.register` node frame. A refusal is HTTP 429 `plan_limit_exceeded` with top-level `error_code`, `limit`, `max`, `current`, `plan` (the same fields on the WebSocket `error` frame). Re-enrolling or rotating an existing node/agent is never capped; an agent's implicit direct node is not a device. +- `EntitlementsProvider.getUsage` reports live registration counts for `devices`/`agents`/`humans` (`countRegistrations` is exported for providers); optional `getPlan` reports the effective plan key for the refusal body. +- `workspaces.plan_limits` (migration `0076`) stores per-workspace cap overrides; `StaticEntitlementsProvider` applies them via `applyWorkspacePlanLimits` and accepts an optional database handle for live counts. +- `createRegistrationCapacityGuard` and `HandleNodeControlMessageArgs.capacity` let a host's node-control transport enforce the agent cap on `agent.register`. ## [11.0.0] - 2026-10-22 diff --git a/packages/engine/src/__tests__/conformance/inboxArchivedMentions.test.ts b/packages/engine/src/__tests__/conformance/inboxArchivedMentions.test.ts index d0116b7d6..cd96de978 100644 --- a/packages/engine/src/__tests__/conformance/inboxArchivedMentions.test.ts +++ b/packages/engine/src/__tests__/conformance/inboxArchivedMentions.test.ts @@ -15,7 +15,7 @@ import { makeNodeStack, createWorkspace, registerAgent, type TestStack } from '. // Test-only entitlements: a high per-minute ceiling so the bounded >200-candidate // fixture is not rate-limited by the free plan. No production limit changes. const testEntitlements = { - getLimits: async () => ({ messages: 1_000_000, agents: 1_000_000, file_bytes: 1_000_000_000, api_calls: 1_000_000, rate_per_min: 100_000 }), + getLimits: async () => ({ messages: 1_000_000, agents: 1_000_000, file_bytes: 1_000_000_000, api_calls: 1_000_000, rate_per_min: 100_000, devices: null, humans: null }), getUsage: async () => 0, }; diff --git a/packages/engine/src/__tests__/conformance/planCapacity.test.ts b/packages/engine/src/__tests__/conformance/planCapacity.test.ts new file mode 100644 index 000000000..018586f6f --- /dev/null +++ b/packages/engine/src/__tests__/conformance/planCapacity.test.ts @@ -0,0 +1,244 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { eq } from 'drizzle-orm'; +import type { EntitlementsProvider, PlanLimits, Workspace, WorkspacePlanLimits } from '../../ports/index.js'; +import { workspaces } from '../../db/schema.js'; +import { PLAN_LIMITS } from '../../providers/static-entitlements.js'; +import { countRegistrations } from '../../engine/planCapacity.js'; +import { createWorkspace, FakeSocket, makeNodeStack, registerAgent, type TestStack } from './harness.js'; + +type Created = { workspaceKey: string; workspaceId: string }; + +const INTRO = PLAN_LIMITS.intro!; + +describe('plan registration caps', () => { + let stack: TestStack; + + afterEach(async () => { + vi.restoreAllMocks(); + await stack?.close(); + }); + + async function workspaceOnPlan(name: string, plan: string, planLimits?: WorkspacePlanLimits): Promise { + const ws = await createWorkspace(stack.app, name); + await stack.runtime.handle.db + .update(workspaces) + .set({ plan, ...(planLimits ? { planLimits } : {}) }) + .where(eq(workspaces.id, ws.workspaceId)); + return ws; + } + + function enrollNode(ws: Created, nodeId: string, name: string, currentToken?: string) { + return stack.app.request('/v1/nodes', { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${ws.workspaceKey}`, + ...(currentToken ? { 'X-Relaycast-Node-Token': currentToken } : {}), + }, + body: JSON.stringify({ node_id: nodeId, name, role: 'broker', capabilities: [], max_agents: 4, tags: ['test'], version: 'v0' }), + }); + } + + function registerAgentRaw(ws: Created, body: Record) { + return stack.app.request('/v1/agents', { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${ws.workspaceKey}` }, + body: JSON.stringify(body), + }); + } + + const refusal = (limit: string, max: number, current: number, plan = 'intro') => ({ + ok: false, + error: { code: 'plan_limit_exceeded' }, + error_code: 'plan_limit_exceeded', + limit, + max, + current, + plan, + }); + + it('allows the 5th device on intro and refuses the 6th with the machine-readable body', async () => { + stack = makeNodeStack(); + const ws = await workspaceOnPlan('cap-devices', 'intro'); + expect(INTRO.devices).toBe(5); + + const tokens: string[] = []; + for (let i = 1; i <= 5; i += 1) { + const res = await enrollNode(ws, `node_${i}`, `device-${i}`); + expect(res.status, `device ${i}`).toBe(201); + tokens.push((await res.json() as { data: { token: string } }).data.token); + } + await expect(countRegistrations(stack.runtime.handle.db, ws.workspaceId, 'devices')).resolves.toBe(5); + + const sixth = await enrollNode(ws, 'node_6', 'device-6'); + expect(sixth.status).toBe(429); + await expect(sixth.json()).resolves.toMatchObject(refusal('devices', 5, 5)); + + // Re-enrolling (rotating) an established device is not a new registration. + const rotate = await enrollNode(ws, 'node_1', 'device-1', tokens[0]); + expect(rotate.status).toBe(201); + await expect(countRegistrations(stack.runtime.handle.db, ws.workspaceId, 'devices')).resolves.toBe(5); + }); + + it('does not count an agent\'s implicit direct node as a device', async () => { + stack = makeNodeStack(); + const ws = await workspaceOnPlan('cap-direct', 'intro', { devices: 1 }); + expect((await enrollNode(ws, 'node_1', 'only-device')).status).toBe(201); + + const agent = await registerAgent(stack.app, ws.workspaceKey, 'worker'); + const nodeToken = await stack.app.request('/v1/agent/node-token', { + method: 'POST', + headers: { authorization: `Bearer ${agent.token}` }, + }); + expect(nodeToken.status).toBe(200); + await expect(countRegistrations(stack.runtime.handle.db, ws.workspaceId, 'devices')).resolves.toBe(1); + expect((await enrollNode(ws, 'node_2', 'second-device')).status).toBe(429); + }); + + it('allows the 15th agent on intro and refuses the 16th; humans are counted separately', async () => { + stack = makeNodeStack(); + const ws = await workspaceOnPlan('cap-agents', 'intro'); + expect(INTRO.agents).toBe(15); + expect(INTRO.humans).toBe(2); + + for (let i = 1; i <= 15; i += 1) { + expect((await registerAgentRaw(ws, { name: `agent-${i}` })).status, `agent ${i}`).toBe(201); + } + const sixteenth = await registerAgentRaw(ws, { name: 'agent-16' }); + expect(sixteenth.status).toBe(429); + await expect(sixteenth.json()).resolves.toMatchObject(refusal('agents', 15, 15)); + + // Humans have their own cap and do not consume agent slots. + expect((await registerAgentRaw(ws, { name: 'will', type: 'human' })).status).toBe(201); + expect((await registerAgentRaw(ws, { name: 'khaliq', type: 'human' })).status).toBe(201); + const third = await registerAgentRaw(ws, { name: 'third-human', type: 'human' }); + expect(third.status).toBe(429); + await expect(third.json()).resolves.toMatchObject(refusal('humans', 2, 2)); + await expect(countRegistrations(stack.runtime.handle.db, ws.workspaceId, 'agents')).resolves.toBe(15); + await expect(countRegistrations(stack.runtime.handle.db, ws.workspaceId, 'humans')).resolves.toBe(2); + }); + + it('answers an established name with the conflict, never the cap', async () => { + stack = makeNodeStack(); + const ws = await workspaceOnPlan('cap-conflict', 'intro', { agents: 1 }); + expect((await registerAgentRaw(ws, { name: 'only' })).status).toBe(201); + const again = await registerAgentRaw(ws, { name: 'only' }); + expect(again.status).toBe(409); + await expect(again.json()).resolves.toMatchObject({ error: { code: 'agent_already_exists' } }); + }); + + it('applies a workspace plan_limits override over the plan table', async () => { + stack = makeNodeStack(); + const ws = await workspaceOnPlan('cap-override', 'enterprise', { devices: 1, agents: null }); + expect((await enrollNode(ws, 'node_1', 'first')).status).toBe(201); + const second = await enrollNode(ws, 'node_2', 'second'); + expect(second.status).toBe(429); + await expect(second.json()).resolves.toMatchObject(refusal('devices', 1, 1, 'enterprise')); + // `agents: null` is unlimited even though it was pushed explicitly. + expect((await registerAgentRaw(ws, { name: 'free-agent' })).status).toBe(201); + }); + + it('refuses a WebSocket agent.register at the cap with the same detail on the error frame', async () => { + stack = makeNodeStack(); + const ws = await workspaceOnPlan('cap-ws', 'intro', { agents: 1 }); + expect((await enrollNode(ws, 'node_a', 'alpha')).status).toBe(201); + + const sock = new FakeSocket(); + const handle = stack.runtime.realtime.attachNodeSocket(ws.workspaceId, 'node_a', sock); + await handle.handleMessage(JSON.stringify({ + v: 1, id: 'reg', type: 'node.register', name: 'alpha', node_id: 'node_a', + capabilities: [{ name: 'spawn:claude', kind: 'capacity' }], + max_agents: 4, tags: [], version: 'test-node', resume_cursor: null, + })); + await handle.handleMessage(JSON.stringify({ v: 1, id: 'a1', type: 'agent.register', name: 'worker-1' })); + expect(sock.ofType('reply').at(-1)).toMatchObject({ id: 'a1', ok: true }); + + await handle.handleMessage(JSON.stringify({ v: 1, id: 'a2', type: 'agent.register', name: 'worker-2' })); + expect(sock.ofType('error').at(-1)).toMatchObject({ + id: 'a2', + ok: false, + code: 'plan_limit_exceeded', + error_code: 'plan_limit_exceeded', + limit: 'agents', + max: 1, + current: 1, + plan: 'intro', + }); + + // Re-registering the established name is a conflict, not a cap refusal. + await handle.handleMessage(JSON.stringify({ v: 1, id: 'a3', type: 'agent.register', name: 'worker-1' })); + expect(sock.ofType('error').at(-1)).toMatchObject({ id: 'a3', code: 'agent_already_exists' }); + }); + + it('counts an A2A proxy identity as an agent', async () => { + stack = makeNodeStack(); + vi.spyOn(globalThis, 'fetch').mockImplementation(async () => new Response('{}', { status: 200 })); + const ws = await workspaceOnPlan('cap-a2a', 'intro', { agents: 1 }); + expect((await registerAgentRaw(ws, { name: 'local' })).status).toBe(201); + + const register = () => stack.app.request('/v1/a2a/register', { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${ws.workspaceKey}` }, + body: JSON.stringify({ + agent_card: { + name: 'northwind', + url: 'https://northwind.example/a2a/rpc', + version: '1.0.0', + skills: [{ id: 'lead', name: 'lead' }], + }, + }), + }); + const refused = await register(); + expect(refused.status).toBe(429); + await expect(refused.json()).resolves.toMatchObject(refusal('agents', 1, 1)); + }); + + it('leaves legacy and self-host tiers uncapped', async () => { + stack = makeNodeStack(); + for (const plan of ['free', 'connect', 'selfhost', 'enterprise']) { + const limits = PLAN_LIMITS[plan]!; + expect(limits.devices, plan).toBeNull(); + expect(limits.humans, plan).toBeNull(); + expect(limits.agents, plan).toBe(Infinity); + } + const ws = await workspaceOnPlan('cap-legacy', 'free'); + for (let i = 1; i <= 6; i += 1) { + expect((await enrollNode(ws, `node_${i}`, `device-${i}`)).status).toBe(201); + } + }); + + it('fails open when the entitlements provider cannot answer', async () => { + const broken: EntitlementsProvider = { + async getLimits(): Promise { + throw new Error('billing unavailable'); + }, + async getUsage() { + return 0; + }, + }; + stack = makeNodeStack({ entitlements: broken }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const ws = await workspaceOnPlan('cap-fail-open', 'intro'); + expect((await enrollNode(ws, 'node_1', 'device')).status).toBe(201); + expect(warn).toHaveBeenCalledWith('[plan-capacity] entitlements lookup failed; allowing registration', expect.objectContaining({ metric: 'devices' })); + }); + + it('reports the provider\'s effective plan in the refusal', async () => { + const raised: EntitlementsProvider = { + async getLimits(workspace: Workspace): Promise { + return { ...PLAN_LIMITS.pro!, devices: 1, ...(workspace.planLimits ?? {}) } as PlanLimits; + }, + getUsage: (workspaceId, metric) => + countRegistrations(stack.runtime.handle.db, workspaceId, metric as 'devices' | 'agents' | 'humans'), + async getPlan() { + return 'pro'; + }, + }; + stack = makeNodeStack({ entitlements: raised }); + const ws = await workspaceOnPlan('cap-effective-plan', 'intro'); + expect((await enrollNode(ws, 'node_1', 'first')).status).toBe(201); + const second = await enrollNode(ws, 'node_2', 'second'); + expect(second.status).toBe(429); + await expect(second.json()).resolves.toMatchObject(refusal('devices', 1, 1, 'pro')); + }); +}); diff --git a/packages/engine/src/__tests__/conformance/rateLimitContract.test.ts b/packages/engine/src/__tests__/conformance/rateLimitContract.test.ts index 6ba66ad6b..6188bcdc6 100644 --- a/packages/engine/src/__tests__/conformance/rateLimitContract.test.ts +++ b/packages/engine/src/__tests__/conformance/rateLimitContract.test.ts @@ -12,6 +12,8 @@ function entitlementsWithRate(ratePerMin: number): EntitlementsProvider { file_bytes: Infinity, api_calls: Infinity, rate_per_min: ratePerMin, + devices: null, + humans: null, }; }, async getUsage() { return 0; }, diff --git a/packages/engine/src/__tests__/conformance/usageQuota.test.ts b/packages/engine/src/__tests__/conformance/usageQuota.test.ts index 5fc2046d9..cafef0edb 100644 --- a/packages/engine/src/__tests__/conformance/usageQuota.test.ts +++ b/packages/engine/src/__tests__/conformance/usageQuota.test.ts @@ -16,6 +16,8 @@ class ApiCallQuotaEntitlements implements EntitlementsProvider { file_bytes: Infinity, api_calls: this.apiCallLimit, rate_per_min: 300, + devices: null, + humans: null, }; } diff --git a/packages/engine/src/adapters/node/index.ts b/packages/engine/src/adapters/node/index.ts index 068ad8bb5..226374c75 100644 --- a/packages/engine/src/adapters/node/index.ts +++ b/packages/engine/src/adapters/node/index.ts @@ -161,7 +161,7 @@ export function createNodeRuntime(options: NodeRuntimeOptions): NodeRuntime { webhookQueue.start(); const auth = options.auth ?? new SqliteApiKeyAuthProvider(); - const entitlements = options.entitlements ?? new StaticEntitlementsProvider(kv); + const entitlements = options.entitlements ?? new StaticEntitlementsProvider(kv, undefined, db); // An explicit host-provided boundary is authoritative (the host may prune // outside this process). Otherwise report the same fallback the local Node @@ -193,6 +193,7 @@ export function createNodeRuntime(options: NodeRuntimeOptions): NodeRuntime { }; realtime.setNodeCompletionDeps(deps); + realtime.setEntitlements(entitlements); const runDeliveryMaintenance = createDeliveryMaintenanceRunner(deps); diff --git a/packages/engine/src/adapters/node/realtime.ts b/packages/engine/src/adapters/node/realtime.ts index 89e2e0dae..c810e49e3 100644 --- a/packages/engine/src/adapters/node/realtime.ts +++ b/packages/engine/src/adapters/node/realtime.ts @@ -26,6 +26,8 @@ import { providerAttachDecision } from '../../engine/placement.js'; import { drainNodeInvocations } from '../../engine/action.js'; import { observerAllowsEvent } from '../../engine/observerToken.js'; import type { InvocationCompletionDeps } from '../../engine/invocationCompletion.js'; +import type { EntitlementsProvider } from '../../ports/entitlements.js'; +import { createRegistrationCapacityGuard } from '../../engine/planCapacity.js'; import type { FleetRelaycastToBrokerMessage } from '@relaycast/types'; /** @@ -108,6 +110,7 @@ export class InProcessRealtime implements RealtimeBus, ConnectionRegistry, NodeC /** Serializes drains per node so concurrent triggers never overlap. */ private readonly nodeDrainChains = new Map>(); private nodeCompletionDeps: InvocationCompletionDeps | undefined; + private entitlements: EntitlementsProvider | undefined; private connectionSeq = 0; constructor(private readonly db: EngineDb) {} @@ -123,6 +126,11 @@ export class InProcessRealtime implements RealtimeBus, ConnectionRegistry, NodeC this.nodeCompletionDeps = deps; } + /** Enforce plan registration caps on `agent.register` frames. */ + setEntitlements(entitlements: EntitlementsProvider): void { + this.entitlements = entitlements; + } + private nodeKey(workspaceId: string, nodeId: string): string { return `${workspaceId}:${nodeId}`; } @@ -844,6 +852,9 @@ export class InProcessRealtime implements RealtimeBus, ConnectionRegistry, NodeC nodeId, socket, connectionId, + capacity: this.entitlements + ? createRegistrationCapacityGuard({ db: this.db, entitlements: this.entitlements, workspace: workspaceId }) + : undefined, raw, }); }, diff --git a/packages/engine/src/db/__tests__/compactMigrations.test.ts b/packages/engine/src/db/__tests__/compactMigrations.test.ts index 1ea2d008d..56b4b8a24 100644 --- a/packages/engine/src/db/__tests__/compactMigrations.test.ts +++ b/packages/engine/src/db/__tests__/compactMigrations.test.ts @@ -53,13 +53,14 @@ function snapshot(handle: SqliteDbHandle) { // so this regression continues to compare all pre-existing user data. // 0070 adds a nullable liveness-recovery marker without backfilling it. if (name === 'workspaces') { - // 0073 adds the rotation grace slot and the generation fence as - // additive columns; exclude them so this comparison stays - // byte-for-byte over every pre-existing value. + // 0073 adds the rotation grace slot and the generation fence, and + // 0076 the plan-limit overrides, as additive columns; exclude them so + // this comparison stays byte-for-byte over every pre-existing value. const { previous_api_key_hash: _previousApiKeyHash, previous_api_key_expires_at: _previousApiKeyExpiresAt, api_key_generation: _apiKeyGeneration, + plan_limits: _planLimits, ...existing } = row as Record; return JSON.stringify(existing); @@ -158,16 +159,17 @@ function expectConstraintsPreserved(before: ReturnType, afte if (table.name === 'agents' && original) { return { ...table, sql: original.sql }; } - // 0073 appends the rotation grace slot and the generation fence. - // Remove only their exact additive DDL so every pre-existing workspace - // constraint stays under comparison. + // 0073 appends the rotation grace slot and the generation fence, and + // 0076 the plan-limit overrides. Remove only their exact additive DDL so + // every pre-existing workspace constraint stays under comparison. if (table.name === 'workspaces') { table = { ...table, sql: table.sql .replace(', previous_api_key_hash TEXT', '') .replace(', previous_api_key_expires_at INTEGER', '') - .replace(', api_key_generation INTEGER NOT NULL DEFAULT 0', ''), + .replace(', api_key_generation INTEGER NOT NULL DEFAULT 0', '') + .replace(', plan_limits TEXT', ''), }; } // 0063 appends four nullable recovery fields. Remove only their exact diff --git a/packages/engine/src/db/__tests__/taskMigration.test.ts b/packages/engine/src/db/__tests__/taskMigration.test.ts index b5cfd36c5..1972e7d6b 100644 --- a/packages/engine/src/db/__tests__/taskMigration.test.ts +++ b/packages/engine/src/db/__tests__/taskMigration.test.ts @@ -39,6 +39,7 @@ it('upgrades existing actions and results additively, preserving rows and every '0073_workspace_key_rotation.sql', '0074_delivery_active_location_node_index.sql', '0075_message_mention_fts.sql', + '0076_workspace_plan_limits.sql', ]); for (const table of before) { const columns = db.pragma(`table_info(${table.name})`) as { name: string }[]; diff --git a/packages/engine/src/db/migrations/0076_workspace_plan_limits.sql b/packages/engine/src/db/migrations/0076_workspace_plan_limits.sql new file mode 100644 index 000000000..42ba4b6a0 --- /dev/null +++ b/packages/engine/src/db/migrations/0076_workspace_plan_limits.sql @@ -0,0 +1,6 @@ +-- Per-workspace registration-cap overrides pushed by the hosting control plane +-- (JSON: devices, agents, humans, session_retention_days; null = unlimited). +-- The entitlements provider applies these over the plan table, so a billing +-- system can change a workspace's caps without a new engine release. NULL +-- means the plan table alone decides. +ALTER TABLE workspaces ADD COLUMN plan_limits TEXT; diff --git a/packages/engine/src/db/schema.ts b/packages/engine/src/db/schema.ts index 4ee26daa8..bb03955c3 100644 --- a/packages/engine/src/db/schema.ts +++ b/packages/engine/src/db/schema.ts @@ -12,6 +12,7 @@ import { import { sql } from 'drizzle-orm'; import type { AnySQLiteColumn } from 'drizzle-orm/sqlite-core'; import type { FleetCapability, FleetTaskContext, WorkspaceProvenance } from '@relaycast/types'; +import type { WorkspacePlanLimits } from '../ports/entitlements.js'; export interface TaskInvocationState extends FleetTaskContext { deadline: string; @@ -67,6 +68,10 @@ export const workspaces = sqliteTable( createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`), metadata: text('metadata', { mode: 'json' }).$type>().default({}), retention: text('retention', { mode: 'json' }).$type(), + // Registration-cap overrides pushed by a control plane (devices/agents/ + // humans; null = unlimited). Applied over the plan table by the + // entitlements provider; null means the plan table alone decides (0076). + planLimits: text('plan_limits', { mode: 'json' }).$type(), // A non-null deadline is an explicit, immutable opt-in to whole-workspace // deletion. Persistent workspaces always leave this null. expiresAt: integer('expires_at', { mode: 'timestamp' }), diff --git a/packages/engine/src/engine.ts b/packages/engine/src/engine.ts index 9b7e07a56..7f7bdb191 100644 --- a/packages/engine/src/engine.ts +++ b/packages/engine/src/engine.ts @@ -1,4 +1,5 @@ import { safeTelemetrySink } from './ports/telemetry.js'; +import { planLimitExceededBody, planLimitExceededDetail } from './engine/planCapacity.js'; import { NoopTelemetrySink } from './providers/noop-telemetry.js'; import { Hono } from 'hono'; import type { ContentfulStatusCode } from 'hono/utils/http-status'; @@ -255,6 +256,8 @@ export function createEngine(deps: EngineDeps): Hono { if (error.code === 'invalid_json' || err instanceof SyntaxError) { return jsonMalformedBody(c); } + const planLimit = planLimitExceededDetail(error); + if (planLimit) return c.json(planLimitExceededBody(error, planLimit), 429); const requestId = c.get('requestId'); return c.json( { diff --git a/packages/engine/src/engine/a2a.ts b/packages/engine/src/engine/a2a.ts index bc96f4ca4..46c561250 100644 --- a/packages/engine/src/engine/a2a.ts +++ b/packages/engine/src/engine/a2a.ts @@ -26,6 +26,7 @@ import type { FileAttachment } from '@relaycast/types'; import type { getDb } from '../db/index.js'; import { a2aAgents, agents } from '../db/schema.js'; import { registerAgent, getAgentByName, updateAgent, updateAgentById } from './agent.js'; +import type { RegistrationCapacityGuard } from './planCapacity.js'; import { rotateAgentToken } from './tokenRotate.js'; import { createAndRunCertification } from './certify.js'; import { isSafeExternalUrl } from '../lib/ssrf.js'; @@ -90,6 +91,8 @@ export interface RegisterA2aAgentInput { authCredential?: string; /** Remote skill/agent that DMs to this proxy should address. */ targetAgent?: string; + /** Plan registration-cap guard, consulted only when a NEW proxy identity is provisioned. */ + capacity?: RegistrationCapacityGuard; } export interface RegisterA2aAgentResult { @@ -342,6 +345,7 @@ export async function registerA2aAgent( } else { const provisioned = await registerAgent(db, workspaceId, { name: relayName, + capacity: input.capacity, type: 'external', persona: `External A2A agent proxy for ${agentCard.name}`, metadata: proxyMetadata, diff --git a/packages/engine/src/engine/agent.ts b/packages/engine/src/engine/agent.ts index 40413c106..432f31b60 100644 --- a/packages/engine/src/engine/agent.ts +++ b/packages/engine/src/engine/agent.ts @@ -6,6 +6,7 @@ import { generateId } from './snowflake.js'; import { invalidateChannelCache } from './cache.js'; import { queryInChunks } from '../lib/queryChunks.js'; import { codedError } from '../lib/httpError.js'; +import type { RegistrationCapacityGuard } from './planCapacity.js'; import { directNodeIdForAgent } from './node.js'; import { addressNodeSelection, formatAgentAddress } from './address.js'; import { runAtomicWrites, type AtomicWrite } from '../ports/database.js'; @@ -216,6 +217,8 @@ export async function registerAgent( autoJoinGeneral?: boolean; recoveryProofHash?: string; workUnitId?: string; + /** Plan registration-cap guard, consulted only when `name` is NEW to the workspace. */ + capacity?: RegistrationCapacityGuard; }, /** * Internal. Set only by the verifier-reclaim retry below, so that retry can @@ -242,6 +245,16 @@ export async function registerAgent( 400, ); } + if (data.capacity && !afterVerifierReclaim) { + // Only a NEW identity counts against the plan. An established name is + // answered by the unique-constraint conflict below — never by the cap — + // so a register-or-rotate client at its cap still reaches its rotation. + const [established] = await db + .select({ id: agents.id }) + .from(agents) + .where(and(eq(agents.workspaceId, workspaceId), eq(agents.name, data.name))); + if (!established) await data.capacity((data.type ?? 'agent') === 'human' ? 'humans' : 'agents'); + } const agentId = generateId(); const directMachineId = data.directMachinePrefix ? `${data.directMachinePrefix}-${agentId}` : null; const token = `at_live_${randomHex(16)}`; diff --git a/packages/engine/src/engine/node.ts b/packages/engine/src/engine/node.ts index 31f481116..a698fc24d 100644 --- a/packages/engine/src/engine/node.ts +++ b/packages/engine/src/engine/node.ts @@ -24,6 +24,7 @@ import type { getDb } from '../db/index.js'; import { actionInvocations, agents, agentNodeBindings, channelMembers, channels, nodeProviders, nodes } from '../db/schema.js'; import { randomHex, sha256Hex } from '../lib/crypto.js'; import { codedError } from '../lib/httpError.js'; +import { planLimitExceededDetail, type RegistrationCapacityGuard } from './planCapacity.js'; import { acceptTaskInvocation, completeTaskInvocation } from './taskInvocation.js'; import { databaseConstraintKind, runAtomic, runAtomicWrites } from '../ports/database.js'; import { retryD1Write } from '../lib/d1Retry.js'; @@ -88,6 +89,11 @@ export interface HandleNodeControlMessageArgs { * caller drives node control without a registry-managed socket. */ connectionId?: string; + /** + * Plan registration-cap guard for `agent.register`. Absent when the host + * enforces no caps (or drives node control without entitlements). + */ + capacity?: RegistrationCapacityGuard; raw: string; } @@ -439,6 +445,8 @@ export async function createNodeToken( idempotencyKeyHash: string; requestDigest: string; }; + /** Plan registration-cap guard, consulted only when this enrolls a NEW device. */ + capacity?: RegistrationCapacityGuard; } = {}, ) { const token = authorization.rotationRecovery?.replacementToken ?? `nt_live_${randomHex(24)}`; @@ -484,6 +492,12 @@ export async function createNodeToken( if (role === 'direct' && maxAgents !== 1) { throw codedError('Direct nodes can bind at most one agent', 'direct_node_capacity_exceeded', 400); } + // Only a NEW device counts against the plan. Rotating or re-enrolling an + // established row never does, and an agent's implicit direct node is its + // own WebSocket, not a device. + if (!existing && role !== 'direct' && authorization.capacity) { + await authorization.capacity('devices'); + } if (existing) { const hasPresentedTokenProof = authorization.expectedTokenHash !== undefined; @@ -2116,10 +2130,19 @@ export async function registerAgentViaNode( nodeId: string, providerName: string, message: FleetAgentRegisterMessage, - options: { deliveryCursorSupported?: boolean } = {}, + options: { deliveryCursorSupported?: boolean; capacity?: RegistrationCapacityGuard } = {}, ): Promise { assertRegistrableAgentName(message.name); const wantsAutoJoin = message.auto_join_general !== false; + if (options.capacity) { + // Only a NEW identity counts against the plan: an established name is + // answered by the conflict below, never by the cap. + const [established] = await db + .select({ id: agents.id }) + .from(agents) + .where(and(eq(agents.workspaceId, workspaceId), eq(agents.name, message.name))); + if (!established) await options.capacity('agents'); + } const registered = await runAtomic(db, async (tx) => { // Every awaited statement is a separate D1 round-trip — and this path runs // under the node-control liveness deadline whose misses surface as @@ -3551,7 +3574,10 @@ export async function handleNodeControlMessage(args: HandleNodeControlMessageArg args.nodeId, frameProviderName, message, - { deliveryCursorSupported: supportsProviderDeliveryReadiness(args.registry) }, + { + deliveryCursorSupported: supportsProviderDeliveryReadiness(args.registry), + capacity: args.capacity, + }, ); // The relay broker's node_control client awaits a `reply` frame keyed by // the request id (it matches `pending_agent_registrations` by `reply.id` @@ -3767,6 +3793,9 @@ export async function handleNodeControlMessage(args: HandleNodeControlMessageArg provider: frameProviderName, message: error.message, }); + // A plan-cap refusal carries the same machine-readable detail as the + // HTTP 429 (`error_code`, `limit`, `max`, `current`, `plan`). + const planLimit = planLimitExceededDetail(err); sendControl(args.socket, { v: 1, id: requestId(message), @@ -3774,6 +3803,7 @@ export async function handleNodeControlMessage(args: HandleNodeControlMessageArg ok: false, code, message: error.message, + ...(planLimit ? { error_code: code, ...planLimit } : {}), }); } }); diff --git a/packages/engine/src/engine/planCapacity.ts b/packages/engine/src/engine/planCapacity.ts new file mode 100644 index 000000000..c8f6943d3 --- /dev/null +++ b/packages/engine/src/engine/planCapacity.ts @@ -0,0 +1,184 @@ +import { and, eq, ne, sql } from 'drizzle-orm'; +import type { EngineDb } from '../ports/database.js'; +import type { + EntitlementsProvider, + PlanLimits, + RegistrationMetric, + WorkspacePlanLimits, +} from '../ports/entitlements.js'; +import type { Workspace } from '../ports/auth.js'; +import { agents, nodes, workspaces } from '../db/schema.js'; +import type { CodedError } from '../lib/httpError.js'; + +export const PLAN_LIMIT_EXCEEDED = 'plan_limit_exceeded'; + +/** The machine-readable detail a registration-cap refusal carries. */ +export interface PlanLimitExceededDetail { + limit: RegistrationMetric; + max: number; + current: number; + plan: string; +} + +export interface PlanLimitExceededError extends CodedError { + code: typeof PLAN_LIMIT_EXCEEDED; + status: 429; + planLimit: PlanLimitExceededDetail; +} + +const REGISTRATION_NOUN: Record = { + devices: 'device', + agents: 'agent', + humans: 'human member', +}; + +export function planLimitExceededError(detail: PlanLimitExceededDetail): PlanLimitExceededError { + const message = + `Plan limit exceeded for ${detail.limit}: the ${detail.plan} plan allows ${detail.max} ` + + `${REGISTRATION_NOUN[detail.limit]}${detail.max === 1 ? '' : 's'} and ${detail.current} ` + + `${detail.current === 1 ? 'is' : 'are'} registered. Upgrade your plan to register more.`; + return Object.assign(new Error(message), { + code: PLAN_LIMIT_EXCEEDED, + status: 429, + clientSafe: true as const, + planLimit: detail, + }) as PlanLimitExceededError; +} + +/** The cap detail when `err` is a registration-cap refusal, else `null`. */ +export function planLimitExceededDetail(err: unknown): PlanLimitExceededDetail | null { + if (!err || typeof err !== 'object') return null; + const candidate = err as Partial; + if (candidate.code !== PLAN_LIMIT_EXCEEDED) return null; + const detail = candidate.planLimit; + if (!detail || typeof detail !== 'object') return null; + if (typeof detail.limit !== 'string' || typeof detail.max !== 'number' || typeof detail.current !== 'number') { + return null; + } + return { limit: detail.limit, max: detail.max, current: detail.current, plan: String(detail.plan) }; +} + +/** + * The HTTP 429 body for a registration-cap refusal: the standard error + * envelope plus the top-level `error_code`/`limit`/`max`/`current`/`plan` + * fields clients key on to explain the refusal and offer an upgrade. + */ +export function planLimitExceededBody(error: PlanLimitExceededError | CodedError, detail: PlanLimitExceededDetail) { + return { + ok: false as const, + error: { code: PLAN_LIMIT_EXCEEDED, message: error.message }, + error_code: PLAN_LIMIT_EXCEEDED, + ...detail, + }; +} + +/** A finite non-negative cap, or `null` for unlimited / unset / malformed. */ +export function registrationCap(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? Math.floor(value) : null; +} + +/** + * Overlay a workspace's stored cap overrides on its plan's limits. `null` is + * unlimited and a finite non-negative number is the cap; an absent or + * malformed key keeps the plan's value, so a corrupt override can never + * widen or tighten a cap by accident. + */ +export function applyWorkspacePlanLimits( + base: PlanLimits, + overrides: WorkspacePlanLimits | null | undefined, +): PlanLimits { + if (!overrides || typeof overrides !== 'object') return base; + const override = (value: unknown): number | null | undefined => + value === null ? null : registrationCap(value) ?? undefined; + const limits: PlanLimits = { ...base }; + const devices = override(overrides.devices); + if (devices !== undefined) limits.devices = devices; + const humans = override(overrides.humans); + if (humans !== undefined) limits.humans = humans; + const agents = override(overrides.agents); + if (agents !== undefined) limits.agents = agents ?? Infinity; + return limits; +} + +/** + * How many of `metric` the workspace currently has registered — the number a + * new registration is compared against. + * + * - `devices`: nodes a host enrolled (`role <> 'direct'`). Every agent owns an + * implicit direct node for its own WebSocket; those are not devices. + * - `agents`: non-human identities that have not been released. + * - `humans`: `type = 'human'` identities that have not been released. + */ +export async function countRegistrations( + db: EngineDb, + workspaceId: string, + metric: RegistrationMetric, +): Promise { + const count = sql`count(*)`; + const rows = metric === 'devices' + ? await db + .select({ count }) + .from(nodes) + .where(and(eq(nodes.workspaceId, workspaceId), ne(nodes.role, 'direct'))) + : await db + .select({ count }) + .from(agents) + .where(and( + eq(agents.workspaceId, workspaceId), + // Tombstoned identities (`RELEASED_AGENT_STATUS` in agent.ts) hold no slot. + ne(agents.status, 'released'), + metric === 'humans' ? eq(agents.type, 'human') : ne(agents.type, 'human'), + )); + return Number(rows[0]?.count ?? 0); +} + +/** + * Refuses a NEW registration of `metric` when the workspace is at its cap. + * Callers invoke it only on the create path — re-registering an existing + * node or agent never counts as new. + */ +export type RegistrationCapacityGuard = (metric: RegistrationMetric) => Promise; + +export interface RegistrationCapacityArgs { + db: EngineDb; + entitlements: EntitlementsProvider; + /** The authenticated workspace row, or its id when the caller only has that. */ + workspace: Workspace | string; +} + +async function resolveWorkspace(db: EngineDb, workspace: Workspace | string): Promise { + if (typeof workspace !== 'string') return workspace; + const [row] = await db.select().from(workspaces).where(eq(workspaces.id, workspace)); + return row ?? null; +} + +/** + * Build the guard the registration paths run before inserting a node or agent. + * The provider decides the cap (`getLimits`) and the current count + * (`getUsage`); a provider failure fails open with a warning, matching the + * api_calls quota middleware — a billing outage must not block registration. + */ +export function createRegistrationCapacityGuard(args: RegistrationCapacityArgs): RegistrationCapacityGuard { + return async (metric) => { + let detail: PlanLimitExceededDetail | null = null; + try { + const workspace = await resolveWorkspace(args.db, args.workspace); + if (!workspace) return; + const limits = await args.entitlements.getLimits(workspace); + const max = registrationCap(limits[metric]); + if (max === null) return; + const current = await args.entitlements.getUsage(workspace.id, metric); + if (current < max) return; + const plan = (await args.entitlements.getPlan?.(workspace)) ?? workspace.plan; + detail = { limit: metric, max, current, plan }; + } catch (err) { + console.warn('[plan-capacity] entitlements lookup failed; allowing registration', { + metric, + workspaceId: typeof args.workspace === 'string' ? args.workspace : args.workspace.id, + error: err instanceof Error ? err.message : String(err), + }); + return; + } + throw planLimitExceededError(detail); + }; +} diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index a31718b53..43d98e71a 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -44,6 +44,18 @@ export { observerAllowsEvent, type ObserverToken } from './engine/observerToken. // OSS default providers (self-host). Cloud injects its own. export { SqliteApiKeyAuthProvider, hashToken } from './auth/index.js'; export { StaticEntitlementsProvider, PLAN_LIMITS } from './providers/static-entitlements.js'; +// Plan registration caps (devices/agents/humans): the live counts a provider's +// `getUsage` reports, the override merge, and the guard a host wires into its +// node-control transport so `agent.register` frames are capped like HTTP. +export { + applyWorkspacePlanLimits, + countRegistrations, + createRegistrationCapacityGuard, + planLimitExceededDetail, + PLAN_LIMIT_EXCEEDED, + type PlanLimitExceededDetail, + type RegistrationCapacityGuard, +} from './engine/planCapacity.js'; // Usage-counter key contract. An `EntitlementsProvider` reads the counters the // engine writes, so it has to agree with the engine on the billing period the // key is scoped to — build the key with these rather than by hand. diff --git a/packages/engine/src/lib/httpError.ts b/packages/engine/src/lib/httpError.ts index a1384cf98..3cd3172c8 100644 --- a/packages/engine/src/lib/httpError.ts +++ b/packages/engine/src/lib/httpError.ts @@ -1,6 +1,7 @@ import type { Context } from 'hono'; import type { ContentfulStatusCode } from 'hono/utils/http-status'; import { jsonError, jsonMalformedBody } from './httpResponse.js'; +import { planLimitExceededBody, planLimitExceededDetail } from '../engine/planCapacity.js'; /** * Errors thrown inside the engine optionally carry a stable error `code` and an @@ -91,6 +92,10 @@ export function errorResponse(c: Context, err: unknown) { if (err instanceof SyntaxError) { return jsonMalformedBody(c); } + // A registration-cap refusal carries machine-readable detail beyond the + // envelope (`error_code`, `limit`, `max`, `current`, `plan`). + const planLimit = planLimitExceededDetail(error); + if (planLimit) return c.json(planLimitExceededBody(error, planLimit), 429); const status = effectiveHttpStatus(error); // Throw the normalized Error: Hono hands only `Error` instances to `onError`. if (!error.code && status >= 500) throw error; diff --git a/packages/engine/src/middleware/planLimits.ts b/packages/engine/src/middleware/planLimits.ts index 7fd81dd50..1a23bb012 100644 --- a/packages/engine/src/middleware/planLimits.ts +++ b/packages/engine/src/middleware/planLimits.ts @@ -1,5 +1,7 @@ import { createMiddleware } from 'hono/factory'; +import type { Context } from 'hono'; import type { AppEnv } from '../env.js'; +import { createRegistrationCapacityGuard, type RegistrationCapacityGuard } from '../engine/planCapacity.js'; import type { EntitlementsProvider, UsageMetric } from '../ports/entitlements.js'; import { usagePeriodResetAt } from '../engine/usage.js'; import { jsonError } from '../lib/httpResponse.js'; @@ -57,3 +59,16 @@ export function checkPlanLimit(metric: UsageMetric) { await next(); }); } + +/** + * The plan registration-cap guard for the authenticated workspace on this + * request. Registration routes hand it to the engine, which runs it only when + * the request would create a NEW node or agent. + */ +export function registrationCapacityGuard(c: Context): RegistrationCapacityGuard { + return createRegistrationCapacityGuard({ + db: c.get('db'), + entitlements: c.get('engine').entitlements, + workspace: c.get('workspace'), + }); +} diff --git a/packages/engine/src/ports/entitlements.ts b/packages/engine/src/ports/entitlements.ts index 43050b37b..4fb4fda59 100644 --- a/packages/engine/src/ports/entitlements.ts +++ b/packages/engine/src/ports/entitlements.ts @@ -1,31 +1,63 @@ import type { Workspace } from './auth.js'; -export type UsageMetric = 'messages' | 'agents' | 'file_bytes' | 'api_calls'; +/** + * `messages`/`file_bytes`/`api_calls` are metered per billing period; + * `devices`/`agents`/`humans` are registration counts — how many of each a + * workspace currently has enrolled — compared against the plan's hard cap when + * a new one is registered. + */ +export type UsageMetric = 'messages' | 'agents' | 'file_bytes' | 'api_calls' | 'devices' | 'humans'; + +/** The metrics enforced as hard registration caps. */ +export type RegistrationMetric = 'devices' | 'agents' | 'humans'; export interface PlanLimits { messages: number; + /** Registered non-human agents. `Infinity` = unlimited. */ agents: number; file_bytes: number; /** Total authenticated API calls allowed for the current billing window. */ api_calls: number; /** Global requests-per-minute ceiling, fed into the rate limiter. */ rate_per_min: number; + /** Registered devices (broker/http_push nodes; implicit direct nodes excluded). `null` = unlimited. */ + devices: number | null; + /** Registered human members (`agents.type = 'human'`). `null` = unlimited. */ + humans: number | null; +} + +/** + * Per-workspace registration-cap overrides a control plane stores on the + * workspace row (`workspaces.plan_limits`). A present key wins over the plan + * table; `null` means unlimited; an absent key falls through to the plan. + * `session_retention_days` is carried for the host's own retention policy and + * is not an engine limit. + */ +export interface WorkspacePlanLimits { + devices?: number | null; + agents?: number | null; + humans?: number | null; + session_retention_days?: number | null; } /** * Pluggable plan limits / usage — the billing seam. * * Self-host uses `StaticEntitlementsProvider`, seeded from today's hardcoded - * `PLAN_LIMITS` (free/connect/pro/enterprise), defaulting single-tenant deployments to a - * generous/unlimited tier. The cloud product injects a provider that resolves - * quotas from its billing system (e.g. a Stripe subscription) per workspace and - * reports metered usage. + * `PLAN_LIMITS` (free/connect/intro/pro/enterprise), defaulting single-tenant + * deployments to a generous/unlimited tier. The cloud product injects a provider + * that resolves quotas from its billing system (e.g. a Stripe subscription) per + * workspace and reports metered usage. */ export interface EntitlementsProvider { /** Resolve the active plan limits for a workspace. */ getLimits(workspace: Workspace): Promise; - /** Current usage for a metric, compared against the limit before allowing an action. */ + /** + * Current usage for a metric, compared against the limit before allowing an + * action. For a {@link RegistrationMetric} this is the number currently + * registered (see `countRegistrations`), not a per-period counter. + */ getUsage(workspaceId: string, metric: UsageMetric): Promise; /** @@ -36,4 +68,11 @@ export interface EntitlementsProvider { * UTC-month period. */ getUsageResetAt?(workspaceId: string, metric: UsageMetric): Promise; + + /** + * The effective plan key reported in a `plan_limit_exceeded` refusal. + * Optional: a provider that raises a workspace above its stored `plan` + * column reports the raised key; omitting it reports `workspace.plan`. + */ + getPlan?(workspace: Workspace): Promise; } diff --git a/packages/engine/src/ports/index.ts b/packages/engine/src/ports/index.ts index bd0d462e8..79cdd0af0 100644 --- a/packages/engine/src/ports/index.ts +++ b/packages/engine/src/ports/index.ts @@ -44,7 +44,9 @@ export type { export type { EntitlementsProvider, PlanLimits, + RegistrationMetric, UsageMetric, + WorkspacePlanLimits, } from './entitlements.js'; export { ENGINE_OBSERVATION_FACTS } from './telemetry.js'; export type { TelemetrySink, TelemetryEvent, EngineObservation, EngineObservationKind, EngineObservationFacts } from './telemetry.js'; diff --git a/packages/engine/src/providers/__tests__/static-entitlements.test.ts b/packages/engine/src/providers/__tests__/static-entitlements.test.ts index b49c5aa25..9b452e7a4 100644 --- a/packages/engine/src/providers/__tests__/static-entitlements.test.ts +++ b/packages/engine/src/providers/__tests__/static-entitlements.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from 'vitest'; import { PLAN_LIMITS, StaticEntitlementsProvider } from '../static-entitlements.js'; -import type { Workspace } from '../../ports/index.js'; +import { applyWorkspacePlanLimits } from '../../engine/planCapacity.js'; +import type { Workspace, WorkspacePlanLimits } from '../../ports/index.js'; -function workspace(plan: string): Workspace { - return { id: 'ws_1', plan } as Workspace; +function workspace(plan: string, planLimits: WorkspacePlanLimits | null = null): Workspace { + return { id: 'ws_1', plan, planLimits } as Workspace; } describe('StaticEntitlementsProvider', () => { @@ -12,14 +13,13 @@ describe('StaticEntitlementsProvider', () => { const limits = await provider.getLimits(workspace('connect')); expect(limits).toBe(PLAN_LIMITS.connect); expect(limits.api_calls).toBe(10_000_000); - expect(limits.agents).toBe(10); }); - it('sizes connect between free and pro on every metric', () => { - for (const metric of ['messages', 'agents', 'file_bytes', 'api_calls', 'rate_per_min'] as const) { + it('sizes connect between free and pro on every metered metric', () => { + for (const metric of ['messages', 'file_bytes', 'api_calls', 'rate_per_min'] as const) { expect(PLAN_LIMITS.connect![metric]).toBeGreaterThan(PLAN_LIMITS.free![metric]); } - for (const metric of ['messages', 'agents', 'file_bytes', 'rate_per_min'] as const) { + for (const metric of ['messages', 'file_bytes', 'rate_per_min'] as const) { expect(PLAN_LIMITS.connect![metric]).toBeLessThan(PLAN_LIMITS.pro![metric]); } // Rooms poll constantly; connect's call budget is set to pro's for @@ -27,8 +27,52 @@ describe('StaticEntitlementsProvider', () => { expect(PLAN_LIMITS.connect!.api_calls).toBeLessThanOrEqual(PLAN_LIMITS.pro!.api_calls); }); + it('carries the billing tiers\' registration caps', () => { + expect(PLAN_LIMITS.intro).toMatchObject({ devices: 5, agents: 15, humans: 2 }); + expect(PLAN_LIMITS.pro).toMatchObject({ devices: 15, agents: 60, humans: 10 }); + expect(PLAN_LIMITS.enterprise).toMatchObject({ devices: null, agents: Infinity, humans: null }); + // Legacy hosted tiers predate registration caps and carry none. + for (const plan of ['free', 'connect', 'selfhost'] as const) { + expect(PLAN_LIMITS[plan]).toMatchObject({ devices: null, agents: Infinity, humans: null }); + } + }); + it('falls back to the default plan for an unknown plan', async () => { const provider = new StaticEntitlementsProvider(undefined, 'free'); expect(await provider.getLimits(workspace('platinum'))).toBe(PLAN_LIMITS.free); }); + + it('overlays the workspace row\'s plan_limits on the plan table', async () => { + const provider = new StaticEntitlementsProvider(undefined, 'free'); + const limits = await provider.getLimits(workspace('intro', { devices: 7, humans: null, session_retention_days: 7 })); + expect(limits).toMatchObject({ devices: 7, humans: null, agents: 15, api_calls: PLAN_LIMITS.intro!.api_calls }); + }); + + it('reports a registration count of zero without a database handle', async () => { + const provider = new StaticEntitlementsProvider(undefined, 'intro'); + await expect(provider.getUsage('ws_1', 'devices')).resolves.toBe(0); + }); +}); + +describe('applyWorkspacePlanLimits', () => { + const base = PLAN_LIMITS.intro!; + + it('returns the plan unchanged without overrides', () => { + expect(applyWorkspacePlanLimits(base, null)).toBe(base); + expect(applyWorkspacePlanLimits(base, undefined)).toBe(base); + expect(applyWorkspacePlanLimits(base, {})).toEqual(base); + }); + + it('treats a present null as unlimited and an absent key as the plan value', () => { + expect(applyWorkspacePlanLimits(base, { devices: null, agents: null })).toMatchObject({ + devices: null, + agents: Infinity, + humans: 2, + }); + }); + + it('keeps the plan value for a malformed override', () => { + const overrides = { devices: -1, agents: 'ten', humans: Number.NaN } as unknown as WorkspacePlanLimits; + expect(applyWorkspacePlanLimits(base, overrides)).toEqual(base); + }); }); diff --git a/packages/engine/src/providers/static-entitlements.ts b/packages/engine/src/providers/static-entitlements.ts index ab3c6fb41..19f6b2512 100644 --- a/packages/engine/src/providers/static-entitlements.ts +++ b/packages/engine/src/providers/static-entitlements.ts @@ -1,43 +1,62 @@ import type { EntitlementsProvider, PlanLimits, UsageMetric, Workspace } from '../ports/index.js'; +import type { EngineDb } from '../ports/database.js'; import type { KeyValueStore } from '../ports/kv.js'; import { getUsageMetric, usagePeriodResetAt } from '../engine/usage.js'; +import { applyWorkspacePlanLimits, countRegistrations } from '../engine/planCapacity.js'; + +const GiB = 1024 * 1024 * 1024; /** - * The default plan limits, lifted from the original `middleware/planLimits.ts`. - * Self-host deployments typically run the `selfhost` tier (everything - * unlimited); the free/connect/pro/enterprise tiers are kept so the same code serves a - * tiered deployment if a workspace's `plan` column is set. + * The default plan limits. Self-host deployments typically run the `selfhost` + * tier (everything unlimited). + * + * `intro`, `pro` and `enterprise` are the hosted billing tiers; their + * `devices`/`agents`/`humans` caps are hard registration limits. `free` and + * `connect` are legacy hosted tiers that predate registration caps and carry + * none, so a workspace that was never moved onto a billing tier is never + * refused a registration. */ export const PLAN_LIMITS: Record = { - free: { messages: 10_000, agents: 5, file_bytes: 100 * 1024 * 1024, api_calls: 100_000, rate_per_min: 300 }, + free: { messages: 10_000, agents: Infinity, file_bytes: 100 * 1024 * 1024, api_calls: 100_000, rate_per_min: 300, devices: null, humans: null }, // Short-lived collaboration rooms (Relay Connect): a handful of seats that // poll their inbox continuously, so api_calls dominates while messages stay // low. Sized for a two-seat room polling every few seconds for a full month. - connect: { messages: 100_000, agents: 10, file_bytes: 1024 * 1024 * 1024, api_calls: 10_000_000, rate_per_min: 1200 }, - pro: { messages: 1_000_000, agents: 100, file_bytes: 50 * 1024 * 1024 * 1024, api_calls: 10_000_000, rate_per_min: 6000 }, - enterprise: { messages: Infinity, agents: Infinity, file_bytes: 500 * 1024 * 1024 * 1024, api_calls: Infinity, rate_per_min: 30000 }, - selfhost: { messages: Infinity, agents: Infinity, file_bytes: Infinity, api_calls: Infinity, rate_per_min: 30000 }, + connect: { messages: 100_000, agents: Infinity, file_bytes: 1 * GiB, api_calls: 10_000_000, rate_per_min: 1200, devices: null, humans: null }, + intro: { messages: 1_000_000, agents: 15, file_bytes: 50 * GiB, api_calls: 10_000_000, rate_per_min: 6000, devices: 5, humans: 2 }, + pro: { messages: 1_000_000, agents: 60, file_bytes: 50 * GiB, api_calls: 10_000_000, rate_per_min: 6000, devices: 15, humans: 10 }, + enterprise: { messages: Infinity, agents: Infinity, file_bytes: 500 * GiB, api_calls: Infinity, rate_per_min: 30000, devices: null, humans: null }, + selfhost: { messages: Infinity, agents: Infinity, file_bytes: Infinity, api_calls: Infinity, rate_per_min: 30000, devices: null, humans: null }, }; +const REGISTRATION_METRICS = new Set(['devices', 'agents', 'humans']); + /** * Static, table-driven entitlements — the self-host default. Resolves limits - * from {@link PLAN_LIMITS} keyed by `workspace.plan`, and reads usage counters - * from the key/value store (the same `usage:::` keys the - * usage tracker increments, so both agree on the current billing period). The - * cloud product injects a billing-backed provider. + * from {@link PLAN_LIMITS} keyed by `workspace.plan`, overlaid with the row's + * own `plan_limits` overrides, and reads usage counters from the key/value + * store (the same `usage:::` keys the usage tracker + * increments, so both agree on the current billing period). Registration + * metrics are counted live from the database. The cloud product injects a + * billing-backed provider. */ export class StaticEntitlementsProvider implements EntitlementsProvider { constructor( private readonly kv?: KeyValueStore, private readonly defaultPlan: string = 'selfhost', + private readonly db?: EngineDb, ) {} async getLimits(workspace: Workspace): Promise { const plan = workspace.plan || this.defaultPlan; - return PLAN_LIMITS[plan] ?? PLAN_LIMITS[this.defaultPlan]; + const base = PLAN_LIMITS[plan] ?? PLAN_LIMITS[this.defaultPlan]!; + return applyWorkspacePlanLimits(base, workspace.planLimits); } async getUsage(workspaceId: string, metric: UsageMetric): Promise { + if (REGISTRATION_METRICS.has(metric)) { + if (!this.db) return 0; + return countRegistrations(this.db, workspaceId, metric as 'devices' | 'agents' | 'humans'); + } if (!this.kv) return 0; try { return await getUsageMetric(this.kv, workspaceId, metric); diff --git a/packages/engine/src/routes/a2a.ts b/packages/engine/src/routes/a2a.ts index b2b582b13..6ea36077a 100644 --- a/packages/engine/src/routes/a2a.ts +++ b/packages/engine/src/routes/a2a.ts @@ -15,6 +15,7 @@ import { import { getWorkspaceExpiry } from '../engine/workspace.js'; import { asCodedError, codedError, errorResponse, type CodedError } from '../lib/httpError.js'; import { rateLimit } from '../middleware/rateLimit.js'; +import { registrationCapacityGuard } from '../middleware/planLimits.js'; import { buildIdempotencyStorageKey, runIdempotent } from '../middleware/idempotency.js'; import { sha256Hex } from '../lib/crypto.js'; import { runInBackground } from './background.js'; @@ -264,6 +265,7 @@ a2aRoutes.post('/v1/a2a/register', requireAuth, rateLimit, async (c) => { authScheme: parsed.data.auth_scheme, authCredential: parsed.data.auth_credential, targetAgent: parsed.data.target_agent, + capacity: registrationCapacityGuard(c), }); return jsonCreated(c, { diff --git a/packages/engine/src/routes/agent.ts b/packages/engine/src/routes/agent.ts index 442f5d307..fb124102c 100644 --- a/packages/engine/src/routes/agent.ts +++ b/packages/engine/src/routes/agent.ts @@ -12,6 +12,7 @@ import { } from '../auth/workspaceExpiry.js'; import { getWorkspaceExpiry } from '../engine/workspace.js'; import { rateLimit } from '../middleware/rateLimit.js'; +import { registrationCapacityGuard } from '../middleware/planLimits.js'; import * as agentEngine from '../engine/agent.js'; import { agentRetentionSchema, retainAgents } from '../engine/agentRetention.js'; import * as agentIdentityEngine from '../engine/agentIdentity.js'; @@ -314,6 +315,7 @@ agentRoutes.post( autoJoinGeneral, recoveryProofHash, workUnitId, + capacity: registrationCapacityGuard(c), }); if (skills?.length) { await directoryEngine.syncSourceAgentDirectoryEntry(db, workspace.id, { diff --git a/packages/engine/src/routes/node.ts b/packages/engine/src/routes/node.ts index bccc31515..b8ac6deae 100644 --- a/packages/engine/src/routes/node.ts +++ b/packages/engine/src/routes/node.ts @@ -5,6 +5,7 @@ import type { AppEnv } from '../env.js'; import { requireAuth, requireNodeToken, requireWorkspaceRead, requireWorkspaceKey } from '../middleware/auth.js'; import { rateLimit } from '../middleware/rateLimit.js'; import { parseIdempotencyKey } from '../middleware/idempotency.js'; +import { registrationCapacityGuard } from '../middleware/planLimits.js'; import { asCodedError, errorResponse } from '../lib/httpError.js'; import { isSafeExternalUrl } from '../lib/ssrf.js'; import { sha256Hex } from '../lib/crypto.js'; @@ -236,7 +237,7 @@ async function enrollNode(c: Context, data: z.infer Date: Sun, 25 Oct 2026 17:45:48 -0700 Subject: [PATCH 2/6] Count every host-enrolled node as a device; document caps on the affected operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A device is any node enrolled through `POST /v1/nodes`, whatever its role or transport; only the implicit direct node minted for an agent (`node_direct_*`, via `registerAgent` or `POST /v1/agent/node-token`) is excluded. An explicit `role: 'direct'` enrollment therefore counts when it is created, and a later re-enroll as `broker` changes its shape, not the count — so the direct-then-broker sequence cannot bypass the cap. README lists the per-plan caps and quotas (legacy free/connect uncapped); openapi references `PlanLimitExceeded` from `POST /agents`, `POST /nodes` and `POST /a2a/register`; the changelog names the legacy-tier agent change. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 +- README.md | 28 +++++++++++-------- openapi.yaml | 6 ++++ .../conformance/planCapacity.test.ts | 23 +++++++++++++++ packages/engine/src/engine/node.ts | 7 +++-- packages/engine/src/engine/planCapacity.ts | 12 +++++--- 6 files changed, 58 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43645a3d2..6dd2a75a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ Packages without a separate changelog are covered by the cross-package notes bel ### Added -- Plans cap how many devices (nodes), agents and human members a workspace can register. New `intro` (5/15/2), `pro` (15/60/10) and `enterprise` (unlimited) tiers; a new registration past the cap is refused with HTTP 429 `plan_limit_exceeded` carrying `limit`, `max`, `current` and `plan`, on HTTP and on the node `agent.register` frame. Existing nodes and agents re-register freely. +- Plans cap how many devices (nodes), agents and human members a workspace can register. New `intro` (5/15/2), `pro` (15/60/10) and `enterprise` (unlimited) tiers; legacy `free` and `connect` carry no registration caps (their never-enforced agent counts are now unlimited); a new registration past the cap is refused with HTTP 429 `plan_limit_exceeded` carrying `limit`, `max`, `current` and `plan`, on HTTP and on the node `agent.register` frame. Existing nodes and agents re-register freely. - A hosting control plane can store per-workspace cap overrides on the workspace row (`workspaces.plan_limits`), which the entitlements provider applies over the plan table. ## [11.0.0] - 2026-10-22 diff --git a/README.md b/README.md index 2548effa4..13dff27fc 100644 --- a/README.md +++ b/README.md @@ -338,22 +338,26 @@ Two different conditions return HTTP 429, and the error code tells you which: | `plan_limit_exceeded` | Plan API-call quota for the current billing period | Clears only at the period boundary; `Retry-After` reports it | | `plan_limit_exceeded` with `limit` | Plan registration cap (`devices`, `agents` or `humans`) hit by a NEW node/agent registration; body carries `max`, `current`, `plan` | Clears when a registration is removed or the plan changes; no `Retry-After` | -Both carry `Retry-After` and `X-RateLimit-Reset`; successful responses carry +The first two carry `Retry-After` and `X-RateLimit-Reset`; successful responses carry `X-RateLimit-Limit` and `X-RateLimit-Remaining`. `Retry-After` is authoritative — a value beyond your retry budget means retrying cannot help, so fail fast rather than spending the budget on it. -A workspace's `plan` (returned by `GET /v1/workspace`) sets both limits: - -| Plan | Agents | API calls / month | Requests / minute | -| --- | --- | --- | --- | -| `free` | 5 | 100,000 | 300 | -| `connect` | 10 | 10,000,000 | 1,200 | -| `pro` | 100 | 10,000,000 | 6,000 | -| `enterprise` | unlimited | unlimited | 30,000 | - -`connect` is sized for short-lived collaboration rooms. Self-hosted deployments -default to `selfhost`, which is unlimited. +A workspace's `plan` (returned by `GET /v1/workspace`) sets the quotas and the +registration caps: + +| Plan | Devices | Agents | Humans | API calls / month | Requests / minute | +| --- | --- | --- | --- | --- | --- | +| `intro` | 5 | 15 | 2 | 10,000,000 | 6,000 | +| `pro` | 15 | 60 | 10 | 10,000,000 | 6,000 | +| `enterprise` | unlimited | unlimited | unlimited | unlimited | 30,000 | +| `free` (legacy) | unlimited | unlimited | unlimited | 100,000 | 300 | +| `connect` (legacy) | unlimited | unlimited | unlimited | 10,000,000 | 1,200 | + +A device is any node a host enrolls through `POST /v1/nodes`; the implicit +direct node every agent gets for its own WebSocket is not one. `connect` is +sized for short-lived collaboration rooms. Self-hosted deployments default to +`selfhost`, which is unlimited. `GET /v1/workspace` is limited in its own bucket, so the identity read used for credential validation and launch preflight stays reachable while the workspace's diff --git a/openapi.yaml b/openapi.yaml index 3d37aa608..a63d5f5bf 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2548,6 +2548,8 @@ paths: description: Malformed registration identity_key (invalid_agent_identity_key) '409': description: Agent name already exists in this workspace + '429': + $ref: '#/components/responses/PlanLimitExceeded' get: summary: List agents @@ -5508,6 +5510,8 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + '429': + $ref: '#/components/responses/PlanLimitExceeded' get: summary: List nodes description: > @@ -6827,6 +6831,8 @@ paths: application/json: schema: type: object + '429': + $ref: '#/components/responses/PlanLimitExceeded' /a2a/agents: get: diff --git a/packages/engine/src/__tests__/conformance/planCapacity.test.ts b/packages/engine/src/__tests__/conformance/planCapacity.test.ts index 018586f6f..a73e3f822 100644 --- a/packages/engine/src/__tests__/conformance/planCapacity.test.ts +++ b/packages/engine/src/__tests__/conformance/planCapacity.test.ts @@ -95,6 +95,29 @@ describe('plan registration caps', () => { expect((await enrollNode(ws, 'node_2', 'second-device')).status).toBe(429); }); + it('counts an explicitly enrolled direct node as a device, so a direct-then-broker re-enroll cannot bypass the cap', async () => { + stack = makeNodeStack(); + const ws = await workspaceOnPlan('cap-explicit-direct', 'intro', { devices: 1 }); + const direct = await stack.app.request('/v1/nodes', { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${ws.workspaceKey}` }, + body: JSON.stringify({ node_id: 'node_d', name: 'webhook', kind: 'http_push', role: 'direct', max_agents: 1, delivery: { url: 'https://hooks.example/relay' } }), + }); + expect(direct.status).toBe(201); + const token = (await direct.json() as { data: { token: string } }).data.token; + await expect(countRegistrations(stack.runtime.handle.db, ws.workspaceId, 'devices')).resolves.toBe(1); + + const broker = await enrollNode(ws, 'node_b', 'broker'); + expect(broker.status).toBe(429); + await expect(broker.json()).resolves.toMatchObject(refusal('devices', 1, 1)); + + // Re-enrolling the counted node as a broker changes its shape, not the count. + const reshaped = await enrollNode(ws, 'node_d', 'webhook', token); + expect(reshaped.status).toBe(201); + await expect(countRegistrations(stack.runtime.handle.db, ws.workspaceId, 'devices')).resolves.toBe(1); + expect((await enrollNode(ws, 'node_b', 'broker')).status).toBe(429); + }); + it('allows the 15th agent on intro and refuses the 16th; humans are counted separately', async () => { stack = makeNodeStack(); const ws = await workspaceOnPlan('cap-agents', 'intro'); diff --git a/packages/engine/src/engine/node.ts b/packages/engine/src/engine/node.ts index a698fc24d..a7913acbd 100644 --- a/packages/engine/src/engine/node.ts +++ b/packages/engine/src/engine/node.ts @@ -493,9 +493,10 @@ export async function createNodeToken( throw codedError('Direct nodes can bind at most one agent', 'direct_node_capacity_exceeded', 400); } // Only a NEW device counts against the plan. Rotating or re-enrolling an - // established row never does, and an agent's implicit direct node is its - // own WebSocket, not a device. - if (!existing && role !== 'direct' && authorization.capacity) { + // established row never does (its role may change, but it was counted when + // it was enrolled), and an agent's implicit direct node — minted on its + // behalf, never enrolled by a host — is its own WebSocket, not a device. + if (!existing && authorization.directAgentId === undefined && authorization.capacity) { await authorization.capacity('devices'); } diff --git a/packages/engine/src/engine/planCapacity.ts b/packages/engine/src/engine/planCapacity.ts index c8f6943d3..02ee72eb0 100644 --- a/packages/engine/src/engine/planCapacity.ts +++ b/packages/engine/src/engine/planCapacity.ts @@ -1,4 +1,4 @@ -import { and, eq, ne, sql } from 'drizzle-orm'; +import { and, eq, like, ne, not, sql } from 'drizzle-orm'; import type { EngineDb } from '../ports/database.js'; import type { EntitlementsProvider, @@ -104,8 +104,9 @@ export function applyWorkspacePlanLimits( * How many of `metric` the workspace currently has registered — the number a * new registration is compared against. * - * - `devices`: nodes a host enrolled (`role <> 'direct'`). Every agent owns an - * implicit direct node for its own WebSocket; those are not devices. + * - `devices`: every node a host enrolled, whatever its role or transport. + * Every agent also owns an implicit direct node (`node_direct_`) + * for its own WebSocket; those are not devices. * - `agents`: non-human identities that have not been released. * - `humans`: `type = 'human'` identities that have not been released. */ @@ -119,7 +120,10 @@ export async function countRegistrations( ? await db .select({ count }) .from(nodes) - .where(and(eq(nodes.workspaceId, workspaceId), ne(nodes.role, 'direct'))) + .where(and( + eq(nodes.workspaceId, workspaceId), + not(and(eq(nodes.role, 'direct'), like(nodes.id, 'node_direct_%'))!), + )) : await db .select({ count }) .from(agents) From 8a0a5e2dcf5b30215fd6e50581ab7253eaf0c68f Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Sun, 25 Oct 2026 19:16:18 -0700 Subject: [PATCH 3/6] Reserve the implicit direct node id namespace at enrollment `node_direct_` is the engine-minted implicit direct node the device count excludes. `createNodeToken` refuses a host enrollment whose `node_id` begins with that prefix (`reserved_node_id`, 400), so the exclusion keys on an identity only the engine can create rather than on a caller-chosen pattern. README states the plan table shows defaults and a workspace's stored `plan_limits` overrides set its effective caps. Co-Authored-By: Claude Fable 5.1 --- README.md | 4 +++- .../conformance/planCapacity.test.ts | 15 +++++++++++++++ packages/engine/src/engine/node.ts | 19 ++++++++++++++++++- packages/engine/src/engine/planCapacity.ts | 3 ++- 4 files changed, 38 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 13dff27fc..bdc0b5de4 100644 --- a/README.md +++ b/README.md @@ -354,7 +354,9 @@ registration caps: | `free` (legacy) | unlimited | unlimited | unlimited | 100,000 | 300 | | `connect` (legacy) | unlimited | unlimited | unlimited | 10,000,000 | 1,200 | -A device is any node a host enrolls through `POST /v1/nodes`; the implicit +The table shows each plan's defaults; a workspace's stored `plan_limits` +overrides (pushed by the hosting control plane) set its effective registration +caps. A device is any node a host enrolls through `POST /v1/nodes`; the implicit direct node every agent gets for its own WebSocket is not one. `connect` is sized for short-lived collaboration rooms. Self-hosted deployments default to `selfhost`, which is unlimited. diff --git a/packages/engine/src/__tests__/conformance/planCapacity.test.ts b/packages/engine/src/__tests__/conformance/planCapacity.test.ts index a73e3f822..28f5da087 100644 --- a/packages/engine/src/__tests__/conformance/planCapacity.test.ts +++ b/packages/engine/src/__tests__/conformance/planCapacity.test.ts @@ -95,6 +95,21 @@ describe('plan registration caps', () => { expect((await enrollNode(ws, 'node_2', 'second-device')).status).toBe(429); }); + it('refuses host enrollment into the reserved implicit-direct id namespace', async () => { + stack = makeNodeStack(); + const ws = await workspaceOnPlan('cap-reserved-id', 'intro', { devices: 1 }); + expect((await enrollNode(ws, 'node_1', 'only-device')).status).toBe(201); + + const spoofed = await stack.app.request('/v1/nodes', { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${ws.workspaceKey}` }, + body: JSON.stringify({ node_id: 'node_direct_custom', name: 'spoof', role: 'direct', max_agents: 1 }), + }); + expect(spoofed.status).toBe(400); + await expect(spoofed.json()).resolves.toMatchObject({ error: { code: 'reserved_node_id' } }); + await expect(countRegistrations(stack.runtime.handle.db, ws.workspaceId, 'devices')).resolves.toBe(1); + }); + it('counts an explicitly enrolled direct node as a device, so a direct-then-broker re-enroll cannot bypass the cap', async () => { stack = makeNodeStack(); const ws = await workspaceOnPlan('cap-explicit-direct', 'intro', { devices: 1 }); diff --git a/packages/engine/src/engine/node.ts b/packages/engine/src/engine/node.ts index a7913acbd..8dc42c8c7 100644 --- a/packages/engine/src/engine/node.ts +++ b/packages/engine/src/engine/node.ts @@ -107,8 +107,11 @@ function resolveProviderIdentity( type CapabilityLike = string | FleetCapability; +/** Id prefix of the implicit direct node minted for every agent; reserved. */ +export const IMPLICIT_DIRECT_NODE_ID_PREFIX = 'node_direct_'; + export function directNodeIdForAgent(agentId: string): string { - return `node_direct_${agentId}`; + return `${IMPLICIT_DIRECT_NODE_ID_PREFIX}${agentId}`; } function directNodeNameForAgent(agentId: string): string { @@ -458,6 +461,20 @@ export async function createNodeToken( if (!name || name.startsWith('#')) { throw codedError('Node name must be non-empty and cannot begin with "#"', 'invalid_node_name', 400); } + // `node_direct_` is the engine-minted implicit direct node, which + // the device count excludes. The namespace is reserved so a host cannot + // enroll a node that is miscounted as implicit. + if ( + data.node_id !== undefined + && authorization.directAgentId === undefined + && data.node_id.startsWith(IMPLICIT_DIRECT_NODE_ID_PREFIX) + ) { + throw codedError( + `Node ids beginning with "${IMPLICIT_DIRECT_NODE_ID_PREFIX}" are reserved for implicit direct nodes`, + 'reserved_node_id', + 400, + ); + } // Enrollment keys on node_id (the stable identity) when supplied; the name // lookup is only a fallback for callers without a stable id. A name held by // a *different* node id is a conflict — mirroring node.register — never a diff --git a/packages/engine/src/engine/planCapacity.ts b/packages/engine/src/engine/planCapacity.ts index 02ee72eb0..3ed1dc8ef 100644 --- a/packages/engine/src/engine/planCapacity.ts +++ b/packages/engine/src/engine/planCapacity.ts @@ -106,7 +106,8 @@ export function applyWorkspacePlanLimits( * * - `devices`: every node a host enrolled, whatever its role or transport. * Every agent also owns an implicit direct node (`node_direct_`) - * for its own WebSocket; those are not devices. + * for its own WebSocket; those are not devices. The prefix is reserved at + * enrollment (`createNodeToken`), so a host cannot enroll into it. * - `agents`: non-human identities that have not been released. * - `humans`: `type = 'human'` identities that have not been released. */ From 11ae18dc94cc1b8909cd9f2de50fe4a791cc1327 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Sun, 25 Oct 2026 20:21:53 -0700 Subject: [PATCH 4/6] Make the engine mechanism-only: limits come from the entitlements provider The engine carries no plan names, numbers or rank. `PLAN_LIMITS` and `PlanLimits` are gone; the entitlements port returns a metric-keyed `Limits` object (`messages`, `agents`, `file_bytes`, `api_calls`, `rate_per_min`, `devices`, `humans`, `message_retention_days`; `null` = unlimited) plus `getUsage`, optional `getUsageResetAt`, and optional `getLimitContext`, whose object the registration guard spreads verbatim onto the top level of a `plan_limit_exceeded` refusal (HTTP body and node-control error frame) so a host's `plan` and `upgrade_url` land where clients key on them. `StaticEntitlementsProvider` is unlimited unless `EngineConfig.limits` sets static limits for the operator's own instance; it never reads `workspace.plan`. The metered quota and rate-limit middleware treat a `null` limit as unlimited and no longer name a tier. `workspaces.limit_overrides` (migration 0076) is a host-owned JSON column the engine never interprets. `WorkspaceDeliveryPolicyConfig.resolve` receives the row's host-assigned `plan` key as `string | null` with no default name. Removing the exported tier constants is a breaking change: the changelogs are marked `[Unreleased - Major]`. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 10 +- README.md | 43 +++--- openapi.yaml | 42 ++++-- packages/engine/CHANGELOG.md | 16 +- .../conformance/connectObserver.test.ts | 6 +- .../src/__tests__/conformance/harness.ts | 3 + .../conformance/inboxArchivedMentions.test.ts | 5 +- .../conformance/planCapacity.test.ts | 141 +++++++++--------- .../conformance/rateLimitContract.test.ts | 14 +- .../__tests__/conformance/usageQuota.test.ts | 16 +- packages/engine/src/adapters/node/index.ts | 2 +- packages/engine/src/adapters/node/realtime.ts | 2 +- .../db/__tests__/compactMigrations.test.ts | 8 +- .../src/db/__tests__/taskMigration.test.ts | 2 +- .../0076_workspace_limit_overrides.sql | 5 + .../migrations/0076_workspace_plan_limits.sql | 6 - packages/engine/src/db/schema.ts | 9 +- packages/engine/src/engine.ts | 6 +- .../__tests__/workspaceDeliveryDepth.test.ts | 12 +- packages/engine/src/engine/a2a.ts | 2 +- packages/engine/src/engine/agent.ts | 4 +- packages/engine/src/engine/node.ts | 20 +-- packages/engine/src/engine/planCapacity.ts | 87 +++++------ .../src/engine/workspaceDeliveryPolicy.ts | 2 +- packages/engine/src/index.ts | 13 +- packages/engine/src/lib/httpError.ts | 8 +- packages/engine/src/middleware/planLimits.ts | 10 +- packages/engine/src/middleware/rateLimit.ts | 9 +- packages/engine/src/ports/entitlements.ts | 88 ++++++----- packages/engine/src/ports/index.ts | 12 +- .../engine/src/ports/workspaceDelivery.ts | 13 +- .../__tests__/static-entitlements.test.ts | 94 +++++------- .../src/providers/static-entitlements.ts | 56 +++---- 33 files changed, 369 insertions(+), 397 deletions(-) create mode 100644 packages/engine/src/db/migrations/0076_workspace_limit_overrides.sql delete mode 100644 packages/engine/src/db/migrations/0076_workspace_plan_limits.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dd2a75a3..9a227672f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,12 +16,16 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Packages without a separate changelog are covered by the cross-package notes below. -## [Unreleased - Minor] +## [Unreleased - Major] ### Added -- Plans cap how many devices (nodes), agents and human members a workspace can register. New `intro` (5/15/2), `pro` (15/60/10) and `enterprise` (unlimited) tiers; legacy `free` and `connect` carry no registration caps (their never-enforced agent counts are now unlimited); a new registration past the cap is refused with HTTP 429 `plan_limit_exceeded` carrying `limit`, `max`, `current` and `plan`, on HTTP and on the node `agent.register` frame. Existing nodes and agents re-register freely. -- A hosting control plane can store per-workspace cap overrides on the workspace row (`workspaces.plan_limits`), which the entitlements provider applies over the plan table. +- Registration caps for devices (nodes), agents and human members: a new registration past the deployment's limit is refused with HTTP 429 `plan_limit_exceeded` carrying `limit`, `max`, `current` and the host's context (`plan`, `upgrade_url` on the hosted gateway), on HTTP and on the node `agent.register` frame. Existing nodes and agents re-register freely. +- Self-hosted operators can cap their own instance with `config.limits`; absent keys stay unlimited. + +### Breaking changes + +- The engine no longer ships plan tiers: the `PLAN_LIMITS` table and every plan name/number are gone, `PlanLimits` is replaced by the metric-keyed `Limits` (`null` = unlimited), and all limits come from the deployment's entitlements provider. Self-hosted deployments are unlimited by default. ## [11.0.0] - 2026-10-22 diff --git a/README.md b/README.md index bdc0b5de4..9a47f6ccb 100644 --- a/README.md +++ b/README.md @@ -328,38 +328,39 @@ deployments — where a control plane owns the canonical key — leave it unset and rotate through that plane, so the route answers `workspace_key_rotation_disabled`. -### Throttling +### Throttling and limits -Two different conditions return HTTP 429, and the error code tells you which: +Three conditions return HTTP 429, and the error code tells you which: | Code | What it is | Retry | | --- | --- | --- | | `rate_limit_exceeded` | Per-minute request ceiling | Always clears inside the window; `Retry-After` is at most 60 | -| `plan_limit_exceeded` | Plan API-call quota for the current billing period | Clears only at the period boundary; `Retry-After` reports it | -| `plan_limit_exceeded` with `limit` | Plan registration cap (`devices`, `agents` or `humans`) hit by a NEW node/agent registration; body carries `max`, `current`, `plan` | Clears when a registration is removed or the plan changes; no `Retry-After` | +| `plan_limit_exceeded` | API-call quota for the current usage period | Clears only at the period boundary; `Retry-After` reports it | +| `plan_limit_exceeded` with `limit` | Registration cap (`devices`, `agents` or `humans`) hit by a NEW node/agent registration; body carries `max`, `current` and the host's context (the hosted gateway adds `plan` and `upgrade_url`) | Clears when a registration is removed or the host raises the limit; no `Retry-After` | The first two carry `Retry-After` and `X-RateLimit-Reset`; successful responses carry `X-RateLimit-Limit` and `X-RateLimit-Remaining`. `Retry-After` is authoritative — a value beyond your retry budget means retrying cannot help, so fail fast rather than spending the budget on it. -A workspace's `plan` (returned by `GET /v1/workspace`) sets the quotas and the -registration caps: - -| Plan | Devices | Agents | Humans | API calls / month | Requests / minute | -| --- | --- | --- | --- | --- | --- | -| `intro` | 5 | 15 | 2 | 10,000,000 | 6,000 | -| `pro` | 15 | 60 | 10 | 10,000,000 | 6,000 | -| `enterprise` | unlimited | unlimited | unlimited | unlimited | 30,000 | -| `free` (legacy) | unlimited | unlimited | unlimited | 100,000 | 300 | -| `connect` (legacy) | unlimited | unlimited | unlimited | 10,000,000 | 1,200 | - -The table shows each plan's defaults; a workspace's stored `plan_limits` -overrides (pushed by the hosting control plane) set its effective registration -caps. A device is any node a host enrolls through `POST /v1/nodes`; the implicit -direct node every agent gets for its own WebSocket is not one. `connect` is -sized for short-lived collaboration rooms. Self-hosted deployments default to -`selfhost`, which is unlimited. +The engine carries no tiers or numbers of its own. Every limit comes from the +deployment's `EntitlementsProvider`, which maps a workspace to a `Limits` +object keyed by metric (`messages`, `agents`, `file_bytes`, `api_calls`, +`rate_per_min`, `devices`, `humans`, `message_retention_days`; `null` is +unlimited). A device is any node a host enrolls through `POST /v1/nodes`; the +implicit direct node every agent gets for its own WebSocket is not one. + +- Self-hosted deployments are unlimited by default. To cap your own instance, + set static limits in the engine config; absent keys stay unlimited: + + ```ts + createNodeRuntime({ config: { limits: { devices: 5, agents: 15, humans: 2, rate_per_min: 600 } } }); + ``` + +- A hosting product injects its own provider (`getLimits`, `getUsage`, optional + `getUsageResetAt` and `getLimitContext`). `getLimitContext` returns fields the + engine spreads verbatim onto the top level of a registration-cap 429 — on + `cast.agentrelay.com` that is `plan` and `upgrade_url`. `GET /v1/workspace` is limited in its own bucket, so the identity read used for credential validation and launch preflight stays reachable while the workspace's diff --git a/openapi.yaml b/openapi.yaml index a63d5f5bf..3a2249527 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -19,15 +19,17 @@ info: credential validation and launch preflight stays reachable when the workspace's data-plane traffic is at its ceiling. - Plans also cap how many devices (nodes), agents and human members a - workspace can register. Enrolling a NEW node (`POST /nodes`), registering a - NEW agent (`POST /agents`, `POST /a2a/register`, or an `agent.register` - node frame) beyond the cap returns HTTP 429 `plan_limit_exceeded` with the - top-level fields `error_code`, `limit` (`devices`|`agents`|`humans`), - `max`, `current` and `plan` so a client can explain the refusal and offer - an upgrade. Re-enrolling or rotating an existing node or agent is never - capped. No `Retry-After` is sent: the cap clears only when the workspace - removes a registration or changes plan. + The deployment's entitlements provider may also cap how many devices + (nodes), agents and human members a workspace can register. Enrolling a NEW + node (`POST /nodes`), registering a NEW agent (`POST /agents`, + `POST /a2a/register`, or an `agent.register` node frame) beyond the cap + returns HTTP 429 `plan_limit_exceeded` with the top-level fields + `error_code`, `limit` (`devices`|`agents`|`humans`), `max` and `current`, + plus whatever context the provider supplies (the hosted gateway adds `plan` + and `upgrade_url`) so a client can explain the refusal and offer an upgrade. + Re-enrolling or rotating an existing node or agent is never capped. No + `Retry-After` is sent: the cap clears only when a registration is removed + or the provider raises the limit. Workspace delivery capacity applies to all local message producers. A write that would exceed it returns HTTP 429 with error code @@ -194,10 +196,10 @@ components: $ref: '#/components/schemas/ErrorResponse' PlanLimitExceeded: description: >- - The workspace is at its plan's registration cap for the requested kind. + The workspace is at its registration cap for the requested kind. Only a NEW registration is refused; re-enrolling or rotating an existing node or agent never is. The cap clears when a registration is removed or - the plan changes, so no `Retry-After` is sent. + the provider raises the limit, so no `Retry-After` is sent. content: application/json: schema: @@ -206,7 +208,12 @@ components: schemas: PlanLimitExceededResponse: type: object - required: [ok, error, error_code, limit, max, current, plan] + required: [ok, error, error_code, limit, max, current] + description: >- + Beyond the required fields, the deployment's entitlements provider may + add context at the top level (for example `plan` and `upgrade_url` on + the hosted gateway); the engine passes it through verbatim. + additionalProperties: true properties: ok: type: boolean @@ -229,13 +236,17 @@ components: description: Which registration cap refused the request. max: type: integer - description: The plan's cap for `limit`. + description: The cap for `limit`. current: type: integer description: How many are registered now. plan: type: string - description: The workspace's effective plan key (for example `intro`, `pro`). + description: Provider-supplied, optional. The hosted gateway reports the workspace's billing tier here. + upgrade_url: + type: string + format: uri + description: Provider-supplied, optional. Where the workspace can raise its limits. WorkspaceProvenanceInput: type: object @@ -314,8 +325,7 @@ components: description: Workspace API key (only returned on creation) plan: type: string - enum: [free, connect, pro, enterprise, selfhost] - description: Stored plan that sets the workspace's usage limits. `connect` is sized for short-lived collaboration rooms. + description: Host-assigned plan key stored on the workspace; opaque to the engine, which takes every limit from its entitlements provider. created_at: type: string format: date-time diff --git a/packages/engine/CHANGELOG.md b/packages/engine/CHANGELOG.md index 47d189f7c..ddd3191d5 100644 --- a/packages/engine/CHANGELOG.md +++ b/packages/engine/CHANGELOG.md @@ -8,16 +8,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased - Minor] +## [Unreleased - Major] ### Added -- `PlanLimits` gains `devices` and `humans` (`number | null`, null = unlimited) and `UsageMetric` gains `devices`/`humans`; `PLAN_LIMITS` gains `intro` and `pro` takes the billing tiers' caps (intro 5/15/2, pro 15/60/10, enterprise unlimited). Legacy `free`/`connect` carry no registration caps. -- Registration caps are enforced at every path that creates a node or agent: `POST /v1/nodes`, `POST /v1/agents`, `POST /v1/a2a/register` and the `agent.register` node frame. A refusal is HTTP 429 `plan_limit_exceeded` with top-level `error_code`, `limit`, `max`, `current`, `plan` (the same fields on the WebSocket `error` frame). Re-enrolling or rotating an existing node/agent is never capped; an agent's implicit direct node is not a device. -- `EntitlementsProvider.getUsage` reports live registration counts for `devices`/`agents`/`humans` (`countRegistrations` is exported for providers); optional `getPlan` reports the effective plan key for the refusal body. -- `workspaces.plan_limits` (migration `0076`) stores per-workspace cap overrides; `StaticEntitlementsProvider` applies them via `applyWorkspacePlanLimits` and accepts an optional database handle for live counts. +- Registration caps are enforced at every path that creates a node or agent: `POST /v1/nodes`, `POST /v1/agents`, `POST /v1/a2a/register` and the `agent.register` node frame. A refusal is HTTP 429 `plan_limit_exceeded` with top-level `error_code`, `limit`, `max`, `current` and the provider's `getLimitContext` fields spread verbatim (the same fields on the WebSocket `error` frame). Re-enrolling or rotating an existing node/agent is never capped; an agent's implicit direct node is not a device, and the `node_direct_` id prefix is reserved for it (`reserved_node_id`). +- `Limits` (metric-keyed, `null` = unlimited) with `UNLIMITED_LIMITS` and `resolveLimits`; `UsageMetric` gains `devices`/`humans` and `EntitlementsProvider.getUsage` reports live registration counts for them (`countRegistrations` is exported for providers). Optional `EntitlementsProvider.getLimitContext` supplies the refusal's host context. +- `EngineConfig.limits` sets static limits for a self-hosted instance; `StaticEntitlementsProvider(kv, limits, db)` applies them and counts registrations from `db`. +- `workspaces.limit_overrides` (migration `0076`) is a host-owned JSON column the engine never interprets. - `createRegistrationCapacityGuard` and `HandleNodeControlMessageArgs.capacity` let a host's node-control transport enforce the agent cap on `agent.register`. +### Breaking changes + +- `PLAN_LIMITS` and every plan name, number and rank are removed from the engine; `PlanLimits` is replaced by `Limits`, whose metered fields are `number | null` (`null` = unlimited) instead of `number`/`Infinity`. `StaticEntitlementsProvider` no longer reads `workspace.plan`: it is unlimited unless `config.limits` is set. Hosts that relied on the built-in tiers must supply them from their own provider. +- `WorkspaceDeliveryPolicyConfig.resolve` receives `plan: string | null` (the row's host-assigned key, no `'free'` default). +- The `rate_limit_exceeded` and `plan_limit_exceeded` quota messages no longer name a plan. + ## [11.0.0] - 2026-10-22 ### Changed diff --git a/packages/engine/src/__tests__/conformance/connectObserver.test.ts b/packages/engine/src/__tests__/conformance/connectObserver.test.ts index 71cb417ad..9c3d3fd04 100644 --- a/packages/engine/src/__tests__/conformance/connectObserver.test.ts +++ b/packages/engine/src/__tests__/conformance/connectObserver.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { eq } from 'drizzle-orm'; -import { messages, observerTokens, workspaces } from '../../db/schema.js'; +import { messages, observerTokens } from '../../db/schema.js'; import { dmFilteredCountConversationChunkSize, dmMessagePageLimit } from '../../engine/dmAll.js'; import { createWorkspace, makeNodeStack, registerAgent, type TestStack } from './harness.js'; @@ -55,10 +55,6 @@ describe('Relay Connect observer capability', () => { it('lists more DM conversations than one D1 statement can bind, for the workspace and an observer', async () => { const room = await createWorkspace(stack.app, 'connect-many-dms'); - await stack.runtime.deps.db - .update(workspaces) - .set({ plan: 'enterprise' }) - .where(eq(workspaces.id, room.workspaceId)); const alice = await registerAgent(stack.app, room.workspaceKey, 'alice'); const peers = Array.from({ length: 505 }, (_, index) => `peer-${String(index).padStart(3, '0')}`); const conversationIds: string[] = []; diff --git a/packages/engine/src/__tests__/conformance/harness.ts b/packages/engine/src/__tests__/conformance/harness.ts index 99983d84e..0f6082601 100644 --- a/packages/engine/src/__tests__/conformance/harness.ts +++ b/packages/engine/src/__tests__/conformance/harness.ts @@ -53,6 +53,8 @@ export function makeNodeStack(options?: { workspaceBootstrapProofRequired?: boolean; allowWorkspaceKeyRotation?: boolean; entitlements?: EntitlementsProvider; + /** Static limits for the default provider (`EngineConfig.limits`). */ + limits?: EngineConfig['limits']; }): TestStack { const tasks = new BackgroundTasks(); observePresence(); @@ -79,6 +81,7 @@ export function makeNodeStack(options?: { workspaceBootstrapSecret: options?.workspaceBootstrapSecret ?? 'test-bootstrap-secret', workspaceBootstrapProofRequired: options?.workspaceBootstrapProofRequired, allowWorkspaceKeyRotation: options?.allowWorkspaceKeyRotation, + limits: options?.limits, }, entitlements: options?.entitlements, // Disable the auto-sweep timer; tests drive presence.sweep() explicitly. diff --git a/packages/engine/src/__tests__/conformance/inboxArchivedMentions.test.ts b/packages/engine/src/__tests__/conformance/inboxArchivedMentions.test.ts index cd96de978..d9c2f7ccd 100644 --- a/packages/engine/src/__tests__/conformance/inboxArchivedMentions.test.ts +++ b/packages/engine/src/__tests__/conformance/inboxArchivedMentions.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { makeNodeStack, createWorkspace, registerAgent, type TestStack } from './harness.js'; +import { resolveLimits } from '../../ports/entitlements.js'; /** * Regression for the captured cross-channel leak: a native MCP post result @@ -13,9 +14,9 @@ import { makeNodeStack, createWorkspace, registerAgent, type TestStack } from '. * non-member never seeing the mention. */ // Test-only entitlements: a high per-minute ceiling so the bounded >200-candidate -// fixture is not rate-limited by the free plan. No production limit changes. +// fixture exercises the limiter without being throttled. const testEntitlements = { - getLimits: async () => ({ messages: 1_000_000, agents: 1_000_000, file_bytes: 1_000_000_000, api_calls: 1_000_000, rate_per_min: 100_000, devices: null, humans: null }), + getLimits: async () => resolveLimits({ messages: 1_000_000, agents: 1_000_000, file_bytes: 1_000_000_000, api_calls: 1_000_000, rate_per_min: 100_000 }), getUsage: async () => 0, }; diff --git a/packages/engine/src/__tests__/conformance/planCapacity.test.ts b/packages/engine/src/__tests__/conformance/planCapacity.test.ts index 28f5da087..cb9b298a7 100644 --- a/packages/engine/src/__tests__/conformance/planCapacity.test.ts +++ b/packages/engine/src/__tests__/conformance/planCapacity.test.ts @@ -1,16 +1,15 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { eq } from 'drizzle-orm'; -import type { EntitlementsProvider, PlanLimits, Workspace, WorkspacePlanLimits } from '../../ports/index.js'; -import { workspaces } from '../../db/schema.js'; -import { PLAN_LIMITS } from '../../providers/static-entitlements.js'; +import type { EntitlementsProvider, Limits, Workspace } from '../../ports/index.js'; +import { resolveLimits } from '../../ports/entitlements.js'; import { countRegistrations } from '../../engine/planCapacity.js'; import { createWorkspace, FakeSocket, makeNodeStack, registerAgent, type TestStack } from './harness.js'; type Created = { workspaceKey: string; workspaceId: string }; -const INTRO = PLAN_LIMITS.intro!; +/** The caps a hosted tier might carry; the engine only knows the numbers. */ +const CAPPED = { devices: 5, agents: 15, humans: 2 } as const; -describe('plan registration caps', () => { +describe('registration caps', () => { let stack: TestStack; afterEach(async () => { @@ -18,15 +17,6 @@ describe('plan registration caps', () => { await stack?.close(); }); - async function workspaceOnPlan(name: string, plan: string, planLimits?: WorkspacePlanLimits): Promise { - const ws = await createWorkspace(stack.app, name); - await stack.runtime.handle.db - .update(workspaces) - .set({ plan, ...(planLimits ? { planLimits } : {}) }) - .where(eq(workspaces.id, ws.workspaceId)); - return ws; - } - function enrollNode(ws: Created, nodeId: string, name: string, currentToken?: string) { return stack.app.request('/v1/nodes', { method: 'POST', @@ -47,20 +37,18 @@ describe('plan registration caps', () => { }); } - const refusal = (limit: string, max: number, current: number, plan = 'intro') => ({ + const refusal = (limit: string, max: number, current: number) => ({ ok: false, error: { code: 'plan_limit_exceeded' }, error_code: 'plan_limit_exceeded', limit, max, current, - plan, }); - it('allows the 5th device on intro and refuses the 6th with the machine-readable body', async () => { - stack = makeNodeStack(); - const ws = await workspaceOnPlan('cap-devices', 'intro'); - expect(INTRO.devices).toBe(5); + it('allows the 5th device and refuses the 6th with the machine-readable body', async () => { + stack = makeNodeStack({ limits: CAPPED }); + const ws = await createWorkspace(stack.app, 'cap-devices'); const tokens: string[] = []; for (let i = 1; i <= 5; i += 1) { @@ -72,7 +60,10 @@ describe('plan registration caps', () => { const sixth = await enrollNode(ws, 'node_6', 'device-6'); expect(sixth.status).toBe(429); - await expect(sixth.json()).resolves.toMatchObject(refusal('devices', 5, 5)); + const body = await sixth.json() as Record; + expect(body).toMatchObject(refusal('devices', 5, 5)); + // The engine adds nothing it does not know: no tier name without a provider context. + expect(body).not.toHaveProperty('plan'); // Re-enrolling (rotating) an established device is not a new registration. const rotate = await enrollNode(ws, 'node_1', 'device-1', tokens[0]); @@ -81,8 +72,8 @@ describe('plan registration caps', () => { }); it('does not count an agent\'s implicit direct node as a device', async () => { - stack = makeNodeStack(); - const ws = await workspaceOnPlan('cap-direct', 'intro', { devices: 1 }); + stack = makeNodeStack({ limits: { devices: 1 } }); + const ws = await createWorkspace(stack.app, 'cap-direct'); expect((await enrollNode(ws, 'node_1', 'only-device')).status).toBe(201); const agent = await registerAgent(stack.app, ws.workspaceKey, 'worker'); @@ -96,8 +87,8 @@ describe('plan registration caps', () => { }); it('refuses host enrollment into the reserved implicit-direct id namespace', async () => { - stack = makeNodeStack(); - const ws = await workspaceOnPlan('cap-reserved-id', 'intro', { devices: 1 }); + stack = makeNodeStack({ limits: { devices: 1 } }); + const ws = await createWorkspace(stack.app, 'cap-reserved-id'); expect((await enrollNode(ws, 'node_1', 'only-device')).status).toBe(201); const spoofed = await stack.app.request('/v1/nodes', { @@ -111,8 +102,8 @@ describe('plan registration caps', () => { }); it('counts an explicitly enrolled direct node as a device, so a direct-then-broker re-enroll cannot bypass the cap', async () => { - stack = makeNodeStack(); - const ws = await workspaceOnPlan('cap-explicit-direct', 'intro', { devices: 1 }); + stack = makeNodeStack({ limits: { devices: 1 } }); + const ws = await createWorkspace(stack.app, 'cap-explicit-direct'); const direct = await stack.app.request('/v1/nodes', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${ws.workspaceKey}` }, @@ -133,11 +124,9 @@ describe('plan registration caps', () => { expect((await enrollNode(ws, 'node_b', 'broker')).status).toBe(429); }); - it('allows the 15th agent on intro and refuses the 16th; humans are counted separately', async () => { - stack = makeNodeStack(); - const ws = await workspaceOnPlan('cap-agents', 'intro'); - expect(INTRO.agents).toBe(15); - expect(INTRO.humans).toBe(2); + it('allows the 15th agent and refuses the 16th; humans are counted separately', async () => { + stack = makeNodeStack({ limits: CAPPED }); + const ws = await createWorkspace(stack.app, 'cap-agents'); for (let i = 1; i <= 15; i += 1) { expect((await registerAgentRaw(ws, { name: `agent-${i}` })).status, `agent ${i}`).toBe(201); @@ -157,28 +146,17 @@ describe('plan registration caps', () => { }); it('answers an established name with the conflict, never the cap', async () => { - stack = makeNodeStack(); - const ws = await workspaceOnPlan('cap-conflict', 'intro', { agents: 1 }); + stack = makeNodeStack({ limits: { agents: 1 } }); + const ws = await createWorkspace(stack.app, 'cap-conflict'); expect((await registerAgentRaw(ws, { name: 'only' })).status).toBe(201); const again = await registerAgentRaw(ws, { name: 'only' }); expect(again.status).toBe(409); await expect(again.json()).resolves.toMatchObject({ error: { code: 'agent_already_exists' } }); }); - it('applies a workspace plan_limits override over the plan table', async () => { - stack = makeNodeStack(); - const ws = await workspaceOnPlan('cap-override', 'enterprise', { devices: 1, agents: null }); - expect((await enrollNode(ws, 'node_1', 'first')).status).toBe(201); - const second = await enrollNode(ws, 'node_2', 'second'); - expect(second.status).toBe(429); - await expect(second.json()).resolves.toMatchObject(refusal('devices', 1, 1, 'enterprise')); - // `agents: null` is unlimited even though it was pushed explicitly. - expect((await registerAgentRaw(ws, { name: 'free-agent' })).status).toBe(201); - }); - it('refuses a WebSocket agent.register at the cap with the same detail on the error frame', async () => { - stack = makeNodeStack(); - const ws = await workspaceOnPlan('cap-ws', 'intro', { agents: 1 }); + stack = makeNodeStack({ limits: { agents: 1 } }); + const ws = await createWorkspace(stack.app, 'cap-ws'); expect((await enrollNode(ws, 'node_a', 'alpha')).status).toBe(201); const sock = new FakeSocket(); @@ -200,7 +178,6 @@ describe('plan registration caps', () => { limit: 'agents', max: 1, current: 1, - plan: 'intro', }); // Re-registering the established name is a conflict, not a cap refusal. @@ -209,12 +186,12 @@ describe('plan registration caps', () => { }); it('counts an A2A proxy identity as an agent', async () => { - stack = makeNodeStack(); + stack = makeNodeStack({ limits: { agents: 1 } }); vi.spyOn(globalThis, 'fetch').mockImplementation(async () => new Response('{}', { status: 200 })); - const ws = await workspaceOnPlan('cap-a2a', 'intro', { agents: 1 }); + const ws = await createWorkspace(stack.app, 'cap-a2a'); expect((await registerAgentRaw(ws, { name: 'local' })).status).toBe(201); - const register = () => stack.app.request('/v1/a2a/register', { + const refused = await stack.app.request('/v1/a2a/register', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${ws.workspaceKey}` }, body: JSON.stringify({ @@ -226,28 +203,24 @@ describe('plan registration caps', () => { }, }), }); - const refused = await register(); expect(refused.status).toBe(429); await expect(refused.json()).resolves.toMatchObject(refusal('agents', 1, 1)); }); - it('leaves legacy and self-host tiers uncapped', async () => { + it('caps nothing by default', async () => { stack = makeNodeStack(); - for (const plan of ['free', 'connect', 'selfhost', 'enterprise']) { - const limits = PLAN_LIMITS[plan]!; - expect(limits.devices, plan).toBeNull(); - expect(limits.humans, plan).toBeNull(); - expect(limits.agents, plan).toBe(Infinity); - } - const ws = await workspaceOnPlan('cap-legacy', 'free'); + const ws = await createWorkspace(stack.app, 'cap-default'); for (let i = 1; i <= 6; i += 1) { expect((await enrollNode(ws, `node_${i}`, `device-${i}`)).status).toBe(201); } + for (let i = 1; i <= 3; i += 1) { + expect((await registerAgentRaw(ws, { name: `human-${i}`, type: 'human' })).status).toBe(201); + } }); it('fails open when the entitlements provider cannot answer', async () => { const broken: EntitlementsProvider = { - async getLimits(): Promise { + async getLimits(): Promise { throw new Error('billing unavailable'); }, async getUsage() { @@ -256,27 +229,51 @@ describe('plan registration caps', () => { }; stack = makeNodeStack({ entitlements: broken }); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const ws = await workspaceOnPlan('cap-fail-open', 'intro'); + const ws = await createWorkspace(stack.app, 'cap-fail-open'); expect((await enrollNode(ws, 'node_1', 'device')).status).toBe(201); expect(warn).toHaveBeenCalledWith('[plan-capacity] entitlements lookup failed; allowing registration', expect.objectContaining({ metric: 'devices' })); }); - it('reports the provider\'s effective plan in the refusal', async () => { - const raised: EntitlementsProvider = { - async getLimits(workspace: Workspace): Promise { - return { ...PLAN_LIMITS.pro!, devices: 1, ...(workspace.planLimits ?? {}) } as PlanLimits; + it('spreads the provider\'s limit context onto the refusal, on HTTP and on the error frame', async () => { + const hosted: EntitlementsProvider = { + async getLimits(_workspace: Workspace): Promise { + return resolveLimits({ devices: 1, agents: 1 }); }, getUsage: (workspaceId, metric) => countRegistrations(stack.runtime.handle.db, workspaceId, metric as 'devices' | 'agents' | 'humans'), - async getPlan() { - return 'pro'; + async getLimitContext() { + return { plan: 'tier-small', upgrade_url: 'https://host.example/upgrade', ok: 'cannot clobber' }; }, }; - stack = makeNodeStack({ entitlements: raised }); - const ws = await workspaceOnPlan('cap-effective-plan', 'intro'); + stack = makeNodeStack({ entitlements: hosted }); + const ws = await createWorkspace(stack.app, 'cap-context'); expect((await enrollNode(ws, 'node_1', 'first')).status).toBe(201); const second = await enrollNode(ws, 'node_2', 'second'); expect(second.status).toBe(429); - await expect(second.json()).resolves.toMatchObject(refusal('devices', 1, 1, 'pro')); + await expect(second.json()).resolves.toEqual({ + ...refusal('devices', 1, 1), + error: { code: 'plan_limit_exceeded', message: expect.any(String) }, + plan: 'tier-small', + upgrade_url: 'https://host.example/upgrade', + }); + + const sock = new FakeSocket(); + const handle = stack.runtime.realtime.attachNodeSocket(ws.workspaceId, 'node_1', sock); + await handle.handleMessage(JSON.stringify({ + v: 1, id: 'reg', type: 'node.register', name: 'first', node_id: 'node_1', + capabilities: [], max_agents: 4, tags: [], version: 'test-node', resume_cursor: null, + })); + expect((await registerAgentRaw(ws, { name: 'only-agent' })).status).toBe(201); + await handle.handleMessage(JSON.stringify({ v: 1, id: 'a2', type: 'agent.register', name: 'second-agent' })); + expect(sock.ofType('error').at(-1)).toMatchObject({ + id: 'a2', + ok: false, + code: 'plan_limit_exceeded', + limit: 'agents', + max: 1, + current: 1, + plan: 'tier-small', + upgrade_url: 'https://host.example/upgrade', + }); }); }); diff --git a/packages/engine/src/__tests__/conformance/rateLimitContract.test.ts b/packages/engine/src/__tests__/conformance/rateLimitContract.test.ts index 6188bcdc6..4c460cb36 100644 --- a/packages/engine/src/__tests__/conformance/rateLimitContract.test.ts +++ b/packages/engine/src/__tests__/conformance/rateLimitContract.test.ts @@ -1,20 +1,12 @@ import { afterEach, describe, expect, it } from 'vitest'; -import type { EntitlementsProvider, PlanLimits, Workspace } from '../../ports/index.js'; +import { resolveLimits, type EntitlementsProvider, type Limits, type Workspace } from '../../ports/index.js'; import { InProcessRateLimiter } from '../../adapters/node/rate-limit.js'; import { createWorkspace, makeNodeStack, type TestStack } from './harness.js'; function entitlementsWithRate(ratePerMin: number): EntitlementsProvider { return { - async getLimits(_workspace: Workspace): Promise { - return { - messages: Infinity, - agents: Infinity, - file_bytes: Infinity, - api_calls: Infinity, - rate_per_min: ratePerMin, - devices: null, - humans: null, - }; + async getLimits(_workspace: Workspace): Promise { + return resolveLimits({ rate_per_min: ratePerMin }); }, async getUsage() { return 0; }, }; diff --git a/packages/engine/src/__tests__/conformance/usageQuota.test.ts b/packages/engine/src/__tests__/conformance/usageQuota.test.ts index cafef0edb..68eb464cc 100644 --- a/packages/engine/src/__tests__/conformance/usageQuota.test.ts +++ b/packages/engine/src/__tests__/conformance/usageQuota.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { EntitlementsProvider, PlanLimits, UsageMetric, Workspace } from '../../ports/index.js'; +import { resolveLimits, type EntitlementsProvider, type Limits, type UsageMetric, type Workspace } from '../../ports/index.js'; import { usageCounterKey, usagePeriodResetAt } from '../../engine/usage.js'; import { createWorkspace, makeNodeStack, registerAgent, type TestStack } from './harness.js'; @@ -9,16 +9,8 @@ class ApiCallQuotaEntitlements implements EntitlementsProvider { private readonly apiCallLimit: number, ) {} - async getLimits(_workspace: Workspace): Promise { - return { - messages: Infinity, - agents: Infinity, - file_bytes: Infinity, - api_calls: this.apiCallLimit, - rate_per_min: 300, - devices: null, - humans: null, - }; + async getLimits(_workspace: Workspace): Promise { + return resolveLimits({ api_calls: this.apiCallLimit, rate_per_min: 300 }); } // Reads the same period-scoped counter the usage tracker writes, so the @@ -41,7 +33,7 @@ describe('usage quotas', () => { await stack?.close(); }); - it('records authenticated API calls and enforces the api_calls plan limit', async () => { + it('records authenticated API calls and enforces the api_calls quota', async () => { stack = makeNodeStack({ entitlements: new ApiCallQuotaEntitlements(() => stack, 2), }); diff --git a/packages/engine/src/adapters/node/index.ts b/packages/engine/src/adapters/node/index.ts index 226374c75..b9a09738b 100644 --- a/packages/engine/src/adapters/node/index.ts +++ b/packages/engine/src/adapters/node/index.ts @@ -161,7 +161,7 @@ export function createNodeRuntime(options: NodeRuntimeOptions): NodeRuntime { webhookQueue.start(); const auth = options.auth ?? new SqliteApiKeyAuthProvider(); - const entitlements = options.entitlements ?? new StaticEntitlementsProvider(kv, undefined, db); + const entitlements = options.entitlements ?? new StaticEntitlementsProvider(kv, options.config?.limits, db); // An explicit host-provided boundary is authoritative (the host may prune // outside this process). Otherwise report the same fallback the local Node diff --git a/packages/engine/src/adapters/node/realtime.ts b/packages/engine/src/adapters/node/realtime.ts index c810e49e3..92254941c 100644 --- a/packages/engine/src/adapters/node/realtime.ts +++ b/packages/engine/src/adapters/node/realtime.ts @@ -126,7 +126,7 @@ export class InProcessRealtime implements RealtimeBus, ConnectionRegistry, NodeC this.nodeCompletionDeps = deps; } - /** Enforce plan registration caps on `agent.register` frames. */ + /** Enforce registration caps on `agent.register` frames. */ setEntitlements(entitlements: EntitlementsProvider): void { this.entitlements = entitlements; } diff --git a/packages/engine/src/db/__tests__/compactMigrations.test.ts b/packages/engine/src/db/__tests__/compactMigrations.test.ts index 56b4b8a24..182d4191e 100644 --- a/packages/engine/src/db/__tests__/compactMigrations.test.ts +++ b/packages/engine/src/db/__tests__/compactMigrations.test.ts @@ -54,13 +54,13 @@ function snapshot(handle: SqliteDbHandle) { // 0070 adds a nullable liveness-recovery marker without backfilling it. if (name === 'workspaces') { // 0073 adds the rotation grace slot and the generation fence, and - // 0076 the plan-limit overrides, as additive columns; exclude them so + // 0076 the host limit overrides, as additive columns; exclude them so // this comparison stays byte-for-byte over every pre-existing value. const { previous_api_key_hash: _previousApiKeyHash, previous_api_key_expires_at: _previousApiKeyExpiresAt, api_key_generation: _apiKeyGeneration, - plan_limits: _planLimits, + limit_overrides: _limitOverrides, ...existing } = row as Record; return JSON.stringify(existing); @@ -160,7 +160,7 @@ function expectConstraintsPreserved(before: ReturnType, afte return { ...table, sql: original.sql }; } // 0073 appends the rotation grace slot and the generation fence, and - // 0076 the plan-limit overrides. Remove only their exact additive DDL so + // 0076 the host limit overrides. Remove only their exact additive DDL so // every pre-existing workspace constraint stays under comparison. if (table.name === 'workspaces') { table = { @@ -169,7 +169,7 @@ function expectConstraintsPreserved(before: ReturnType, afte .replace(', previous_api_key_hash TEXT', '') .replace(', previous_api_key_expires_at INTEGER', '') .replace(', api_key_generation INTEGER NOT NULL DEFAULT 0', '') - .replace(', plan_limits TEXT', ''), + .replace(', limit_overrides TEXT', ''), }; } // 0063 appends four nullable recovery fields. Remove only their exact diff --git a/packages/engine/src/db/__tests__/taskMigration.test.ts b/packages/engine/src/db/__tests__/taskMigration.test.ts index 1972e7d6b..b0c03e80e 100644 --- a/packages/engine/src/db/__tests__/taskMigration.test.ts +++ b/packages/engine/src/db/__tests__/taskMigration.test.ts @@ -39,7 +39,7 @@ it('upgrades existing actions and results additively, preserving rows and every '0073_workspace_key_rotation.sql', '0074_delivery_active_location_node_index.sql', '0075_message_mention_fts.sql', - '0076_workspace_plan_limits.sql', + '0076_workspace_limit_overrides.sql', ]); for (const table of before) { const columns = db.pragma(`table_info(${table.name})`) as { name: string }[]; diff --git a/packages/engine/src/db/migrations/0076_workspace_limit_overrides.sql b/packages/engine/src/db/migrations/0076_workspace_limit_overrides.sql new file mode 100644 index 000000000..83b0d43a6 --- /dev/null +++ b/packages/engine/src/db/migrations/0076_workspace_limit_overrides.sql @@ -0,0 +1,5 @@ +-- Host-owned per-workspace limit overrides (JSON). The hosting control plane +-- stores whatever its entitlements provider needs to derive this workspace's +-- limits; the engine never reads the value itself. NULL means the host's +-- defaults alone decide. +ALTER TABLE workspaces ADD COLUMN limit_overrides TEXT; diff --git a/packages/engine/src/db/migrations/0076_workspace_plan_limits.sql b/packages/engine/src/db/migrations/0076_workspace_plan_limits.sql deleted file mode 100644 index 42ba4b6a0..000000000 --- a/packages/engine/src/db/migrations/0076_workspace_plan_limits.sql +++ /dev/null @@ -1,6 +0,0 @@ --- Per-workspace registration-cap overrides pushed by the hosting control plane --- (JSON: devices, agents, humans, session_retention_days; null = unlimited). --- The entitlements provider applies these over the plan table, so a billing --- system can change a workspace's caps without a new engine release. NULL --- means the plan table alone decides. -ALTER TABLE workspaces ADD COLUMN plan_limits TEXT; diff --git a/packages/engine/src/db/schema.ts b/packages/engine/src/db/schema.ts index bb03955c3..df4756a59 100644 --- a/packages/engine/src/db/schema.ts +++ b/packages/engine/src/db/schema.ts @@ -12,7 +12,6 @@ import { import { sql } from 'drizzle-orm'; import type { AnySQLiteColumn } from 'drizzle-orm/sqlite-core'; import type { FleetCapability, FleetTaskContext, WorkspaceProvenance } from '@relaycast/types'; -import type { WorkspacePlanLimits } from '../ports/entitlements.js'; export interface TaskInvocationState extends FleetTaskContext { deadline: string; @@ -68,10 +67,10 @@ export const workspaces = sqliteTable( createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`), metadata: text('metadata', { mode: 'json' }).$type>().default({}), retention: text('retention', { mode: 'json' }).$type(), - // Registration-cap overrides pushed by a control plane (devices/agents/ - // humans; null = unlimited). Applied over the plan table by the - // entitlements provider; null means the plan table alone decides (0076). - planLimits: text('plan_limits', { mode: 'json' }).$type(), + // Host-owned per-workspace limit overrides (0076). The engine never + // interprets them: the host's entitlements provider maps them, with + // whatever else it knows about the workspace, to the engine's `Limits`. + limitOverrides: text('limit_overrides', { mode: 'json' }).$type>(), // A non-null deadline is an explicit, immutable opt-in to whole-workspace // deletion. Persistent workspaces always leave this null. expiresAt: integer('expires_at', { mode: 'timestamp' }), diff --git a/packages/engine/src/engine.ts b/packages/engine/src/engine.ts index 7f7bdb191..cef64eed7 100644 --- a/packages/engine/src/engine.ts +++ b/packages/engine/src/engine.ts @@ -1,5 +1,5 @@ import { safeTelemetrySink } from './ports/telemetry.js'; -import { planLimitExceededBody, planLimitExceededDetail } from './engine/planCapacity.js'; +import { planLimitExceeded, planLimitExceededBody } from './engine/planCapacity.js'; import { NoopTelemetrySink } from './providers/noop-telemetry.js'; import { Hono } from 'hono'; import type { ContentfulStatusCode } from 'hono/utils/http-status'; @@ -256,8 +256,8 @@ export function createEngine(deps: EngineDeps): Hono { if (error.code === 'invalid_json' || err instanceof SyntaxError) { return jsonMalformedBody(c); } - const planLimit = planLimitExceededDetail(error); - if (planLimit) return c.json(planLimitExceededBody(error, planLimit), 429); + const refusal = planLimitExceeded(error); + if (refusal) return c.json(planLimitExceededBody(error, refusal), 429); const requestId = c.get('requestId'); return c.json( { diff --git a/packages/engine/src/engine/__tests__/workspaceDeliveryDepth.test.ts b/packages/engine/src/engine/__tests__/workspaceDeliveryDepth.test.ts index 15103824b..bea2b8d93 100644 --- a/packages/engine/src/engine/__tests__/workspaceDeliveryDepth.test.ts +++ b/packages/engine/src/engine/__tests__/workspaceDeliveryDepth.test.ts @@ -173,18 +173,18 @@ describe('workspace delivery growth guard (channel broadcast)', () => { it('resolves the dynamic host policy through the async resolver, clamped within cap', async () => { const config = { workspaceDelivery: { - resolve: async (w: { id: string; plan: string }) => - ({ cap: w.plan === 'enterprise' ? 5000 : 500, reserve: 16 }), + resolve: async (w: { id: string; plan: string | null }) => + ({ cap: w.plan === 'tier-large' ? 5000 : 500, reserve: 16 }), }, }; - expect(await resolveWorkspaceDeliveryPolicyFor(config, { id: 'ws', plan: 'enterprise' })) + expect(await resolveWorkspaceDeliveryPolicyFor(config, { id: 'ws', plan: 'tier-large' })) .toEqual({ cap: 5000, reserve: 16 }); - expect(await resolveWorkspaceDeliveryPolicyFor(config, { id: 'ws', plan: 'free' })) + expect(await resolveWorkspaceDeliveryPolicyFor(config, { id: 'ws', plan: 'tier-small' })) .toEqual({ cap: 500, reserve: 16 }); // No host policy => undefined (self-host has no workspace guard). - expect(await resolveWorkspaceDeliveryPolicyFor(undefined, { id: 'ws', plan: 'free' })).toBeUndefined(); + expect(await resolveWorkspaceDeliveryPolicyFor(undefined, { id: 'ws', plan: 'tier-small' })).toBeUndefined(); // A reserve >= cap is clamped into `0 <= reserve < cap`. - expect(await resolveWorkspaceDeliveryPolicyFor({ workspaceDelivery: { cap: 100, reserve: 200 } }, { id: 'ws', plan: 'free' })) + expect(await resolveWorkspaceDeliveryPolicyFor({ workspaceDelivery: { cap: 100, reserve: 200 } }, { id: 'ws', plan: 'tier-small' })) .toEqual({ cap: 100, reserve: 99 }); }); diff --git a/packages/engine/src/engine/a2a.ts b/packages/engine/src/engine/a2a.ts index 46c561250..150195d22 100644 --- a/packages/engine/src/engine/a2a.ts +++ b/packages/engine/src/engine/a2a.ts @@ -91,7 +91,7 @@ export interface RegisterA2aAgentInput { authCredential?: string; /** Remote skill/agent that DMs to this proxy should address. */ targetAgent?: string; - /** Plan registration-cap guard, consulted only when a NEW proxy identity is provisioned. */ + /** Registration-cap guard, consulted only when a NEW proxy identity is provisioned. */ capacity?: RegistrationCapacityGuard; } diff --git a/packages/engine/src/engine/agent.ts b/packages/engine/src/engine/agent.ts index 432f31b60..656d87768 100644 --- a/packages/engine/src/engine/agent.ts +++ b/packages/engine/src/engine/agent.ts @@ -217,7 +217,7 @@ export async function registerAgent( autoJoinGeneral?: boolean; recoveryProofHash?: string; workUnitId?: string; - /** Plan registration-cap guard, consulted only when `name` is NEW to the workspace. */ + /** Registration-cap guard, consulted only when `name` is NEW to the workspace. */ capacity?: RegistrationCapacityGuard; }, /** @@ -246,7 +246,7 @@ export async function registerAgent( ); } if (data.capacity && !afterVerifierReclaim) { - // Only a NEW identity counts against the plan. An established name is + // Only a NEW identity counts against the cap. An established name is // answered by the unique-constraint conflict below — never by the cap — // so a register-or-rotate client at its cap still reaches its rotation. const [established] = await db diff --git a/packages/engine/src/engine/node.ts b/packages/engine/src/engine/node.ts index 8dc42c8c7..4748b8138 100644 --- a/packages/engine/src/engine/node.ts +++ b/packages/engine/src/engine/node.ts @@ -24,7 +24,7 @@ import type { getDb } from '../db/index.js'; import { actionInvocations, agents, agentNodeBindings, channelMembers, channels, nodeProviders, nodes } from '../db/schema.js'; import { randomHex, sha256Hex } from '../lib/crypto.js'; import { codedError } from '../lib/httpError.js'; -import { planLimitExceededDetail, type RegistrationCapacityGuard } from './planCapacity.js'; +import { planLimitExceeded, type RegistrationCapacityGuard } from './planCapacity.js'; import { acceptTaskInvocation, completeTaskInvocation } from './taskInvocation.js'; import { databaseConstraintKind, runAtomic, runAtomicWrites } from '../ports/database.js'; import { retryD1Write } from '../lib/d1Retry.js'; @@ -90,7 +90,7 @@ export interface HandleNodeControlMessageArgs { */ connectionId?: string; /** - * Plan registration-cap guard for `agent.register`. Absent when the host + * Registration-cap guard for `agent.register`. Absent when the host * enforces no caps (or drives node control without entitlements). */ capacity?: RegistrationCapacityGuard; @@ -448,7 +448,7 @@ export async function createNodeToken( idempotencyKeyHash: string; requestDigest: string; }; - /** Plan registration-cap guard, consulted only when this enrolls a NEW device. */ + /** Registration-cap guard, consulted only when this enrolls a NEW device. */ capacity?: RegistrationCapacityGuard; } = {}, ) { @@ -509,7 +509,7 @@ export async function createNodeToken( if (role === 'direct' && maxAgents !== 1) { throw codedError('Direct nodes can bind at most one agent', 'direct_node_capacity_exceeded', 400); } - // Only a NEW device counts against the plan. Rotating or re-enrolling an + // Only a NEW device counts against the cap. Rotating or re-enrolling an // established row never does (its role may change, but it was counted when // it was enrolled), and an agent's implicit direct node — minted on its // behalf, never enrolled by a host — is its own WebSocket, not a device. @@ -2153,7 +2153,7 @@ export async function registerAgentViaNode( assertRegistrableAgentName(message.name); const wantsAutoJoin = message.auto_join_general !== false; if (options.capacity) { - // Only a NEW identity counts against the plan: an established name is + // Only a NEW identity counts against the cap: an established name is // answered by the conflict below, never by the cap. const [established] = await db .select({ id: agents.id }) @@ -3811,17 +3811,19 @@ export async function handleNodeControlMessage(args: HandleNodeControlMessageArg provider: frameProviderName, message: error.message, }); - // A plan-cap refusal carries the same machine-readable detail as the - // HTTP 429 (`error_code`, `limit`, `max`, `current`, `plan`). - const planLimit = planLimitExceededDetail(err); + // A cap refusal carries the same machine-readable detail as the HTTP 429 + // (`error_code`, `limit`, `max`, `current`) plus the host's context. + const refusal = planLimitExceeded(err); sendControl(args.socket, { + // Host context first: the frame's own fields always win over it. + ...(refusal?.context ?? {}), v: 1, id: requestId(message), type: 'error', ok: false, code, message: error.message, - ...(planLimit ? { error_code: code, ...planLimit } : {}), + ...(refusal ? { error_code: code, ...refusal.detail } : {}), }); } }); diff --git a/packages/engine/src/engine/planCapacity.ts b/packages/engine/src/engine/planCapacity.ts index 3ed1dc8ef..e2a68dc4b 100644 --- a/packages/engine/src/engine/planCapacity.ts +++ b/packages/engine/src/engine/planCapacity.ts @@ -1,11 +1,6 @@ import { and, eq, like, ne, not, sql } from 'drizzle-orm'; import type { EngineDb } from '../ports/database.js'; -import type { - EntitlementsProvider, - PlanLimits, - RegistrationMetric, - WorkspacePlanLimits, -} from '../ports/entitlements.js'; +import type { EntitlementsProvider, RegistrationMetric } from '../ports/entitlements.js'; import type { Workspace } from '../ports/auth.js'; import { agents, nodes, workspaces } from '../db/schema.js'; import type { CodedError } from '../lib/httpError.js'; @@ -17,13 +12,14 @@ export interface PlanLimitExceededDetail { limit: RegistrationMetric; max: number; current: number; - plan: string; } export interface PlanLimitExceededError extends CodedError { code: typeof PLAN_LIMIT_EXCEEDED; status: 429; planLimit: PlanLimitExceededDetail; + /** Host-owned context from `EntitlementsProvider.getLimitContext`, passed through verbatim. */ + limitContext: Record; } const REGISTRATION_NOUN: Record = { @@ -32,21 +28,27 @@ const REGISTRATION_NOUN: Record = { humans: 'human member', }; -export function planLimitExceededError(detail: PlanLimitExceededDetail): PlanLimitExceededError { +export function planLimitExceededError( + detail: PlanLimitExceededDetail, + limitContext: Record = {}, +): PlanLimitExceededError { const message = - `Plan limit exceeded for ${detail.limit}: the ${detail.plan} plan allows ${detail.max} ` + + `Limit exceeded for ${detail.limit}: this workspace allows ${detail.max} ` + `${REGISTRATION_NOUN[detail.limit]}${detail.max === 1 ? '' : 's'} and ${detail.current} ` + - `${detail.current === 1 ? 'is' : 'are'} registered. Upgrade your plan to register more.`; + `${detail.current === 1 ? 'is' : 'are'} registered.`; return Object.assign(new Error(message), { code: PLAN_LIMIT_EXCEEDED, status: 429, clientSafe: true as const, planLimit: detail, + limitContext, }) as PlanLimitExceededError; } -/** The cap detail when `err` is a registration-cap refusal, else `null`. */ -export function planLimitExceededDetail(err: unknown): PlanLimitExceededDetail | null { +/** The cap detail and host context when `err` is a registration-cap refusal, else `null`. */ +export function planLimitExceeded( + err: unknown, +): { detail: PlanLimitExceededDetail; context: Record } | null { if (!err || typeof err !== 'object') return null; const candidate = err as Partial; if (candidate.code !== PLAN_LIMIT_EXCEEDED) return null; @@ -55,20 +57,30 @@ export function planLimitExceededDetail(err: unknown): PlanLimitExceededDetail | if (typeof detail.limit !== 'string' || typeof detail.max !== 'number' || typeof detail.current !== 'number') { return null; } - return { limit: detail.limit, max: detail.max, current: detail.current, plan: String(detail.plan) }; + const context = candidate.limitContext; + return { + detail: { limit: detail.limit, max: detail.max, current: detail.current }, + context: context && typeof context === 'object' && !Array.isArray(context) ? context : {}, + }; } /** * The HTTP 429 body for a registration-cap refusal: the standard error - * envelope plus the top-level `error_code`/`limit`/`max`/`current`/`plan` - * fields clients key on to explain the refusal and offer an upgrade. + * envelope plus top-level `error_code`/`limit`/`max`/`current` and the + * provider's context spread at top level (so a hosted gateway's `plan` and + * `upgrade_url` land where clients key on them). The fixed fields win over a + * context key of the same name. */ -export function planLimitExceededBody(error: PlanLimitExceededError | CodedError, detail: PlanLimitExceededDetail) { +export function planLimitExceededBody( + error: CodedError, + refusal: { detail: PlanLimitExceededDetail; context: Record }, +) { return { + ...refusal.context, ok: false as const, error: { code: PLAN_LIMIT_EXCEEDED, message: error.message }, error_code: PLAN_LIMIT_EXCEEDED, - ...detail, + ...refusal.detail, }; } @@ -77,29 +89,6 @@ export function registrationCap(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? Math.floor(value) : null; } -/** - * Overlay a workspace's stored cap overrides on its plan's limits. `null` is - * unlimited and a finite non-negative number is the cap; an absent or - * malformed key keeps the plan's value, so a corrupt override can never - * widen or tighten a cap by accident. - */ -export function applyWorkspacePlanLimits( - base: PlanLimits, - overrides: WorkspacePlanLimits | null | undefined, -): PlanLimits { - if (!overrides || typeof overrides !== 'object') return base; - const override = (value: unknown): number | null | undefined => - value === null ? null : registrationCap(value) ?? undefined; - const limits: PlanLimits = { ...base }; - const devices = override(overrides.devices); - if (devices !== undefined) limits.devices = devices; - const humans = override(overrides.humans); - if (humans !== undefined) limits.humans = humans; - const agents = override(overrides.agents); - if (agents !== undefined) limits.agents = agents ?? Infinity; - return limits; -} - /** * How many of `metric` the workspace currently has registered — the number a * new registration is compared against. @@ -159,13 +148,15 @@ async function resolveWorkspace(db: EngineDb, workspace: Workspace | string): Pr /** * Build the guard the registration paths run before inserting a node or agent. - * The provider decides the cap (`getLimits`) and the current count - * (`getUsage`); a provider failure fails open with a warning, matching the - * api_calls quota middleware — a billing outage must not block registration. + * The provider decides the cap (`getLimits`), the current count (`getUsage`) + * and the refusal's host context (`getLimitContext`); a provider failure fails + * open with a warning, matching the api_calls quota middleware — a billing + * outage must not block registration. */ export function createRegistrationCapacityGuard(args: RegistrationCapacityArgs): RegistrationCapacityGuard { return async (metric) => { let detail: PlanLimitExceededDetail | null = null; + let context: Record = {}; try { const workspace = await resolveWorkspace(args.db, args.workspace); if (!workspace) return; @@ -174,8 +165,12 @@ export function createRegistrationCapacityGuard(args: RegistrationCapacityArgs): if (max === null) return; const current = await args.entitlements.getUsage(workspace.id, metric); if (current < max) return; - const plan = (await args.entitlements.getPlan?.(workspace)) ?? workspace.plan; - detail = { limit: metric, max, current, plan }; + detail = { limit: metric, max, current }; + try { + context = (await args.entitlements.getLimitContext?.(workspace)) ?? {}; + } catch { + // The refusal stands without the host's extra fields. + } } catch (err) { console.warn('[plan-capacity] entitlements lookup failed; allowing registration', { metric, @@ -184,6 +179,6 @@ export function createRegistrationCapacityGuard(args: RegistrationCapacityArgs): }); return; } - throw planLimitExceededError(detail); + throw planLimitExceededError(detail, context); }; } diff --git a/packages/engine/src/engine/workspaceDeliveryPolicy.ts b/packages/engine/src/engine/workspaceDeliveryPolicy.ts index a77922885..dd24a927d 100644 --- a/packages/engine/src/engine/workspaceDeliveryPolicy.ts +++ b/packages/engine/src/engine/workspaceDeliveryPolicy.ts @@ -51,7 +51,7 @@ export async function resolveWorkspaceDeliveryPolicyFor( ): Promise { const dynamic = await config?.workspaceDelivery?.resolve?.({ id: workspace.id, - plan: workspace.plan ?? 'free', + plan: workspace.plan ?? null, }); if (dynamic !== undefined) return dynamicWorkspaceDeliveryPolicySchema.parse(dynamic); return resolveWorkspaceDeliveryPolicy(config, workspace.id); diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 43d98e71a..911d641fe 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -30,7 +30,7 @@ export type { Workspace, Agent, EntitlementsProvider, - PlanLimits, + Limits, UsageMetric, TelemetrySink, TelemetryEvent, @@ -43,15 +43,16 @@ export { observerAllowsEvent, type ObserverToken } from './engine/observerToken. // OSS default providers (self-host). Cloud injects its own. export { SqliteApiKeyAuthProvider, hashToken } from './auth/index.js'; -export { StaticEntitlementsProvider, PLAN_LIMITS } from './providers/static-entitlements.js'; -// Plan registration caps (devices/agents/humans): the live counts a provider's -// `getUsage` reports, the override merge, and the guard a host wires into its +export { StaticEntitlementsProvider } from './providers/static-entitlements.js'; +export { UNLIMITED_LIMITS, resolveLimits } from './ports/entitlements.js'; +// Registration caps (devices/agents/humans): the live counts a provider's +// `getUsage` reports, the cap normalizer, and the guard a host wires into its // node-control transport so `agent.register` frames are capped like HTTP. export { - applyWorkspacePlanLimits, countRegistrations, createRegistrationCapacityGuard, - planLimitExceededDetail, + planLimitExceeded, + registrationCap, PLAN_LIMIT_EXCEEDED, type PlanLimitExceededDetail, type RegistrationCapacityGuard, diff --git a/packages/engine/src/lib/httpError.ts b/packages/engine/src/lib/httpError.ts index 3cd3172c8..08725b789 100644 --- a/packages/engine/src/lib/httpError.ts +++ b/packages/engine/src/lib/httpError.ts @@ -1,7 +1,7 @@ import type { Context } from 'hono'; import type { ContentfulStatusCode } from 'hono/utils/http-status'; import { jsonError, jsonMalformedBody } from './httpResponse.js'; -import { planLimitExceededBody, planLimitExceededDetail } from '../engine/planCapacity.js'; +import { planLimitExceeded, planLimitExceededBody } from '../engine/planCapacity.js'; /** * Errors thrown inside the engine optionally carry a stable error `code` and an @@ -93,9 +93,9 @@ export function errorResponse(c: Context, err: unknown) { return jsonMalformedBody(c); } // A registration-cap refusal carries machine-readable detail beyond the - // envelope (`error_code`, `limit`, `max`, `current`, `plan`). - const planLimit = planLimitExceededDetail(error); - if (planLimit) return c.json(planLimitExceededBody(error, planLimit), 429); + // envelope (`error_code`, `limit`, `max`, `current`) plus the host's context. + const refusal = planLimitExceeded(error); + if (refusal) return c.json(planLimitExceededBody(error, refusal), 429); const status = effectiveHttpStatus(error); // Throw the normalized Error: Hono hands only `Error` instances to `onError`. if (!error.code && status >= 500) throw error; diff --git a/packages/engine/src/middleware/planLimits.ts b/packages/engine/src/middleware/planLimits.ts index 1a23bb012..a6571d72c 100644 --- a/packages/engine/src/middleware/planLimits.ts +++ b/packages/engine/src/middleware/planLimits.ts @@ -7,9 +7,6 @@ import { usagePeriodResetAt } from '../engine/usage.js'; import { jsonError } from '../lib/httpResponse.js'; import { setRetryContract } from '../lib/throttle.js'; -// Re-exported for back-compat with callers/tests that imported the table here. -export { PLAN_LIMITS } from '../providers/static-entitlements.js'; - /** * A provider billing on its own cycle reports that cycle's end. An absent or * failing hook falls back to the engine's UTC-month period, so how the reset is @@ -41,18 +38,17 @@ export function checkPlanLimit(metric: UsageMetric) { return; } const limit = limits[metric]; - if (typeof limit !== 'number' || limit === Infinity) { await next(); return; } + if (typeof limit !== 'number' || !Number.isFinite(limit)) { await next(); return; } try { const current = await entitlements.getUsage(workspace.id, metric); if (current >= limit) { - const plan = workspace.plan || 'free'; // Quota clears on the billing period, not on a backoff. Advertising the // real reset lets a caller with a bounded retry budget fail fast instead // of spending it on a condition retrying cannot fix. const resetAt = await resolveUsageResetAt(entitlements, workspace.id, metric); setRetryContract(c, resetAt); - return jsonError(c, 'plan_limit_exceeded', `Plan limit exceeded for ${metric}. Current plan: ${plan}. Limit: ${limit} per usage period. Current usage: ${current}. Quota resets at ${new Date(resetAt).toISOString()}. Upgrade your plan to increase limits.`, 429); + return jsonError(c, 'plan_limit_exceeded', `Limit exceeded for ${metric}: ${limit} per usage period allowed, ${current} used. Quota resets at ${new Date(resetAt).toISOString()}.`, 429); } } catch { /* fail open */ } @@ -61,7 +57,7 @@ export function checkPlanLimit(metric: UsageMetric) { } /** - * The plan registration-cap guard for the authenticated workspace on this + * The registration-cap guard for the authenticated workspace on this * request. Registration routes hand it to the engine, which runs it only when * the request would create a NEW node or agent. */ diff --git a/packages/engine/src/middleware/rateLimit.ts b/packages/engine/src/middleware/rateLimit.ts index d2214979e..f2a279a0c 100644 --- a/packages/engine/src/middleware/rateLimit.ts +++ b/packages/engine/src/middleware/rateLimit.ts @@ -109,12 +109,17 @@ export const rateLimit = createMiddleware(async (c, next) => { // Resolve the per-minute limit. If entitlements are unavailable, fall back to // a conservative default and still enforce it — an entitlements outage must // NOT translate into unlimited throughput. - let globalLimit: number; + let globalLimit: number | null; try { globalLimit = (await entitlements.getLimits(workspace)).rate_per_min; } catch { globalLimit = FALLBACK_RATE_PER_MIN; } + // A null ceiling is unlimited: the provider chose not to throttle this workspace. + if (globalLimit === null || !Number.isFinite(globalLimit)) { + await presenceRefresh(c, async () => {}); + return usageTracker(c, next); + } const limit = routeKey ? Math.ceil(globalLimit * ROUTE_MULTIPLIERS[routeKey]) : globalLimit; try { @@ -128,7 +133,7 @@ export const rateLimit = createMiddleware(async (c, next) => { // Transient by construction: the bucket is keyed to this minute, so the // advertised wait is the real one. setRetryContract(c, resetAt, now); - return jsonError(c, 'rate_limit_exceeded', `Rate limit exceeded. ${limit} requests per minute allowed for ${workspace.plan} plan.`, 429); + return jsonError(c, 'rate_limit_exceeded', `Rate limit exceeded. ${limit} requests per minute allowed.`, 429); } } catch { // Only the limiter backend failing is fail-open — a transient infra hiccup diff --git a/packages/engine/src/ports/entitlements.ts b/packages/engine/src/ports/entitlements.ts index 4fb4fda59..b89f59ffe 100644 --- a/packages/engine/src/ports/entitlements.ts +++ b/packages/engine/src/ports/entitlements.ts @@ -3,55 +3,68 @@ import type { Workspace } from './auth.js'; /** * `messages`/`file_bytes`/`api_calls` are metered per billing period; * `devices`/`agents`/`humans` are registration counts — how many of each a - * workspace currently has enrolled — compared against the plan's hard cap when - * a new one is registered. + * workspace currently has enrolled — compared against the hard cap when a new + * one is registered. */ export type UsageMetric = 'messages' | 'agents' | 'file_bytes' | 'api_calls' | 'devices' | 'humans'; /** The metrics enforced as hard registration caps. */ export type RegistrationMetric = 'devices' | 'agents' | 'humans'; -export interface PlanLimits { - messages: number; - /** Registered non-human agents. `Infinity` = unlimited. */ - agents: number; - file_bytes: number; - /** Total authenticated API calls allowed for the current billing window. */ - api_calls: number; - /** Global requests-per-minute ceiling, fed into the rate limiter. */ - rate_per_min: number; - /** Registered devices (broker/http_push nodes; implicit direct nodes excluded). `null` = unlimited. */ +/** + * A workspace's effective limits, keyed by metric. `null` is unlimited. The + * engine carries no tiers or numbers of its own: a host's entitlements + * provider maps whatever it knows about a workspace (billing plan, stored + * overrides, operator config) to this object. + */ +export interface Limits { + /** Messages per usage period. */ + messages: number | null; + /** Registered non-human agents (hard cap). */ + agents: number | null; + /** Stored file bytes. */ + file_bytes: number | null; + /** Authenticated API calls per usage period. */ + api_calls: number | null; + /** Requests per minute, fed into the rate limiter. */ + rate_per_min: number | null; + /** Registered devices: nodes a host enrolled, excluding each agent's implicit direct node (hard cap). */ devices: number | null; - /** Registered human members (`agents.type = 'human'`). `null` = unlimited. */ + /** Registered human members (`agents.type = 'human'`, hard cap). */ humans: number | null; + /** Message retention in days the host applies for this workspace; `null` = never prune. */ + message_retention_days: number | null; } -/** - * Per-workspace registration-cap overrides a control plane stores on the - * workspace row (`workspaces.plan_limits`). A present key wins over the plan - * table; `null` means unlimited; an absent key falls through to the plan. - * `session_retention_days` is carried for the host's own retention policy and - * is not an engine limit. - */ -export interface WorkspacePlanLimits { - devices?: number | null; - agents?: number | null; - humans?: number | null; - session_retention_days?: number | null; +/** Every metric unlimited — the self-hosted default. */ +export const UNLIMITED_LIMITS: Readonly = Object.freeze({ + messages: null, + agents: null, + file_bytes: null, + api_calls: null, + rate_per_min: null, + devices: null, + humans: null, + message_retention_days: null, +}); + +/** A full {@link Limits} object from a partial one; absent keys are unlimited. */ +export function resolveLimits(limits?: Partial | null): Limits { + return { ...UNLIMITED_LIMITS, ...(limits ?? {}) }; } /** - * Pluggable plan limits / usage — the billing seam. + * Pluggable limits / usage — the hosting seam. * - * Self-host uses `StaticEntitlementsProvider`, seeded from today's hardcoded - * `PLAN_LIMITS` (free/connect/intro/pro/enterprise), defaulting single-tenant - * deployments to a generous/unlimited tier. The cloud product injects a provider - * that resolves quotas from its billing system (e.g. a Stripe subscription) per - * workspace and reports metered usage. + * Self-host uses `StaticEntitlementsProvider`: unlimited unless the operator + * sets `EngineConfig.limits` for their own instance. The hosted product injects + * a provider that resolves limits from its billing system per workspace, + * reports metered usage, and supplies the context its clients need to explain + * a refusal. */ export interface EntitlementsProvider { - /** Resolve the active plan limits for a workspace. */ - getLimits(workspace: Workspace): Promise; + /** Resolve the effective limits for the authenticated workspace row. */ + getLimits(workspace: Workspace): Promise; /** * Current usage for a metric, compared against the limit before allowing an @@ -70,9 +83,10 @@ export interface EntitlementsProvider { getUsageResetAt?(workspaceId: string, metric: UsageMetric): Promise; /** - * The effective plan key reported in a `plan_limit_exceeded` refusal. - * Optional: a provider that raises a workspace above its stored `plan` - * column reports the raised key; omitting it reports `workspace.plan`. + * Host-owned context spread verbatim onto the top level of a + * `plan_limit_exceeded` refusal (for example the workspace's billing tier and + * an upgrade URL). The engine never interprets it. Optional; a failure or + * omission yields no extra fields. */ - getPlan?(workspace: Workspace): Promise; + getLimitContext?(workspace: Workspace): Promise>; } diff --git a/packages/engine/src/ports/index.ts b/packages/engine/src/ports/index.ts index 79cdd0af0..53d2abd37 100644 --- a/packages/engine/src/ports/index.ts +++ b/packages/engine/src/ports/index.ts @@ -43,11 +43,11 @@ export type { } from './auth.js'; export type { EntitlementsProvider, - PlanLimits, + Limits, RegistrationMetric, UsageMetric, - WorkspacePlanLimits, } from './entitlements.js'; +export { UNLIMITED_LIMITS, resolveLimits } from './entitlements.js'; export { ENGINE_OBSERVATION_FACTS } from './telemetry.js'; export type { TelemetrySink, TelemetryEvent, EngineObservation, EngineObservationKind, EngineObservationFacts } from './telemetry.js'; export type { EngineLogRecord, EngineLogExport } from './logging.js'; @@ -60,7 +60,7 @@ import type { FileStorage } from './files.js'; import type { KeyValueStore } from './kv.js'; import type { EventQueue } from './event-queue.js'; import type { AuthProvider } from './auth.js'; -import type { EntitlementsProvider } from './entitlements.js'; +import type { EntitlementsProvider, Limits } from './entitlements.js'; import type { TelemetrySink } from './telemetry.js'; import type { EngineLogExport } from './logging.js'; @@ -96,6 +96,12 @@ export interface EngineDeps extends EnginePorts, EngineProviders { export interface EngineConfig { environment?: string; + /** + * Static limits for every workspace on this instance, applied by the default + * `StaticEntitlementsProvider` (absent keys are unlimited). Ignored when a + * host injects its own entitlements provider. + */ + limits?: Partial; appVersion?: string; appSemver?: string; sdkSemver?: string; diff --git a/packages/engine/src/ports/workspaceDelivery.ts b/packages/engine/src/ports/workspaceDelivery.ts index 0b00761fa..98f0ef661 100644 --- a/packages/engine/src/ports/workspaceDelivery.ts +++ b/packages/engine/src/ports/workspaceDelivery.ts @@ -15,11 +15,12 @@ export interface WorkspaceDeliveryPolicyConfig { reserve?: number; workspaces?: Record; /** - * Server-only async resolver for dynamic, request-time plans (e.g. a - * KV-backed effective plan lookup). When present it takes precedence over the - * static `cap`/`workspaces` fields, so a host whose plan is async does not - * have to flatten every workspace into static config. Never source this from - * a client request. + * Server-only async resolver for dynamic, request-time policy (e.g. a + * KV-backed effective tier lookup). When present it takes precedence over the + * static `cap`/`workspaces` fields, so a host whose policy is async does not + * have to flatten every workspace into static config. `plan` is the + * workspace row's host-assigned key, opaque to the engine. Never source this + * from a client request. */ - resolve?: (workspace: { id: string; plan: string }) => Promise; + resolve?: (workspace: { id: string; plan: string | null }) => Promise; } diff --git a/packages/engine/src/providers/__tests__/static-entitlements.test.ts b/packages/engine/src/providers/__tests__/static-entitlements.test.ts index 9b452e7a4..b83e853d9 100644 --- a/packages/engine/src/providers/__tests__/static-entitlements.test.ts +++ b/packages/engine/src/providers/__tests__/static-entitlements.test.ts @@ -1,78 +1,52 @@ import { describe, expect, it } from 'vitest'; -import { PLAN_LIMITS, StaticEntitlementsProvider } from '../static-entitlements.js'; -import { applyWorkspacePlanLimits } from '../../engine/planCapacity.js'; -import type { Workspace, WorkspacePlanLimits } from '../../ports/index.js'; +import { StaticEntitlementsProvider } from '../static-entitlements.js'; +import { UNLIMITED_LIMITS, resolveLimits } from '../../ports/entitlements.js'; +import type { Workspace } from '../../ports/index.js'; -function workspace(plan: string, planLimits: WorkspacePlanLimits | null = null): Workspace { - return { id: 'ws_1', plan, planLimits } as Workspace; -} +const workspace = { id: 'ws_1', plan: 'anything' } as Workspace; describe('StaticEntitlementsProvider', () => { - it('resolves the connect profile for room workspaces', async () => { - const provider = new StaticEntitlementsProvider(undefined, 'free'); - const limits = await provider.getLimits(workspace('connect')); - expect(limits).toBe(PLAN_LIMITS.connect); - expect(limits.api_calls).toBe(10_000_000); - }); - - it('sizes connect between free and pro on every metered metric', () => { - for (const metric of ['messages', 'file_bytes', 'api_calls', 'rate_per_min'] as const) { - expect(PLAN_LIMITS.connect![metric]).toBeGreaterThan(PLAN_LIMITS.free![metric]); - } - for (const metric of ['messages', 'file_bytes', 'rate_per_min'] as const) { - expect(PLAN_LIMITS.connect![metric]).toBeLessThan(PLAN_LIMITS.pro![metric]); - } - // Rooms poll constantly; connect's call budget is set to pro's for - // headroom, while its other limits stay below pro. - expect(PLAN_LIMITS.connect!.api_calls).toBeLessThanOrEqual(PLAN_LIMITS.pro!.api_calls); - }); - - it('carries the billing tiers\' registration caps', () => { - expect(PLAN_LIMITS.intro).toMatchObject({ devices: 5, agents: 15, humans: 2 }); - expect(PLAN_LIMITS.pro).toMatchObject({ devices: 15, agents: 60, humans: 10 }); - expect(PLAN_LIMITS.enterprise).toMatchObject({ devices: null, agents: Infinity, humans: null }); - // Legacy hosted tiers predate registration caps and carry none. - for (const plan of ['free', 'connect', 'selfhost'] as const) { - expect(PLAN_LIMITS[plan]).toMatchObject({ devices: null, agents: Infinity, humans: null }); - } - }); - - it('falls back to the default plan for an unknown plan', async () => { - const provider = new StaticEntitlementsProvider(undefined, 'free'); - expect(await provider.getLimits(workspace('platinum'))).toBe(PLAN_LIMITS.free); + it('is unlimited by default, whatever the workspace row says', async () => { + const provider = new StaticEntitlementsProvider(); + await expect(provider.getLimits(workspace)).resolves.toEqual(UNLIMITED_LIMITS); + await expect(provider.getLimits({ id: 'ws_2', plan: 'other' } as Workspace)).resolves.toEqual(UNLIMITED_LIMITS); + }); + + it('applies the operator\'s configured limits over unlimited', async () => { + const provider = new StaticEntitlementsProvider(undefined, { devices: 5, rate_per_min: 600 }); + await expect(provider.getLimits(workspace)).resolves.toEqual({ + ...UNLIMITED_LIMITS, + devices: 5, + rate_per_min: 600, + }); }); - it('overlays the workspace row\'s plan_limits on the plan table', async () => { - const provider = new StaticEntitlementsProvider(undefined, 'free'); - const limits = await provider.getLimits(workspace('intro', { devices: 7, humans: null, session_retention_days: 7 })); - expect(limits).toMatchObject({ devices: 7, humans: null, agents: 15, api_calls: PLAN_LIMITS.intro!.api_calls }); + it('carries no tier names or numbers of its own', () => { + for (const value of Object.values(UNLIMITED_LIMITS)) expect(value).toBeNull(); + expect(Object.keys(UNLIMITED_LIMITS).sort()).toEqual([ + 'agents', 'api_calls', 'devices', 'file_bytes', 'humans', 'message_retention_days', 'messages', 'rate_per_min', + ]); }); it('reports a registration count of zero without a database handle', async () => { - const provider = new StaticEntitlementsProvider(undefined, 'intro'); + const provider = new StaticEntitlementsProvider(undefined, { devices: 1 }); await expect(provider.getUsage('ws_1', 'devices')).resolves.toBe(0); }); -}); - -describe('applyWorkspacePlanLimits', () => { - const base = PLAN_LIMITS.intro!; - it('returns the plan unchanged without overrides', () => { - expect(applyWorkspacePlanLimits(base, null)).toBe(base); - expect(applyWorkspacePlanLimits(base, undefined)).toBe(base); - expect(applyWorkspacePlanLimits(base, {})).toEqual(base); + it('reports zero metered usage without a key/value store', async () => { + const provider = new StaticEntitlementsProvider(); + await expect(provider.getUsage('ws_1', 'api_calls')).resolves.toBe(0); }); +}); - it('treats a present null as unlimited and an absent key as the plan value', () => { - expect(applyWorkspacePlanLimits(base, { devices: null, agents: null })).toMatchObject({ - devices: null, - agents: Infinity, - humans: 2, - }); +describe('resolveLimits', () => { + it('fills absent keys with unlimited', () => { + expect(resolveLimits()).toEqual(UNLIMITED_LIMITS); + expect(resolveLimits(null)).toEqual(UNLIMITED_LIMITS); + expect(resolveLimits({ agents: 3 })).toEqual({ ...UNLIMITED_LIMITS, agents: 3 }); }); - it('keeps the plan value for a malformed override', () => { - const overrides = { devices: -1, agents: 'ten', humans: Number.NaN } as unknown as WorkspacePlanLimits; - expect(applyWorkspacePlanLimits(base, overrides)).toEqual(base); + it('keeps an explicit null as unlimited', () => { + expect(resolveLimits({ agents: null, humans: 2 })).toEqual({ ...UNLIMITED_LIMITS, humans: 2 }); }); }); diff --git a/packages/engine/src/providers/static-entitlements.ts b/packages/engine/src/providers/static-entitlements.ts index 19f6b2512..b164cfbde 100644 --- a/packages/engine/src/providers/static-entitlements.ts +++ b/packages/engine/src/providers/static-entitlements.ts @@ -1,55 +1,33 @@ -import type { EntitlementsProvider, PlanLimits, UsageMetric, Workspace } from '../ports/index.js'; +import type { EntitlementsProvider, Limits, UsageMetric, Workspace } from '../ports/index.js'; +import { resolveLimits } from '../ports/entitlements.js'; import type { EngineDb } from '../ports/database.js'; import type { KeyValueStore } from '../ports/kv.js'; import { getUsageMetric, usagePeriodResetAt } from '../engine/usage.js'; -import { applyWorkspacePlanLimits, countRegistrations } from '../engine/planCapacity.js'; - -const GiB = 1024 * 1024 * 1024; - -/** - * The default plan limits. Self-host deployments typically run the `selfhost` - * tier (everything unlimited). - * - * `intro`, `pro` and `enterprise` are the hosted billing tiers; their - * `devices`/`agents`/`humans` caps are hard registration limits. `free` and - * `connect` are legacy hosted tiers that predate registration caps and carry - * none, so a workspace that was never moved onto a billing tier is never - * refused a registration. - */ -export const PLAN_LIMITS: Record = { - free: { messages: 10_000, agents: Infinity, file_bytes: 100 * 1024 * 1024, api_calls: 100_000, rate_per_min: 300, devices: null, humans: null }, - // Short-lived collaboration rooms (Relay Connect): a handful of seats that - // poll their inbox continuously, so api_calls dominates while messages stay - // low. Sized for a two-seat room polling every few seconds for a full month. - connect: { messages: 100_000, agents: Infinity, file_bytes: 1 * GiB, api_calls: 10_000_000, rate_per_min: 1200, devices: null, humans: null }, - intro: { messages: 1_000_000, agents: 15, file_bytes: 50 * GiB, api_calls: 10_000_000, rate_per_min: 6000, devices: 5, humans: 2 }, - pro: { messages: 1_000_000, agents: 60, file_bytes: 50 * GiB, api_calls: 10_000_000, rate_per_min: 6000, devices: 15, humans: 10 }, - enterprise: { messages: Infinity, agents: Infinity, file_bytes: 500 * GiB, api_calls: Infinity, rate_per_min: 30000, devices: null, humans: null }, - selfhost: { messages: Infinity, agents: Infinity, file_bytes: Infinity, api_calls: Infinity, rate_per_min: 30000, devices: null, humans: null }, -}; +import { countRegistrations } from '../engine/planCapacity.js'; const REGISTRATION_METRICS = new Set(['devices', 'agents', 'humans']); /** - * Static, table-driven entitlements — the self-host default. Resolves limits - * from {@link PLAN_LIMITS} keyed by `workspace.plan`, overlaid with the row's - * own `plan_limits` overrides, and reads usage counters from the key/value - * store (the same `usage:::` keys the usage tracker - * increments, so both agree on the current billing period). Registration - * metrics are counted live from the database. The cloud product injects a - * billing-backed provider. + * Static entitlements — the self-host default. Every workspace gets the same + * limits: unlimited unless the operator sets some through + * `EngineConfig.limits`. Metered usage is read from the key/value store (the + * same `usage:::` keys the usage tracker increments, so + * both agree on the current billing period); registration metrics are counted + * live from the database. The hosted product injects its own provider. */ export class StaticEntitlementsProvider implements EntitlementsProvider { + private readonly limits: Limits; + constructor( private readonly kv?: KeyValueStore, - private readonly defaultPlan: string = 'selfhost', + limits?: Partial | null, private readonly db?: EngineDb, - ) {} + ) { + this.limits = resolveLimits(limits); + } - async getLimits(workspace: Workspace): Promise { - const plan = workspace.plan || this.defaultPlan; - const base = PLAN_LIMITS[plan] ?? PLAN_LIMITS[this.defaultPlan]!; - return applyWorkspacePlanLimits(base, workspace.planLimits); + async getLimits(_workspace: Workspace): Promise { + return this.limits; } async getUsage(workspaceId: string, metric: UsageMetric): Promise { From f8633a1483a207c64a31345973382e665b103f24 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Sun, 25 Oct 2026 20:45:01 -0700 Subject: [PATCH 5/6] Count implicit direct nodes by GLOB and require a database for configured caps `countRegistrations` excludes the implicit per-agent node with `GLOB 'node_direct_*'`: in LIKE `_` is a wildcard, so a host-enrolled `nodeXdirectXcustom` would have slipped out of the device count while passing the literal reserved-prefix check. `StaticEntitlementsProvider` refuses registration caps it has no database handle to count against, so a cap can never look configured while admitting everything. README and openapi state when the rate-limit headers are present, give a complete `createNodeRuntime` example, and describe the node-frame refusal as an `error` frame rather than an HTTP 429. Co-Authored-By: Claude Fable 5.1 --- README.md | 12 +++++++++--- openapi.yaml | 14 ++++++++------ .../src/__tests__/conformance/planCapacity.test.ts | 10 ++++++++++ packages/engine/src/engine/planCapacity.ts | 6 ++++-- .../__tests__/static-entitlements.test.ts | 12 +++++++----- .../engine/src/providers/static-entitlements.ts | 9 +++++++++ 6 files changed, 47 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 9a47f6ccb..a5e55a173 100644 --- a/README.md +++ b/README.md @@ -338,8 +338,10 @@ Three conditions return HTTP 429, and the error code tells you which: | `plan_limit_exceeded` | API-call quota for the current usage period | Clears only at the period boundary; `Retry-After` reports it | | `plan_limit_exceeded` with `limit` | Registration cap (`devices`, `agents` or `humans`) hit by a NEW node/agent registration; body carries `max`, `current` and the host's context (the hosted gateway adds `plan` and `upgrade_url`) | Clears when a registration is removed or the host raises the limit; no `Retry-After` | -The first two carry `Retry-After` and `X-RateLimit-Reset`; successful responses carry -`X-RateLimit-Limit` and `X-RateLimit-Remaining`. `Retry-After` is authoritative +The first two carry `Retry-After` and `X-RateLimit-Reset`; when a finite per-minute +limit applies, successful responses carry `X-RateLimit-Limit` and +`X-RateLimit-Remaining` (an unlimited workspace — the self-hosted default — gets +neither). `Retry-After` is authoritative — a value beyond your retry budget means retrying cannot help, so fail fast rather than spending the budget on it. @@ -354,7 +356,11 @@ implicit direct node every agent gets for its own WebSocket is not one. set static limits in the engine config; absent keys stay unlimited: ```ts - createNodeRuntime({ config: { limits: { devices: 5, agents: 15, humans: 2, rate_per_min: 600 } } }); + createNodeRuntime({ + dbPath: './relaycast.db', + baseUrl: 'https://relay.example.com', + config: { limits: { devices: 5, agents: 15, humans: 2, rate_per_min: 600 } }, + }); ``` - A hosting product injects its own provider (`getLimits`, `getUsage`, optional diff --git a/openapi.yaml b/openapi.yaml index 3a2249527..3cdacc159 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -21,12 +21,14 @@ info: The deployment's entitlements provider may also cap how many devices (nodes), agents and human members a workspace can register. Enrolling a NEW - node (`POST /nodes`), registering a NEW agent (`POST /agents`, - `POST /a2a/register`, or an `agent.register` node frame) beyond the cap - returns HTTP 429 `plan_limit_exceeded` with the top-level fields - `error_code`, `limit` (`devices`|`agents`|`humans`), `max` and `current`, - plus whatever context the provider supplies (the hosted gateway adds `plan` - and `upgrade_url`) so a client can explain the refusal and offer an upgrade. + node (`POST /nodes`) or registering a NEW agent (`POST /agents`, + `POST /a2a/register`) beyond the cap returns HTTP 429 `plan_limit_exceeded` + with the top-level fields `error_code`, `limit` (`devices`|`agents`|`humans`), + `max` and `current`, plus whatever context the provider supplies (the hosted + gateway adds `plan` and `upgrade_url`) so a client can explain the refusal + and offer an upgrade. An `agent.register` node-control frame at the cap is + answered on the WebSocket with an `error` frame carrying `code: + plan_limit_exceeded` and the same fields, not with an HTTP response. Re-enrolling or rotating an existing node or agent is never capped. No `Retry-After` is sent: the cap clears only when a registration is removed or the provider raises the limit. diff --git a/packages/engine/src/__tests__/conformance/planCapacity.test.ts b/packages/engine/src/__tests__/conformance/planCapacity.test.ts index cb9b298a7..629819035 100644 --- a/packages/engine/src/__tests__/conformance/planCapacity.test.ts +++ b/packages/engine/src/__tests__/conformance/planCapacity.test.ts @@ -99,6 +99,16 @@ describe('registration caps', () => { expect(spoofed.status).toBe(400); await expect(spoofed.json()).resolves.toMatchObject({ error: { code: 'reserved_node_id' } }); await expect(countRegistrations(stack.runtime.handle.db, ws.workspaceId, 'devices')).resolves.toBe(1); + + // A near miss on the prefix (`_` is a LIKE wildcard, not a GLOB one) is an + // ordinary host-enrolled direct node: not reserved, and counted. + const nearMiss = await stack.app.request('/v1/nodes', { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${ws.workspaceKey}` }, + body: JSON.stringify({ node_id: 'nodeXdirectXcustom', name: 'near-miss', role: 'direct', max_agents: 1 }), + }); + expect(nearMiss.status).toBe(429); + await expect(nearMiss.json()).resolves.toMatchObject(refusal('devices', 1, 1)); }); it('counts an explicitly enrolled direct node as a device, so a direct-then-broker re-enroll cannot bypass the cap', async () => { diff --git a/packages/engine/src/engine/planCapacity.ts b/packages/engine/src/engine/planCapacity.ts index e2a68dc4b..eb7bed501 100644 --- a/packages/engine/src/engine/planCapacity.ts +++ b/packages/engine/src/engine/planCapacity.ts @@ -1,4 +1,4 @@ -import { and, eq, like, ne, not, sql } from 'drizzle-orm'; +import { and, eq, ne, not, sql } from 'drizzle-orm'; import type { EngineDb } from '../ports/database.js'; import type { EntitlementsProvider, RegistrationMetric } from '../ports/entitlements.js'; import type { Workspace } from '../ports/auth.js'; @@ -112,7 +112,9 @@ export async function countRegistrations( .from(nodes) .where(and( eq(nodes.workspaceId, workspaceId), - not(and(eq(nodes.role, 'direct'), like(nodes.id, 'node_direct_%'))!), + // GLOB, not LIKE: in LIKE `_` is a wildcard, so `nodeXdirectX…` would + // slip out of the count while passing the literal reserved-prefix check. + not(and(eq(nodes.role, 'direct'), sql`${nodes.id} GLOB 'node_direct_*'`)!), )) : await db .select({ count }) diff --git a/packages/engine/src/providers/__tests__/static-entitlements.test.ts b/packages/engine/src/providers/__tests__/static-entitlements.test.ts index b83e853d9..2fa2592a2 100644 --- a/packages/engine/src/providers/__tests__/static-entitlements.test.ts +++ b/packages/engine/src/providers/__tests__/static-entitlements.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { StaticEntitlementsProvider } from '../static-entitlements.js'; import { UNLIMITED_LIMITS, resolveLimits } from '../../ports/entitlements.js'; -import type { Workspace } from '../../ports/index.js'; +import type { EngineDb, Workspace } from '../../ports/index.js'; const workspace = { id: 'ws_1', plan: 'anything' } as Workspace; @@ -13,7 +13,7 @@ describe('StaticEntitlementsProvider', () => { }); it('applies the operator\'s configured limits over unlimited', async () => { - const provider = new StaticEntitlementsProvider(undefined, { devices: 5, rate_per_min: 600 }); + const provider = new StaticEntitlementsProvider(undefined, { devices: 5, rate_per_min: 600 }, {} as EngineDb); await expect(provider.getLimits(workspace)).resolves.toEqual({ ...UNLIMITED_LIMITS, devices: 5, @@ -28,9 +28,11 @@ describe('StaticEntitlementsProvider', () => { ]); }); - it('reports a registration count of zero without a database handle', async () => { - const provider = new StaticEntitlementsProvider(undefined, { devices: 1 }); - await expect(provider.getUsage('ws_1', 'devices')).resolves.toBe(0); + it('refuses registration caps it has no database to count against', () => { + expect(() => new StaticEntitlementsProvider(undefined, { devices: 1 })) + .toThrow(/registration caps \(devices\) require a database handle/); + // Metered-only limits need no database. + expect(() => new StaticEntitlementsProvider(undefined, { rate_per_min: 60 })).not.toThrow(); }); it('reports zero metered usage without a key/value store', async () => { diff --git a/packages/engine/src/providers/static-entitlements.ts b/packages/engine/src/providers/static-entitlements.ts index b164cfbde..ae5845f2c 100644 --- a/packages/engine/src/providers/static-entitlements.ts +++ b/packages/engine/src/providers/static-entitlements.ts @@ -6,6 +6,7 @@ import { getUsageMetric, usagePeriodResetAt } from '../engine/usage.js'; import { countRegistrations } from '../engine/planCapacity.js'; const REGISTRATION_METRICS = new Set(['devices', 'agents', 'humans']); +const REGISTRATION_CAPS = ['devices', 'agents', 'humans'] as const; /** * Static entitlements — the self-host default. Every workspace gets the same @@ -24,6 +25,14 @@ export class StaticEntitlementsProvider implements EntitlementsProvider { private readonly db?: EngineDb, ) { this.limits = resolveLimits(limits); + // A cap the provider cannot count against would admit everything while + // looking configured; refuse the misconfiguration up front. + const uncountable = REGISTRATION_CAPS.filter((metric) => typeof this.limits[metric] === 'number'); + if (uncountable.length > 0 && !db) { + throw new Error( + `StaticEntitlementsProvider: registration caps (${uncountable.join(', ')}) require a database handle to count against`, + ); + } } async getLimits(_workspace: Workspace): Promise { From c2c3daa146a878d47bb1268f694b0a590c5d824e Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Sun, 25 Oct 2026 22:13:31 -0700 Subject: [PATCH 6/6] Type the packed-consumer resolver fixture against the nullable workspace plan WorkspaceDeliveryPolicyConfig.resolve receives plan: string | null; the packed public-consumer compile check asserts that contract. Co-Authored-By: Claude Fable 5.1 --- packages/engine/scripts/public-consumer-compile.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/engine/scripts/public-consumer-compile.mjs b/packages/engine/scripts/public-consumer-compile.mjs index 15f9947e2..a19659af7 100644 --- a/packages/engine/scripts/public-consumer-compile.mjs +++ b/packages/engine/scripts/public-consumer-compile.mjs @@ -22,7 +22,7 @@ const config = { workspaceDelivery: { resolve: async (workspace) => { const id: string = workspace.id; - const plan: string = workspace.plan; + const plan: string | null = workspace.plan; await Promise.resolve(); return id && plan === 'enterprise' ? { cap: 5000, reserve: 0 } : undefined; },