release(v4.8.1): SHADE_DISABLE_RATE_LIMIT env var for single-tenant deploys

Plumbing fix only — both createPrekeyRoutes and createInboxRoutes
already accepted disableRateLimit; standalone.ts just didn't read
the env. Now SHADE_DISABLE_RATE_LIMIT=1 turns off IP rate-limits on
every prekey + inbox route, with a WARN log on startup so operators
see it.

Single-tenant deployments only — multi-tenant relays must leave it
unset. Documented in docs/DEPLOYMENT.md.

Reported by Prism: ~6 pair attempts/hour from a single dev IP +
the sidecar's register call tripped the 5/hour REGISTER_LIMIT every
dev iteration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 00:55:57 +02:00
parent 1fb59a7076
commit 680d6386f3
29 changed files with 134 additions and 26 deletions

View File

@@ -5,6 +5,50 @@ All notable changes to Shade are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [4.8.1] — 2026-05-08 — `SHADE_DISABLE_RATE_LIMIT` env var for single-tenant deploys
The standalone server's `routes.ts` and `inbox-server`'s
`createInboxRoutes` already accepted a `disableRateLimit?: boolean`
option, but the standalone entry just didn't read it from environment.
Self-hosted single-tenant deploys (Prism's relay is a typical case —
only Prism PC clients + their paired browsers) tripped the
`REGISTER_LIMIT` (5/hour per IP) every dev iteration: ~6 pair attempts
in an hour from the same IP plus the sidecar's register call killed
the dev loop until the bucket refilled (~1 token per 12 minutes).
Reported by Prism. Two-line plumbing fix: `standalone.ts` now reads
`SHADE_DISABLE_RATE_LIMIT=1` and forwards `disableRateLimit` to both
`createPrekeyRoutes` and `createInboxRoutes`.
### Added
#### `@shade/server`
- `SHADE_DISABLE_RATE_LIMIT=1` env var disables IP rate-limits on every
prekey + inbox route in `standalone.ts`. Logged as a `WARN` on startup
(`SHADE_DISABLE_RATE_LIMIT=1 — IP rate limits OFF on prekey + inbox
routes`) so operators see it in stderr/log aggregation.
- **Single-tenant deployments only** — multi-tenant relays must leave
this unset. The rate-limit defends multi-tenant relays against abuse;
flipping it off is appropriate for self-hosted single-team setups
where every caller is a known client. Documented in
[`docs/DEPLOYMENT.md`](./docs/DEPLOYMENT.md) under "Environment variable
reference".
### Tests
- `packages/shade-server/tests/rate-limit.test.ts` — the existing
"register endpoint rate-limits per IP" test verifies the default-on
path; a new sister test exercises
`createPrekeyServer({ disableRateLimit: true })` and confirms 12
consecutive register calls from the same IP all return 200 (no 429).
The env-var → option conversion in `standalone.ts` is a one-liner
verified by inspection.
### Migration
None. Default is unchanged (rate limits stay ON). Self-hosted
single-tenant operators add `SHADE_DISABLE_RATE_LIMIT=1` to their
deployment env to flip it off.
## [4.8.0] — 2026-05-08 — Sender-fingerprint attribution + `Inbox.start()` race fix ## [4.8.0] — 2026-05-08 — Sender-fingerprint attribution + `Inbox.start()` race fix
Two unblocking changes for first-contact flows. First, the relay now Two unblocking changes for first-contact flows. First, the relay now

View File

