Advanced

FounderVoice

AI personal-branding SaaS that learns a founder's "Voice DNA" and auto-writes on-brand content with RAG over pgvector.

Founder & Full Stack Engineer
2024 – present
Solo build
2024
  • Next.js
  • TypeScript
  • Supabase
  • Google Gemini
  • pgvector
  • Vercel

Problem

Founders know their business cold, but almost none of them can consistently write about it. The result is a content graveyard: a LinkedIn profile last touched eight months ago, a Notion doc of half-drafted posts, and the nagging guilt that "building in public" is happening to everyone else. Generic AI writing tools do not fix this because they all sound the same — the moment a founder posts obviously-GPT prose, their credibility drops.

Target user: early-to-growth-stage founders and solo operators who have a strong point of view but no time (or appetite) to write daily.

Constraints that shaped the system:

  • The output has to sound like them, not like a language model. That is the entire product.
  • It has to be a real SaaS: authenticated, multi-tenant, billed, and safe with third-party OAuth tokens (LinkedIn) that can post on the user's behalf.
  • It is a solo build, so every subsystem has to be operable by one person and cheap enough to run on usage-based infrastructure.

Business goal: convert "I should post more" into a background process — the user drops in raw thoughts, the system returns publish-ready content in their voice, and (optionally) publishes it automatically on a schedule.

Architecture

FounderVoice is a Next.js app on Vercel with Supabase (Postgres + Auth + Storage + Edge Functions) as the backend of record. The interesting part is not the CRUD — it is the Voice DNA pipeline and the security posture around autonomous publishing.

At a high level:

  • Next.js (App Router) renders the dashboard and the composer, and calls Supabase Edge Functions for anything AI- or secret-adjacent.
  • 10 Deno Edge Functions own generation, retrieval, OAuth exchange, publishing, and webhook verification. They stream tokens back to the client.
  • Postgres (20+ RLS tables) stores users, voice samples, embeddings, drafts, schedules, billing state, and OAuth connections. Row-Level Security is the tenancy boundary — there is no "trust the app server" layer.
  • pgvector stores per-user embeddings of writing samples and powers similarity search via a match_voice_chunks RPC.
  • pg_cron drives auto-publishing: scheduled posts are dequeued and pushed to LinkedIn without a human in the loop.

Voice DNA data flow

  1. The user pastes writing samples (past posts, emails, voice-memo transcripts).
  2. Each sample is chunked and embedded; chunks land in a voice_chunks table with a vector column.
  3. On generation, the user's prompt is embedded, and match_voice_chunks does a cosine-similarity search to pull the most representative chunks of their voice.
  4. Those chunks become the style context in a RAG prompt to Gemini.
  5. Every 5 new samples, a trigger re-runs embedding maintenance so the Voice DNA sharpens as the user writes more.

Engineering Decisions

pgvector instead of a dedicated vector database. A separate vector store (Pinecone/Weaviate) would have meant a second system to secure, bill, and keep consistent with Postgres. Because voice chunks are per-user and gated by RLS, keeping embeddings inside Postgres let me reuse the exact same tenancy model for vectors as for every other row. Tradeoff: pgvector's ANN indexing is less tunable than a purpose-built engine, but at per-user chunk counts (hundreds, not millions) an IVFFlat index is comfortably sub-100ms. Revisit only if a single user's corpus explodes.

Supabase Edge Functions (Deno) for anything touching a secret or a model. The browser never sees a model key or an OAuth token. Alternatives were Next.js route handlers (fine, but co-locating with Supabase Auth/JWT and pg was cleaner) or a separate Node service (more ops for a solo build). Deno's web-standard fetch/streaming made token streaming trivial.

AES-256-GCM encryption of OAuth tokens at rest. LinkedIn tokens can post as the user. Storing them plaintext — even behind RLS — was unacceptable. They are encrypted application-side before insert and decrypted only inside the edge function that publishes. Tradeoff: key management complexity, mitigated by keeping the data-encryption key out of the database entirely.

