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:
103
src/relay/store.ts
Normal file
103
src/relay/store.ts
Normal 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");
|
||||
}
|
||||
Reference in New Issue
Block a user