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:
44
CHANGELOG.md
44
CHANGELOG.md
@@ -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/),
|
||||
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
|
||||
|
||||
Two unblocking changes for first-contact flows. First, the relay now
|
||||
|
||||
@@ -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_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_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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/cli",
|
||||
"version": "4.8.0",
|
||||
"version": "4.8.1",
|
||||
"type": "module",
|
||||
"main": "src/cli.ts",
|
||||
"bin": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/core",
|
||||
"version": "4.8.0",
|
||||
"version": "4.8.1",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/crypto-web",
|
||||
"version": "4.8.0",
|
||||
"version": "4.8.1",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/dashboard",
|
||||
"version": "4.8.0",
|
||||
"version": "4.8.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/files",
|
||||
"version": "4.8.0",
|
||||
"version": "4.8.1",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/inbox-server",
|
||||
"version": "4.8.0",
|
||||
"version": "4.8.1",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/inbox",
|
||||
"version": "4.8.0",
|
||||
"version": "4.8.1",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/key-transparency",
|
||||
"version": "4.8.0",
|
||||
"version": "4.8.1",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/keychain",
|
||||
"version": "4.8.0",
|
||||
"version": "4.8.1",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/observability",
|
||||
"version": "4.8.0",
|
||||
"version": "4.8.1",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/observer",
|
||||
"version": "4.8.0",
|
||||
"version": "4.8.1",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/proto",
|
||||
"version": "4.8.0",
|
||||
"version": "4.8.1",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/recovery",
|
||||
"version": "4.8.0",
|
||||
"version": "4.8.1",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/sdk",
|
||||
"version": "4.8.0",
|
||||
"version": "4.8.1",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/server",
|
||||
"version": "4.8.0",
|
||||
"version": "4.8.1",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -154,6 +154,20 @@ const inboxEvents = new InboxServerEvents();
|
||||
// SHADE_KT_PG_URL (or SHADE_PREKEY_PG_URL) is set, else memory.
|
||||
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
|
||||
const app = new Hono();
|
||||
app.use('*', metricsMiddleware());
|
||||
@@ -164,10 +178,17 @@ app.route(
|
||||
'/',
|
||||
createPrekeyRoutes(store, crypto, {
|
||||
events,
|
||||
disableRateLimit,
|
||||
...(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.
|
||||
// Held as a top-level reference so the WebSocket handler can be passed to
|
||||
|
||||
@@ -102,6 +102,48 @@ describe('Rate limiting integration with routes', () => {
|
||||
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 () => {
|
||||
const app = createPrekeyServer({ crypto, store: new MemoryPrekeyStore() });
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/storage-encrypted",
|
||||
"version": "4.8.0",
|
||||
"version": "4.8.1",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/storage-indexeddb",
|
||||
"version": "4.8.0",
|
||||
"version": "4.8.1",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/storage-postgres",
|
||||
"version": "4.8.0",
|
||||
"version": "4.8.1",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/storage-sqlite",
|
||||
"version": "4.8.0",
|
||||
"version": "4.8.1",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/streams",
|
||||
"version": "4.8.0",
|
||||
"version": "4.8.1",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/transfer",
|
||||
"version": "4.8.0",
|
||||
"version": "4.8.1",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/transport-bridge",
|
||||
"version": "4.8.0",
|
||||
"version": "4.8.1",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/transport-webrtc",
|
||||
"version": "4.8.0",
|
||||
"version": "4.8.1",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/transport",
|
||||
"version": "4.8.0",
|
||||
"version": "4.8.1",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/widgets",
|
||||
"version": "4.8.0",
|
||||
"version": "4.8.1",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
Reference in New Issue
Block a user