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

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

View File

@@ -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",

View File

@@ -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",

View File

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

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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

View File

@@ -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() });

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",