HMAC-signed OAuth state + webhook verification. OAuth state is HMAC-signed so a forged callback cannot complete a connection, and inbound webhooks (billing) are verified by signature before any state change. This closes CSRF-on-OAuth and spoofed-webhook classes of bug.

Dual payment providers (Lemon Squeezy + Dodo). Merchant-of-record coverage across regions and a fallback if one provider has issues. Tradeoff: two webhook formats to normalize into one internal billing state machine.

Source Code Walkthrough

The voice-similarity retrieval RPC

The core of Voice DNA is a single Postgres function that takes a query embedding and returns the closest chunks for the calling user only. RLS plus an explicit user_id filter means retrieval can never leak one founder's voice into another's output.

-- match_voice_chunks: cosine-similarity search over a single user's corpus.
-- Why it exists: RAG needs the most "on-voice" passages as style context.
-- Why written this way: the <=> operator uses the IVFFlat index; ordering by
-- distance and capping with match_count keeps prompt context small and cheap.
create or replace function match_voice_chunks(
  p_user_id   uuid,
  query_embedding vector(768),
  match_count int default 6,
  min_similarity float default 0.5
)
returns table (id uuid, content text, similarity float)
language sql stable
as $$
  select
    vc.id,
    vc.content,
    1 - (vc.embedding <=> query_embedding) as similarity
  from voice_chunks vc
  where vc.user_id = p_user_id
    and 1 - (vc.embedding <=> query_embedding) >= min_similarity
  order by vc.embedding <=> query_embedding
  limit match_count;
$$;

Performance considerations: the function is stable (safe to cache within a statement), returns a bounded match_count, and pushes the distance filter into SQL so we never ship low-relevance chunks into the model prompt (tokens cost money and dilute style). Possible improvements: switch IVFFlat to HNSW for higher recall as corpora grow, and pre-warm the index on cold connections.

The streaming generation edge function

Generation runs in a Deno edge function so the model key stays server-side and tokens stream to the UI as they are produced.

// supabase/functions/generate/index.ts
// Why: stream first token fast; assemble Voice DNA context before calling Gemini.
Deno.serve(async (req) => {
  const { userId, prompt } = await req.json();
 
  // 1) Embed the prompt and pull the user's most on-voice chunks.
  const queryEmbedding = await embed(prompt);
  const { data: chunks } = await supabase.rpc("match_voice_chunks", {
    p_user_id: userId,
    query_embedding: queryEmbedding,
    match_count: 6,
  });
 
  // 2) Build a RAG prompt: their voice as style context, prompt as intent.
  const style = chunks.map((c) => c.content).join("\n---\n");
  const stream = await gemini.generateContentStream({
    contents: buildRagPrompt({ style, prompt }),
  });
 
  // 3) Stream tokens straight to the browser (no buffering the full response).
  return new Response(
    new ReadableStream({
      async start(controller) {
        for await (const chunk of stream) {
          controller.enqueue(new TextEncoder().encode(chunk.text()));
        }
        controller.close();
      },
    }),
    { headers: { "content-type": "text/plain; charset=utf-8" } },
  );
});

Why written this way: streaming makes latency feel like ~200–300ms even when full generation takes seconds; the user watches their voice appear. Improvements: cache the assembled style context per user for a short TTL so back-to-back generations skip the retrieval round-trip.

AES-256-GCM token encryption (encrypt-before-insert)

LinkedIn tokens can post as the user, so they never touch Postgres in plaintext. The data-encryption key lives only in the edge function's environment — never in a column, never in a migration — so a database dump or a rogue select * yields ciphertext, not credentials. GCM (an AEAD cipher) gives confidentiality and tamper-detection: if the stored blob is modified, decrypt throws instead of returning garbage that we might act on.

// supabase/functions/_shared/crypto.ts
// Why: OAuth tokens are "post-as-the-user" capabilities. Encrypt at rest with
// an authenticated cipher; keep the key out of the DB entirely.
// Why GCM: AEAD — the 16-byte auth tag detects tampering on decrypt.
// Format: base64(iv).base64(ciphertext+tag) — self-describing, no side table.
const KEY = await crypto.subtle.importKey(
  "raw",
  decodeBase64(mustEnv("TOKEN_ENC_KEY")), // 32 bytes = AES-256; fail fast if unset
  { name: "AES-GCM" },
  false, // non-extractable: the CryptoKey can't be read back out
  ["encrypt", "decrypt"],
);
 
