release(v4.9.0): relay-side encrypted blob primitive + SDK Profile namespace
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:
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user