@@ -81,6 +81,7 @@ Tables will be created automatically with the `shade_server_*` prefix, so they c
| `SHADE_CLEANUP_INTERVAL_HOURS` | `24` | Cleanup cycle interval | | `SHADE_CLEANUP_INTERVAL_HOURS` | `24` | Cleanup cycle interval |
| `SHADE_LOG_LEVEL` | `info` | `debug` / `info` / `warn` / `error` | | `SHADE_LOG_LEVEL` | `info` | `debug` / `info` / `warn` / `error` |
| `SHADE_OTEL_ENABLED` | unset | Set to `1`/`true` to enable OpenTelemetry tracing on `withTracer()`-configured deployments. See [`observability.md`](./observability.md). | | `SHADE_OTEL_ENABLED` | unset | Set to `1`/`true` to enable OpenTelemetry tracing on `withTracer()`-configured deployments. See [`observability.md`](./observability.md). |
| `SHADE_DISABLE_RATE_LIMIT` | unset | Set to `1` to disable IP rate-limits on every prekey + inbox route. **Single-tenant deployments only** — multi-tenant relays must leave this unset to keep the abuse defenses on. |
## Health and observability ## Health and observability

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/cli", "name": "@shade/cli",
"version": "4.8.0", "version": "4.8.1",
"type": "module", "type": "module",
"main": "src/cli.ts", "main": "src/cli.ts",
"bin": { "bin": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/core", "name": "@shade/core",
"version": "4.8.0", "version": "4.8.1",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/crypto-web", "name": "@shade/crypto-web",
"version": "4.8.0", "version": "4.8.1",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/dashboard", "name": "@shade/dashboard",
"version": "4.8.0", "version": "4.8.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/files", "name": "@shade/files",
"version": "4.8.0", "version": "4.8.1",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/inbox-server", "name": "@shade/inbox-server",
"version": "4.8.0", "version": "4.8.1",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/inbox", "name": "@shade/inbox",
"version": "4.8.0", "version": "4.8.1",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/key-transparency", "name": "@shade/key-transparency",
"version": "4.8.0", "version": "4.8.1",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/keychain", "name": "@shade/keychain",
"version": "4.8.0", "version": "4.8.1",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/observability", "name": "@shade/observability",
"version": "4.8.0", "version": "4.8.1",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/observer", "name": "@shade/observer",
"version": "4.8.0", "version": "4.8.1",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/proto", "name": "@shade/proto",
"version": "4.8.0", "version": "4.8.1",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/recovery", "name": "@shade/recovery",
"version": "4.8.0", "version": "4.8.1",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/sdk", "name": "@shade/sdk",
"version": "4.8.0", "version": "4.8.1",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/server", "name": "@shade/server",
"version": "4.8.0", "version": "4.8.1",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -154,6 +154,20 @@ const inboxEvents = new InboxServerEvents();
// SHADE_KT_PG_URL (or SHADE_PREKEY_PG_URL) is set, else memory. // SHADE_KT_PG_URL (or SHADE_PREKEY_PG_URL) is set, else memory.
const kt = await maybeCreateKT(); const kt = await maybeCreateKT();
// V4.8.1 — `SHADE_DISABLE_RATE_LIMIT=1` turns off the IP-based
// register/replenish/fetch token-buckets on every prekey + inbox
// route. INTENDED FOR SELF-HOSTED SINGLE-TEAM (DEV / SINGLE-TENANT)
// DEPLOYMENTS ONLY — the rate-limit defends multi-tenant relays
// against abuse, so a public/shared deployment must leave this
// unset. Without it, the existing 5/hour REGISTER_LIMIT etc. apply
// unchanged.
const disableRateLimit = process.env.SHADE_DISABLE_RATE_LIMIT === '1';
if (disableRateLimit) {
logger.warn(
'SHADE_DISABLE_RATE_LIMIT=1 — IP rate limits OFF on prekey + inbox routes. Use only for single-tenant deployments.',
);
}
// Compose the full app: metrics middleware + health + metrics + prekey routes // Compose the full app: metrics middleware + health + metrics + prekey routes
const app = new Hono(); const app = new Hono();
app.use('*', metricsMiddleware()); app.use('*', metricsMiddleware());
@@ -164,10 +178,17 @@ app.route(
'/', '/',
createPrekeyRoutes(store, crypto, { createPrekeyRoutes(store, crypto, {
events, events,
disableRateLimit,
...(kt ? { keyTransparency: kt } : {}), ...(kt ? { keyTransparency: kt } : {}),
}), }),
); );
app.route('/', createInboxRoutes(inboxStore, crypto, { events: inboxEvents })); app.route(
'/',
createInboxRoutes(inboxStore, crypto, {
events: inboxEvents,
disableRateLimit,
}),
);
// V3.7 transport-bridge — SSE / long-poll / WS fallbacks for the inbox. // V3.7 transport-bridge — SSE / long-poll / WS fallbacks for the inbox.
// Held as a top-level reference so the WebSocket handler can be passed to // Held as a top-level reference so the WebSocket handler can be passed to