export async function encryptToken(plaintext: string): Promise<string> {
  // A fresh 96-bit IV per encryption is mandatory for GCM — IV reuse under the
  // same key is catastrophic (it leaks the auth key). crypto.getRandomValues
  // is a CSPRNG, so we never derive IVs from counters or timestamps.
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const ct = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv },
    KEY,
    new TextEncoder().encode(plaintext),
  );
  return `${encodeBase64(iv)}.${encodeBase64(new Uint8Array(ct))}`;
}
 
export async function decryptToken(blob: string): Promise<string> {
  const [ivB64, ctB64] = blob.split(".");
  if (!ivB64 || !ctB64) throw new Error("malformed_ciphertext"); // defensive: never feed junk to subtle
  const pt = await crypto.subtle.decrypt(
    { name: "AES-GCM", iv: decodeBase64(ivB64) },
    KEY,
    decodeBase64(ctB64),
  ); // throws on tag mismatch — tampered data can't silently pass through
  return new TextDecoder().decode(pt);
}

Performance considerations: importKey runs once per cold start and the CryptoKey is reused; per-call cost is a single AES-GCM op (microseconds) — negligible next to the network hop to LinkedIn. Edge cases handled: missing key env fails the function on boot rather than silently storing plaintext; malformed ciphertext and tag-mismatch both throw before any downstream use. Possible improvements: wrap the format in a v1. version prefix now so key rotation (envelope encryption with a per-token DEK) is a non-breaking change later; today rotation would require a re-encrypt migration.

Idempotent dual-provider billing webhook normalizer

Two merchants-of-record (Lemon Squeezy + Dodo) send different payloads for the same conceptual events, and both retry aggressively. The normalizer collapses them into one internal entitlement event and makes processing idempotent on the provider's event id, so a retry (or a duplicate from an at-least-once delivery) can never double-provision or double-refund.

// supabase/functions/billing-webhook/normalize.ts
// Why: two providers, one entitlement state machine. Normalize first, then
// dedupe on (provider, event_id) so retries are safe by construction.
type EntitlementEvent =
  | { kind: "activate"; userId: string; plan: Plan }
  | { kind: "deactivate"; userId: string };
 
function normalize(provider: Provider, raw: unknown): NormalizedEvent {
  // Parse at the boundary with a per-provider Zod schema — never trust the
  // shape of an inbound webhook, even a signature-verified one.
  const evt = providerSchema[provider].parse(raw);
  return {
    // Composite key is the idempotency anchor. Same provider event delivered
    // twice → same key → second INSERT is a no-op.
    dedupeKey: `${provider}:${evt.id}`,
    entitlement: mapToEntitlement(provider, evt), // provider-specific → internal
  };
}
 
async function handle(provider: Provider, raw: unknown) {
  const { dedupeKey, entitlement } = normalize(provider, raw);
 
  // Claim the event atomically. ON CONFLICT DO NOTHING means the winner of a
  // race processes; every duplicate short-circuits. This is the whole trick:
  // idempotency lives in a UNIQUE constraint, not in application if-checks.
  const { rowCount } = await sql`
    insert into billing_events (dedupe_key, provider, payload)
    values (${dedupeKey}, ${provider}, ${raw as object})
    on conflict (dedupe_key) do nothing
  `;
  if (rowCount === 0) return; // already processed — safe to ack and drop
 
  await applyEntitlement(entitlement); // only ever runs once per real event
}

Why written this way: idempotency is enforced by a UNIQUE(dedupe_key) constraint rather than a read-then-write check, so it is correct even under concurrent duplicate deliveries (the classic TOCTOU bug in naive webhook handlers). Security: signature verification happens before this, on the raw body; normalize still re-validates shape with Zod because a valid signature only proves origin, not that the payload matches our assumptions. Possible improvements: the billing_events row is the natural audit log and replay source — a nightly reconciliation job could diff it against provider APIs to catch any event we never received at all.

