release(v4.9.0): relay-side encrypted blob primitive + SDK Profile namespace
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled

Ships the Prism FR (encrypted-profile-storage-v4.9.md) as a generic
relay-side encrypted blob primitive: deterministically-located,
AEAD-sealed blobs keyed by a 32-byte slotId derived client-side via
HKDF from the user's master key. Unlocks credential-only bootstrap
of new devices into existing E2EE state — no QR, no physical access.

Server: BlobStore interface + Memory/Sqlite/Postgres impls,
createBlobRoutes for GET/PUT/DELETE /v1/blob/:slotId with TOFU pubkey
auth and If-Match CAS (409/412 semantics). Mounted on the same Hono
app as the inbox; SHADE_BLOB_PG_URL / SHADE_BLOB_DB_PATH /
SHADE_DISABLE_BLOB env-var plumbing in standalone.

SDK: createProfileNamespace high-level wrapper (HKDF derivation,
random-nonce AEAD seal, slotId-bound AAD) + low-level BlobClient.
Cross-platform test vectors in test-vectors/blob-storage.json.

New errors: ConflictError (409), PreconditionFailedError (412).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 02:44:42 +02:00
parent 3c0db14904
commit 80c410f518
51 changed files with 2138 additions and 58 deletions

View File

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

View File

@@ -3,10 +3,13 @@ import { SubtleCryptoProvider } from '@shade/crypto-web';
import {
createInboxRoutes,
createBridgeRoutes,
createBlobRoutes,
InboxServerEvents,
InboxPruneTask,
MemoryInboxStore,
MemoryBlobStore,
type InboxStore,
type BlobStore,
} from '@shade/inbox-server';
import { createPrekeyRoutes } from './routes.js';
import { createHealthRoutes } from './health.js';
@@ -71,6 +74,41 @@ async function createInboxStore(): Promise<InboxStore & { close?: () => void | P
return new MemoryInboxStore();
}
/**
* V4.9 — encrypted-blob primitive (`/v1/blob/<slotId>`).
*
* Backend selection mirrors the inbox store: an explicit
* `SHADE_BLOB_PG_URL` wins, then a SQLite path, then we fall back to the
* shared `SHADE_PREKEY_PG_URL` if present, then memory. Operators can
* also opt the blob store *off* entirely via `SHADE_DISABLE_BLOB=1` —
* useful for relays that only want the inbox surface.
*/
async function createBlobStore(): Promise<BlobStore & { close?: () => void | Promise<void> }> {
const sqlitePath = process.env.SHADE_BLOB_DB_PATH;
const pgUrl = process.env.SHADE_BLOB_PG_URL ?? process.env.SHADE_PREKEY_PG_URL;
if (pgUrl && process.env.SHADE_BLOB_PG_URL) {
const { PostgresBlobStore } = await import('@shade/storage-postgres');
logger.info('Using PostgreSQL blob store', { url: maskUrl(pgUrl) });
return PostgresBlobStore.create(pgUrl);
}
if (sqlitePath) {
const { SqliteBlobStore } = await import('@shade/storage-sqlite');
logger.info('Using SQLite blob store', { path: sqlitePath });
return new SqliteBlobStore(sqlitePath);
}
if (pgUrl) {
const { PostgresBlobStore } = await import('@shade/storage-postgres');
logger.info('Using PostgreSQL blob store (sharing prekey URL)', { url: maskUrl(pgUrl) });
return PostgresBlobStore.create(pgUrl);
}
logger.warn('Using in-memory blob store — data will not persist across restarts');
return new MemoryBlobStore();
}
async function maybeCreateKT(): Promise<KeyTransparencyService | undefined> {
const skPriv = process.env.SHADE_KT_SIGNING_PRIVATE_KEY;
const skPub = process.env.SHADE_KT_SIGNING_PUBLIC_KEY;
@@ -204,6 +242,19 @@ app.route(
);
app.route('/', bridgeRoutes.app);
// V4.9 — encrypted-blob primitive. Powers Prism's credential-driven
// device-linking (Phase 2) and any other Shade app that needs a
// "sign in from any device" UX. Mounted on the same Hono app so a
// single relay process serves prekey + inbox + blob from one port.
const blobDisabled = process.env.SHADE_DISABLE_BLOB === '1';
const blobStore = blobDisabled ? null : await createBlobStore();
if (blobDisabled) {
logger.info('Blob primitive disabled (SHADE_DISABLE_BLOB=1)');
} else if (blobStore) {
app.route('/', createBlobRoutes(blobStore, crypto, { disableRateLimit }));
logger.info('Blob primitive enabled', { route: '/v1/blob/:slotId' });
}
// ─── Optional: Observer + Dashboard ──────────────────────────
const observerToken = process.env.SHADE_OBSERVER_TOKEN;
@@ -278,6 +329,9 @@ async function shutdown(signal: string) {
if ('close' in inboxStore && typeof inboxStore.close === 'function') {
await inboxStore.close();
}
if (blobStore && 'close' in blobStore && typeof blobStore.close === 'function') {
await blobStore.close();
}
logger.info('Shutdown complete');
process.exit(0);
} catch (err) {