5 Commits

Author SHA1 Message Date
96c20cb4b2 fix(session): remember where aliasSession moved a session
aliasSession knew that two labels name the same peer, then threw that
knowledge away. The binding lived only in the caller's memory, so a
restart lost it — and the peer could not repair it from its side.

First contact forces the receiver to label a session by the only sender
hint a relay surfaces, an 8-byte signing-key fingerprint (`fp:<hex>`).
Once the peer announces its canonical address, aliasSession moves the
session there. But the peer keeps sending under `fp:<hex>`, because its
transport derives the same label from the same hint every time. After a
restart the session sat under the canonical address, inbound frames
resolved to `fp:<hex>`, and nothing matched. The peer held a valid
session so it never re-ran X3DH: the failure was permanent, and only a
manual re-link cleared it.

Observed in Prism as `No session for address: fp:579c3b335d66e2c0` on
every receive for three days, with a phone whose every RPC timed out.

StorageProvider gains saveSessionAlias / getSessionAlias /
removeSessionAliasesFor, optional so third-party implementations keep
compiling, and implemented across all seven backends. Lookups resolve
through resolveLabel(), which runs BEFORE the peer mutex — locking the
alias while mutating the canonical session would let an aliased and a
canonical caller ratchet the same state concurrently.

A live session under a label always wins over an alias, and prekey
envelopes never resolve: both keep a re-link establishing a fresh
session instead of being redirected into the stale one. Aliases are
dropped in resetSession and acceptIdentityChange, and memoized so the
hot path costs no extra read.

The sdk.test.ts case that asserted a dead fp-label encoded the old
behaviour; it now pins the new contract.

Verified: 1166 tests pass (from 1160). With alias persistence disabled
as a negative control, 5 of the 6 new tests fail, including both
restart cases.

