Shreyas Sane
Explore

Coding

Kinnections

The server stores a map of your relationships and cannot read a byte of it. Every decision in this paid, encrypted product had to survive that.

Role
Sole author: product, cryptography design, full-stack implementation, billing and operations
Years
Present
Built with
Next.js, React 19, TypeScript, Web Crypto API, React Flow, Three.js, React Three Fiber, PostgreSQL, Prisma, Supabase, Upstash Redis, Stripe, Cloudflare R2, Resend, Playwright, Vitest, Vercel

A list is the wrong shape

The applications that hold the people in your life are almost all lists. A contact list is flat and alphabetical. A family tree points backwards, and it models exactly one kind of relationship. A personal CRM borrows its vocabulary from sales and asks you to log touchpoints, as though your sister were a lead.

None of that matches how the relationships actually sit. People arrive through other people. Groups overlap. The useful question is usually relational: how do I know this person, who else is in that circle, which of these two communities does she belong to. A list cannot answer a relational question.

Kinnections puts everyone on one canvas you can pan and zoom without an edge, plus a 3D globe view over the same data. The graph is the point. Not a better address book, but a picture of a structure that was always there and never had a surface.

The server is not allowed to read it

A map of everyone you know, how you met them, what you remember about them, and which of them know each other is among the most sensitive datasets a person could assemble. Building it means answering an obvious question: why would anyone trust me with that?

The answer could not be a promise. It had to be structural. Every personal field is encrypted in the browser with AES-GCM before it goes anywhere. The server receives and returns ciphertext. It holds names, notes, relationship types, photos, dates, tags, and custom fields as bytes it has no key for. This is the model used by password managers and encrypted mail providers, and it changes what the privacy claim even is: not that I choose not to look, but that I cannot.

model Node {
  id            String   @id @default(uuid()) @db.Uuid
  graphId       String   @map("graph_id") @db.Uuid
  isMe          Boolean  @default(false) @map("is_me")
  encryptedBlob Bytes    @map("encrypted_blob")
  blobIv        Bytes    @map("blob_iv")
  photoRef      String?  @map("photo_ref") @db.VarChar(255)
  positionX     Float    @map("position_x")
  positionY     Float    @map("position_y")
  createdAt     DateTime @default(now()) @map("created_at") @db.Timestamptz()
  updatedAt     DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz()

  graph       Graph  @relation(fields: [graphId], references: [id], onDelete: Cascade)
  edgesSource Edge[] @relation("EdgeSource")
  edgesTarget Edge[] @relation("EdgeTarget")

  @@index([graphId], map: "idx_nodes_graph_id")
  @@map("nodes")
}

schema.prismaexit-zero-labs/kinnections · prisma/

Everything a person is stored as sits in encrypted_blob. The server keeps the graph's shape and coordinates so it can serve them, and holds the rest as bytes it has no key for.

All of it runs on the browser's own crypto.subtle. AES-GCM for content, PBKDF2 for deriving keys from a passphrase, ECDH for sharing, HKDF for derivation, AES-KW for wrapping keys. There is not a single npm cryptography dependency in the project. For the most sensitive code path in the product, the supply chain is the browser vendor.

What encryption took away

Zero-knowledge sounds like a feature until you build the rest of the application against it. Most of what a backend normally does for free becomes unavailable, because the backend is holding noise.

Search was the first casualty. A server cannot match a query against ciphertext, so there is no server-side search at all. Search happens in the browser, as fuzzy matching over the decrypted graph in memory, which is fast enough because the working set is small: one person's relationships. Sorting and filtering moved for the same reason.

Sharing was harder. The usual answer is a permission row in a table, but a permission row is meaningless when the server cannot decrypt what it is granting access to. Read-only sharing instead runs through an ECDH key exchange, so access is a cryptographic fact. There is no flag for anyone to flip.

The cost shows up as work moved to the client, and that work has to stay off the main thread. Encryption and force-directed layout both run in Web Workers, so decrypting a large graph does not freeze the canvas you are decrypting it into.

Operated by one person

Kinnections is a paid product with a subscription, a trial, billing, transactional email, rate limiting, error reporting, and an on-call rotation of one. Every architectural choice was made against that.

It runs as a single Next.js deployment. No services, no queues, no internal network to reason about. The pieces that are genuinely someone else's problem — the database, authentication, object storage, payments, email — are managed, and the boundaries between them are kept small and boring on purpose.

That is not an architecture I would defend for a team of thirty. For one person shipping and operating a product alongside a full-time job, every boundary avoided is an outage not investigated at midnight.

What the server is allowed to know

The trust boundary sits at the browser, not at the API. Everything below it handles bytes it cannot interpret.

Browser
Key derivation, encryption and decryption, search, sorting, and graph layout. The only place plaintext exists.
Web Workers
Bulk cryptography and force-directed layout, kept off the main thread so the canvas stays interactive.
PostgreSQL
Ciphertext, opaque identifiers, and relational structure. No readable personal field.
Authentication and billing
Account identity and subscription state, which are the only things the platform legitimately needs in the clear.