View File

@@ -102,6 +102,48 @@ describe('Rate limiting integration with routes', () => {
expect(results.filter((s) => s === 429).length).toBeGreaterThanOrEqual(1); expect(results.filter((s) => s === 429).length).toBeGreaterThanOrEqual(1);
}); });
// V4.8.1 — `SHADE_DISABLE_RATE_LIMIT=1` in standalone.ts is plumbed
// through to `createPrekeyServer({ disableRateLimit })`. This test
// covers the "what happens when the flag is true" path; the env-var
// → option conversion in standalone.ts is a one-liner verified by
// inspection.
test('register endpoint allows >5/hour from a single IP when disableRateLimit is set', async () => {
const app = createPrekeyServer({
crypto,
store: new MemoryPrekeyStore(),
disableRateLimit: true,
});
async function doRegister(addressSuffix: number) {
const identity = await generateIdentityKeyPair(crypto);
const body: any = {
address: `user${addressSuffix}`,
identitySigningKey: Buffer.from(identity.signingPublicKey).toString('base64'),
identityDHKey: Buffer.from(identity.dhPublicKey).toString('base64'),
signedPreKey: {
keyId: 1,
publicKey: Buffer.from(crypto.randomBytes(32)).toString('base64'),
signature: Buffer.from(crypto.randomBytes(64)).toString('base64'),
},
};
const signed = await signPayload(crypto, identity.signingPrivateKey, body);
return app.request('/v1/keys/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-forwarded-for': '203.0.113.1' },
body: JSON.stringify(signed),
});
}
const results: number[] = [];
for (let i = 0; i < 12; i++) {
const res = await doRegister(i);
results.push(res.status);
}
// No 429 anywhere — the limit is OFF.
expect(results.filter((s) => s === 429).length).toBe(0);
expect(results.filter((s) => s === 200).length).toBe(12);
});
test('rate limit returns Retry-After header', async () => { test('rate limit returns Retry-After header', async () => {
const app = createPrekeyServer({ crypto, store: new MemoryPrekeyStore() }); const app = createPrekeyServer({ crypto, store: new MemoryPrekeyStore() });

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/storage-encrypted", "name": "@shade/storage-encrypted",
"version": "4.8.0", "version": "4.8.1",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/storage-indexeddb", "name": "@shade/storage-indexeddb",
"version": "4.8.0", "version": "4.8.1",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/storage-postgres", "name": "@shade/storage-postgres",
"version": "4.8.0", "version": "4.8.1",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/storage-sqlite", "name": "@shade/storage-sqlite",
"version": "4.8.0", "version": "4.8.1",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/streams", "name": "@shade/streams",
"version": "4.8.0", "version": "4.8.1",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/transfer", "name": "@shade/transfer",
"version": "4.8.0", "version": "4.8.1",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/transport-bridge", "name": "@shade/transport-bridge",
"version": "4.8.0", "version": "4.8.1",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/transport-webrtc", "name": "@shade/transport-webrtc",
"version": "4.8.0", "version": "4.8.1",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/transport", "name": "@shade/transport",
"version": "4.8.0", "version": "4.8.1",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/widgets", "name": "@shade/widgets",
"version": "4.8.0", "version": "4.8.1",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",