Also drops `baseUrl` from the consumer-strict tsconfig — removed in
TS 6.0, and it was failing the typecheck that gates publishing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 19:36:31 +02:00
b44acf867b release(files): prepare 4.11.2 hotfix
Some checks failed
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Test / test (push) Has been cancelled
2026-07-10 17:41:25 +02:00
8c67a00f37 fix(files): await pull-mode stream readiness 2026-07-10 17:38:35 +02:00
306bf08452 chore: adopter Scaffold plan-kontrakt
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 19:04:48 +02:00
3243647aa1 release(v4.11.1): ship pre-built dist/ to npm registry
Some checks failed
Test / test (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled
publish-all.ts now does a tsc → dist/ build per package before pack, then
rewrites package.json's main/types/exports to point at the built artefacts
and ensures `files: ["dist"]` so the tarball ships only the built code.
The in-repo package.json is restored in the finally block so dev/typecheck
keep working without a build pass.

Why: strict-mode consumers (Cyndr) were forced to recompile Shade source
under their own tsconfig and tripped on internal `process.env.X` accesses
and implicit-any parameters. Shipping pre-built `.js` + `.d.ts` makes the
strictness contract live entirely inside Shade.
2026-05-21 13:29:52 +02:00
49 changed files with 789 additions and 53 deletions

13
.scaffold/log.md Normal file
View File

@@ -0,0 +1,13 @@
# Logg — Shade
## 2026-07-09 — Adoptert Scaffold plan-kontrakt
Migrerte `docs/ROADMAP.md` og `android/shade-android/ROADMAP-ANDROID.md` til `.scaffold/`: nåbildet destillert inn i `plan.md`, originalene arkivert under `.scaffold/archive/` med opprinnelig mappestruktur. Versjonsdesigndokumentene (`docs/V5.0.md`, `docs/archive/V*.md`) er bevisst latt stå — de er spesifikasjoner, ikke planfiler.
## 2026-05-15 — v4.11: streaming Double-Ratchet sub-sessions
Siste post-GA-økning på 4.x-linjen: `ShadeStream` gir in-memory sub-sessions (seal/open uten keystore-I/O per frame) med nye wire-typer 0x31–0x33. Låst beslutning: stream-ratchets persisteres aldri — en droppet stream gjenåpnes, aldri gjenopptas.
## 2026-05-09 — Android M-Cross 1–4 fullført + Keystore-adapter
All kryptografisk paritet TS↔Kotlin grønn via delte test-vektorer: KDF-kjede, HKDF-labels, X3DH, ratchet-steg, fingerprint, wire 0x02 og 0x11 (streams), backup-HKDF, group sender-keys og storage-HKDF. Etterslepet fra Android-roadmapen landet samme dag: scrypt + argon2id via Bouncy Castle og `shade-android-keystore`-modulen (hardware-backed master key + `KeystoreStorage`). Gjenstår: socket-interop-test og instrumenterte Keystore-tester.
## 2026-05-03 — V4.0 GA: V3.x-konsolidering og audit-pakking
Alle fasene V3.1–V3.12 ferdige og merget: dokumentasjon/hardening, at-rest storage-kryptering, trust-UX, observability (OTel), Android-paritet, inbox (store-and-forward), transport-bridge, web workers-krypto, filmetadata, social key recovery, WebRTC P2P og key transparency. Wire-formatet låst — uendret fra 0.4.x, så 4.0-peers interopererer byte-for-byte med 0.4.x. Kjernen pakket for ekstern review. Låst beslutning: alt VOIP/video skilt ut til V5.0, bygget oppå den frosne 4.0-stacken via reserverte envelope-typer (ikke breaking).

27
.scaffold/plan.md Normal file
View File

@@ -0,0 +1,27 @@
---
project: "Shade"
phase: "Post-4.x GA — V5.0 (Voice & Video) i idéfase"
updated: 2026-07-09
focus: "4.x-kjernen er GA-frosset og revisjonsklar; neste løft er V5.0 sanntid oppå den låste stacken"
blockers: []
---
# Plan — Shade
## Mål
- E2EE-bibliotek som implementerer Signal-protokollen (X3DH + Double Ratchet) for TypeScript/Bun — drop-in for frontend, backend og mobil, med forward secrecy og post-compromise recovery.
- Byte-for-byte kryssplattform-paritet: Kotlin/Android-porten verifiseres kontinuerlig mot TS-referansen via delte test-vektorer i `test-vectors/`.
- GA-frosset, revisjonsklar 4.0-kjerne som fundament; nye kapabiliteter (sanntid i V5.0) bygges oppå uten å røre kjernekrypto-revisjonen.
## Nå
- [ ] V5.0 fra `Idea` til `Design`: designnotatet `docs/V5.0.md` (SFrame-style frame encryption, `@shade/voice` / `@shade/video` / `@shade/broadcast`) må modnes og godkjennes
- [ ] Ende-til-ende interop-test TS-server ↔ Kotlin-klient over ekte socket (anbefalt før «production»-label på Android)
- [ ] Instrumenterte tester for `KeystoreStorage` (krever Android-emulator/enhet — utsatt i v4.9/v4.10-porten)
## Neste
- V5.0-implementasjon: `@shade/voice` (1:1 voice) → `@shade/video` → `@shade/broadcast` (1:N med relay-helper)
- «Production»-label på Android når interop-testen er grønn
## Senere
- Re-pinne backup-vektoren når begge porter bytter til argon2id som passord-KDF (HKDF-placeholder flagget i threat-model; `deriveMasterKeyArgon2id` finnes allerede i Kotlin)
- Versjonsdesigndokumentene bor fortsatt i `docs/V5.0.md` og `docs/archive/V*.md` — de er spesifikasjoner, ikke planfiler

17
.scaffold/tasks.yaml Normal file
View File

@@ -0,0 +1,17 @@
# Scaffold kanban-kort. Skjema per kort:
# - id: kort-slug # unik i fila
# title: ""
# description: ""
# status: todo # todo | doing | done | blocked
# column: todo # speiler status (kanban-kolonne)
# priority: medium # low | medium | high
# assignee: human # cursor | claude | human | any
# context: "" # valgfri: hvorfor/hvor i koden
# acceptance: "" # valgfri: hva «ferdig» betyr
# todos:
# - text: "Underoppgave"
# done: false
# created: 2026-07-09
# updated: 2026-07-09
# completed_at: null
tasks: []

View File

@@ -5,6 +5,20 @@ All notable changes to Shade are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [4.11.2] — 2026-07-10 — Files pull-mode startup hotfix
**`@shade/files`**
- Fix a construction race in HTTP pull-mode clients: an immediate streamed
read/write now awaits asynchronous stream-bridge registration instead of
treating the not-yet-created queue drainer as proof that the client is
inline-only.
- Add a deterministic integration test that holds incoming-transfer
registration, starts a 512 KiB write immediately, and verifies that it
remains pending until streaming is ready.
- Add `publish-all.ts --only <pkg>` so isolated package hotfixes do not publish
unrelated workspace packages.
## [4.11.0] — 2026-05-15 — Streaming Double-Ratchet sub-sessions ## [4.11.0] — 2026-05-15 — Streaming Double-Ratchet sub-sessions
Answers Vyvern FR `shade-ws-streaming-ratchet.md` (the last Phase-2 Answers Vyvern FR `shade-ws-streaming-ratchet.md` (the last Phase-2

4
CLAUDE.md Normal file
View File

@@ -0,0 +1,4 @@
## Planlegging
Planen for dette prosjektet bor i `.scaffold/`: `plan.md` er nåbildet (hold den kort,
bump `updated` i frontmatter ved endring), `log.md` er append-only historikk (nyeste øverst),
`tasks.yaml` er kanban. Ikke opprett planfiler andre steder.

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/core", "name": "@shade/core",
"version": "4.11.0", "version": "4.12.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -86,6 +86,14 @@ export class ShadeSessionManager {
* fully concurrent. * fully concurrent.
*/ */
private readonly peerOpChains = new Map<string, Promise<unknown>>(); private readonly peerOpChains = new Map<string, Promise<unknown>>();
/**
* Memoized `alias → canonical` lookups; `null` records "no alias" so a
* label without one costs a single storage read for the life of the
* process instead of one per encrypt/decrypt. Aliases change only via
* the mutators in this class, each of which clears the whole map — it
* holds at most one entry per peer, so rebuilding is cheap.
*/
private readonly aliasCache = new Map<string, string | null>();
constructor( constructor(
private readonly crypto: CryptoProvider, private readonly crypto: CryptoProvider,
@@ -151,6 +159,46 @@ export class ShadeSessionManager {
} }
} }
/**
* Map a session label onto the label its state actually lives under.
*
* `aliasSession` moves a session from a first-contact label (typically
* `fp:<hex>`) to the peer's canonical address, but the peer keeps
* sending under the old one. The persisted alias lets us follow that
* move across restarts — see the alias block in `StorageProvider`.
*
* A live session under `label` always wins: after a re-link the peer
* re-runs X3DH and a fresh session is established under the
* first-contact label again, and that new session — not the stale
* alias target — is the one that can decrypt what follows.
*
* Resolves one hop only. Aliases are always written pointing at a
* canonical label, so a chain would mean corrupt state; following it
* would risk a loop for no legitimate gain.
*
* MUST be called before taking the peer mutex: locking the alias while
* mutating the canonical session would let an aliased caller and a
* canonical caller ratchet the same state concurrently.
*/
private async resolveLabel(label: string): Promise<string> {
let canonical = this.aliasCache.get(label);
if (canonical === undefined) {
canonical = (await this.storage.getSessionAlias?.(label)) ?? null;
this.aliasCache.set(label, canonical);
}
if (canonical === null || canonical === label) return label;
if (await this.storage.getSession(label)) return label;
return canonical;
}
/**
* Public label resolution for callers that need to know where a
* peer's state lives (e.g. a transport routing an inbound frame).
*/
async resolveSessionLabel(label: string): Promise<string> {
return this.resolveLabel(label);
}
/** Get the event emitter (if observability is enabled) */ /** Get the event emitter (if observability is enabled) */
getEvents(): ShadeEventEmitter | undefined { getEvents(): ShadeEventEmitter | undefined {
return this.events; return this.events;
@@ -268,6 +316,11 @@ export class ShadeSessionManager {
*/ */
async resetSession(address: string): Promise<void> { async resetSession(address: string): Promise<void> {
await this.storage.removeSession(address); await this.storage.removeSession(address);
// Aliases pointing here are now dangling — drop them so the next
// first-contact frame resolves to its own label and establishes the
// fresh session this reset exists to force.
await this.storage.removeSessionAliasesFor?.(address);
this.aliasCache.clear();
this.events?.emit('session.removed', { address }); this.events?.emit('session.removed', { address });
// Note: we keep the trusted identity; new session will verify against it. // Note: we keep the trusted identity; new session will verify against it.
} }
@@ -336,6 +389,14 @@ export class ShadeSessionManager {
await this.storage.bumpPeerIdentityVersion(newLabel); await this.storage.bumpPeerIdentityVersion(newLabel);
} }
await this.storage.removeSession(oldLabel); await this.storage.removeSession(oldLabel);
// Remember the move durably. The peer goes on sending under
// `oldLabel` — its transport derives the same first-contact label
// from our relay hint every time — so without this record every
// inbound frame after a restart resolves to a label whose session
// we just removed, and the peer (holding a valid session, never
// re-running X3DH) can never recover on its own.
await this.storage.saveSessionAlias?.(oldLabel, newLabel);
this.aliasCache.clear();
this.events?.emit('session.aliased', { oldLabel, newLabel }); this.events?.emit('session.aliased', { oldLabel, newLabel });
} }
@@ -349,6 +410,10 @@ export class ShadeSessionManager {
// because isTrustedIdentity() compares not retrieves; we just emit the new hash) // because isTrustedIdentity() compares not retrieves; we just emit the new hash)
await this.storage.saveTrustedIdentity(address, newIdentityKey); await this.storage.saveTrustedIdentity(address, newIdentityKey);
await this.storage.removeSession(address); await this.storage.removeSession(address);
// The peer rotated identity — any alias into the old session is
// dangling and must not redirect frames meant for the new one.
await this.storage.removeSessionAliasesFor?.(address);
this.aliasCache.clear();
if (this.events) { if (this.events) {
const newHash = await shortHash(this.crypto, newIdentityKey); const newHash = await shortHash(this.crypto, newIdentityKey);
@@ -516,14 +581,19 @@ export class ShadeSessionManager {
* Subsequent messages are standard RatchetMessages. * Subsequent messages are standard RatchetMessages.
*/ */
async encrypt(address: string, plaintext: string): Promise<ShadeEnvelope> { async encrypt(address: string, plaintext: string): Promise<ShadeEnvelope> {
return this.withSpan('encrypt', address, async () => { // Follow a persisted alias so a caller still holding a first-contact
const session = await this.storage.getSession(address); // label reaches the session that alias moved to — without this, a
if (!session) throw new NoSessionError(address); // restarted host can decrypt a peer's frames but not reply to them.
// Resolved before the mutex so the lock lands on the canonical label.
const target = await this.resolveLabel(address);
return this.withSpan('encrypt', target, async () => {
const session = await this.storage.getSession(target);
if (!session) throw new NoSessionError(target);
const ratchetMsg = await ratchetEncrypt(this.crypto, session, enc.encode(plaintext)); const ratchetMsg = await ratchetEncrypt(this.crypto, session, enc.encode(plaintext));
this.events?.emit('message.encrypted', { this.events?.emit('message.encrypted', {
address, address: target,
counter: ratchetMsg.counter, counter: ratchetMsg.counter,
ciphertextSize: ratchetMsg.ciphertext.length, ciphertextSize: ratchetMsg.ciphertext.length,
}); });
@@ -532,7 +602,7 @@ export class ShadeSessionManager {
const x3dh = (session as any).__x3dh; const x3dh = (session as any).__x3dh;
if (x3dh) { if (x3dh) {
delete (session as any).__x3dh; delete (session as any).__x3dh;
await this.storage.saveSession(address, session); await this.storage.saveSession(target, session);
const preKeyMsg: PreKeyMessage = { const preKeyMsg: PreKeyMessage = {
registrationId: x3dh.registrationId, registrationId: x3dh.registrationId,
@@ -546,16 +616,16 @@ export class ShadeSessionManager {
type: 'prekey', type: 'prekey',
content: preKeyMsg, content: preKeyMsg,
timestamp: Date.now(), timestamp: Date.now(),
senderAddress: address, senderAddress: target,
}; };
} }
await this.storage.saveSession(address, session); await this.storage.saveSession(target, session);
return { return {
type: 'ratchet', type: 'ratchet',
content: ratchetMsg, content: ratchetMsg,
timestamp: Date.now(), timestamp: Date.now(),
senderAddress: address, senderAddress: target,
}; };
}); });
} }
@@ -564,11 +634,17 @@ export class ShadeSessionManager {
* Decrypt a message from a peer. Handles both PreKeyMessage and RatchetMessage. * Decrypt a message from a peer. Handles both PreKeyMessage and RatchetMessage.
*/ */
async decrypt(address: string, envelope: ShadeEnvelope): Promise<string> { async decrypt(address: string, envelope: ShadeEnvelope): Promise<string> {
return this.withSpan('decrypt', address, async () => { // A prekey envelope carries its own X3DH material and establishes a
// fresh session, which must land under the label it arrived on —
// that is exactly what a re-link looks like. Only ratchet envelopes,
// which need state that already exists, follow an alias.
const target =
envelope.type === 'prekey' ? address : await this.resolveLabel(address);
return this.withSpan('decrypt', target, async () => {
if (envelope.type === 'prekey') { if (envelope.type === 'prekey') {
return this.decryptPreKeyMessage(address, envelope.content as PreKeyMessage); return this.decryptPreKeyMessage(target, envelope.content as PreKeyMessage);
} }
return this.decryptRatchetMessage(address, envelope.content as RatchetMessage); return this.decryptRatchetMessage(target, envelope.content as RatchetMessage);
}); });
} }

View File

@@ -165,6 +165,36 @@ export interface StorageProvider {
/** Remove session for a peer */ /** Remove session for a peer */
removeSession(address: string): Promise<void>; removeSession(address: string): Promise<void>;
// ─── Session label aliases (V4.12) ────────────────────────
//
// First contact forces the receiver to label a session by the only
// sender hint the relay surfaces — an 8-byte signing-key fingerprint
// (`fp:<hex>`). A later in-band announcement reveals the peer's
// canonical address and `aliasSession` moves the session there.
//
// The peer, however, keeps sending under whatever label its own
// transport derives — which for a fingerprint-hinted relay is still
// `fp:<hex>`. Before V4.12 that binding lived only in the consumer's
// memory: after a restart the alias was gone, inbound ratchet frames
// resolved to `fp:<hex>`, found no session there, and failed forever
// (the peer holds a valid session so it never re-runs X3DH).
//
// Persisting the alias makes the binding survive restarts, so
// `getSession` can follow it. Optional so third-party storage
// implementations keep compiling — they simply lose alias recovery.
/**
* Record that `alias` names the same peer session as `canonical`.
* Idempotent upsert on `alias`.
*/
saveSessionAlias?(alias: string, canonical: string): Promise<void>;
/** Resolve an alias to its canonical label (null when unaliased). */
getSessionAlias?(alias: string): Promise<string | null>;
/** Drop every alias pointing at `canonical` (session teardown). */
removeSessionAliasesFor?(canonical: string): Promise<void>;
/** Check if we trust a remote identity key (for TOFU or pinned keys) */ /** Check if we trust a remote identity key (for TOFU or pinned keys) */
isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean>; isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean>;

View File

@@ -0,0 +1,165 @@
import { describe, test, expect, beforeEach } from 'bun:test';
import { SubtleCryptoProvider, MemoryStorage } from '@shade/crypto-web';
import { ShadeSessionManager } from '../src/index.js';
const crypto = new SubtleCryptoProvider();
/**
* Durable session-label aliases (V4.12).
*
* THE BUG THIS FILE EXISTS TO KILL — diagnosed live in Prism:
*
* A phone pairs with a host. First contact forces the host to label
* the session by the only sender hint the relay surfaces, an 8-byte
* signing-key fingerprint (`fp:<hex>`). The pair handshake then
* announces the phone's canonical address and the host calls
* `aliasSession(fp:<hex> → device:<addr>)`, which moved the session
* on disk and dropped the binding.
*
* The phone, however, keeps sending under `fp:<hex>` — its transport
* derives the same label from the same relay hint every time. While
* the host process lived, an in-memory map papered over the gap.
* After a restart that map was empty, every inbound ratchet frame
* resolved to `fp:<hex>`, found no session, and failed. The phone
* held a perfectly valid session so it never re-ran X3DH — meaning
* the failure was permanent and self-inflicted, not transient.
*
* Observed as `No session for address: fp:579c3b335d66e2c0` on every
* receive for three days, with the phone's RPCs timing out forever.
*
* The fix: `aliasSession` persists the binding, and session lookup
* follows it. These tests pin the restart behaviour specifically —
* a same-process test cannot fail the way production did.
*/
describe('session label aliases', () => {
let alice: ShadeSessionManager;
let bob: ShadeSessionManager;
let aliceStorage: MemoryStorage;
let bobStorage: MemoryStorage;
/** The first-contact label Alice is forced to use for Bob. */
const FP = 'fp:579c3b335d66e2c0';
beforeEach(async () => {
aliceStorage = new MemoryStorage();
bobStorage = new MemoryStorage();
alice = new ShadeSessionManager(crypto, aliceStorage);
bob = new ShadeSessionManager(crypto, bobStorage);
await alice.initialize();
await bob.initialize();
});
/**
* Bob initiates X3DH against Alice, exactly like a phone reaching a
* host it just scanned. Returns Bob's first (prekey) envelope.
*/
async function bobInitiates(target: ShadeSessionManager, initiator: ShadeSessionManager) {
const otpks = await target.generateOneTimePreKeys(10);
const bundle = await target.createPreKeyBundle();
const otpk = otpks[0]!;
bundle.oneTimePreKey = { keyId: otpk.keyId, publicKey: otpk.keyPair.publicKey };
await initiator.initSessionFromBundle('alice', bundle);
}
/** Simulate a host restart: fresh manager, same durable storage. */
async function restartAlice(): Promise<ShadeSessionManager> {
const revived = new ShadeSessionManager(crypto, aliceStorage);
await revived.initialize();
return revived;
}
test('an aliased session still decrypts under the old label after a restart', async () => {
await bobInitiates(alice, bob);
// First contact lands under the fingerprint label.
const env1 = await bob.encrypt('alice', 'hello, my address is bob');
expect(await alice.decrypt(FP, env1)).toBe('hello, my address is bob');
// Alice canonicalizes to Bob's announced address.
await alice.aliasSession(FP, 'bob');
// The host restarts. Storage survives; every in-memory map does not.
const alice2 = await restartAlice();
// Bob has a valid session and keeps sending under the same label he
// always has. Before the fix this threw NoSessionError forever.
const env2 = await bob.encrypt('alice', 'still here after restart');
expect(await alice2.decrypt(FP, env2)).toBe('still here after restart');
});
test('the host can reply under the old label after a restart', async () => {
await bobInitiates(alice, bob);
const env1 = await bob.encrypt('alice', 'hi');
await alice.decrypt(FP, env1);
await alice.aliasSession(FP, 'bob');
const alice2 = await restartAlice();
// Decrypting is only half of it — a host that cannot encrypt back
// leaves every RPC hanging just the same.
const reply = await alice2.encrypt(FP, 'reply from the host');
expect(await bob.decrypt('alice', reply)).toBe('reply from the host');
});
test('a live session under the label wins over an alias (re-link)', async () => {
await bobInitiates(alice, bob);
const env1 = await bob.encrypt('alice', 'first pairing');
await alice.decrypt(FP, env1);
await alice.aliasSession(FP, 'bob');
const alice2 = await restartAlice();
// Bob reinstalls: brand-new identity, same relay fingerprint label.
const bob2Storage = new MemoryStorage();
const bob2 = new ShadeSessionManager(crypto, bob2Storage);
await bob2.initialize();
await bobInitiates(alice2, bob2);
// The prekey envelope must establish a FRESH session under FP rather
// than being redirected into the stale aliased one.
const fresh1 = await bob2.encrypt('alice', 'fresh contact');
expect(await alice2.decrypt(FP, fresh1)).toBe('fresh contact');
// And subsequent ratchet frames must keep using that new session.
const fresh2 = await bob2.encrypt('alice', 'second message');
expect(await alice2.decrypt(FP, fresh2)).toBe('second message');
});
test('resolveSessionLabel reports where the state actually lives', async () => {
await bobInitiates(alice, bob);
const env1 = await bob.encrypt('alice', 'hi');
await alice.decrypt(FP, env1);
expect(await alice.resolveSessionLabel(FP)).toBe(FP);
await alice.aliasSession(FP, 'bob');
const alice2 = await restartAlice();
expect(await alice2.resolveSessionLabel(FP)).toBe('bob');
// An unaliased label resolves to itself.
expect(await alice2.resolveSessionLabel('carol')).toBe('carol');
});
test('resetSession drops aliases pointing at the cleared session', async () => {
await bobInitiates(alice, bob);
const env1 = await bob.encrypt('alice', 'hi');
await alice.decrypt(FP, env1);
await alice.aliasSession(FP, 'bob');
expect(await aliceStorage.getSessionAlias(FP)).toBe('bob');
await alice.resetSession('bob');
// A dangling alias would redirect the next first-contact frame into
// a session that no longer exists, defeating the reset.
expect(await aliceStorage.getSessionAlias(FP)).toBeNull();
expect(await alice.resolveSessionLabel(FP)).toBe(FP);
});
test('aliasing persists the binding to storage', async () => {
await bobInitiates(alice, bob);
const env1 = await bob.encrypt('alice', 'hi');
await alice.decrypt(FP, env1);
expect(await aliceStorage.getSessionAlias(FP)).toBeNull();
await alice.aliasSession(FP, 'bob');
expect(await aliceStorage.getSessionAlias(FP)).toBe('bob');
});
});

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/crypto-web", "name": "@shade/crypto-web",
"version": "4.11.0", "version": "4.12.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -15,6 +15,8 @@ export class MemoryStorage implements StorageProvider {
private signedPreKeys = new Map<number, SignedPreKey>(); private signedPreKeys = new Map<number, SignedPreKey>();
private oneTimePreKeys = new Map<number, OneTimePreKey>(); private oneTimePreKeys = new Map<number, OneTimePreKey>();
private sessions = new Map<string, SessionState>(); private sessions = new Map<string, SessionState>();
/** alias → canonical session label (V4.12). */
private sessionAliases = new Map<string, string>();
private trustedIdentities = new Map<string, Uint8Array>(); private trustedIdentities = new Map<string, Uint8Array>();
private retiredIdentities: RetiredIdentity[] = []; private retiredIdentities: RetiredIdentity[] = [];
@@ -82,6 +84,22 @@ export class MemoryStorage implements StorageProvider {
this.sessions.delete(address); this.sessions.delete(address);
} }
// ─── Session label aliases ────────────────────────────────
async getSessionAlias(alias: string): Promise<string | null> {
return this.sessionAliases.get(alias) ?? null;
}
async saveSessionAlias(alias: string, canonical: string): Promise<void> {
this.sessionAliases.set(alias, canonical);
}
async removeSessionAliasesFor(canonical: string): Promise<void> {
for (const [alias, target] of this.sessionAliases) {
if (target === canonical) this.sessionAliases.delete(alias);
}
}
// ─── Trust ──────────────────────────────────────────────── // ─── Trust ────────────────────────────────────────────────
async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean> { async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean> {

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/files", "name": "@shade/files",
"version": "4.11.0", "version": "4.12.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -444,8 +444,12 @@ export function createFilesHttpClient(
}; };
return out; return out;
} }
// Streamed read — only supported when the queue drainer is wired. // Streamed read — only supported when pull-mode was configured.
if (drainer === null) { // `drainer` is assigned asynchronously after the streams bridge has
// subscribed, so checking it here races client construction. The
// promise itself is the synchronous configuration signal; awaiting it
// also guarantees the drainer has been installed by its `.then()`.
if (streamsBridgePromise === null) {
throw new InternalFileError( throw new InternalFileError(
`http RPC client received a streamed read (size ${wire.size}) but is in inline-only mode. Pass { outboundQueueUrl, transferBaseUrl } when constructing the client to enable streamed reads.`, `http RPC client received a streamed read (size ${wire.size}) but is in inline-only mode. Pass { outboundQueueUrl, transferBaseUrl } when constructing the client to enable streamed reads.`,
); );
@@ -507,8 +511,11 @@ export function createFilesHttpClient(
); );
} }
// Streamed write — requires the queue drainer + streams-bridge. // Streamed write — requires pull-mode configuration. Do not inspect
if (drainer === null) { // `drainer` as a readiness flag: bridge registration is asynchronous,
// and an immediate large write must wait for it instead of being
// misclassified as an inline-only client.
if (streamsBridgePromise === null) {
throw new ConflictError( throw new ConflictError(
`http RPC client supports inline writes only (≤ ${INLINE_THRESHOLD} bytes). The supplied input was promoted to streams (size ${decision.size ?? 'unknown'}). Pass { outboundQueueUrl, transferBaseUrl } to enable streamed writes.`, `http RPC client supports inline writes only (≤ ${INLINE_THRESHOLD} bytes). The supplied input was promoted to streams (size ${decision.size ?? 'unknown'}). Pass { outboundQueueUrl, transferBaseUrl } to enable streamed writes.`,
); );

View File

@@ -7,8 +7,10 @@ import {
} from '@shade/server'; } from '@shade/server';
import { SubtleCryptoProvider } from '@shade/crypto-web'; import { SubtleCryptoProvider } from '@shade/crypto-web';
import { Hono } from 'hono'; import { Hono } from 'hono';
import { createFilesHttpClient, type FileEntry } from '../../src/index.js';
const crypto = new SubtleCryptoProvider(); const crypto = new SubtleCryptoProvider();
type ShadeInstance = Awaited<ReturnType<typeof createShade>>;
/** /**
* Stand up the full pull-mode rig: * Stand up the full pull-mode rig:
@@ -20,7 +22,8 @@ const crypto = new SubtleCryptoProvider();
* no inbound listener, streams supported via long-poll. * no inbound listener, streams supported via long-poll.
*/ */
async function setupPullRig(opts: { async function setupPullRig(opts: {
bobHandler: Parameters<NonNullable<Awaited<ReturnType<typeof createShade>>['files']>['serve']>[0]; bobHandler: Parameters<NonNullable<ShadeInstance['files']>['serve']>[0];
wrapClientShade?: (shade: ShadeInstance) => Parameters<typeof createFilesHttpClient>[0];
}) { }) {
const prekey = createPrekeyServer({ const prekey = createPrekeyServer({
crypto, crypto,
@@ -46,7 +49,7 @@ async function setupPullRig(opts: {
const bobServer = Bun.serve({ port: 0, fetch: app.fetch }); const bobServer = Bun.serve({ port: 0, fetch: app.fetch });
const baseUrl = `http://localhost:${bobServer.port}`; const baseUrl = `http://localhost:${bobServer.port}`;
const fs = alice.files.httpClient('bob', { const fs = createFilesHttpClient(opts.wrapClientShade?.(alice) ?? alice, 'bob', {
rpcUrl: `${baseUrl}/rpc`, rpcUrl: `${baseUrl}/rpc`,
outboundQueueUrl: `${baseUrl}/queue`, outboundQueueUrl: `${baseUrl}/queue`,
transferBaseUrl: baseUrl, transferBaseUrl: baseUrl,
@@ -70,6 +73,83 @@ async function setupPullRig(opts: {
} }
describe('@shade/files HTTP RPC — pull-mode streams', () => { describe('@shade/files HTTP RPC — pull-mode streams', () => {
test('immediate streamed write waits for asynchronous stream-bridge registration', async () => {
const payload = new Uint8Array(512 * 1024);
for (let i = 0; i < payload.length; i++) payload[i] = (i * 53) & 0xff;
let releaseRegistration!: () => void;
const registrationGate = new Promise<void>((resolve) => {
releaseRegistration = resolve;
});
let registrationStarted!: () => void;
const started = new Promise<void>((resolve) => {
registrationStarted = resolve;
});
const rig = await setupPullRig({
wrapClientShade: (alice) =>
new Proxy(alice, {
get(target, property) {
if (property === 'onIncomingTransfer') {
return async (handler: Parameters<typeof target.onIncomingTransfer>[0]) => {
registrationStarted();
await registrationGate;
return await target.onIncomingTransfer(handler);
};
}
const value = Reflect.get(target, property, target) as unknown;
return typeof value === 'function' ? value.bind(target) : value;
},
}),
bobHandler: {
write: async (ctx) => {
const content = ctx.args.content;
if (content.kind !== 'streams') throw new Error('expected streamed content');
const reader = content.stream.getReader();
let received = 0;
while (true) {
const { value, done } = await reader.read();
if (done) break;
received += value?.byteLength ?? 0;
}
reader.releaseLock();
await content.sha256;
const entry: FileEntry = {
name: 'immediate.bin',
kind: 'file',
size: received,
mtime: Date.now(),
metadata: {},
};
return { entry };
},
},
});
try {
let settled = false;
const write = rig.fs.write('/immediate.bin', payload);
void write.then(
() => {
settled = true;
},
() => {
settled = true;
},
);
await started;
await Bun.sleep(10);
expect(settled).toBe(false);
releaseRegistration();
const result = await write;
expect(result.entry.size).toBe(payload.byteLength);
} finally {
releaseRegistration();
await rig.teardown();
}
}, 15_000);
test('streamed read (4 MiB) via long-poll queue', async () => { test('streamed read (4 MiB) via long-poll queue', async () => {
const payload = new Uint8Array(4 * 1024 * 1024); const payload = new Uint8Array(4 * 1024 * 1024);
for (let i = 0; i < payload.length; i++) payload[i] = (i * 97) & 0xff; for (let i = 0; i < payload.length; i++) payload[i] = (i * 97) & 0xff;

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/inbox-server", "name": "@shade/inbox-server",
"version": "4.11.0", "version": "4.12.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/inbox", "name": "@shade/inbox",
"version": "4.11.0", "version": "4.12.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/key-transparency", "name": "@shade/key-transparency",
"version": "4.11.0", "version": "4.12.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/keychain", "name": "@shade/keychain",
"version": "4.11.0", "version": "4.12.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/observability", "name": "@shade/observability",
"version": "4.11.0", "version": "4.12.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/observer", "name": "@shade/observer",
"version": "4.11.0", "version": "4.12.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/proto", "name": "@shade/proto",
"version": "4.11.0", "version": "4.12.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/recovery", "name": "@shade/recovery",
"version": "4.11.0", "version": "4.12.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/sdk", "name": "@shade/sdk",
"version": "4.11.0", "version": "4.12.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -701,6 +701,22 @@ export class Shade {
this.decryptChains.delete(oldLabel); this.decryptChains.delete(oldLabel);
} }
/**
* Resolve a session label to the label its state actually lives under,
* following any alias left behind by `aliasSession`.
*
* Transports need this to route by the canonical address after a
* restart, when the only sender hint they hold is the first-contact
* `fp:<hex>` label. `encrypt`/`decrypt` resolve internally — this is
* for callers that must know the address itself.
*
* V4.12 — durable session-label aliases.
*/
async resolveSessionLabel(label: string): Promise<string> {
if (!this.initialized) throw new Error('Not initialized');
return this.manager.resolveSessionLabel(label);
}
/** /**
* Accept a peer's rotated identity. Bumps the per-peer identity-version * Accept a peer's rotated identity. Bumps the per-peer identity-version
* counter so any earlier verification automatically goes stale, then * counter so any earlier verification automatically goes stale, then

View File

@@ -162,10 +162,15 @@ describe('createShade — happy path', () => {
const env3 = await alice.send('bob', 'reply 2'); const env3 = await alice.send('bob', 'reply 2');
expect(await bob.receive('alice', env3)).toBe('reply 2'); expect(await bob.receive('alice', env3)).toBe('reply 2');
// The old fp-label has no session — receive under it would now // V4.12: the fp-label is no longer a dead end. `aliasSession` leaves
// fail. (We don't assert the error shape, only that the label is // a durable binding behind, so a peer that keeps sending under the
// gone.) // first-contact label — which is exactly what a fingerprint-hinted
await expect(alice.receive(fpLabel, env3)).rejects.toThrow(); // relay makes it do — still reaches the canonicalized session.
// See `shade-core/tests/session-aliases.test.ts` for the restart
// behaviour this binding exists to protect.
expect(await alice.resolveSessionLabel(fpLabel)).toBe('bob');
const env4 = await bob.send('alice', 'reply 3');
expect(await alice.receive(fpLabel, env4)).toBe('reply 3');
}); });
test('aliasSession refuses to overwrite an existing session', async () => { test('aliasSession refuses to overwrite an existing session', async () => {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/server", "name": "@shade/server",
"version": "4.11.0", "version": "4.12.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/storage-encrypted", "name": "@shade/storage-encrypted",
"version": "4.11.0", "version": "4.12.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -100,6 +100,10 @@ export class EncryptedIndexedDBStorage implements StorageProvider {
}); });
members.createIndex('byChannelId', 'channelId'); members.createIndex('byChannelId', 'channelId');
} }
if (oldVersion < 3) {
const aliases = db.createObjectStore('session_aliases_enc', { keyPath: 'alias' });
aliases.createIndex('byCanonical', 'canonical');
}
}, },
}); });
const store = new EncryptedIndexedDBStorage(db, opts.keyManager); const store = new EncryptedIndexedDBStorage(db, opts.keyManager);
@@ -212,6 +216,27 @@ export class EncryptedIndexedDBStorage implements StorageProvider {
await this.db.delete('sessions_enc', address); await this.db.delete('sessions_enc', address);
} }
// ─── Session label aliases ─────────────────────────────────
//
// Labels are already the clear-text keyPath of sessions_enc, so the
// mapping between two of them exposes nothing the store didn't hold.
async getSessionAlias(alias: string): Promise<string | null> {
const row = await this.db.get('session_aliases_enc', alias);
return row?.canonical ?? null;
}
async saveSessionAlias(alias: string, canonical: string): Promise<void> {
await this.db.put('session_aliases_enc', { alias, canonical });
}
async removeSessionAliasesFor(canonical: string): Promise<void> {
const tx = this.db.transaction('session_aliases_enc', 'readwrite');
const matches = await tx.store.index('byCanonical').getAllKeys(canonical);
await Promise.all(matches.map((key) => tx.store.delete(key)));
await tx.done;
}
// ─── Trust ───────────────────────────────────────────────── // ─── Trust ─────────────────────────────────────────────────
async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean> { async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean> {
@@ -466,7 +491,7 @@ export class EncryptedIndexedDBStorage implements StorageProvider {
// ─── Schema ──────────────────────────────────────────────── // ─── Schema ────────────────────────────────────────────────
const SCHEMA_VERSION = 2; const SCHEMA_VERSION = 3;
interface MetaRow { key: string; value: string } interface MetaRow { key: string; value: string }
interface IdentityRow { id: 1; ciphertext: Uint8Array } interface IdentityRow { id: 1; ciphertext: Uint8Array }
@@ -522,6 +547,11 @@ interface EncryptedShadeSchema extends DBSchema {
signed_prekeys_enc: { key: number; value: SignedPreKeyRow }; signed_prekeys_enc: { key: number; value: SignedPreKeyRow };
one_time_prekeys_enc: { key: number; value: OneTimePreKeyRow }; one_time_prekeys_enc: { key: number; value: OneTimePreKeyRow };
sessions_enc: { key: string; value: SessionRow }; sessions_enc: { key: string; value: SessionRow };
session_aliases_enc: {
key: string;
value: { alias: string; canonical: string };
indexes: { byCanonical: string };
};
trusted_identities_enc: { key: string; value: TrustedIdentityRow }; trusted_identities_enc: { key: string; value: TrustedIdentityRow };
retired_identities_enc: { retired_identities_enc: {
key: number; key: number;

View File

@@ -183,6 +183,30 @@ export class EncryptedPostgresStorage implements StorageProvider {
await this.sql`DELETE FROM shade_sessions_enc WHERE address = ${address}`; await this.sql`DELETE FROM shade_sessions_enc WHERE address = ${address}`;
} }
// ─── Session label aliases ─────────────────────────────────
//
// Labels are already stored in the clear as the sessions_enc primary
// key, so a mapping between two of them reveals nothing new.
async getSessionAlias(alias: string): Promise<string | null> {
const rows = await this.sql<Array<{ canonical: string }>>`
SELECT canonical FROM shade_session_aliases_enc WHERE alias = ${alias}
`;
return rows.length ? rows[0]!.canonical : null;
}
async saveSessionAlias(alias: string, canonical: string): Promise<void> {
await this.sql`
INSERT INTO shade_session_aliases_enc (alias, canonical)
VALUES (${alias}, ${canonical})
ON CONFLICT (alias) DO UPDATE SET canonical = EXCLUDED.canonical
`;
}
async removeSessionAliasesFor(canonical: string): Promise<void> {
await this.sql`DELETE FROM shade_session_aliases_enc WHERE canonical = ${canonical}`;
}
// ─── Trust ───────────────────────────────────────────────── // ─── Trust ─────────────────────────────────────────────────
async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean> { async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean> {
@@ -515,6 +539,16 @@ export async function ensureEncryptedClientTables(sql: Sql): Promise<void> {
ciphertext BYTEA NOT NULL ciphertext BYTEA NOT NULL
) )
`; `;
await sql`
CREATE TABLE IF NOT EXISTS shade_session_aliases_enc (
alias TEXT PRIMARY KEY,
canonical TEXT NOT NULL
)
`;
await sql`
CREATE INDEX IF NOT EXISTS idx_shade_session_aliases_enc_canonical
ON shade_session_aliases_enc(canonical)
`;
await sql` await sql`
CREATE TABLE IF NOT EXISTS shade_trusted_identities_enc ( CREATE TABLE IF NOT EXISTS shade_trusted_identities_enc (
address TEXT PRIMARY KEY, address TEXT PRIMARY KEY,

View File

@@ -52,6 +52,9 @@ export class EncryptedSQLiteStorage implements StorageProvider {
getSession: ReturnType<Database['prepare']>; getSession: ReturnType<Database['prepare']>;
saveSession: ReturnType<Database['prepare']>; saveSession: ReturnType<Database['prepare']>;
removeSession: ReturnType<Database['prepare']>; removeSession: ReturnType<Database['prepare']>;
getSessionAlias: ReturnType<Database['prepare']>;
saveSessionAlias: ReturnType<Database['prepare']>;
removeAliasesFor: ReturnType<Database['prepare']>;
getTrust: ReturnType<Database['prepare']>; getTrust: ReturnType<Database['prepare']>;
saveTrust: ReturnType<Database['prepare']>; saveTrust: ReturnType<Database['prepare']>;
addRetired: ReturnType<Database['prepare']>; addRetired: ReturnType<Database['prepare']>;
@@ -134,6 +137,15 @@ export class EncryptedSQLiteStorage implements StorageProvider {
address TEXT PRIMARY KEY, address TEXT PRIMARY KEY,
ciphertext BLOB NOT NULL ciphertext BLOB NOT NULL
); );
-- Session-label aliases (V4.12). Labels are already stored in the
-- clear as the sessions_enc primary key, so the mapping between two
-- of them reveals nothing new; only session state is encrypted.
CREATE TABLE IF NOT EXISTS session_aliases_enc (
alias TEXT PRIMARY KEY,
canonical TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_session_aliases_enc_canonical
ON session_aliases_enc(canonical);
CREATE TABLE IF NOT EXISTS trusted_identities_enc ( CREATE TABLE IF NOT EXISTS trusted_identities_enc (
address TEXT PRIMARY KEY, address TEXT PRIMARY KEY,
ciphertext BLOB NOT NULL ciphertext BLOB NOT NULL
@@ -203,6 +215,9 @@ export class EncryptedSQLiteStorage implements StorageProvider {
getSession: this.db.prepare('SELECT ciphertext FROM sessions_enc WHERE address = ?'), getSession: this.db.prepare('SELECT ciphertext FROM sessions_enc WHERE address = ?'),
saveSession: this.db.prepare('INSERT OR REPLACE INTO sessions_enc (address, ciphertext) VALUES (?, ?)'), saveSession: this.db.prepare('INSERT OR REPLACE INTO sessions_enc (address, ciphertext) VALUES (?, ?)'),
removeSession: this.db.prepare('DELETE FROM sessions_enc WHERE address = ?'), removeSession: this.db.prepare('DELETE FROM sessions_enc WHERE address = ?'),
getSessionAlias: this.db.prepare('SELECT canonical FROM session_aliases_enc WHERE alias = ?'),
saveSessionAlias: this.db.prepare('INSERT OR REPLACE INTO session_aliases_enc (alias, canonical) VALUES (?, ?)'),
removeAliasesFor: this.db.prepare('DELETE FROM session_aliases_enc WHERE canonical = ?'),
getTrust: this.db.prepare('SELECT ciphertext FROM trusted_identities_enc WHERE address = ?'), getTrust: this.db.prepare('SELECT ciphertext FROM trusted_identities_enc WHERE address = ?'),
saveTrust: this.db.prepare('INSERT OR REPLACE INTO trusted_identities_enc (address, ciphertext) VALUES (?, ?)'), saveTrust: this.db.prepare('INSERT OR REPLACE INTO trusted_identities_enc (address, ciphertext) VALUES (?, ?)'),
addRetired: this.db.prepare('INSERT OR REPLACE INTO retired_identities_enc (retired_at, ciphertext) VALUES (?, ?)'), addRetired: this.db.prepare('INSERT OR REPLACE INTO retired_identities_enc (retired_at, ciphertext) VALUES (?, ?)'),
@@ -377,6 +392,21 @@ export class EncryptedSQLiteStorage implements StorageProvider {
this.stmts.removeSession.run(address); this.stmts.removeSession.run(address);
} }
// ─── Session label aliases ─────────────────────────────────
async getSessionAlias(alias: string): Promise<string | null> {
const row = this.stmts.getSessionAlias.get(alias) as { canonical: string } | undefined;
return row?.canonical ?? null;
}
async saveSessionAlias(alias: string, canonical: string): Promise<void> {
this.stmts.saveSessionAlias.run(alias, canonical);
}
async removeSessionAliasesFor(canonical: string): Promise<void> {
this.stmts.removeAliasesFor.run(canonical);
}
// ─── Trust ───────────────────────────────────────────────── // ─── Trust ─────────────────────────────────────────────────
async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean> { async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean> {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/storage-indexeddb", "name": "@shade/storage-indexeddb",
"version": "4.11.0", "version": "4.12.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -74,6 +74,10 @@ export class IndexedDBStorage implements StorageProvider {
const members = db.createObjectStore('broadcastMembers', { keyPath: ['channelId', 'peerAddress'] }); const members = db.createObjectStore('broadcastMembers', { keyPath: ['channelId', 'peerAddress'] });
members.createIndex('byChannelId', 'channelId'); members.createIndex('byChannelId', 'channelId');
} }
if (oldVersion < 3) {
const aliases = db.createObjectStore('sessionAliases', { keyPath: 'alias' });
aliases.createIndex('byCanonical', 'canonical');
}
}, },
}); });
return new IndexedDBStorage(db); return new IndexedDBStorage(db);
@@ -174,6 +178,24 @@ export class IndexedDBStorage implements StorageProvider {
await this.db.delete('sessions', address); await this.db.delete('sessions', address);
} }
// ─── Session label aliases ────────────────────────────────
async getSessionAlias(alias: string): Promise<string | null> {
const row = await this.db.get('sessionAliases', alias);
return row?.canonical ?? null;
}
async saveSessionAlias(alias: string, canonical: string): Promise<void> {
await this.db.put('sessionAliases', { alias, canonical });
}
async removeSessionAliasesFor(canonical: string): Promise<void> {
const tx = this.db.transaction('sessionAliases', 'readwrite');
const matches = await tx.store.index('byCanonical').getAllKeys(canonical);
await Promise.all(matches.map((key) => tx.store.delete(key)));
await tx.done;
}
// ─── Trust ──────────────────────────────────────────────── // ─── Trust ────────────────────────────────────────────────
async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean> { async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean> {
@@ -360,7 +382,7 @@ export class IndexedDBStorage implements StorageProvider {
// ─── Schema ──────────────────────────────────────────────── // ─── Schema ────────────────────────────────────────────────
const SCHEMA_VERSION = 2; const SCHEMA_VERSION = 3;
interface IdentityRow { interface IdentityRow {
id: 1; id: 1;
@@ -390,6 +412,11 @@ interface SessionRow {
stateJson: string; stateJson: string;
} }
interface SessionAliasRow {
alias: string;
canonical: string;
}
interface TrustedIdentityRow { interface TrustedIdentityRow {
address: string; address: string;
identityKey: string; identityKey: string;
@@ -457,6 +484,11 @@ interface ShadeSchema extends DBSchema {
signedPreKeys: { key: number; value: SignedPreKeyRow }; signedPreKeys: { key: number; value: SignedPreKeyRow };
oneTimePreKeys: { key: number; value: OneTimePreKeyRow }; oneTimePreKeys: { key: number; value: OneTimePreKeyRow };
sessions: { key: string; value: SessionRow }; sessions: { key: string; value: SessionRow };
sessionAliases: {
key: string;
value: SessionAliasRow;
indexes: { byCanonical: string };
};
trustedIdentities: { key: string; value: TrustedIdentityRow }; trustedIdentities: { key: string; value: TrustedIdentityRow };
retiredIdentities: { retiredIdentities: {
key: number; key: number;

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/storage-postgres", "name": "@shade/storage-postgres",
"version": "4.11.0", "version": "4.12.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -43,6 +43,16 @@ export async function ensureClientTables(sql: Sql): Promise<void> {
state_json TEXT NOT NULL state_json TEXT NOT NULL
) )
`; `;
await sql`
CREATE TABLE IF NOT EXISTS shade_session_aliases (
alias TEXT PRIMARY KEY,
canonical TEXT NOT NULL
)
`;
await sql`
CREATE INDEX IF NOT EXISTS idx_shade_session_aliases_canonical
ON shade_session_aliases(canonical)
`;
await sql` await sql`
CREATE TABLE IF NOT EXISTS shade_trusted_identities ( CREATE TABLE IF NOT EXISTS shade_trusted_identities (
address TEXT PRIMARY KEY, address TEXT PRIMARY KEY,

View File

@@ -160,6 +160,27 @@ export class PostgresStorage implements StorageProvider {
await this.sql`DELETE FROM shade_sessions WHERE address = ${address}`; await this.sql`DELETE FROM shade_sessions WHERE address = ${address}`;
} }
// ─── Session label aliases ────────────────────────────────
async getSessionAlias(alias: string): Promise<string | null> {
const rows = await this.sql<Array<{ canonical: string }>>`
SELECT canonical FROM shade_session_aliases WHERE alias = ${alias}
`;
return rows.length ? rows[0]!.canonical : null;
}
async saveSessionAlias(alias: string, canonical: string): Promise<void> {
await this.sql`
INSERT INTO shade_session_aliases (alias, canonical)
VALUES (${alias}, ${canonical})
ON CONFLICT (alias) DO UPDATE SET canonical = EXCLUDED.canonical
`;
}
async removeSessionAliasesFor(canonical: string): Promise<void> {
await this.sql`DELETE FROM shade_session_aliases WHERE canonical = ${canonical}`;
}
// ─── Trust ──────────────────────────────────────────────── // ─── Trust ────────────────────────────────────────────────
async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean> { async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean> {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/storage-sqlite", "name": "@shade/storage-sqlite",
"version": "4.11.0", "version": "4.12.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -41,6 +41,9 @@ export class SQLiteStorage implements StorageProvider {
getSession: ReturnType<Database['prepare']>; getSession: ReturnType<Database['prepare']>;
saveSession: ReturnType<Database['prepare']>; saveSession: ReturnType<Database['prepare']>;
removeSession: ReturnType<Database['prepare']>; removeSession: ReturnType<Database['prepare']>;
getSessionAlias: ReturnType<Database['prepare']>;
saveSessionAlias: ReturnType<Database['prepare']>;
removeAliasesFor: ReturnType<Database['prepare']>;
getTrust: ReturnType<Database['prepare']>; getTrust: ReturnType<Database['prepare']>;
saveTrust: ReturnType<Database['prepare']>; saveTrust: ReturnType<Database['prepare']>;
addRetired: ReturnType<Database['prepare']>; addRetired: ReturnType<Database['prepare']>;
@@ -100,6 +103,12 @@ export class SQLiteStorage implements StorageProvider {
address TEXT PRIMARY KEY, address TEXT PRIMARY KEY,
state_json TEXT NOT NULL state_json TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS session_aliases (
alias TEXT PRIMARY KEY,
canonical TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_session_aliases_canonical
ON session_aliases(canonical);
CREATE TABLE IF NOT EXISTS trusted_identities ( CREATE TABLE IF NOT EXISTS trusted_identities (
address TEXT PRIMARY KEY, address TEXT PRIMARY KEY,
identity_key TEXT NOT NULL identity_key TEXT NOT NULL
@@ -179,6 +188,9 @@ export class SQLiteStorage implements StorageProvider {
getSession: this.db.prepare('SELECT state_json FROM sessions WHERE address = ?'), getSession: this.db.prepare('SELECT state_json FROM sessions WHERE address = ?'),
saveSession: this.db.prepare('INSERT OR REPLACE INTO sessions (address, state_json) VALUES (?, ?)'), saveSession: this.db.prepare('INSERT OR REPLACE INTO sessions (address, state_json) VALUES (?, ?)'),
removeSession: this.db.prepare('DELETE FROM sessions WHERE address = ?'), removeSession: this.db.prepare('DELETE FROM sessions WHERE address = ?'),
getSessionAlias: this.db.prepare('SELECT canonical FROM session_aliases WHERE alias = ?'),
saveSessionAlias: this.db.prepare('INSERT OR REPLACE INTO session_aliases (alias, canonical) VALUES (?, ?)'),
removeAliasesFor: this.db.prepare('DELETE FROM session_aliases WHERE canonical = ?'),
getTrust: this.db.prepare('SELECT identity_key FROM trusted_identities WHERE address = ?'), getTrust: this.db.prepare('SELECT identity_key FROM trusted_identities WHERE address = ?'),
saveTrust: this.db.prepare('INSERT OR REPLACE INTO trusted_identities (address, identity_key) VALUES (?, ?)'), saveTrust: this.db.prepare('INSERT OR REPLACE INTO trusted_identities (address, identity_key) VALUES (?, ?)'),
addRetired: this.db.prepare('INSERT INTO retired_identities (data_json, retired_at) VALUES (?, ?)'), addRetired: this.db.prepare('INSERT INTO retired_identities (data_json, retired_at) VALUES (?, ?)'),
@@ -337,6 +349,21 @@ export class SQLiteStorage implements StorageProvider {
this.stmts.removeSession.run(address); this.stmts.removeSession.run(address);
} }
// ─── Session label aliases ────────────────────────────────
async getSessionAlias(alias: string): Promise<string | null> {
const row = this.stmts.getSessionAlias.get(alias) as { canonical: string } | undefined;
return row?.canonical ?? null;
}
async saveSessionAlias(alias: string, canonical: string): Promise<void> {
this.stmts.saveSessionAlias.run(alias, canonical);
}
async removeSessionAliasesFor(canonical: string): Promise<void> {
this.stmts.removeAliasesFor.run(canonical);
}
// ─── Trust ──────────────────────────────────────────────── // ─── Trust ────────────────────────────────────────────────
async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean> { async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean> {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/streams", "name": "@shade/streams",
"version": "4.11.0", "version": "4.12.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/transfer", "name": "@shade/transfer",
"version": "4.11.0", "version": "4.12.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/transport-bridge", "name": "@shade/transport-bridge",
"version": "4.11.0", "version": "4.12.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/transport-webrtc", "name": "@shade/transport-webrtc",
"version": "4.11.0", "version": "4.12.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/transport", "name": "@shade/transport",
"version": "4.11.0", "version": "4.12.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/widgets", "name": "@shade/widgets",
"version": "4.11.0", "version": "4.12.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -13,6 +13,7 @@
* *
* Optional: * Optional:
* DRY_RUN=1 — pack tarballs but do not publish (no token required) * DRY_RUN=1 — pack tarballs but do not publish (no token required)
* --only <pkg> — publish one package (e.g. shade-files or @shade/files)
*/ */
import { readFileSync, writeFileSync, existsSync } from 'fs'; import { readFileSync, writeFileSync, existsSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
@@ -51,10 +52,27 @@ const PACKAGES = [
const REGISTRY_HOST = 'gt.zyon.no'; const REGISTRY_HOST = 'gt.zyon.no';
const ROOT = join(import.meta.dir, '..'); const ROOT = join(import.meta.dir, '..');
function selectedPackages(argv: string[]): string[] {
const onlyIndex = argv.indexOf('--only');
if (onlyIndex === -1) return PACKAGES;
const requested = argv[onlyIndex + 1];
if (!requested) throw new Error('Usage: publish-all.ts --only <shade-files|@shade/files>');
const normalized = requested.startsWith('@shade/')
? `shade-${requested.slice('@shade/'.length)}`
: requested.startsWith('shade-')
? requested
: `shade-${requested}`;
if (!PACKAGES.includes(normalized)) {
throw new Error(`Unknown Shade package: ${requested}`);
}
return [normalized];
}
async function main() { async function main() {
const token = process.env.GITEA_TOKEN; const token = process.env.GITEA_TOKEN;
const user = process.env.GITEA_USER ?? 'Stian'; const user = process.env.GITEA_USER ?? 'Stian';
const dryRun = process.env.DRY_RUN === '1'; const dryRun = process.env.DRY_RUN === '1';
const packagesToPublish = selectedPackages(process.argv.slice(2));
if (!token && !dryRun) { if (!token && !dryRun) {
console.error('GITEA_TOKEN is required (or set DRY_RUN=1)'); console.error('GITEA_TOKEN is required (or set DRY_RUN=1)');
@@ -64,6 +82,7 @@ async function main() {
const registryUrl = `https://${REGISTRY_HOST}/api/packages/${user}/npm/`; const registryUrl = `https://${REGISTRY_HOST}/api/packages/${user}/npm/`;
console.log(`Target registry: ${registryUrl}`); console.log(`Target registry: ${registryUrl}`);
console.log(`Dry run: ${dryRun ? 'yes' : 'no'}`); console.log(`Dry run: ${dryRun ? 'yes' : 'no'}`);
console.log(`Packages: ${packagesToPublish.join(', ')}`);
console.log(); console.log();
const npmrcPath = join(ROOT, '.npmrc.publish'); const npmrcPath = join(ROOT, '.npmrc.publish');
@@ -86,7 +105,7 @@ async function main() {
let alreadyPublished = 0; let alreadyPublished = 0;
let failed = 0; let failed = 0;
for (const pkg of PACKAGES) { for (const pkg of packagesToPublish) {
const pkgDir = join(ROOT, 'packages', pkg); const pkgDir = join(ROOT, 'packages', pkg);
const pkgJsonPath = join(pkgDir, 'package.json'); const pkgJsonPath = join(pkgDir, 'package.json');
if (!existsSync(pkgJsonPath)) { if (!existsSync(pkgJsonPath)) {
@@ -99,10 +118,23 @@ async function main() {
const pkgJson = JSON.parse(originalPkgJson); const pkgJson = JSON.parse(originalPkgJson);
console.log(`→ ${pkgJson.name}@${pkgJson.version}`); console.log(`→ ${pkgJson.name}@${pkgJson.version}`);
rewriteWorkspaceSpecs(pkgJson, versionByName);
writeFileSync(pkgJsonPath, `${JSON.stringify(pkgJson, null, 2)}\n`);
try { try {
// Phase 1 — build. Leave `main`/`types`/`exports` pointing at `src/`
// so `tsc` can resolve cross-package `@shade/*` imports through
// workspace source (`dist/` doesn't exist yet for sibling packages
// when their package.json points there). We ship pre-built artefacts
// so strict-mode consumers (Cyndr et al.) don't recompile our source.
await $`cd ${pkgDir} && rm -rf dist && bunx tsc -p tsconfig.json`.quiet();
// Phase 2 — rewrite package.json for the publish surface. Swap entry
// points to `dist/`, drop `workspace:*` for real versions, ensure
// `files: ["dist"]` so npm pack ships only the built artefacts.
// Restored from `originalPkgJson` in the `finally` block at the end.
rewriteWorkspaceSpecs(pkgJson, versionByName);
rewriteEntryPointsForDist(pkgJson);
ensureFilesIncludesDist(pkgJson);
writeFileSync(pkgJsonPath, `${JSON.stringify(pkgJson, null, 2)}\n`);
if (dryRun) { if (dryRun) {
await $`cd ${pkgDir} && bun pm pack --dry-run`.quiet(); await $`cd ${pkgDir} && bun pm pack --dry-run`.quiet();
} else { } else {
@@ -159,6 +191,55 @@ function rewriteWorkspaceSpecs(
} }
} }
/**
* Swap `src/*.ts` references in `main`, `types`, and every `exports.<subpath>`
* entry over to `dist/*.{js,d.ts}` for the duration of the publish. The
* in-repo `package.json` is restored from `originalPkgJson` after pack so
* the source-pointing form survives in git.
*
* Why this lives at publish time instead of permanently: tooling that runs
* before any build (`tsc --noEmit`, IDE hover, the workspace dev loop) needs
* `main` to point at source so cross-package imports resolve without a
* forced build pass. Shipping dist-only is a consumer-facing concern.
*/
function rewriteEntryPointsForDist(pkgJson: Record<string, unknown>): void {
if (typeof pkgJson.main === 'string') {
pkgJson.main = pkgJson.main.replace(/^src\//, 'dist/').replace(/\.ts$/, '.js');
}
if (typeof pkgJson.types === 'string') {
pkgJson.types = pkgJson.types.replace(/^src\//, 'dist/').replace(/\.ts$/, '.d.ts');
}
if (pkgJson.exports !== undefined) {
pkgJson.exports = mapExportsForDist(pkgJson.exports);
}
}
function mapExportsForDist(node: unknown, isTypesCondition = false): unknown {
if (typeof node === 'string') {
if (!node.startsWith('./src/')) return node;
return node
.replace(/^\.\/src\//, './dist/')
.replace(/\.ts$/, isTypesCondition ? '.d.ts' : '.js');
}
if (node === null || typeof node !== 'object' || Array.isArray(node)) {
return node;
}
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
out[key] = mapExportsForDist(value, key === 'types' || isTypesCondition);
}
return out;
}
function ensureFilesIncludesDist(pkgJson: Record<string, unknown>): void {
const files = pkgJson['files'];
if (Array.isArray(files)) {
if (!files.includes('dist')) files.push('dist');
} else {
pkgJson['files'] = ['dist'];
}
}
main().catch((err) => { main().catch((err) => {
console.error(err); console.error(err);
process.exit(1); process.exit(1);

View File

@@ -14,7 +14,6 @@
"noEmit": true, "noEmit": true,
"types": ["bun-types"], "types": ["bun-types"],
"ignoreDeprecations": "6.0", "ignoreDeprecations": "6.0",
"baseUrl": ".",
"paths": { "paths": {
"@shade/core": ["../../packages/shade-core/src/index.ts"], "@shade/core": ["../../packages/shade-core/src/index.ts"],
"@shade/proto": ["../../packages/shade-proto/src/index.ts"], "@shade/proto": ["../../packages/shade-proto/src/index.ts"],