Technical Challenges

  • Autonomous publishing without a server the user trusts. pg_cron dequeues scheduled posts and invokes the publish function, which decrypts the LinkedIn token in memory only. Getting the failure semantics right mattered more than the happy path, because "post to the world on the user's behalf" is not a safely-retryable operation. The classification I settled on: a network timeout after LinkedIn may have accepted the post is ambiguous, so publish is made idempotent with a client-generated dedupe token per scheduled post — a retry with the same token can't produce a double-post. A 401 means the OAuth token was revoked → don't retry, mark the connection stale and notify the user to reconnect. A 429 is honored with backoff. Only genuinely transient 5xx/timeouts retry (bounded), and anything that exhausts retries lands in a dead-letter state that surfaces in the UI rather than failing silently — a silently-dropped scheduled post erodes trust faster than a visible error.
  • Secret leakage into the client bundle. With a Next.js app calling edge functions, it is easy to accidentally import a server-only constant into a client component. I wrote a build-time client-bundle secret scanner that greps the emitted client chunks for key-shaped strings and fails the build.
  • Rate limiting a single-tenant-feeling but multi-tenant product. Generation is expensive; a 30 req/min/user limit protects cost and providers while staying invisible to normal use.
  • Normalizing two billing webhooks (Lemon Squeezy + Dodo) into one entitlement state without double-provisioning on retries — solved with idempotency keys keyed on provider event id.

Code Quality & Testing

For a solo build, tests are not about coverage vanity — they are the only thing standing in for the code reviewer I do not have. The strategy is deliberately weighted toward the two places a bug is expensive: the RAG boundary and the secret/tenancy boundary.

Type safety. TypeScript runs in strict mode (strictNullChecks, noUncheckedIndexedAccess) with no implicit any allowed to survive CI. But types only prove internal consistency — they say nothing about what a model, a webhook, or a browser actually sends. So every trust boundary is re-validated at runtime with Zod v4: inbound webhook payloads, edge-function request bodies, and the JSON shape Gemini returns for structured generations are all parsed, not cast. A z.infer keeps the static type and the runtime schema from drifting apart. The rule is simple: as is a smell at a boundary; parse is the default.

Testing strategy.

  • Unit + property-based (Vitest + fast-check). Pure logic — the billing entitlement state machine, chunking, prompt assembly — is tested with property-based tests rather than a handful of examples. fast-check found the bugs that mattered: e.g. that duplicate webhook deliveries in any interleaving must converge to the same entitlement (idempotency as an invariant, not a lucky path).
// billing.property.test.ts — idempotency is a property, not a test case.
import { fc, test } from "@fast-check/vitest";
 
test.prop([fc.array(billingEventArb, { minLength: 1, maxLength: 20 })])(
  "applying any permutation of the same events yields one entitlement",
  async (events) => {
    const a = await runNormalizer(events);
    const b = await runNormalizer(shuffle(events)); // reordered + duplicated
    expect(a.entitlement).toEqual(b.entitlement); // order/dupes must not matter
  },
);
  • API mocking (MSW). Gemini, LinkedIn, and both billing providers are mocked at the network layer with MSW, so tests exercise real fetch code paths — including timeouts, 429s, and malformed responses — without hitting a provider. This is where fallback and retry semantics get proven.
  • RLS cross-tenant isolation probes. An integration suite authenticates as user A and asserts that no query — direct table access or match_voice_chunks RPC — can return user B's voice chunks. This runs against a real Postgres with policies applied, because RLS correctness is a property of the database, not the app. It is the single most important test in the repo: a leak here is the product's worst failure mode.

Error handling & observability. Edge functions log structured events ({ event, userId, latencyMs, provider }) rather than free-text strings, so generation latency percentiles and provider error rates are queryable. Failures are typed (UnauthorizedWebhookError, ProviderTimeoutError) and mapped to deliberate HTTP semantics — a provider timeout is a 503 the client can retry, a bad signature is a 401 it must not.

