release(v4.0.0): Shade GA — V3.x consolidation + audit prep
V3.1 → V3.12 consolidated and tagged for the first GA release. Wire
format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers
byte-for-byte. The version bump is semantic: audit-cycle complete,
opt-in surface fully exposed, threat model refreshed for every new
surface.
Highlights:
- All 24 @shade/* packages bumped to 4.0.0 in lockstep.
- CHANGELOG 4.0.0 section is the canonical manifest of what landed.
- THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12
Web-Worker boundary) + residual-risks table refreshed.
- OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox,
bridge, observer, /metrics, /healthz, /ready.
- MIGRATION 0.3.x → 4.0 documented + smoke-tested against
shade migrate-storage on a real SQLite DB.
- docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer.
- scripts/soak.ts harness for the GA-stable 2-week soak window.
- All V*.md plans archived under docs/archive/ with Status: Done.
- Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen
non-realtime stack.
Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green.
Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports
version 4.0.0 on /health.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:35:35 +02:00
|
|
|
import { Hono } from 'hono';
|
|
|
|
|
import type { CryptoProvider } from '@shade/core';
|
|
|
|
|
import {
|
|
|
|
|
errorToHttpStatus,
|
|
|
|
|
ShadeError,
|
|
|
|
|
ValidationError,
|
|
|
|
|
RateLimitError,
|
|
|
|
|
UnauthorizedError,
|
|
|
|
|
fromBase64,
|
|
|
|
|
toBase64,
|
|
|
|
|
constantTimeEqual,
|
|
|
|
|
} from '@shade/core';
|
|
|
|
|
import {
|
|
|
|
|
verifyPayload,
|
|
|
|
|
validateAddress,
|
|
|
|
|
RateLimiter,
|
|
|
|
|
MemoryRateLimitStore,
|
|
|
|
|
type RateLimitConfig,
|
|
|
|
|
} from '@shade/server';
|
|
|
|
|
import {
|
|
|
|
|
ATTR_ERROR_CODE,
|
|
|
|
|
ATTR_HTTP_STATUS,
|
|
|
|
|
ATTR_ROUTE,
|
|
|
|
|
NOOP_HOOK,
|
|
|
|
|
type ObservabilityHook,
|
|
|
|
|
} from '@shade/observability';
|
|
|
|
|
import type { InboxStore } from './store.js';
|
|
|
|
|
import { InboxServerEvents, shortHash } from './events.js';
|
|
|
|
|
import { computeMsgId, isValidMsgId, constantTimeStringEqual } from './msg-id.js';
|
|
|
|
|
import { clampTtl, DEFAULT_INBOX_QUOTA, type InboxQuotaConfig } from './quota.js';
|
2026-05-08 16:31:42 +02:00
|
|
|
import type { BridgeDeliveryLog } from './bridge-delivery-log.js';
|
release(v4.0.0): Shade GA — V3.x consolidation + audit prep
V3.1 → V3.12 consolidated and tagged for the first GA release. Wire
format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers
byte-for-byte. The version bump is semantic: audit-cycle complete,
opt-in surface fully exposed, threat model refreshed for every new
surface.
Highlights:
- All 24 @shade/* packages bumped to 4.0.0 in lockstep.
- CHANGELOG 4.0.0 section is the canonical manifest of what landed.
- THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12
Web-Worker boundary) + residual-risks table refreshed.
- OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox,
bridge, observer, /metrics, /healthz, /ready.
- MIGRATION 0.3.x → 4.0 documented + smoke-tested against
shade migrate-storage on a real SQLite DB.
- docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer.
- scripts/soak.ts harness for the GA-stable 2-week soak window.
- All V*.md plans archived under docs/archive/ with Status: Done.
- Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen
non-realtime stack.
Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green.
Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports
version 4.0.0 on /health.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:35:35 +02:00
|
|
|
|
|
|
|
|
/** Max metadata-only body size (no ciphertext). 64 KB is enough headroom. */
|
|
|
|
|
const MAX_META_BODY_SIZE = 64 * 1024;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Per-route token-bucket presets. PUT is intentionally generous (senders
|
|
|
|
|
* may burst) but bound on the recipient side (per-address quota in the
|
|
|
|
|
* store). FETCH and DELETE are per-address.
|
|
|
|
|
*/
|
|
|
|
|
const INBOX_PUT_LIMIT: RateLimitConfig = { capacity: 60, refillPerSecond: 1 };
|
|
|
|
|
const INBOX_FETCH_LIMIT: RateLimitConfig = { capacity: 60, refillPerSecond: 1 };
|
|
|
|
|
const INBOX_DELETE_LIMIT: RateLimitConfig = { capacity: 60, refillPerSecond: 1 };
|
|
|
|
|
const INBOX_REGISTER_LIMIT: RateLimitConfig = {
|
|
|
|
|
capacity: 5,
|
|
|
|
|
refillPerSecond: 5 / 3600,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export interface InboxRoutesOptions {
|
|
|
|
|
/** Disable rate limiting (used in tests). */
|
|
|
|
|
disableRateLimit?: boolean;
|
|
|
|
|
/** Optional event emitter. */
|
|
|
|
|
events?: InboxServerEvents;
|
|
|
|
|
/** OTel observability hook. */
|
|
|
|
|
observability?: ObservabilityHook;
|
|
|
|
|
/** Override quota policy. */
|
|
|
|
|
quota?: Partial<InboxQuotaConfig>;
|
2026-05-08 16:31:42 +02:00
|
|
|
/**
|
|
|
|
|
* V4.8.4 — shared bridge delivery log. When provided (and the same
|
|
|
|
|
* instance is wired into `createBridgeRoutes`), the inbox-fetch route
|
|
|
|
|
* filters out blobs already pushed via bridge within the log's grace
|
|
|
|
|
* window. Without this, a recipient that runs both a bridge
|
|
|
|
|
* subscription and inbox-poll receives the same envelope twice.
|
|
|
|
|
* Optional — leaving it unset preserves the pre-V4.8.4 behavior of
|
|
|
|
|
* always returning every blob the cursor matches.
|
|
|
|
|
*/
|
|
|
|
|
bridgeDeliveryLog?: BridgeDeliveryLog;
|
release(v4.0.0): Shade GA — V3.x consolidation + audit prep
V3.1 → V3.12 consolidated and tagged for the first GA release. Wire
format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers
byte-for-byte. The version bump is semantic: audit-cycle complete,
opt-in surface fully exposed, threat model refreshed for every new
surface.
Highlights:
- All 24 @shade/* packages bumped to 4.0.0 in lockstep.
- CHANGELOG 4.0.0 section is the canonical manifest of what landed.
- THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12
Web-Worker boundary) + residual-risks table refreshed.
- OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox,
bridge, observer, /metrics, /healthz, /ready.
- MIGRATION 0.3.x → 4.0 documented + smoke-tested against
shade migrate-storage on a real SQLite DB.
- docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer.
- scripts/soak.ts harness for the GA-stable 2-week soak window.
- All V*.md plans archived under docs/archive/ with Status: Done.
- Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen
non-realtime stack.
Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green.
Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports
version 4.0.0 on /health.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:35:35 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function createInboxRoutes(
|
|
|
|
|
store: InboxStore,
|
|
|
|
|
crypto: CryptoProvider,
|
|
|
|
|
options: InboxRoutesOptions = {},
|
|
|
|
|
): Hono {
|
|
|
|
|
const app = new Hono();
|
|
|
|
|
const events = options.events;
|
|
|
|
|
const observability = options.observability ?? NOOP_HOOK;
|
|
|
|
|
const quota: InboxQuotaConfig = { ...DEFAULT_INBOX_QUOTA, ...(options.quota ?? {}) };
|
|
|
|
|
|
|
|
|
|
app.use('*', async (c, next) => {
|
|
|
|
|
const route = c.req.routePath ?? c.req.path ?? '<unknown>';
|
|
|
|
|
const span = observability.startSpan('shade.inbox.request', {
|
|
|
|
|
[ATTR_ROUTE]: route,
|
|
|
|
|
});
|
|
|
|
|
try {
|
|
|
|
|
await next();
|
|
|
|
|
span.setAttribute(ATTR_HTTP_STATUS, c.res.status);
|
|
|
|
|
span.setStatus(c.res.status >= 500 ? 'error' : 'ok');
|
|
|
|
|
} catch (err) {
|
|
|
|
|
const code =
|
|
|
|
|
err instanceof ShadeError ? err.code ?? 'SHADE_ERROR' : 'SHADE_INTERNAL';
|
|
|
|
|
span.setAttribute(ATTR_ERROR_CODE, code);
|
|
|
|
|
span.recordException(err);
|
|
|
|
|
span.setStatus('error', code);
|
|
|
|
|
throw err;
|
|
|
|
|
} finally {
|
|
|
|
|
span.end();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const rlStore = new MemoryRateLimitStore();
|
|
|
|
|
const putRL = new RateLimiter(rlStore, INBOX_PUT_LIMIT);
|
|
|
|
|
const fetchRL = new RateLimiter(rlStore, INBOX_FETCH_LIMIT);
|
|
|
|
|
const deleteRL = new RateLimiter(rlStore, INBOX_DELETE_LIMIT);
|
|
|
|
|
const registerRL = new RateLimiter(rlStore, INBOX_REGISTER_LIMIT);
|
|
|
|
|
const rateLimitEnabled = !options.disableRateLimit;
|
|
|
|
|
|
|
|
|
|
const getClientIp = (c: any): string =>
|
|
|
|
|
c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ??
|
|
|
|
|
c.req.header('x-real-ip') ??
|
|
|
|
|
'unknown';
|
|
|
|
|
|
|
|
|
|
app.get('/health', (c) => c.json({ status: 'ok', service: 'shade-inbox-server' }));
|
|
|
|
|
|
|
|
|
|
app.onError((err, c) => {
|
|
|
|
|
if (err instanceof RateLimitError) {
|
|
|
|
|
events?.emit('inbox.rate_limited', {
|
|
|
|
|
route: c.req.routePath ?? c.req.path,
|
|
|
|
|
key: getClientIp(c),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
if (err instanceof ShadeError) {
|
|
|
|
|
const status = errorToHttpStatus(err);
|
|
|
|
|
const body: any = err.toJSON();
|
|
|
|
|
if ((err as any).retryAfterSeconds) {
|
|
|
|
|
c.header('Retry-After', String((err as any).retryAfterSeconds));
|
|
|
|
|
}
|
|
|
|
|
return c.json(body, status as any);
|
|
|
|
|
}
|
|
|
|
|
console.error('[Shade] Unhandled inbox error:', err);
|
|
|
|
|
return c.json({ error: 'Internal server error' }, 500);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ─── Register address (TOFU) ───────────────────────────────
|
|
|
|
|
// Recipient claims an address by uploading its signing key and a
|
|
|
|
|
// signature over the canonical body. Subsequent PUT/GET/DELETE for the
|
|
|
|
|
// address are authenticated against this key. Idempotent if the same key
|
|
|
|
|
// re-registers; rejects if a different key tries to take an existing slot.
|
|
|
|
|
app.post('/v1/inbox/register', async (c) => {
|
|
|
|
|
if (rateLimitEnabled) await registerRL.consume(`inbox-register:${getClientIp(c)}`);
|
|
|
|
|
|
|
|
|
|
const rawBody = await c.req.text();
|
|
|
|
|
if (rawBody.length > MAX_META_BODY_SIZE) {
|
|
|
|
|
throw new ValidationError(`Request body too large (max ${MAX_META_BODY_SIZE} bytes)`);
|
|
|
|
|
}
|
|
|
|
|
const body = JSON.parse(rawBody);
|
|
|
|
|
const { address, signingKey } = body;
|
|
|
|
|
const addr = validateAddress(address);
|
|
|
|
|
if (typeof signingKey !== 'string') {
|
|
|
|
|
throw new ValidationError('Missing signingKey', 'signingKey');
|
|
|
|
|
}
|
|
|
|
|
const key = b64ToBytes(signingKey);
|
|
|
|
|
|
|
|
|
|
// Verify signature against the asserted key (TOFU).
|
|
|
|
|
await verifyPayload(crypto, key, body);
|
|
|
|
|
|
|
|
|
|
const existing = await store.getAddressOwner(addr);
|
|
|
|
|
if (existing && !constantTimeEqual(existing, key)) {
|
|
|
|
|
throw new UnauthorizedError(`Address already claimed by a different key`);
|
|
|
|
|
}
|
|
|
|
|
await store.saveAddressOwner(addr, key);
|
|
|
|
|
|
|
|
|
|
if (events) {
|
|
|
|
|
events.emit('inbox.address_registered', {
|
|
|
|
|
address: addr,
|
|
|
|
|
signingKeyHash: await shortHash(key),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return c.json({ ok: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ─── Unregister (signed) ───────────────────────────────────
|
|
|
|
|
app.delete('/v1/inbox/register/:address', async (c) => {
|
|
|
|
|
const address = validateAddress(c.req.param('address'));
|
|
|
|
|
if (rateLimitEnabled) await registerRL.consume(`inbox-unregister:${address}`);
|
|
|
|
|
|
|
|
|
|
const owner = await store.getAddressOwner(address);
|
|
|
|
|
if (!owner) {
|
|
|
|
|
return c.json({ error: 'Address not registered', code: 'SHADE_NOT_FOUND' }, 404);
|
|
|
|
|
}
|
|
|
|
|
const body = await c.req.json();
|
|
|
|
|
await verifyPayload(crypto, owner, { ...body, address });
|
|
|
|
|
|
|
|
|
|
await store.deleteAddress(address);
|
2026-05-08 16:31:42 +02:00
|
|
|
options.bridgeDeliveryLog?.forgetAddress(address);
|
release(v4.0.0): Shade GA — V3.x consolidation + audit prep
V3.1 → V3.12 consolidated and tagged for the first GA release. Wire
format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers
byte-for-byte. The version bump is semantic: audit-cycle complete,
opt-in surface fully exposed, threat model refreshed for every new
surface.
Highlights:
- All 24 @shade/* packages bumped to 4.0.0 in lockstep.
- CHANGELOG 4.0.0 section is the canonical manifest of what landed.
- THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12
Web-Worker boundary) + residual-risks table refreshed.
- OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox,
bridge, observer, /metrics, /healthz, /ready.
- MIGRATION 0.3.x → 4.0 documented + smoke-tested against
shade migrate-storage on a real SQLite DB.
- docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer.
- scripts/soak.ts harness for the GA-stable 2-week soak window.
- All V*.md plans archived under docs/archive/ with Status: Done.
- Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen
non-realtime stack.
Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green.
Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports
version 4.0.0 on /health.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:35:35 +02:00
|
|
|
events?.emit('inbox.address_deleted', { address });
|
|
|
|
|
return c.json({ ok: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ─── PUT a blob (signed by sender) ─────────────────────────
|
|
|
|
|
// Body format:
|
|
|
|
|
// {
|
|
|
|
|
// senderSigningKey: b64,
|
|
|
|
|
// msgId: hex(sha256(ciphertext)),
|
|
|
|
|
// ciphertext: b64,
|
|
|
|
|
// ttlSeconds?: number,
|
|
|
|
|
// signedAt: number,
|
|
|
|
|
// signature: b64, // over the canonical body sans signature
|
|
|
|
|
// }
|
|
|
|
|
// The recipient address is the path parameter. The sender authenticates
|
|
|
|
|
// itself via `senderSigningKey` (TOFU per request — the *recipient*
|
|
|
|
|
// determines whether to accept the sender, via the encrypted envelope).
|
|
|
|
|
app.post('/v1/inbox/:address', async (c) => {
|
|
|
|
|
if (rateLimitEnabled) await putRL.consume(`inbox-put:${getClientIp(c)}`);
|
|
|
|
|
|
|
|
|
|
const address = validateAddress(c.req.param('address'));
|
|
|
|
|
|
|
|
|
|
const rawBody = await c.req.text();
|
|
|
|
|
// Allow up to (maxBlobBytes * 4/3) for base64 + JSON overhead.
|
|
|
|
|
const hardLimit = Math.ceil(quota.maxBlobBytes * 1.4) + MAX_META_BODY_SIZE;
|
|
|
|
|
if (rawBody.length > hardLimit) {
|
|
|
|
|
events?.emit('inbox.quota_rejected', { address, reason: 'body-too-large' });
|
|
|
|
|
throw new ValidationError(`Request body too large`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const body = JSON.parse(rawBody);
|
|
|
|
|
const { senderSigningKey, msgId, ciphertext, ttlSeconds } = body;
|
|
|
|
|
|
|
|
|
|
if (typeof senderSigningKey !== 'string') {
|
|
|
|
|
throw new ValidationError('Missing senderSigningKey', 'senderSigningKey');
|
|
|
|
|
}
|
|
|
|
|
if (typeof ciphertext !== 'string') {
|
|
|
|
|
throw new ValidationError('Missing ciphertext', 'ciphertext');
|
|
|
|
|
}
|
|
|
|
|
if (!isValidMsgId(msgId)) {
|
|
|
|
|
throw new ValidationError('msgId must be 64 lowercase hex chars', 'msgId');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const senderKey = b64ToBytes(senderSigningKey);
|
|
|
|
|
const ctBytes = b64ToBytes(ciphertext);
|
|
|
|
|
|
|
|
|
|
if (ctBytes.length === 0) {
|
|
|
|
|
throw new ValidationError('ciphertext is empty', 'ciphertext');
|
|
|
|
|
}
|
|
|
|
|
if (ctBytes.length > quota.maxBlobBytes) {
|
|
|
|
|
events?.emit('inbox.quota_rejected', { address, reason: 'body-too-large' });
|
|
|
|
|
throw new ValidationError(
|
|
|
|
|
`ciphertext exceeds maxBlobBytes (${ctBytes.length} > ${quota.maxBlobBytes})`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Verify the claimed msgId matches the actual ciphertext digest.
|
|
|
|
|
const recomputed = await computeMsgId(ctBytes);
|
|
|
|
|
if (!constantTimeStringEqual(recomputed, msgId)) {
|
|
|
|
|
throw new ValidationError('msgId does not match sha256(ciphertext)', 'msgId');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Verify sender signature.
|
|
|
|
|
await verifyPayload(crypto, senderKey, body);
|
|
|
|
|
|
|
|
|
|
// Recipient address must be registered (avoids DoS against unclaimed
|
|
|
|
|
// slots — see THREAT-MODEL).
|
|
|
|
|
const recipient = await store.getAddressOwner(address);
|
|
|
|
|
if (!recipient) {
|
|
|
|
|
return c.json({ error: 'Recipient not registered', code: 'SHADE_NOT_FOUND' }, 404);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ttl = clampTtl(typeof ttlSeconds === 'number' ? ttlSeconds : undefined, quota);
|
|
|
|
|
const expiresAt = Date.now() + ttl * 1000;
|
|
|
|
|
|
|
|
|
|
// Per-address quota check before the write so the cap is enforced.
|
|
|
|
|
const currentCount = await store.countBlobs(address, Date.now());
|
|
|
|
|
if (currentCount >= quota.maxBlobsPerAddress) {
|
|
|
|
|
events?.emit('inbox.quota_rejected', { address, reason: 'address-quota' });
|
|
|
|
|
throw new ValidationError(
|
|
|
|
|
`Recipient inbox is full (${currentCount} >= ${quota.maxBlobsPerAddress})`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 00:11:59 +02:00
|
|
|
// V4.8: capture sender fingerprint at PUT time. The sender's
|
|
|
|
|
// signing key was just verified for this request, so the fingerprint
|
|
|
|
|
// is bound to the same authentication path that authorized the
|
|
|
|
|
// store. Surfaced on bridge push + inbox-fetch responses to
|
|
|
|
|
// bootstrap unknown-sender first-contact (X3DH pair handshake).
|
|
|
|
|
const senderFp = await shortHash(senderKey);
|
|
|
|
|
|
release(v4.0.0): Shade GA — V3.x consolidation + audit prep
V3.1 → V3.12 consolidated and tagged for the first GA release. Wire
format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers
byte-for-byte. The version bump is semantic: audit-cycle complete,
opt-in surface fully exposed, threat model refreshed for every new
surface.
Highlights:
- All 24 @shade/* packages bumped to 4.0.0 in lockstep.
- CHANGELOG 4.0.0 section is the canonical manifest of what landed.
- THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12
Web-Worker boundary) + residual-risks table refreshed.
- OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox,
bridge, observer, /metrics, /healthz, /ready.
- MIGRATION 0.3.x → 4.0 documented + smoke-tested against
shade migrate-storage on a real SQLite DB.
- docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer.
- scripts/soak.ts harness for the GA-stable 2-week soak window.
- All V*.md plans archived under docs/archive/ with Status: Done.
- Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen
non-realtime stack.
Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green.
Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports
version 4.0.0 on /health.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:35:35 +02:00
|
|
|
const result = await store.putBlob({
|
|
|
|
|
address,
|
|
|
|
|
msgId,
|
|
|
|
|
ciphertext: ctBytes,
|
|
|
|
|
expiresAt,
|
2026-05-08 00:11:59 +02:00
|
|
|
senderFp,
|
release(v4.0.0): Shade GA — V3.x consolidation + audit prep
V3.1 → V3.12 consolidated and tagged for the first GA release. Wire
format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers
byte-for-byte. The version bump is semantic: audit-cycle complete,
opt-in surface fully exposed, threat model refreshed for every new
surface.
Highlights:
- All 24 @shade/* packages bumped to 4.0.0 in lockstep.
- CHANGELOG 4.0.0 section is the canonical manifest of what landed.
- THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12
Web-Worker boundary) + residual-risks table refreshed.
- OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox,
bridge, observer, /metrics, /healthz, /ready.
- MIGRATION 0.3.x → 4.0 documented + smoke-tested against
shade migrate-storage on a real SQLite DB.
- docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer.
- scripts/soak.ts harness for the GA-stable 2-week soak window.
- All V*.md plans archived under docs/archive/ with Status: Done.
- Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen
non-realtime stack.
Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green.
Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports
version 4.0.0 on /health.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:35:35 +02:00
|
|
|
});
|
|
|
|
|
if (result.created) {
|
|
|
|
|
events?.emit('inbox.blob_stored', {
|
|
|
|
|
address,
|
|
|
|
|
msgId,
|
|
|
|
|
bytes: ctBytes.length,
|
|
|
|
|
ttlSeconds: ttl,
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
events?.emit('inbox.blob_idempotent_replay', { address, msgId });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return c.json({
|
|
|
|
|
ok: true,
|
|
|
|
|
msgId,
|
|
|
|
|
receivedAt: result.receivedAt,
|
|
|
|
|
idempotent: !result.created,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ─── GET blobs (signed challenge by recipient) ─────────────
|
|
|
|
|
// Auth model: recipient signs the canonical (address, sinceCursor,
|
|
|
|
|
// signedAt) tuple. Server verifies against the address's registered
|
|
|
|
|
// signing key. Cursor is opaque — clients pass back the highest
|
|
|
|
|
// `receivedAt` seen so far.
|
|
|
|
|
app.post('/v1/inbox/:address/fetch', async (c) => {
|
|
|
|
|
const address = validateAddress(c.req.param('address'));
|
|
|
|
|
if (rateLimitEnabled) await fetchRL.consume(`inbox-fetch:${address}`);
|
|
|
|
|
|
|
|
|
|
const owner = await store.getAddressOwner(address);
|
|
|
|
|
if (!owner) {
|
|
|
|
|
return c.json({ error: 'Address not registered', code: 'SHADE_NOT_FOUND' }, 404);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rawBody = await c.req.text();
|
|
|
|
|
if (rawBody.length > MAX_META_BODY_SIZE) {
|
|
|
|
|
throw new ValidationError(`Request body too large`);
|
|
|
|
|
}
|
|
|
|
|
const body = JSON.parse(rawBody);
|
|
|
|
|
let { sinceCursor } = body;
|
|
|
|
|
if (sinceCursor === undefined || sinceCursor === null) sinceCursor = 0;
|
|
|
|
|
if (typeof sinceCursor !== 'number' || !Number.isFinite(sinceCursor) || sinceCursor < 0) {
|
|
|
|
|
throw new ValidationError('sinceCursor must be a non-negative number', 'sinceCursor');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Bind the address to the signed payload to prevent cross-address replay.
|
|
|
|
|
await verifyPayload(crypto, owner, { ...body, address });
|
|
|
|
|
|
|
|
|
|
const now = Date.now();
|
2026-05-08 16:31:42 +02:00
|
|
|
const rawRows = await store.fetchBlobs({
|
release(v4.0.0): Shade GA — V3.x consolidation + audit prep
V3.1 → V3.12 consolidated and tagged for the first GA release. Wire
format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers
byte-for-byte. The version bump is semantic: audit-cycle complete,
opt-in surface fully exposed, threat model refreshed for every new
surface.
Highlights:
- All 24 @shade/* packages bumped to 4.0.0 in lockstep.
- CHANGELOG 4.0.0 section is the canonical manifest of what landed.
- THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12
Web-Worker boundary) + residual-risks table refreshed.
- OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox,
bridge, observer, /metrics, /healthz, /ready.
- MIGRATION 0.3.x → 4.0 documented + smoke-tested against
shade migrate-storage on a real SQLite DB.
- docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer.
- scripts/soak.ts harness for the GA-stable 2-week soak window.
- All V*.md plans archived under docs/archive/ with Status: Done.
- Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen
non-realtime stack.
Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green.
Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports
version 4.0.0 on /health.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:35:35 +02:00
|
|
|
address,
|
|
|
|
|
sinceCursor,
|
|
|
|
|
now,
|
|
|
|
|
limit: quota.fetchPageLimit,
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-08 16:31:42 +02:00
|
|
|
// V4.8.4 — drop blobs the bridge has already pushed to this address
|
|
|
|
|
// within the grace window. This is the cross-channel dedup gate that
|
|
|
|
|
// makes "one inbox.send ⇒ one observable delivery" hold even when
|
|
|
|
|
// the recipient runs both a bridge subscription and inbox-poll. The
|
|
|
|
|
// cursor still advances over the whole `rawRows` window so the
|
|
|
|
|
// client doesn't get stuck behind suppressed blobs — pollOnce uses
|
|
|
|
|
// `nextCursor` (max receivedAt seen by the server, suppressed or
|
|
|
|
|
// not) for the next fetch.
|
|
|
|
|
const rows = options.bridgeDeliveryLog
|
|
|
|
|
? options.bridgeDeliveryLog.filterRecent(address, rawRows, now)
|
|
|
|
|
: rawRows;
|
|
|
|
|
|
release(v4.0.0): Shade GA — V3.x consolidation + audit prep
V3.1 → V3.12 consolidated and tagged for the first GA release. Wire
format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers
byte-for-byte. The version bump is semantic: audit-cycle complete,
opt-in surface fully exposed, threat model refreshed for every new
surface.
Highlights:
- All 24 @shade/* packages bumped to 4.0.0 in lockstep.
- CHANGELOG 4.0.0 section is the canonical manifest of what landed.
- THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12
Web-Worker boundary) + residual-risks table refreshed.
- OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox,
bridge, observer, /metrics, /healthz, /ready.
- MIGRATION 0.3.x → 4.0 documented + smoke-tested against
shade migrate-storage on a real SQLite DB.
- docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer.
- scripts/soak.ts harness for the GA-stable 2-week soak window.
- All V*.md plans archived under docs/archive/ with Status: Done.
- Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen
non-realtime stack.
Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green.
Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports
version 4.0.0 on /health.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:35:35 +02:00
|
|
|
let bytes = 0;
|
|
|
|
|
const blobs = rows.map((r) => {
|
|
|
|
|
bytes += r.ciphertext.length;
|
2026-05-08 00:11:59 +02:00
|
|
|
const out: {
|
|
|
|
|
msgId: string;
|
|
|
|
|
ciphertext: string;
|
|
|
|
|
receivedAt: number;
|
|
|
|
|
expiresAt: number;
|
|
|
|
|
from?: string;
|
|
|
|
|
} = {
|
release(v4.0.0): Shade GA — V3.x consolidation + audit prep
V3.1 → V3.12 consolidated and tagged for the first GA release. Wire
format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers
byte-for-byte. The version bump is semantic: audit-cycle complete,
opt-in surface fully exposed, threat model refreshed for every new
surface.
Highlights:
- All 24 @shade/* packages bumped to 4.0.0 in lockstep.
- CHANGELOG 4.0.0 section is the canonical manifest of what landed.
- THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12
Web-Worker boundary) + residual-risks table refreshed.
- OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox,
bridge, observer, /metrics, /healthz, /ready.
- MIGRATION 0.3.x → 4.0 documented + smoke-tested against
shade migrate-storage on a real SQLite DB.
- docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer.
- scripts/soak.ts harness for the GA-stable 2-week soak window.
- All V*.md plans archived under docs/archive/ with Status: Done.
- Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen
non-realtime stack.
Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green.
Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports
version 4.0.0 on /health.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:35:35 +02:00
|
|
|
msgId: r.msgId,
|
|
|
|
|
ciphertext: toBase64(r.ciphertext),
|
|
|
|
|
receivedAt: r.receivedAt,
|
|
|
|
|
expiresAt: r.expiresAt,
|
|
|
|
|
};
|
2026-05-08 00:11:59 +02:00
|
|
|
// V4.8: surface sender fingerprint when present. Empty for blobs
|
|
|
|
|
// persisted by a pre-4.8 relay that didn't track sender provenance.
|
|
|
|
|
if (r.senderFp) out.from = r.senderFp;
|
|
|
|
|
return out;
|
release(v4.0.0): Shade GA — V3.x consolidation + audit prep
V3.1 → V3.12 consolidated and tagged for the first GA release. Wire
format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers
byte-for-byte. The version bump is semantic: audit-cycle complete,
opt-in surface fully exposed, threat model refreshed for every new
surface.
Highlights:
- All 24 @shade/* packages bumped to 4.0.0 in lockstep.
- CHANGELOG 4.0.0 section is the canonical manifest of what landed.
- THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12
Web-Worker boundary) + residual-risks table refreshed.
- OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox,
bridge, observer, /metrics, /healthz, /ready.
- MIGRATION 0.3.x → 4.0 documented + smoke-tested against
shade migrate-storage on a real SQLite DB.
- docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer.
- scripts/soak.ts harness for the GA-stable 2-week soak window.
- All V*.md plans archived under docs/archive/ with Status: Done.
- Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen
non-realtime stack.
Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green.
Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports
version 4.0.0 on /health.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:35:35 +02:00
|
|
|
});
|
2026-05-08 16:31:42 +02:00
|
|
|
// Advance the cursor past the FULL rawRows window — including blobs
|
|
|
|
|
// we suppressed because the bridge already pushed them. If we
|
|
|
|
|
// anchored the cursor on `rows` only, suppressed blobs in the
|
|
|
|
|
// middle of the window would block all subsequent fetches forever
|
|
|
|
|
// (re-fetched on every poll, re-suppressed, no progress). The
|
|
|
|
|
// bridge-delivery contract is "the bridge frame is the canonical
|
|
|
|
|
// delivery"; if the recipient missed processing it, they fall back
|
|
|
|
|
// to ack-via-DELETE or the blob ages out at TTL — same as a
|
|
|
|
|
// recipient that crashes mid-handler in the no-bridge case.
|
|
|
|
|
const cursorAnchor = rawRows.length > 0 ? rawRows[rawRows.length - 1]!.receivedAt : sinceCursor;
|
|
|
|
|
const nextCursor = cursorAnchor;
|
release(v4.0.0): Shade GA — V3.x consolidation + audit prep
V3.1 → V3.12 consolidated and tagged for the first GA release. Wire
format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers
byte-for-byte. The version bump is semantic: audit-cycle complete,
opt-in surface fully exposed, threat model refreshed for every new
surface.
Highlights:
- All 24 @shade/* packages bumped to 4.0.0 in lockstep.
- CHANGELOG 4.0.0 section is the canonical manifest of what landed.
- THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12
Web-Worker boundary) + residual-risks table refreshed.
- OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox,
bridge, observer, /metrics, /healthz, /ready.
- MIGRATION 0.3.x → 4.0 documented + smoke-tested against
shade migrate-storage on a real SQLite DB.
- docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer.
- scripts/soak.ts harness for the GA-stable 2-week soak window.
- All V*.md plans archived under docs/archive/ with Status: Done.
- Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen
non-realtime stack.
Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green.
Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports
version 4.0.0 on /health.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:35:35 +02:00
|
|
|
|
|
|
|
|
events?.emit('inbox.blob_fetched', { address, count: blobs.length, bytes });
|
|
|
|
|
|
|
|
|
|
return c.json({
|
|
|
|
|
blobs,
|
|
|
|
|
cursor: nextCursor,
|
2026-05-08 16:31:42 +02:00
|
|
|
hasMore: rawRows.length === quota.fetchPageLimit,
|
release(v4.0.0): Shade GA — V3.x consolidation + audit prep
V3.1 → V3.12 consolidated and tagged for the first GA release. Wire
format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers
byte-for-byte. The version bump is semantic: audit-cycle complete,
opt-in surface fully exposed, threat model refreshed for every new
surface.
Highlights:
- All 24 @shade/* packages bumped to 4.0.0 in lockstep.
- CHANGELOG 4.0.0 section is the canonical manifest of what landed.
- THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12
Web-Worker boundary) + residual-risks table refreshed.
- OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox,
bridge, observer, /metrics, /healthz, /ready.
- MIGRATION 0.3.x → 4.0 documented + smoke-tested against
shade migrate-storage on a real SQLite DB.
- docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer.
- scripts/soak.ts harness for the GA-stable 2-week soak window.
- All V*.md plans archived under docs/archive/ with Status: Done.
- Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen
non-realtime stack.
Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green.
Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports
version 4.0.0 on /health.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:35:35 +02:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ─── DELETE a single blob (signed challenge by recipient) ──
|
|
|
|
|
app.delete('/v1/inbox/:address/:msgId', async (c) => {
|
|
|
|
|
const address = validateAddress(c.req.param('address'));
|
|
|
|
|
if (rateLimitEnabled) await deleteRL.consume(`inbox-delete:${address}`);
|
|
|
|
|
|
|
|
|
|
const msgId = c.req.param('msgId');
|
|
|
|
|
if (!isValidMsgId(msgId)) {
|
|
|
|
|
throw new ValidationError('msgId must be 64 lowercase hex chars', 'msgId');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const owner = await store.getAddressOwner(address);
|
|
|
|
|
if (!owner) {
|
|
|
|
|
return c.json({ error: 'Address not registered', code: 'SHADE_NOT_FOUND' }, 404);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const body = await c.req.json();
|
|
|
|
|
await verifyPayload(crypto, owner, { ...body, address, msgId });
|
|
|
|
|
|
|
|
|
|
const removed = await store.deleteBlob(address, msgId);
|
|
|
|
|
if (removed) {
|
|
|
|
|
events?.emit('inbox.blob_acked', { address, msgId });
|
|
|
|
|
}
|
2026-05-08 16:31:42 +02:00
|
|
|
// Drop any bridge-delivery mark — keeps the log bounded under
|
|
|
|
|
// sustained traffic (otherwise long-lived addresses accumulate
|
|
|
|
|
// entries even after the underlying blob is gone).
|
|
|
|
|
options.bridgeDeliveryLog?.forget(address, msgId);
|
release(v4.0.0): Shade GA — V3.x consolidation + audit prep
V3.1 → V3.12 consolidated and tagged for the first GA release. Wire
format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers
byte-for-byte. The version bump is semantic: audit-cycle complete,
opt-in surface fully exposed, threat model refreshed for every new
surface.
Highlights:
- All 24 @shade/* packages bumped to 4.0.0 in lockstep.
- CHANGELOG 4.0.0 section is the canonical manifest of what landed.
- THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12
Web-Worker boundary) + residual-risks table refreshed.
- OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox,
bridge, observer, /metrics, /healthz, /ready.
- MIGRATION 0.3.x → 4.0 documented + smoke-tested against
shade migrate-storage on a real SQLite DB.
- docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer.
- scripts/soak.ts harness for the GA-stable 2-week soak window.
- All V*.md plans archived under docs/archive/ with Status: Done.
- Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen
non-realtime stack.
Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green.
Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports
version 4.0.0 on /health.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:35:35 +02:00
|
|
|
return c.json({ ok: removed });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return app;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Base64 helpers ──────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function b64ToBytes(s: string): Uint8Array {
|
|
|
|
|
return fromBase64(s);
|
|
|
|
|
}
|