release(v4.0.0): Shade GA — V3.x consolidation + audit prep
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled
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>
This commit is contained in:
219
packages/shade-inbox/src/client.ts
Normal file
219
packages/shade-inbox/src/client.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import type { CryptoProvider, ShadeEnvelope } from '@shade/core';
|
||||
import {
|
||||
NetworkError,
|
||||
toBase64,
|
||||
fromBase64,
|
||||
ShadeError,
|
||||
ValidationError,
|
||||
} from '@shade/core';
|
||||
import { signPayload } from '@shade/server';
|
||||
import { encodeEnvelope, decodeEnvelope } from '@shade/proto';
|
||||
import { computeMsgId } from './msg-id.js';
|
||||
|
||||
/**
|
||||
* Low-level HTTP client for `@shade/inbox-server`.
|
||||
*
|
||||
* Stateless and reusable across many recipients. Higher-level orchestration
|
||||
* (queue, poll loop, ack-on-decrypt) lives in `Inbox` (see `inbox.ts`),
|
||||
* which composes this client.
|
||||
*/
|
||||
export interface InboxClientOptions {
|
||||
baseUrl: string;
|
||||
crypto: CryptoProvider;
|
||||
/** Used to sign requests on behalf of *this* identity. */
|
||||
signingPrivateKey: Uint8Array;
|
||||
/** Optional fetch override (defaults to globalThis.fetch). */
|
||||
fetch?: typeof fetch;
|
||||
}
|
||||
|
||||
export interface PutResult {
|
||||
msgId: string;
|
||||
receivedAt: number;
|
||||
idempotent: boolean;
|
||||
}
|
||||
|
||||
export interface FetchedBlob {
|
||||
msgId: string;
|
||||
/** Wire-encoded ShadeEnvelope bytes. */
|
||||
ciphertext: Uint8Array;
|
||||
/** Server-assigned monotonic timestamp; pass back as `sinceCursor`. */
|
||||
receivedAt: number;
|
||||
/** Absolute expiry time (ms since epoch) reported by the server. */
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export interface FetchResult {
|
||||
blobs: FetchedBlob[];
|
||||
cursor: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export class InboxClient {
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
|
||||
constructor(private readonly options: InboxClientOptions) {
|
||||
this.fetchImpl = options.fetch ?? globalThis.fetch;
|
||||
}
|
||||
|
||||
/**
|
||||
* TOFU-register the address that this signing key will own.
|
||||
* Idempotent if the same key re-registers; rejected by the server if a
|
||||
* different key has already claimed the address.
|
||||
*/
|
||||
async register(args: {
|
||||
address: string;
|
||||
signingKey: Uint8Array;
|
||||
}): Promise<void> {
|
||||
const body = await signPayload(this.options.crypto, this.options.signingPrivateKey, {
|
||||
address: args.address,
|
||||
signingKey: toBase64(args.signingKey),
|
||||
});
|
||||
await this.postJson(`/v1/inbox/register`, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister the address. Drops every queued blob. Signed by the
|
||||
* registered signing key.
|
||||
*/
|
||||
async unregister(address: string): Promise<void> {
|
||||
const body = await signPayload(this.options.crypto, this.options.signingPrivateKey, {
|
||||
address,
|
||||
});
|
||||
await this.requestJson('DELETE', `/v1/inbox/register/${encodeURIComponent(address)}`, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT a ShadeEnvelope to a recipient's inbox. Idempotent: same
|
||||
* ciphertext yields the same msgId and the server folds the second PUT
|
||||
* into a 200 with `idempotent: true`.
|
||||
*/
|
||||
async put(args: {
|
||||
/** Recipient address (inbox owner). */
|
||||
recipientAddress: string;
|
||||
/** Sender's identity signing public key (the one matching `signingPrivateKey`). */
|
||||
senderSigningKey: Uint8Array;
|
||||
/** Encrypted Shade envelope. Either pre-encoded bytes or a parsed envelope. */
|
||||
envelope: ShadeEnvelope | Uint8Array;
|
||||
ttlSeconds?: number;
|
||||
}): Promise<PutResult> {
|
||||
const ciphertext =
|
||||
args.envelope instanceof Uint8Array ? args.envelope : encodeEnvelope(args.envelope);
|
||||
if (ciphertext.length === 0) {
|
||||
throw new ValidationError('Empty ciphertext');
|
||||
}
|
||||
const msgId = await computeMsgId(ciphertext);
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
senderSigningKey: toBase64(args.senderSigningKey),
|
||||
msgId,
|
||||
ciphertext: toBase64(ciphertext),
|
||||
};
|
||||
if (args.ttlSeconds !== undefined) payload.ttlSeconds = args.ttlSeconds;
|
||||
|
||||
const signed = await signPayload(
|
||||
this.options.crypto,
|
||||
this.options.signingPrivateKey,
|
||||
payload,
|
||||
);
|
||||
|
||||
const json = await this.postJson(
|
||||
`/v1/inbox/${encodeURIComponent(args.recipientAddress)}`,
|
||||
signed,
|
||||
);
|
||||
return {
|
||||
msgId: String(json.msgId),
|
||||
receivedAt: Number(json.receivedAt),
|
||||
idempotent: Boolean(json.idempotent),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all blobs for `address` whose `received_at > sinceCursor`.
|
||||
* Returns at most one server page; clients keep calling with the new
|
||||
* cursor until `hasMore === false`.
|
||||
*/
|
||||
async fetch(args: { address: string; sinceCursor?: number }): Promise<FetchResult> {
|
||||
const sinceCursor = args.sinceCursor ?? 0;
|
||||
const body = await signPayload(this.options.crypto, this.options.signingPrivateKey, {
|
||||
address: args.address,
|
||||
sinceCursor,
|
||||
});
|
||||
const json = await this.postJson(
|
||||
`/v1/inbox/${encodeURIComponent(args.address)}/fetch`,
|
||||
body,
|
||||
);
|
||||
const blobs = Array.isArray(json.blobs) ? json.blobs : [];
|
||||
return {
|
||||
blobs: blobs.map((b: any) => ({
|
||||
msgId: String(b.msgId),
|
||||
ciphertext: fromBase64(String(b.ciphertext)),
|
||||
receivedAt: Number(b.receivedAt),
|
||||
expiresAt: Number(b.expiresAt),
|
||||
})),
|
||||
cursor: Number(json.cursor ?? sinceCursor),
|
||||
hasMore: Boolean(json.hasMore),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Acknowledge — delete a fetched blob. Should be called after the
|
||||
* caller has successfully decrypted (or persisted) the message.
|
||||
*/
|
||||
async ack(args: { address: string; msgId: string }): Promise<boolean> {
|
||||
const body = await signPayload(this.options.crypto, this.options.signingPrivateKey, {
|
||||
address: args.address,
|
||||
msgId: args.msgId,
|
||||
});
|
||||
const json = await this.requestJson(
|
||||
'DELETE',
|
||||
`/v1/inbox/${encodeURIComponent(args.address)}/${encodeURIComponent(args.msgId)}`,
|
||||
body,
|
||||
);
|
||||
return Boolean(json.ok);
|
||||
}
|
||||
|
||||
// ─── HTTP plumbing ──────────────────────────────────────────
|
||||
|
||||
private async postJson(path: string, body: unknown): Promise<any> {
|
||||
return this.requestJson('POST', path, body);
|
||||
}
|
||||
|
||||
private async requestJson(method: string, path: string, body: unknown): Promise<any> {
|
||||
const url = joinUrl(this.options.baseUrl, path);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await this.fetchImpl(url, {
|
||||
method,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch (err) {
|
||||
throw new NetworkError(`Inbox request failed: ${(err as Error).message}`);
|
||||
}
|
||||
const text = await res.text();
|
||||
let json: any;
|
||||
try {
|
||||
json = text.length > 0 ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
throw new NetworkError(`Inbox response not JSON: ${text.slice(0, 200)}`, res.status);
|
||||
}
|
||||
if (!res.ok) {
|
||||
// Surface server-mapped Shade errors in their original shape.
|
||||
const code = String(json.code ?? '');
|
||||
const message = String(json.message ?? text);
|
||||
throw new ShadeError(code || 'SHADE_NETWORK', message);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
}
|
||||
|
||||
function joinUrl(base: string, path: string): string {
|
||||
if (base.endsWith('/') && path.startsWith('/')) return base + path.slice(1);
|
||||
if (!base.endsWith('/') && !path.startsWith('/')) return base + '/' + path;
|
||||
return base + path;
|
||||
}
|
||||
|
||||
/** Decode a fetched blob into a ShadeEnvelope. */
|
||||
export function decodeFetchedEnvelope(b: FetchedBlob): ShadeEnvelope {
|
||||
return decodeEnvelope(b.ciphertext);
|
||||
}
|
||||
26
packages/shade-inbox/src/cursor-store.ts
Normal file
26
packages/shade-inbox/src/cursor-store.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Persistent receive cursor.
|
||||
*
|
||||
* Tracks the highest `received_at` we've consumed from the inbox per
|
||||
* (ownAddress, ourSelf-poll context). The InboxClient pulls everything
|
||||
* strictly greater than this on each poll.
|
||||
*
|
||||
* The default in-memory implementation is sufficient for short-lived
|
||||
* processes (CLIs, tests). Long-lived services should plug in a SQLite,
|
||||
* Postgres, or IndexedDB backed store so a restart doesn't redownload all
|
||||
* messages still in TTL.
|
||||
*/
|
||||
export interface CursorStore {
|
||||
load(address: string): Promise<number>;
|
||||
save(address: string, cursor: number): Promise<void>;
|
||||
}
|
||||
|
||||
export class MemoryCursorStore implements CursorStore {
|
||||
private cursors = new Map<string, number>();
|
||||
async load(address: string): Promise<number> {
|
||||
return this.cursors.get(address) ?? 0;
|
||||
}
|
||||
async save(address: string, cursor: number): Promise<void> {
|
||||
this.cursors.set(address, cursor);
|
||||
}
|
||||
}
|
||||
72
packages/shade-inbox/src/events.ts
Normal file
72
packages/shade-inbox/src/events.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Client-side inbox event emitter.
|
||||
*
|
||||
* The high-level `Inbox` orchestrator emits structural events — message
|
||||
* queued, delivered, polled, decrypted — so apps can drive UI badges,
|
||||
* push hooks, or telemetry without polling internal state.
|
||||
*/
|
||||
|
||||
export interface InboxClientEventMap {
|
||||
/**
|
||||
* A new ciphertext entry was added to the outgoing queue. The push-hook
|
||||
* mentioned in the V3.6 spec dispatches off this event.
|
||||
*/
|
||||
'inbox.message_queued': {
|
||||
recipientAddress: string;
|
||||
msgId: string;
|
||||
bytes: number;
|
||||
ttlSeconds: number;
|
||||
};
|
||||
/** Server confirmed a queued blob landed. */
|
||||
'inbox.message_delivered': {
|
||||
recipientAddress: string;
|
||||
msgId: string;
|
||||
idempotent: boolean;
|
||||
};
|
||||
/** Delivery attempt failed; will retry on next flush. */
|
||||
'inbox.message_failed': {
|
||||
recipientAddress: string;
|
||||
msgId: string;
|
||||
attempts: number;
|
||||
error: string;
|
||||
};
|
||||
/** A poll cycle completed and pulled `count` blobs. */
|
||||
'inbox.poll_completed': { ownAddress: string; count: number; cursor: number };
|
||||
/** Caller successfully decrypted and acked a blob. */
|
||||
'inbox.message_received': {
|
||||
senderHint: string | null;
|
||||
msgId: string;
|
||||
};
|
||||
/** Caller failed to decrypt — typically tampering or stale ratchet. */
|
||||
'inbox.message_decrypt_failed': {
|
||||
msgId: string;
|
||||
error: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type InboxClientEventName = keyof InboxClientEventMap;
|
||||
export type InboxClientEvent = {
|
||||
[K in InboxClientEventName]: { name: K; data: InboxClientEventMap[K]; timestamp: number };
|
||||
}[InboxClientEventName];
|
||||
|
||||
export type InboxClientListener = (e: InboxClientEvent) => void;
|
||||
|
||||
export class InboxClientEvents {
|
||||
private readonly listeners = new Set<InboxClientListener>();
|
||||
|
||||
on(listener: InboxClientListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
emit<K extends InboxClientEventName>(name: K, data: InboxClientEventMap[K]): void {
|
||||
const event = { name, data, timestamp: Date.now() } as InboxClientEvent;
|
||||
for (const l of this.listeners) {
|
||||
try {
|
||||
l(event);
|
||||
} catch (err) {
|
||||
console.error('[Shade] Inbox client listener threw:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
407
packages/shade-inbox/src/inbox.ts
Normal file
407
packages/shade-inbox/src/inbox.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
import type { CryptoProvider, ShadeEnvelope } from '@shade/core';
|
||||
import { encodeEnvelope } from '@shade/proto';
|
||||
import { InboxClient, type FetchedBlob } from './client.js';
|
||||
import {
|
||||
MemoryOutgoingQueueStore,
|
||||
type OutgoingEntry,
|
||||
type OutgoingQueueStore,
|
||||
} from './queue-store.js';
|
||||
import { MemoryCursorStore, type CursorStore } from './cursor-store.js';
|
||||
import { computeMsgId } from './msg-id.js';
|
||||
import { InboxClientEvents, type InboxClientListener } from './events.js';
|
||||
|
||||
/**
|
||||
* Caller-supplied incoming-blob handler. Receives raw wire bytes; the app
|
||||
* is expected to call `decodeEnvelope` + `Shade.receive` (or whatever
|
||||
* decrypt path it owns) and either return a sender-hint for telemetry
|
||||
* (the address the SDK extracted, or `null`) or throw to keep the blob
|
||||
* on the server for a later retry.
|
||||
*/
|
||||
export type DecryptHandler = (
|
||||
raw: { msgId: string; ciphertext: Uint8Array; receivedAt: number; expiresAt: number },
|
||||
) => Promise<string | null | undefined> | string | null | undefined;
|
||||
|
||||
export interface InboxOptions {
|
||||
/** Inbox-server base URL (e.g. "https://inbox.example.com"). */
|
||||
baseUrl: string;
|
||||
/** Recipient address — the address that owns this queue. */
|
||||
ownAddress: string;
|
||||
/** Crypto provider — used for signing requests. */
|
||||
crypto: CryptoProvider;
|
||||
/** Identity signing private key. */
|
||||
signingPrivateKey: Uint8Array;
|
||||
/** Identity signing public key. */
|
||||
signingPublicKey: Uint8Array;
|
||||
/**
|
||||
* Default TTL for outgoing PUTs (seconds). The server clamps to its own
|
||||
* min/max. Defaults to 7 days as mandated by the V3.6 spec.
|
||||
*/
|
||||
defaultTtlSeconds?: number;
|
||||
/** Polling interval (ms). Default: 30s. Set to 0 to disable auto-poll. */
|
||||
pollIntervalMs?: number;
|
||||
/** Outgoing queue persistence. Defaults to in-memory. */
|
||||
queueStore?: OutgoingQueueStore;
|
||||
/** Cursor persistence. Defaults to in-memory. */
|
||||
cursorStore?: CursorStore;
|
||||
/** Override the underlying fetch (tests). */
|
||||
fetch?: typeof fetch;
|
||||
/** Maximum delivery attempts before dropping a queued entry. Default: 10. */
|
||||
maxAttempts?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_TTL_SECONDS = 7 * 24 * 60 * 60;
|
||||
const DEFAULT_POLL_INTERVAL_MS = 30_000;
|
||||
const DEFAULT_MAX_ATTEMPTS = 10;
|
||||
|
||||
/**
|
||||
* High-level inbox orchestrator.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Outgoing: serialize ciphertext → queue → flush to server with retry.
|
||||
* - Incoming: poll → decrypt-handler → ack on success.
|
||||
* - Push hook: `onMessageQueued(handler)` lets a transport vendor (FCM,
|
||||
* APNs) wake the recipient out-of-band when a blob is enqueued for them
|
||||
* here. The hook is called with the recipient address — the actual
|
||||
* push-delivery wire is left to the integrator.
|
||||
*
|
||||
* The class never *encrypts* — that's `Shade.send`'s job. Apps wire it up
|
||||
* like:
|
||||
*
|
||||
* const envelope = await shade.send(bob, "hi");
|
||||
* await inbox.send(bob, envelope);
|
||||
*
|
||||
* On Bob's device:
|
||||
*
|
||||
* inbox.onIncoming(async (env) => {
|
||||
* await shade.receive(senderAddress, env);
|
||||
* });
|
||||
* inbox.start();
|
||||
*/
|
||||
export class Inbox {
|
||||
private readonly client: InboxClient;
|
||||
private readonly queueStore: OutgoingQueueStore;
|
||||
private readonly cursorStore: CursorStore;
|
||||
private readonly events = new InboxClientEvents();
|
||||
private readonly defaultTtlSeconds: number;
|
||||
private readonly pollIntervalMs: number;
|
||||
private readonly maxAttempts: number;
|
||||
|
||||
private incomingHandler: DecryptHandler | null = null;
|
||||
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private pollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private flushing = false;
|
||||
private polling = false;
|
||||
private started = false;
|
||||
private registered = false;
|
||||
|
||||
constructor(private readonly options: InboxOptions) {
|
||||
const clientOptions: ConstructorParameters<typeof InboxClient>[0] = {
|
||||
baseUrl: options.baseUrl,
|
||||
crypto: options.crypto,
|
||||
signingPrivateKey: options.signingPrivateKey,
|
||||
};
|
||||
if (options.fetch !== undefined) clientOptions.fetch = options.fetch;
|
||||
this.client = new InboxClient(clientOptions);
|
||||
this.queueStore = options.queueStore ?? new MemoryOutgoingQueueStore();
|
||||
this.cursorStore = options.cursorStore ?? new MemoryCursorStore();
|
||||
this.defaultTtlSeconds = options.defaultTtlSeconds ?? DEFAULT_TTL_SECONDS;
|
||||
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
||||
this.maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
||||
}
|
||||
|
||||
/** Subscribe to client events (queued / delivered / received / failed). */
|
||||
on(listener: InboxClientListener): () => void {
|
||||
return this.events.on(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Push-hook: invoked once per new blob enqueued for delivery. Vendors
|
||||
* implement FCM/APNs/email wake-ups inside the handler. The handler
|
||||
* fires *after* the blob is durably in the local queue but before the
|
||||
* server has confirmed PUT.
|
||||
*/
|
||||
onMessageQueued(handler: (recipientAddress: string, msgId: string) => void | Promise<void>): () => void {
|
||||
return this.events.on((e) => {
|
||||
if (e.name === 'inbox.message_queued') {
|
||||
Promise.resolve(handler(e.data.recipientAddress, e.data.msgId)).catch((err) => {
|
||||
console.error('[Shade] onMessageQueued handler threw:', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Register a handler that processes incoming blobs. Replaces any prior. */
|
||||
onIncoming(handler: DecryptHandler): () => void {
|
||||
this.incomingHandler = handler;
|
||||
return () => {
|
||||
if (this.incomingHandler === handler) this.incomingHandler = null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotent TOFU registration with the server. Called automatically by
|
||||
* `start()`; can also be invoked directly e.g. during onboarding.
|
||||
*/
|
||||
async register(): Promise<void> {
|
||||
if (this.registered) return;
|
||||
await this.client.register({
|
||||
address: this.options.ownAddress,
|
||||
signingKey: this.options.signingPublicKey,
|
||||
});
|
||||
this.registered = true;
|
||||
}
|
||||
|
||||
/** Drop the address from the server. Local queue/cursor are preserved. */
|
||||
async unregister(): Promise<void> {
|
||||
await this.client.unregister(this.options.ownAddress);
|
||||
this.registered = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue an envelope for delivery. The actual PUT happens
|
||||
* asynchronously — the call returns once the entry is durably in the
|
||||
* outgoing queue. Returns the deterministic msgId.
|
||||
*
|
||||
* `envelope` accepts a `ShadeEnvelope` (the type returned from
|
||||
* `Shade.send`) or pre-encoded wire bytes (`Uint8Array`).
|
||||
*/
|
||||
async send(args: {
|
||||
recipientAddress: string;
|
||||
envelope: ShadeEnvelope | Uint8Array;
|
||||
ttlSeconds?: number;
|
||||
}): Promise<string> {
|
||||
const ciphertext =
|
||||
args.envelope instanceof Uint8Array ? args.envelope : encodeEnvelope(args.envelope);
|
||||
const msgId = await computeMsgId(ciphertext);
|
||||
const ttlSeconds = args.ttlSeconds ?? this.defaultTtlSeconds;
|
||||
|
||||
const entry: OutgoingEntry = {
|
||||
recipientAddress: args.recipientAddress,
|
||||
msgId,
|
||||
ciphertext,
|
||||
ttlSeconds,
|
||||
queuedAt: Date.now(),
|
||||
attempts: 0,
|
||||
};
|
||||
await this.queueStore.enqueue(entry);
|
||||
|
||||
this.events.emit('inbox.message_queued', {
|
||||
recipientAddress: args.recipientAddress,
|
||||
msgId,
|
||||
bytes: ciphertext.length,
|
||||
ttlSeconds,
|
||||
});
|
||||
|
||||
// Kick the flush loop. Don't await — caller doesn't need to block on
|
||||
// network for a "queued" return.
|
||||
this.scheduleFlush();
|
||||
return msgId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a flush + poll cycle now (useful right after registering or
|
||||
* after a push-trigger arrives). Does not throw on transient errors.
|
||||
*/
|
||||
async tick(): Promise<{ flushed: number; received: number }> {
|
||||
const flushed = await this.flushOnce();
|
||||
const received = await this.pollOnce();
|
||||
return { flushed, received };
|
||||
}
|
||||
|
||||
/** Start background flush + poll timers. Idempotent. */
|
||||
start(): void {
|
||||
if (this.started) return;
|
||||
this.started = true;
|
||||
this.register().catch((err) => {
|
||||
console.warn('[Shade] Inbox register failed (will retry):', (err as Error).message);
|
||||
this.scheduleRegisterRetry();
|
||||
});
|
||||
this.scheduleFlush();
|
||||
this.schedulePoll(0);
|
||||
}
|
||||
|
||||
/** Stop background timers. Pending entries remain in the queue. */
|
||||
stop(): void {
|
||||
this.started = false;
|
||||
if (this.flushTimer) {
|
||||
clearTimeout(this.flushTimer);
|
||||
this.flushTimer = null;
|
||||
}
|
||||
if (this.pollTimer) {
|
||||
clearTimeout(this.pollTimer);
|
||||
this.pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Number of entries currently waiting to be flushed. */
|
||||
async pendingCount(): Promise<number> {
|
||||
return this.queueStore.size();
|
||||
}
|
||||
|
||||
// ─── internals ──────────────────────────────────────────────
|
||||
|
||||
private scheduleRegisterRetry(): void {
|
||||
if (!this.started) return;
|
||||
setTimeout(() => {
|
||||
if (!this.started) return;
|
||||
this.register().catch(() => this.scheduleRegisterRetry());
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
private scheduleFlush(delayMs = 0): void {
|
||||
if (this.flushTimer) return;
|
||||
this.flushTimer = setTimeout(() => {
|
||||
this.flushTimer = null;
|
||||
this.flushOnce()
|
||||
.then(() => {
|
||||
// If anything is still queued, retry with backoff.
|
||||
this.queueStore.size().then((n) => {
|
||||
if (n > 0 && this.started) this.scheduleFlush(15_000);
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (this.started) this.scheduleFlush(15_000);
|
||||
});
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
private schedulePoll(delayMs: number): void {
|
||||
if (!this.started || this.pollIntervalMs === 0) return;
|
||||
if (this.pollTimer) clearTimeout(this.pollTimer);
|
||||
this.pollTimer = setTimeout(() => {
|
||||
this.pollTimer = null;
|
||||
this.pollOnce()
|
||||
.catch(() => {})
|
||||
.finally(() => this.schedulePoll(this.pollIntervalMs));
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
private async flushOnce(): Promise<number> {
|
||||
if (this.flushing) return 0;
|
||||
this.flushing = true;
|
||||
let delivered = 0;
|
||||
try {
|
||||
const entries = await this.queueStore.list();
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
const result = await this.client.put({
|
||||
recipientAddress: entry.recipientAddress,
|
||||
senderSigningKey: this.options.signingPublicKey,
|
||||
envelope: entry.ciphertext,
|
||||
ttlSeconds: entry.ttlSeconds,
|
||||
});
|
||||
await this.queueStore.remove(entry.recipientAddress, entry.msgId);
|
||||
delivered++;
|
||||
this.events.emit('inbox.message_delivered', {
|
||||
recipientAddress: entry.recipientAddress,
|
||||
msgId: result.msgId,
|
||||
idempotent: result.idempotent,
|
||||
});
|
||||
} catch (err) {
|
||||
await this.queueStore.bumpAttempts(entry.recipientAddress, entry.msgId);
|
||||
const attempts = entry.attempts + 1;
|
||||
this.events.emit('inbox.message_failed', {
|
||||
recipientAddress: entry.recipientAddress,
|
||||
msgId: entry.msgId,
|
||||
attempts,
|
||||
error: (err as Error).message,
|
||||
});
|
||||
if (attempts >= this.maxAttempts) {
|
||||
await this.queueStore.remove(entry.recipientAddress, entry.msgId);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.flushing = false;
|
||||
}
|
||||
return delivered;
|
||||
}
|
||||
|
||||
private async pollOnce(): Promise<number> {
|
||||
if (this.polling) return 0;
|
||||
if (!this.incomingHandler) return 0;
|
||||
this.polling = true;
|
||||
let total = 0;
|
||||
try {
|
||||
let cursor = await this.cursorStore.load(this.options.ownAddress);
|
||||
// Pull all pages.
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
let page;
|
||||
try {
|
||||
page = await this.client.fetch({
|
||||
address: this.options.ownAddress,
|
||||
sinceCursor: cursor,
|
||||
});
|
||||
} catch (err) {
|
||||
// Surface but don't crash the loop.
|
||||
console.warn('[Shade] Inbox fetch failed:', (err as Error).message);
|
||||
break;
|
||||
}
|
||||
for (const blob of page.blobs) {
|
||||
const handled = await this.handleBlob(blob);
|
||||
if (handled) total++;
|
||||
if (blob.receivedAt > cursor) cursor = blob.receivedAt;
|
||||
}
|
||||
await this.cursorStore.save(this.options.ownAddress, cursor);
|
||||
this.events.emit('inbox.poll_completed', {
|
||||
ownAddress: this.options.ownAddress,
|
||||
count: page.blobs.length,
|
||||
cursor,
|
||||
});
|
||||
if (!page.hasMore) break;
|
||||
}
|
||||
} finally {
|
||||
this.polling = false;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private async handleBlob(blob: FetchedBlob): Promise<boolean> {
|
||||
if (!this.incomingHandler) return false;
|
||||
|
||||
// Defense-in-depth: verify msgId ↔ ciphertext at the client too. A
|
||||
// server bug or malicious operator can't sneak a different blob past
|
||||
// the client's hash check.
|
||||
const recomputed = await computeMsgId(blob.ciphertext);
|
||||
if (recomputed !== blob.msgId) {
|
||||
this.events.emit('inbox.message_decrypt_failed', {
|
||||
msgId: blob.msgId,
|
||||
error: 'msgId/ciphertext mismatch',
|
||||
});
|
||||
// Don't ack — let the operator notice the divergence.
|
||||
return false;
|
||||
}
|
||||
|
||||
let senderHint: string | null = null;
|
||||
try {
|
||||
const result = await this.incomingHandler({
|
||||
msgId: blob.msgId,
|
||||
ciphertext: blob.ciphertext,
|
||||
receivedAt: blob.receivedAt,
|
||||
expiresAt: blob.expiresAt,
|
||||
});
|
||||
senderHint = result ?? null;
|
||||
} catch (err) {
|
||||
this.events.emit('inbox.message_decrypt_failed', {
|
||||
msgId: blob.msgId,
|
||||
error: (err as Error).message,
|
||||
});
|
||||
// Don't ack — caller can retry on next poll.
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.client.ack({ address: this.options.ownAddress, msgId: blob.msgId });
|
||||
} catch (err) {
|
||||
// Decryption succeeded; ack just failed. Will be retried later, and
|
||||
// the duplicate-message ratchet check on `Shade.receive` will dedupe.
|
||||
console.warn('[Shade] Inbox ack failed (will retry):', (err as Error).message);
|
||||
}
|
||||
this.events.emit('inbox.message_received', {
|
||||
senderHint,
|
||||
msgId: blob.msgId,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
45
packages/shade-inbox/src/index.ts
Normal file
45
packages/shade-inbox/src/index.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export {
|
||||
InboxClient,
|
||||
decodeFetchedEnvelope,
|
||||
} from './client.js';
|
||||
export type {
|
||||
InboxClientOptions,
|
||||
PutResult,
|
||||
FetchedBlob,
|
||||
FetchResult,
|
||||
} from './client.js';
|
||||
|
||||
export {
|
||||
Inbox,
|
||||
} from './inbox.js';
|
||||
export type {
|
||||
InboxOptions,
|
||||
DecryptHandler,
|
||||
} from './inbox.js';
|
||||
|
||||
export {
|
||||
MemoryOutgoingQueueStore,
|
||||
} from './queue-store.js';
|
||||
export type {
|
||||
OutgoingEntry,
|
||||
OutgoingQueueStore,
|
||||
} from './queue-store.js';
|
||||
|
||||
export {
|
||||
MemoryCursorStore,
|
||||
} from './cursor-store.js';
|
||||
export type {
|
||||
CursorStore,
|
||||
} from './cursor-store.js';
|
||||
|
||||
export {
|
||||
InboxClientEvents,
|
||||
} from './events.js';
|
||||
export type {
|
||||
InboxClientEvent,
|
||||
InboxClientEventName,
|
||||
InboxClientEventMap,
|
||||
InboxClientListener,
|
||||
} from './events.js';
|
||||
|
||||
export { computeMsgId } from './msg-id.js';
|
||||
14
packages/shade-inbox/src/msg-id.ts
Normal file
14
packages/shade-inbox/src/msg-id.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Client-side msgId helper. Mirrors `@shade/inbox-server/msg-id` but lives
|
||||
* in this package so client code doesn't need to import the server bundle.
|
||||
*
|
||||
* `msgId = lowercase-hex( sha256(ciphertext) )`
|
||||
*/
|
||||
export async function computeMsgId(ciphertext: Uint8Array): Promise<string> {
|
||||
const buf = await globalThis.crypto.subtle.digest(
|
||||
'SHA-256',
|
||||
ciphertext as unknown as ArrayBuffer,
|
||||
);
|
||||
const arr = new Uint8Array(buf);
|
||||
return Array.from(arr, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
77
packages/shade-inbox/src/queue-store.ts
Normal file
77
packages/shade-inbox/src/queue-store.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Outgoing-queue persistence interface.
|
||||
*
|
||||
* The client buffers ciphertext blobs until the inbox server confirms the
|
||||
* PUT. If the process restarts mid-flush, the queue must survive — so we
|
||||
* model it as an explicit interface that apps can back with SQLite, IndexedDB,
|
||||
* AsyncStorage, etc.
|
||||
*
|
||||
* Each entry is keyed by `(recipientAddress, msgId)` (the same key the
|
||||
* server uses) so retries are naturally idempotent.
|
||||
*/
|
||||
export interface OutgoingEntry {
|
||||
/** Recipient address (the inbox owner). */
|
||||
recipientAddress: string;
|
||||
/** Hex SHA-256 of `ciphertext` — server-side msg id. */
|
||||
msgId: string;
|
||||
/** Wire-encoded ShadeEnvelope to deliver. */
|
||||
ciphertext: Uint8Array;
|
||||
/** Time-to-live in seconds. The server clamps to its allowed range. */
|
||||
ttlSeconds: number;
|
||||
/** Local timestamp when the entry was queued (ms). */
|
||||
queuedAt: number;
|
||||
/** Number of failed delivery attempts so far. */
|
||||
attempts: number;
|
||||
}
|
||||
|
||||
export interface OutgoingQueueStore {
|
||||
/** Insert a new entry. Idempotent on (recipientAddress, msgId). */
|
||||
enqueue(entry: OutgoingEntry): Promise<void>;
|
||||
/** List entries in insertion order. Caller filters/limits. */
|
||||
list(): Promise<OutgoingEntry[]>;
|
||||
/** Remove an entry by composite key. Returns true if found. */
|
||||
remove(recipientAddress: string, msgId: string): Promise<boolean>;
|
||||
/** Update the attempts counter for an entry. */
|
||||
bumpAttempts(recipientAddress: string, msgId: string): Promise<void>;
|
||||
/** Total queued count. */
|
||||
size(): Promise<number>;
|
||||
}
|
||||
|
||||
export class MemoryOutgoingQueueStore implements OutgoingQueueStore {
|
||||
private entries: OutgoingEntry[] = [];
|
||||
|
||||
async enqueue(entry: OutgoingEntry): Promise<void> {
|
||||
const dup = this.entries.some(
|
||||
(e) => e.recipientAddress === entry.recipientAddress && e.msgId === entry.msgId,
|
||||
);
|
||||
if (dup) return;
|
||||
this.entries.push({ ...entry, ciphertext: new Uint8Array(entry.ciphertext) });
|
||||
}
|
||||
|
||||
async list(): Promise<OutgoingEntry[]> {
|
||||
return this.entries.map((e) => ({
|
||||
...e,
|
||||
ciphertext: new Uint8Array(e.ciphertext),
|
||||
}));
|
||||
}
|
||||
|
||||
async remove(recipientAddress: string, msgId: string): Promise<boolean> {
|
||||
const idx = this.entries.findIndex(
|
||||
(e) => e.recipientAddress === recipientAddress && e.msgId === msgId,
|
||||
);
|
||||
if (idx === -1) return false;
|
||||
this.entries.splice(idx, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
async bumpAttempts(recipientAddress: string, msgId: string): Promise<void> {
|
||||
const e = this.entries.find(
|
||||
(x) => x.recipientAddress === recipientAddress && x.msgId === msgId,
|
||||
);
|
||||
if (e) e.attempts++;
|
||||
}
|
||||
|
||||
async size(): Promise<number> {
|
||||
return this.entries.length;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user