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 { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
|
|
|
import { tmpdir } from 'os';
|
|
|
|
|
import { join } from 'path';
|
|
|
|
|
import { unlinkSync } from 'fs';
|
|
|
|
|
import { SqliteInboxStore } from '../src/sqlite-inbox-store.js';
|
|
|
|
|
|
|
|
|
|
function randBytes(n: number): Uint8Array {
|
|
|
|
|
const buf = new Uint8Array(n);
|
|
|
|
|
globalThis.crypto.getRandomValues(buf);
|
|
|
|
|
return buf;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function tempDbPath(): string {
|
|
|
|
|
return join(
|
|
|
|
|
tmpdir(),
|
|
|
|
|
`shade-inbox-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('SqliteInboxStore', () => {
|
|
|
|
|
let dbPath: string;
|
|
|
|
|
let store: SqliteInboxStore;
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
dbPath = tempDbPath();
|
|
|
|
|
store = new SqliteInboxStore(dbPath);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
store.close();
|
|
|
|
|
try {
|
|
|
|
|
unlinkSync(dbPath);
|
|
|
|
|
} catch {}
|
|
|
|
|
try {
|
|
|
|
|
unlinkSync(dbPath + '-wal');
|
|
|
|
|
} catch {}
|
|
|
|
|
try {
|
|
|
|
|
unlinkSync(dbPath + '-shm');
|
|
|
|
|
} catch {}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('owner save + get', async () => {
|
|
|
|
|
const key = randBytes(32);
|
|
|
|
|
await store.saveAddressOwner('bob', key);
|
|
|
|
|
const got = await store.getAddressOwner('bob');
|
|
|
|
|
expect(got).not.toBeNull();
|
|
|
|
|
expect(got!).toEqual(key);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('putBlob is idempotent on (address, msgId)', async () => {
|
|
|
|
|
const ct = randBytes(64);
|
|
|
|
|
const a = await store.putBlob({
|
|
|
|
|
address: 'bob',
|
|
|
|
|
msgId: 'a'.repeat(64),
|
|
|
|
|
ciphertext: ct,
|
|
|
|
|
expiresAt: Date.now() + 60_000,
|
|
|
|
|
});
|
|
|
|
|
expect(a.created).toBe(true);
|
|
|
|
|
const b = await store.putBlob({
|
|
|
|
|
address: 'bob',
|
|
|
|
|
msgId: 'a'.repeat(64),
|
|
|
|
|
ciphertext: ct,
|
|
|
|
|
expiresAt: Date.now() + 60_000,
|
|
|
|
|
});
|
|
|
|
|
expect(b.created).toBe(false);
|
|
|
|
|
expect(b.receivedAt).toBe(a.receivedAt);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('fetchBlobs respects sinceCursor and expires_at', async () => {
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
await store.putBlob({
|
|
|
|
|
address: 'bob',
|
|
|
|
|
msgId: '1'.repeat(64),
|
|
|
|
|
ciphertext: randBytes(8),
|
|
|
|
|
expiresAt: now + 60_000,
|
|
|
|
|
});
|
|
|
|
|
await store.putBlob({
|
|
|
|
|
address: 'bob',
|
|
|
|
|
msgId: '2'.repeat(64),
|
|
|
|
|
ciphertext: randBytes(8),
|
|
|
|
|
expiresAt: now - 1000, // already expired
|
|
|
|
|
});
|
|
|
|
|
await store.putBlob({
|
|
|
|
|
address: 'bob',
|
|
|
|
|
msgId: '3'.repeat(64),
|
|
|
|
|
ciphertext: randBytes(8),
|
|
|
|
|
expiresAt: now + 60_000,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const all = await store.fetchBlobs({
|
|
|
|
|
address: 'bob',
|
|
|
|
|
sinceCursor: 0,
|
|
|
|
|
now,
|
|
|
|
|
limit: 100,
|
|
|
|
|
});
|
|
|
|
|
// Expired one filtered out.
|
|
|
|
|
expect(all.length).toBe(2);
|
|
|
|
|
expect(all.map((r) => r.msgId).sort()).toEqual(['1'.repeat(64), '3'.repeat(64)]);
|
|
|
|
|
|
|
|
|
|
// Cursor advances strictly.
|
|
|
|
|
const half = await store.fetchBlobs({
|
|
|
|
|
address: 'bob',
|
|
|
|
|
sinceCursor: all[0]!.receivedAt,
|
|
|
|
|
now,
|
|
|
|
|
limit: 100,
|
|
|
|
|
});
|
|
|
|
|
expect(half.length).toBe(1);
|
|
|
|
|
expect(half[0]!.msgId).toBe(all[1]!.msgId);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('purgeExpired removes only expired rows', async () => {
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
await store.putBlob({
|
|
|
|
|
address: 'bob',
|
|
|
|
|
msgId: '1'.repeat(64),
|
|
|
|
|
ciphertext: randBytes(8),
|
|
|
|
|
expiresAt: now - 1,
|
|
|
|
|
});
|
|
|
|
|
await store.putBlob({
|
|
|
|
|
address: 'bob',
|
|
|
|
|
msgId: '2'.repeat(64),
|
|
|
|
|
ciphertext: randBytes(8),
|
|
|
|
|
expiresAt: now + 60_000,
|
|
|
|
|
});
|
|
|
|
|
const removed = await store.purgeExpired(now);
|
|
|
|
|
expect(removed).toBe(1);
|
|
|
|
|
const remaining = await store.fetchBlobs({
|
|
|
|
|
address: 'bob',
|
|
|
|
|
sinceCursor: 0,
|
|
|
|
|
now,
|
|
|
|
|
limit: 10,
|
|
|
|
|
});
|
|
|
|
|
expect(remaining.length).toBe(1);
|
|
|
|
|
expect(remaining[0]!.msgId).toBe('2'.repeat(64));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('persists across reopen', async () => {
|
|
|
|
|
const key = randBytes(32);
|
|
|
|
|
await store.saveAddressOwner('bob', key);
|
|
|
|
|
const ct = randBytes(64);
|
|
|
|
|
await store.putBlob({
|
|
|
|
|
address: 'bob',
|
|
|
|
|
msgId: '5'.repeat(64),
|
|
|
|
|
ciphertext: ct,
|
|
|
|
|
expiresAt: Date.now() + 60_000,
|
|
|
|
|
});
|
|
|
|
|
store.close();
|
|
|
|
|
store = new SqliteInboxStore(dbPath);
|
|
|
|
|
const got = await store.getAddressOwner('bob');
|
|
|
|
|
expect(got).toEqual(key);
|
|
|
|
|
const blobs = await store.fetchBlobs({
|
|
|
|
|
address: 'bob',
|
|
|
|
|
sinceCursor: 0,
|
|
|
|
|
now: Date.now(),
|
|
|
|
|
limit: 10,
|
|
|
|
|
});
|
|
|
|
|
expect(blobs.length).toBe(1);
|
|
|
|
|
expect(blobs[0]!.ciphertext).toEqual(ct);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('deleteAddress drops owner + blobs', async () => {
|
|
|
|
|
const key = randBytes(32);
|
|
|
|
|
await store.saveAddressOwner('bob', key);
|
|
|
|
|
await store.putBlob({
|
|
|
|
|
address: 'bob',
|
|
|
|
|
msgId: '7'.repeat(64),
|
|
|
|
|
ciphertext: randBytes(8),
|
|
|
|
|
expiresAt: Date.now() + 60_000,
|
|
|
|
|
});
|
|
|
|
|
await store.deleteAddress('bob');
|
|
|
|
|
expect(await store.getAddressOwner('bob')).toBeNull();
|
|
|
|
|
const blobs = await store.fetchBlobs({
|
|
|
|
|
address: 'bob',
|
|
|
|
|
sinceCursor: 0,
|
|
|
|
|
now: Date.now(),
|
|
|
|
|
limit: 10,
|
|
|
|
|
});
|
|
|
|
|
expect(blobs.length).toBe(0);
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-08 00:11:59 +02:00
|
|
|
test('senderFp round-trips through put + fetch (V4.8)', async () => {
|
|
|
|
|
const ct = randBytes(40);
|
|
|
|
|
const fp = '0123456789abcdef';
|
|
|
|
|
await store.putBlob({
|
|
|
|
|
address: 'bob',
|
|
|
|
|
msgId: 'a'.repeat(64),
|
|
|
|
|
ciphertext: ct,
|
|
|
|
|
expiresAt: Date.now() + 60_000,
|
|
|
|
|
senderFp: fp,
|
|
|
|
|
});
|
|
|
|
|
const blobs = await store.fetchBlobs({
|
|
|
|
|
address: 'bob',
|
|
|
|
|
sinceCursor: 0,
|
|
|
|
|
now: Date.now(),
|
|
|
|
|
limit: 10,
|
|
|
|
|
});
|
|
|
|
|
expect(blobs.length).toBe(1);
|
|
|
|
|
expect(blobs[0]!.senderFp).toBe(fp);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('senderFp omitted on put → fetched row has senderFp undefined (V4.8 backward-compat)', async () => {
|
|
|
|
|
const ct = randBytes(40);
|
|
|
|
|
await store.putBlob({
|
|
|
|
|
address: 'bob',
|
|
|
|
|
msgId: 'b'.repeat(64),
|
|
|
|
|
ciphertext: ct,
|
|
|
|
|
expiresAt: Date.now() + 60_000,
|
|
|
|
|
});
|
|
|
|
|
const blobs = await store.fetchBlobs({
|
|
|
|
|
address: 'bob',
|
|
|
|
|
sinceCursor: 0,
|
|
|
|
|
now: Date.now(),
|
|
|
|
|
limit: 10,
|
|
|
|
|
});
|
|
|
|
|
expect(blobs.length).toBe(1);
|
|
|
|
|
expect(blobs[0]!.senderFp).toBeUndefined();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('ALTER TABLE migration adds sender_fp to a pre-4.8 schema (V4.8)', async () => {
|
|
|
|
|
// Reproduce a pre-4.8 schema in a fresh DB, then reopen via
|
|
|
|
|
// SqliteInboxStore which should run the idempotent ALTER without
|
|
|
|
|
// dropping the existing rows.
|
|
|
|
|
store.close();
|
|
|
|
|
try {
|
|
|
|
|
unlinkSync(dbPath);
|
|
|
|
|
} catch {}
|
|
|
|
|
try {
|
|
|
|
|
unlinkSync(dbPath + '-wal');
|
|
|
|
|
} catch {}
|
|
|
|
|
try {
|
|
|
|
|
unlinkSync(dbPath + '-shm');
|
|
|
|
|
} catch {}
|
|
|
|
|
|
|
|
|
|
const { Database } = await import('bun:sqlite');
|
|
|
|
|
const legacy = new Database(dbPath, { create: true });
|
|
|
|
|
legacy.exec(`
|
|
|
|
|
CREATE TABLE inbox_blobs (
|
|
|
|
|
address TEXT NOT NULL,
|
|
|
|
|
msg_id TEXT NOT NULL,
|
|
|
|
|
ciphertext TEXT NOT NULL,
|
|
|
|
|
received_at INTEGER NOT NULL,
|
|
|
|
|
expires_at INTEGER NOT NULL,
|
|
|
|
|
PRIMARY KEY (address, msg_id)
|
|
|
|
|
);
|
|
|
|
|
`);
|
|
|
|
|
legacy.prepare(
|
|
|
|
|
'INSERT INTO inbox_blobs (address, msg_id, ciphertext, received_at, expires_at) VALUES (?, ?, ?, ?, ?)',
|
|
|
|
|
).run('bob', 'c'.repeat(64), 'AAAA', Date.now(), Date.now() + 60_000);
|
|
|
|
|
legacy.close();
|
|
|
|
|
|
|
|
|
|
store = new SqliteInboxStore(dbPath);
|
|
|
|
|
const blobs = await store.fetchBlobs({
|
|
|
|
|
address: 'bob',
|
|
|
|
|
sinceCursor: 0,
|
|
|
|
|
now: Date.now(),
|
|
|
|
|
limit: 10,
|
|
|
|
|
});
|
|
|
|
|
expect(blobs.length).toBe(1);
|
|
|
|
|
expect(blobs[0]!.senderFp).toBeUndefined();
|
|
|
|
|
|
|
|
|
|
// New writes after migration carry senderFp.
|
|
|
|
|
await store.putBlob({
|
|
|
|
|
address: 'bob',
|
|
|
|
|
msgId: 'd'.repeat(64),
|
|
|
|
|
ciphertext: randBytes(8),
|
|
|
|
|
expiresAt: Date.now() + 60_000,
|
|
|
|
|
senderFp: 'feedfacedeadbeef',
|
|
|
|
|
});
|
|
|
|
|
const after = await store.fetchBlobs({
|
|
|
|
|
address: 'bob',
|
|
|
|
|
sinceCursor: 0,
|
|
|
|
|
now: Date.now(),
|
|
|
|
|
limit: 10,
|
|
|
|
|
});
|
|
|
|
|
const newer = after.find((b) => b.msgId === 'd'.repeat(64));
|
|
|
|
|
expect(newer?.senderFp).toBe('feedfacedeadbeef');
|
|
|
|
|
});
|
|
|
|
|
|
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
|
|
|
test('countBlobs ignores expired entries', async () => {
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
await store.putBlob({
|
|
|
|
|
address: 'bob',
|
|
|
|
|
msgId: '1'.repeat(64),
|
|
|
|
|
ciphertext: randBytes(8),
|
|
|
|
|
expiresAt: now - 1,
|
|
|
|
|
});
|
|
|
|
|
await store.putBlob({
|
|
|
|
|
address: 'bob',
|
|
|
|
|
msgId: '2'.repeat(64),
|
|
|
|
|
ciphertext: randBytes(8),
|
|
|
|
|
expiresAt: now + 60_000,
|
|
|
|
|
});
|
|
|
|
|
expect(await store.countBlobs('bob', now)).toBe(1);
|
|
|
|
|
});
|
|
|
|
|
});
|