Module & service boundaries. A hard no-server-actions policy: anything touching a secret or a model runs in a Deno edge function, never in a Next.js Server Action or route handler co-located with client code. This keeps the secret surface in one auditable place and makes the client bundle structurally incapable of importing a key. Shared crypto/validation lives in functions/_shared, imported by function, never by the client.

What CI enforces (fail-the-build gates).

  • tsc --noEmit in strict mode, ESLint, and the full Vitest suite.
  • The build-time client-bundle secret scanner (below) — the load-bearing gate. It runs after next build and greps the emitted client chunks for key-shaped strings and known env-var names. If a server-only secret ever leaks into a client chunk, the build fails before it can ship.
# scripts/scan-client-bundle.sh — runs in CI after `next build`.
# Why: a single stray `import` of a server constant into a client component
# can ship a secret to every browser. This is the backstop for that mistake.
set -euo pipefail
 
CHUNKS=".next/static/chunks"
# Patterns: provider key prefixes + our own server-only env var names.
PATTERNS='sk-[A-Za-z0-9]{20,}|AIza[0-9A-Za-z_-]{35}|TOKEN_ENC_KEY|SUPABASE_SERVICE_ROLE'
 
if grep -REn --include='*.js' "$PATTERNS" "$CHUNKS"; then
  echo "::error::secret-shaped string found in client bundle — failing build"
  exit 1
fi
echo "client bundle clean"

Honest limitation: this scanner is a blunt regex, not an AST check, so it can false-positive on a variable literally named like an env var and false-negative on a secret that has been transformed before emission. It is a backstop, not a proof — the real defense is the no-server-actions boundary above. Moving it to an AST-based check on the module graph is queued tech debt.

Benchmarks

Streaming latency is the number that defines the product feel. Cold edge invocations sit around 800ms to first token; warm invocations drop to ~310ms; and when the assembled voice context is cached, first token lands near 190ms. Retrieval via match_voice_chunks is comfortably sub-100ms at p95, which keeps it off the critical path. The Voice DNA auto-retrain fires every 5 new samples, trading a small background embedding cost for continuously sharper style matching. See the charts above for the full series.

AI generation latency (streamed first token) (ms)
Voice similarity retrieval (ms)
5samples

Lessons Learned

  • What succeeded: keeping vectors in Postgres. Reusing RLS for tenancy on embeddings removed an entire class of "did I leak a user's data" anxiety.
  • What failed first: my initial non-streaming generation felt broken even though it was fast — perceived latency is a feature, and streaming fixed it.
  • What I would redesign: the billing normalization grew organically across two providers; I would model the entitlement state machine explicitly up front next time.
  • Remaining tech debt: IVFFlat index tuning is manual; HNSW migration is queued. The secret scanner is a blunt regex and should move to an AST-based check.
  • Future improvements: multi-platform publishing (X, threads), and a "voice drift" detector that flags when generated output strays from the DNA.

Media

  • Product walkthrough recording of the composer streaming on-voice output.
  • Screenshots of the Voice DNA training screen and the auto-publish scheduler.
  • Architecture diagram of the RAG + edge-function + pg_cron pipeline.
  • Live product: thefoundervoice.com
  • FounderVoice is a closed-source commercial SaaS; internal architecture and runbooks are maintained privately.

Build Logs

  • Requirements & planning. Framed the entire product as one bet: output must sound like the user. Everything else (billing, publishing) is table stakes. Chose Supabase to compress backend surface area for a solo build.
  • Architecture. Decided vectors live in Postgres; edge functions own secrets and models; RLS is the tenancy boundary.
  • Implementation. Built the sample → chunk → embed pipeline, then match_voice_chunks, then streaming generation. Added LinkedIn OAuth with HMAC-signed state and AES-256-GCM token encryption.
  • Testing. Verified RLS isolation with cross-tenant probes; added the client-bundle secret scanner to the build.
  • Deployment. Vercel for the app, Supabase for backend, pg_cron for auto-publishing. Wired dual billing webhooks with idempotency.
  • Monitoring. Track generation latency percentiles and provider error rates; rate-limit at 30 req/min/user.
  • Retrospective. The vector-in-Postgres decision paid for itself; the billing layer is the next refactor.