feat(sdk): M-Magic 1-4 — high-level SDK with magic drop-in
Phase A complete: createShade() one-liner with auto-establish, auto-publish,
and auto-replenish.
M-Magic 1-4 rolled into @shade/sdk:
- createShade() factory with config validation and storage resolution
(memory | sqlite:... | { type: 'postgres', url: ... } | explicit instance)
- Shade class wraps crypto + storage + session manager + transport
- Auto-publish: initialize() automatically registers with the prekey server
- Auto-establish: send() transparently fetches bundles and creates sessions
on first message to a new peer
- Per-address mutex serializes concurrent sends to prevent ratchet corruption
- BackgroundTasks class for periodic replenishment + opt-in identity rotation
- rotate() rebuilds the transport with the new signing key so subsequent
signed operations work after rotation
- onMessage() handler API for incoming plaintext
API:
const shade = await createShade({ prekeyServer, storage });
await shade.send('bob', 'hello');
await shade.receive('alice', envelope);
shade.onMessage((from, msg) => ...);
await shade.rotate();
await shade.shutdown();
13 new SDK tests covering: happy path, auto-publish, two-process
conversation, onMessage handlers, concurrent sends, unknown peer,
fingerprint verification, shutdown, manual replenish, auto-replenish
off, rotate, and config validation.
233 tests passing, 0 failures.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:27:59 +02:00
|
|
|
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
|
|
|
import { createShade, type Shade } from '../src/index.js';
|
|
|
|
|
import {
|
|
|
|
|
createPrekeyServer,
|
|
|
|
|
MemoryPrekeyStore,
|
|
|
|
|
PrekeyServerEvents,
|
|
|
|
|
} from '@shade/server';
|
2026-05-05 17:58:45 +02:00
|
|
|
import { SubtleCryptoProvider, MemoryStorage } from '@shade/crypto-web';
|
feat(sdk): M-Magic 1-4 — high-level SDK with magic drop-in
Phase A complete: createShade() one-liner with auto-establish, auto-publish,
and auto-replenish.
M-Magic 1-4 rolled into @shade/sdk:
- createShade() factory with config validation and storage resolution
(memory | sqlite:... | { type: 'postgres', url: ... } | explicit instance)
- Shade class wraps crypto + storage + session manager + transport
- Auto-publish: initialize() automatically registers with the prekey server
- Auto-establish: send() transparently fetches bundles and creates sessions
on first message to a new peer
- Per-address mutex serializes concurrent sends to prevent ratchet corruption
- BackgroundTasks class for periodic replenishment + opt-in identity rotation
- rotate() rebuilds the transport with the new signing key so subsequent
signed operations work after rotation
- onMessage() handler API for incoming plaintext
API:
const shade = await createShade({ prekeyServer, storage });
await shade.send('bob', 'hello');
await shade.receive('alice', envelope);
shade.onMessage((from, msg) => ...);
await shade.rotate();
await shade.shutdown();
13 new SDK tests covering: happy path, auto-publish, two-process
conversation, onMessage handlers, concurrent sends, unknown peer,
fingerprint verification, shutdown, manual replenish, auto-replenish
off, rotate, and config validation.
233 tests passing, 0 failures.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:27:59 +02:00
|
|
|
|
|
|
|
|
const crypto = new SubtleCryptoProvider();
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Spin up a real prekey server on a random port and return its URL
|
|
|
|
|
* + a teardown function.
|
|
|
|
|
*/
|
|
|
|
|
async function startPrekeyServer(): Promise<{
|
|
|
|
|
url: string;
|
|
|
|
|
stop: () => void;
|
|
|
|
|
events: PrekeyServerEvents;
|
|
|
|
|
}> {
|
|
|
|
|
const events = new PrekeyServerEvents();
|
|
|
|
|
const server = createPrekeyServer({
|
|
|
|
|
crypto,
|
|
|
|
|
store: new MemoryPrekeyStore(),
|
|
|
|
|
disableRateLimit: true,
|
|
|
|
|
events,
|
|
|
|
|
});
|
|
|
|
|
const port = 19500 + Math.floor(Math.random() * 500);
|
|
|
|
|
const handle = Bun.serve({ port, fetch: server.fetch });
|
|
|
|
|
return {
|
|
|
|
|
url: `http://localhost:${port}`,
|
|
|
|
|
stop: () => handle.stop(),
|
|
|
|
|
events,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('createShade — happy path', () => {
|
|
|
|
|
let server: Awaited<ReturnType<typeof startPrekeyServer>>;
|
|
|
|
|
let alice: Shade;
|
|
|
|
|
let bob: Shade;
|
|
|
|
|
|
|
|
|
|
beforeEach(async () => {
|
|
|
|
|
server = await startPrekeyServer();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterEach(async () => {
|
|
|
|
|
await alice?.shutdown();
|
|
|
|
|
await bob?.shutdown();
|
|
|
|
|
server.stop();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('one-liner creation and initialization', async () => {
|
|
|
|
|
alice = await createShade({
|
|
|
|
|
prekeyServer: server.url,
|
|
|
|
|
address: 'alice',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(alice.myAddress).toBe('alice');
|
|
|
|
|
const fp = await alice.fingerprint;
|
|
|
|
|
expect(fp.split(' ').length).toBe(12);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('auto-publishes bundle on init', async () => {
|
|
|
|
|
const registered: string[] = [];
|
|
|
|
|
server.events.on((e) => {
|
|
|
|
|
if (e.name === 'server.identity_registered') {
|
|
|
|
|
registered.push(e.data.address);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
alice = await createShade({
|
|
|
|
|
prekeyServer: server.url,
|
|
|
|
|
address: 'alice',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(registered).toContain('alice');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('two-process conversation: Alice ↔ Bob via SDK only', async () => {
|
|
|
|
|
alice = await createShade({ prekeyServer: server.url, address: 'alice' });
|
|
|
|
|
bob = await createShade({ prekeyServer: server.url, address: 'bob' });
|
|
|
|
|
|
|
|
|
|
// Alice sends to Bob — SDK auto-establishes session
|
|
|
|
|
const env1 = await alice.send('bob', 'hello Bob');
|
|
|
|
|
const plain1 = await bob.receive('alice', env1);
|
|
|
|
|
expect(plain1).toBe('hello Bob');
|
|
|
|
|
|
|
|
|
|
// Bob replies (DH ratchet triggers)
|
|
|
|
|
const env2 = await bob.send('alice', 'hi Alice');
|
|
|
|
|
const plain2 = await alice.receive('bob', env2);
|
|
|
|
|
expect(plain2).toBe('hi Alice');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('onMessage handler fires on receive', async () => {
|
|
|
|
|
alice = await createShade({ prekeyServer: server.url, address: 'alice' });
|
|
|
|
|
bob = await createShade({ prekeyServer: server.url, address: 'bob' });
|
|
|
|
|
|
|
|
|
|
const received: Array<{ from: string; msg: string }> = [];
|
|
|
|
|
bob.onMessage((from, msg) => received.push({ from, msg }));
|
|
|
|
|
|
|
|
|
|
const env = await alice.send('bob', 'callback test');
|
|
|
|
|
await bob.receive('alice', env);
|
|
|
|
|
|
|
|
|
|
expect(received.length).toBe(1);
|
|
|
|
|
expect(received[0]!.from).toBe('alice');
|
|
|
|
|
expect(received[0]!.msg).toBe('callback test');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('concurrent sends to same new peer establish exactly one session', async () => {
|
|
|
|
|
alice = await createShade({ prekeyServer: server.url, address: 'alice' });
|
|
|
|
|
bob = await createShade({ prekeyServer: server.url, address: 'bob' });
|
|
|
|
|
|
|
|
|
|
// Fire 3 parallel sends to Bob
|
|
|
|
|
const results = await Promise.all([
|
|
|
|
|
alice.send('bob', 'msg1'),
|
|
|
|
|
alice.send('bob', 'msg2'),
|
|
|
|
|
alice.send('bob', 'msg3'),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// All 3 should succeed and be decryptable in order
|
|
|
|
|
expect(results.length).toBe(3);
|
|
|
|
|
const decrypted: string[] = [];
|
|
|
|
|
for (const env of results) {
|
|
|
|
|
decrypted.push(await bob.receive('alice', env));
|
|
|
|
|
}
|
|
|
|
|
expect(decrypted.sort()).toEqual(['msg1', 'msg2', 'msg3']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('send to unknown address throws clear error', async () => {
|
|
|
|
|
alice = await createShade({ prekeyServer: server.url, address: 'alice' });
|
|
|
|
|
await expect(alice.send('nobody', 'ghost')).rejects.toThrow();
|
|
|
|
|
});
|
|
|
|
|
|
release(v4.8.3): cross-channel msgId dedup + Shade.aliasSession
Two follow-ups to the V4.8.2 duplicate-fan-out fixes Prism filed.
1. `Inbox.acceptBridgeFrame(blob)` + shared 4096-entry msgId LRU.
The relay durably stores blobs and pushes them to every active
delivery channel; without a cross-channel ack the bridge frame
ran first and the next inbox-poll re-dispatched the same blob
~30 s later, tripping on consumed prekeys. Bridge consumers now
plumb pushed frames through `acceptBridgeFrame`, which shares
the dedup gate + ack path with `pollOnce`. Whichever channel
delivers first wins; the other acks-and-skips. Inbox records
the msgId before the ack so a parallel poll can't observe an
in-flight ack window.
2. `Shade.aliasSession(oldLabel, newLabel)`. First-contact forces
the receiver to label the new session by the relay's sender
fingerprint hint (`fp:<senderfp>`); the post-decrypt plaintext
typically announces the peer's real address. Aliasing moves
session, trusted identity, peer-verification, and identity-
version under the canonical label. Holds the per-peer mutex on
both labels (lexicographic order) so concurrent crypto ops can't
observe a half-moved state. Refuses to overwrite an existing
session at the new label.
Wire change: `IncomingMessage.expiresAt?` now surfaces the relay's
expiry so receivers can pass bridge frames straight to
`acceptBridgeFrame` without inventing a TTL.
Tests cover bridge-then-poll, poll-then-bridge, aliasSession happy
path, refuse-to-overwrite, and same-label no-op.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 15:49:36 +02:00
|
|
|
test('aliasSession migrates a session from fp:<hex> to a canonical address label (V4.8.3)', async () => {
|
|
|
|
|
// Reproduces the Prism FR `session-label-asymmetry-v4.8.2`. Bob
|
|
|
|
|
// initiates X3DH against Alice using Alice's prekey-server
|
|
|
|
|
// address. Alice receives the prekey envelope under the relay's
|
|
|
|
|
// sender-fingerprint hint (`fp:<bobfp>`), because that's the only
|
|
|
|
|
// sender label the bridge surfaces at first contact. The
|
|
|
|
|
// post-decrypt plaintext announces Bob's real address; Alice then
|
|
|
|
|
// canonicalizes the session by aliasing `fp:<bobfp>` → `bob` and
|
|
|
|
|
// every subsequent send/receive operates symmetrically.
|
|
|
|
|
alice = await createShade({ prekeyServer: server.url, address: 'alice' });
|
|
|
|
|
bob = await createShade({ prekeyServer: server.url, address: 'bob' });
|
|
|
|
|
|
|
|
|
|
// First contact — Bob sends, Alice receives under the fp-label.
|
|
|
|
|
const env1 = await bob.send('alice', 'hello, my address is bob');
|
|
|
|
|
const fpLabel = 'fp:bobfingerprint16';
|
|
|
|
|
expect(await alice.receive(fpLabel, env1)).toBe('hello, my address is bob');
|
|
|
|
|
|
|
|
|
|
// Alice canonicalizes: move the session from the fp-label to bob's
|
|
|
|
|
// real address.
|
|
|
|
|
await alice.aliasSession(fpLabel, 'bob');
|
|
|
|
|
|
|
|
|
|
// Subsequent ratchet messages flow under the canonical label both
|
|
|
|
|
// directions. Bob's session for Alice is keyed under `alice`
|
|
|
|
|
// (Bob's send target); Alice's session for Bob is now keyed under
|
|
|
|
|
// `bob` (post-alias). Symmetry restored.
|
|
|
|
|
const env2 = await bob.send('alice', 'reply 1');
|
|
|
|
|
expect(await alice.receive('bob', env2)).toBe('reply 1');
|
|
|
|
|
|
|
|
|
|
const env3 = await alice.send('bob', 'reply 2');
|
|
|
|
|
expect(await bob.receive('alice', env3)).toBe('reply 2');
|
|
|
|
|
|
|
|
|
|
// The old fp-label has no session — receive under it would now
|
|
|
|
|
// fail. (We don't assert the error shape, only that the label is
|
|
|
|
|
// gone.)
|
|
|
|
|
await expect(alice.receive(fpLabel, env3)).rejects.toThrow();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('aliasSession refuses to overwrite an existing session', async () => {
|
|
|
|
|
alice = await createShade({ prekeyServer: server.url, address: 'alice' });
|
|
|
|
|
bob = await createShade({ prekeyServer: server.url, address: 'bob' });
|
|
|
|
|
const carol = await createShade({ prekeyServer: server.url, address: 'carol' });
|
|
|
|
|
try {
|
|
|
|
|
// Two distinct first-contact prekey envelopes — one from Bob,
|
|
|
|
|
// one from Carol — let Alice end up with two real sessions in
|
|
|
|
|
// storage at two different labels.
|
|
|
|
|
const env1 = await bob.send('alice', 'one');
|
|
|
|
|
await alice.receive('fp:bobfp', env1);
|
|
|
|
|
const env2 = await carol.send('alice', 'two');
|
|
|
|
|
await alice.receive('fp:carolfp', env2);
|
|
|
|
|
|
|
|
|
|
await expect(alice.aliasSession('fp:carolfp', 'fp:bobfp')).rejects.toThrow(
|
|
|
|
|
/refusing to overwrite/i,
|
|
|
|
|
);
|
|
|
|
|
} finally {
|
|
|
|
|
await carol.shutdown();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('aliasSession is a no-op when oldLabel === newLabel', async () => {
|
|
|
|
|
alice = await createShade({ prekeyServer: server.url, address: 'alice' });
|
|
|
|
|
bob = await createShade({ prekeyServer: server.url, address: 'bob' });
|
|
|
|
|
const env = await bob.send('alice', 'hi');
|
|
|
|
|
await alice.receive('fp:bobfp', env);
|
|
|
|
|
// Same-label alias is a no-op; session must still decrypt the next message.
|
|
|
|
|
await alice.aliasSession('fp:bobfp', 'fp:bobfp');
|
|
|
|
|
const env2 = await bob.send('alice', 'hi again');
|
|
|
|
|
expect(await alice.receive('fp:bobfp', env2)).toBe('hi again');
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-08 12:13:46 +02:00
|
|
|
test('concurrent receive(from, env) for same `from` does not race the ratchet (V4.8.2)', async () => {
|
|
|
|
|
// Reproduces the Prism FR scenario: a single PUT is fanned out
|
|
|
|
|
// multiple times by the relay (or any duplicating transport), the
|
|
|
|
|
// receiver dispatches several `shade.receive(from, env)` in
|
|
|
|
|
// parallel, and the underlying SessionManager + StorageProvider
|
|
|
|
|
// would race on the ratchet (and on storage writes — sqlite throws
|
|
|
|
|
// "database is locked", IDB throws transaction conflicts) without
|
|
|
|
|
// per-`from` serialization. We pre-establish a session, then fire
|
|
|
|
|
// the same envelope at `bob.receive` from many concurrent callers
|
|
|
|
|
// and verify all of them either decrypt to the same plaintext or
|
|
|
|
|
// surface a benign "already-consumed" error. Crucially: no
|
|
|
|
|
// unhandled storage races, no ratchet corruption, and the next
|
|
|
|
|
// legitimate message still decrypts.
|
|
|
|
|
alice = await createShade({ prekeyServer: server.url, address: 'alice' });
|
|
|
|
|
bob = await createShade({ prekeyServer: server.url, address: 'bob' });
|
|
|
|
|
|
|
|
|
|
const env1 = await alice.send('bob', 'first');
|
|
|
|
|
expect(await bob.receive('alice', env1)).toBe('first');
|
|
|
|
|
|
|
|
|
|
const env2 = await alice.send('bob', 'second');
|
|
|
|
|
// Fan the same envelope out to 8 concurrent receives — exactly the
|
|
|
|
|
// shape of the relay duplicate fan-out described in the FR.
|
|
|
|
|
const dispatches = await Promise.allSettled(
|
|
|
|
|
Array.from({ length: 8 }, () => bob.receive('alice', env2)),
|
|
|
|
|
);
|
|
|
|
|
// At least one must have succeeded with the right plaintext; the
|
|
|
|
|
// others may legitimately reject (replay protection / OTPK
|
|
|
|
|
// already-consumed) but MUST NOT corrupt the ratchet or throw
|
|
|
|
|
// "database is locked".
|
|
|
|
|
const fulfilled = dispatches.filter((d) => d.status === 'fulfilled') as Array<
|
|
|
|
|
PromiseFulfilledResult<string>
|
|
|
|
|
>;
|
|
|
|
|
expect(fulfilled.length).toBeGreaterThan(0);
|
|
|
|
|
expect(fulfilled[0]!.value).toBe('second');
|
|
|
|
|
|
|
|
|
|
for (const d of dispatches) {
|
|
|
|
|
if (d.status === 'rejected') {
|
|
|
|
|
const msg = String((d.reason as Error)?.message ?? d.reason);
|
|
|
|
|
expect(msg).not.toMatch(/database is locked/i);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ratchet must still advance — the next legitimate message decrypts.
|
|
|
|
|
const env3 = await alice.send('bob', 'third');
|
|
|
|
|
expect(await bob.receive('alice', env3)).toBe('third');
|
|
|
|
|
});
|
|
|
|
|
|
feat(sdk): M-Magic 1-4 — high-level SDK with magic drop-in
Phase A complete: createShade() one-liner with auto-establish, auto-publish,
and auto-replenish.
M-Magic 1-4 rolled into @shade/sdk:
- createShade() factory with config validation and storage resolution
(memory | sqlite:... | { type: 'postgres', url: ... } | explicit instance)
- Shade class wraps crypto + storage + session manager + transport
- Auto-publish: initialize() automatically registers with the prekey server
- Auto-establish: send() transparently fetches bundles and creates sessions
on first message to a new peer
- Per-address mutex serializes concurrent sends to prevent ratchet corruption
- BackgroundTasks class for periodic replenishment + opt-in identity rotation
- rotate() rebuilds the transport with the new signing key so subsequent
signed operations work after rotation
- onMessage() handler API for incoming plaintext
API:
const shade = await createShade({ prekeyServer, storage });
await shade.send('bob', 'hello');
await shade.receive('alice', envelope);
shade.onMessage((from, msg) => ...);
await shade.rotate();
await shade.shutdown();
13 new SDK tests covering: happy path, auto-publish, two-process
conversation, onMessage handlers, concurrent sends, unknown peer,
fingerprint verification, shutdown, manual replenish, auto-replenish
off, rotate, and config validation.
233 tests passing, 0 failures.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:27:59 +02:00
|
|
|
test('verify fingerprint matches pinned identity', async () => {
|
|
|
|
|
alice = await createShade({ prekeyServer: server.url, address: 'alice' });
|
|
|
|
|
bob = await createShade({ prekeyServer: server.url, address: 'bob' });
|
|
|
|
|
|
|
|
|
|
// Establish session
|
|
|
|
|
const env = await alice.send('bob', 'init');
|
|
|
|
|
await bob.receive('alice', env);
|
|
|
|
|
|
|
|
|
|
const bobFp = await bob.fingerprint;
|
|
|
|
|
// Alice knows Bob via session; but remote fingerprint is derived from
|
|
|
|
|
// stored DH key only (not full identity), so we just check it's returned
|
|
|
|
|
const remoteFp = await alice.getFingerprintFor('bob');
|
|
|
|
|
expect(remoteFp.split(' ').length).toBe(12);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('shutdown clears background timers and closes storage', async () => {
|
|
|
|
|
alice = await createShade({
|
|
|
|
|
prekeyServer: server.url,
|
|
|
|
|
address: 'alice',
|
|
|
|
|
autoReplenish: { min: 5, target: 20, intervalMs: 100 },
|
|
|
|
|
});
|
|
|
|
|
await alice.shutdown();
|
|
|
|
|
// If background timer wasn't cleared, this test would hang after the
|
|
|
|
|
// final afterEach. We rely on bun test's cleanup to catch that.
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('manual replenish is callable', async () => {
|
|
|
|
|
alice = await createShade({ prekeyServer: server.url, address: 'alice' });
|
|
|
|
|
// Initially has 20 prekeys, so replenish is a no-op
|
|
|
|
|
const n = await alice.replenish();
|
|
|
|
|
expect(n).toBe(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('auto-replenish is disabled when set to false', async () => {
|
|
|
|
|
alice = await createShade({
|
|
|
|
|
prekeyServer: server.url,
|
|
|
|
|
address: 'alice',
|
|
|
|
|
autoReplenish: false,
|
|
|
|
|
});
|
|
|
|
|
expect(alice.myAddress).toBe('alice');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('rotate regenerates identity', async () => {
|
|
|
|
|
alice = await createShade({ prekeyServer: server.url, address: 'alice' });
|
|
|
|
|
const oldFp = await alice.fingerprint;
|
|
|
|
|
|
|
|
|
|
await alice.rotate();
|
|
|
|
|
|
|
|
|
|
const newFp = await alice.fingerprint;
|
|
|
|
|
expect(newFp).not.toBe(oldFp);
|
|
|
|
|
});
|
2026-05-05 17:58:45 +02:00
|
|
|
|
|
|
|
|
test('identityPublicKey exposes the device Ed25519 key and tracks rotation', async () => {
|
|
|
|
|
const storage = new MemoryStorage();
|
|
|
|
|
alice = await createShade({ prekeyServer: server.url, address: 'alice', storage });
|
|
|
|
|
|
|
|
|
|
const pk = await alice.identityPublicKey;
|
|
|
|
|
expect(pk).toBeInstanceOf(Uint8Array);
|
|
|
|
|
expect(pk.length).toBe(32);
|
|
|
|
|
|
|
|
|
|
// Matches what the underlying storage holds
|
|
|
|
|
const stored = await storage.getIdentityKeyPair();
|
|
|
|
|
expect(stored).not.toBeNull();
|
|
|
|
|
expect(pk).toEqual(stored!.signingPublicKey);
|
|
|
|
|
|
|
|
|
|
// Reflects the new key after rotate (acceptance criteria #3)
|
|
|
|
|
await alice.rotate();
|
|
|
|
|
const pkAfter = await alice.identityPublicKey;
|
|
|
|
|
expect(pkAfter.length).toBe(32);
|
|
|
|
|
expect(pkAfter).not.toEqual(pk);
|
|
|
|
|
});
|
feat(sdk): M-Magic 1-4 — high-level SDK with magic drop-in
Phase A complete: createShade() one-liner with auto-establish, auto-publish,
and auto-replenish.
M-Magic 1-4 rolled into @shade/sdk:
- createShade() factory with config validation and storage resolution
(memory | sqlite:... | { type: 'postgres', url: ... } | explicit instance)
- Shade class wraps crypto + storage + session manager + transport
- Auto-publish: initialize() automatically registers with the prekey server
- Auto-establish: send() transparently fetches bundles and creates sessions
on first message to a new peer
- Per-address mutex serializes concurrent sends to prevent ratchet corruption
- BackgroundTasks class for periodic replenishment + opt-in identity rotation
- rotate() rebuilds the transport with the new signing key so subsequent
signed operations work after rotation
- onMessage() handler API for incoming plaintext
API:
const shade = await createShade({ prekeyServer, storage });
await shade.send('bob', 'hello');
await shade.receive('alice', envelope);
shade.onMessage((from, msg) => ...);
await shade.rotate();
await shade.shutdown();
13 new SDK tests covering: happy path, auto-publish, two-process
conversation, onMessage handlers, concurrent sends, unknown peer,
fingerprint verification, shutdown, manual replenish, auto-replenish
off, rotate, and config validation.
233 tests passing, 0 failures.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:27:59 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('createShade — validation', () => {
|
|
|
|
|
test('throws when prekeyServer is missing', async () => {
|
|
|
|
|
await expect(createShade({} as any)).rejects.toThrow(/prekeyServer is required/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('throws when observer token is too short', async () => {
|
|
|
|
|
await expect(
|
|
|
|
|
createShade({
|
|
|
|
|
prekeyServer: 'http://localhost:9999',
|
|
|
|
|
observer: { token: 'short' },
|
|
|
|
|
}),
|
|
|
|
|
).rejects.toThrow(/at least 16/);
|
|
|
|
|
});
|
|
|
|
|
});
|