feat: rework CookieBridge to v2 architecture per CEO feedback

Architecture changes:
- Extension connects directly to server (no local proxy/daemon)
- Dual transport: WebSocket (real-time) + HTTP polling (fallback)
- Server stores encrypted cookie blobs (E2E encrypted, server-blind)
- Device registration with API token auth
- Pairing records stored server-side for cross-device cookie access
- Agent Skill API: AI agents get tokens to retrieve encrypted cookies
  with domain-level access control

New modules:
- src/relay/store.ts — encrypted cookie blob storage (LWW, per-device limits)
- src/relay/tokens.ts — device registry, agent registry, pairing tracking
- Protocol spec v2 with new types (EncryptedCookieBlob, AgentToken, etc.)

38 tests passing (crypto, pairing, conflict, full integration with
HTTP polling, agent API, and WebSocket relay).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
徐枫
2026-03-17 15:26:24 +08:00
parent 4326276505
commit 1bd7a34de8
7 changed files with 978 additions and 257 deletions

103
src/relay/store.ts Normal file
View File

@@ -0,0 +1,103 @@
import sodium from "sodium-native";
import type { EncryptedCookieBlob } from "../protocol/spec.js";
import { MAX_STORED_COOKIES_PER_DEVICE } from "../protocol/spec.js";
type CookieKey = string; // "domain|name|path"
function blobKey(blob: Pick<EncryptedCookieBlob, "domain" | "cookieName" | "path">): CookieKey {
return `${blob.domain}|${blob.cookieName}|${blob.path}`;
}
/**
* In-memory encrypted cookie storage.
* The server stores opaque ciphertext — it cannot read cookie values.
* Keyed by (deviceId, domain, cookieName, path).
*/
export class CookieBlobStore {
// deviceId -> (cookieKey -> blob)
private store = new Map<string, Map<CookieKey, EncryptedCookieBlob>>();
/** Upsert an encrypted cookie blob. LWW by lamportTs. */
upsert(blob: Omit<EncryptedCookieBlob, "id" | "updatedAt">): EncryptedCookieBlob {
let deviceMap = this.store.get(blob.deviceId);
if (!deviceMap) {
deviceMap = new Map();
this.store.set(blob.deviceId, deviceMap);
}
const key = blobKey(blob);
const existing = deviceMap.get(key);
if (existing && existing.lamportTs >= blob.lamportTs) {
return existing; // stale update
}
const stored: EncryptedCookieBlob = {
...blob,
id: existing?.id ?? generateId(),
updatedAt: new Date().toISOString(),
};
deviceMap.set(key, stored);
// Enforce per-device limit
if (deviceMap.size > MAX_STORED_COOKIES_PER_DEVICE) {
// Remove oldest by updatedAt
let oldest: { key: CookieKey; time: string } | null = null;
for (const [k, v] of deviceMap) {
if (!oldest || v.updatedAt < oldest.time) {
oldest = { key: k, time: v.updatedAt };
}
}
if (oldest) deviceMap.delete(oldest.key);
}
return stored;
}
/** Delete a cookie blob. */
delete(deviceId: string, domain: string, cookieName: string, path: string): boolean {
const deviceMap = this.store.get(deviceId);
if (!deviceMap) return false;
return deviceMap.delete(blobKey({ domain, cookieName, path }));
}
/** Get all blobs for a device, optionally filtered by domain. */
getByDevice(deviceId: string, domain?: string): EncryptedCookieBlob[] {
const deviceMap = this.store.get(deviceId);
if (!deviceMap) return [];
const blobs = Array.from(deviceMap.values());
if (domain) return blobs.filter((b) => b.domain === domain);
return blobs;
}
/** Get blobs across all paired devices for a set of deviceIds. */
getByDevices(deviceIds: string[], domain?: string): EncryptedCookieBlob[] {
const result: EncryptedCookieBlob[] = [];
for (const id of deviceIds) {
result.push(...this.getByDevice(id, domain));
}
return result;
}
/** Get all blobs updated after a given timestamp (for polling). */
getUpdatedSince(deviceIds: string[], since: string): EncryptedCookieBlob[] {
const result: EncryptedCookieBlob[] = [];
for (const id of deviceIds) {
const deviceMap = this.store.get(id);
if (!deviceMap) continue;
for (const blob of deviceMap.values()) {
if (blob.updatedAt > since) {
result.push(blob);
}
}
}
return result;
}
}
function generateId(): string {
const buf = Buffer.alloc(16);
sodium.randombytes_buf(buf);
return buf.toString("hex");
}