diff --git a/plans/chipotle-lambda-parity.md b/plans/chipotle-lambda-parity.md new file mode 100644 index 00000000..717dacef --- /dev/null +++ b/plans/chipotle-lambda-parity.md @@ -0,0 +1,351 @@ +# Chipotle: Lambda-Parity for Frontend-Callable Actions + +Status: design proposal / work plan +Author: Chris (with Claude) +Date: 2026-06-04 +Related: [private-apps-backend.md](./private-apps-backend.md) — this is the +gateway-side dependency that lets that plan drop its relay requirement. + +## Goal + +Make a Chipotle **usage API key safe to embed directly in a frontend**, with +abuse/spend blast-radius control on par with a **public AWS Lambda Function URL**. +Achieving this lets "private apps" host only a frontend and call Lit Actions +directly — no relay needed. + +## The framing (why this is achievable) + +A public Lambda URL is itself a credential-free, internet-callable endpoint. +Anyone can hit it; AWS's protection is **not secrecy**, it's **bounded, +configurable blast radius**: the URL invokes only that one function, throttled, +concurrency-capped, with budgets + alarms. A determined griefer can still run up +*capped* cost — parity means the worst case is a number you chose and got alerted +about, not "drains the account." + +Two things are notably **out of scope of the risk** and must stay that way: + +- **Confidentiality is unaffected.** Action plaintext/secrets are TEE- and + CID-gated. Griefing a public key burns money/availability; it can never read + encrypted data. Nothing here touches that guarantee. +- **CID scoping already bounds *what* runs.** Usage keys are group-scoped + (`execute_in_groups`), groups pin exact CIDs, and execute permission is checked + on-chain (`accounts/mod.rs::can_execute_action`). A leaked key on a group that + pins only your public actions can run **only those actions** — nothing else, + no account management. This is the "Function URL → one function" property, and + it already exists. + +So the gaps are all about bounding **rate, concurrency, and spend per key**, plus +defense-in-depth. + +## What already exists (verify + document, don't rebuild) + +| Capability | Where | Status | +|---|---|---| +| Per-key CID scoping (leak radius = pinned actions) | `accounts/mod.rs::can_execute_action`; group `cid_hashes` | ✅ works | +| Global overload shedding → 429 | `core/v1/guards/cpu_overload.rs` (CPL-202, commit d2743fdb) | ✅ but global, not per-key | +| Account-wide prepaid credits → 402 when empty | `core/v1/guards/billing.rs::BilledLitActionApiKey`; `stripe.rs` | ✅ but account-wide, crude | +| Per-second execution charge | `actions/client/execution.rs::flush_unbilled_seconds`; `stripe.rs` `COST_LIT_ACTION_PER_SECOND_CENTS` | ✅ | + +## Architecture: where state lives & the zero-latency path + +The hot path today is already built on one principle, and everything here follows +it: **authoritative data lives in a slow store (chain or Stripe); the request path +only ever touches an in-process [moka](https://docs.rs/moka) cache with TTL + +stale-while-revalidate (SWR) + request coalescing; staleness is tolerated within +the TTL; money/usage settles asynchronously off the response path.** We are not +inventing an architecture — we are adding cached fields that obey the existing one. + +What the code already establishes (verified): + +- **On-chain permission reads are already cached on the hot path.** + `accounts/blockchain_cache.rs` caches `canExecuteAction` / `canUseWalletInAction` + / wallet derivation (60-min TTL, per-account **generation-counter** invalidation + bumped on any write). The key's on-chain record is in memory when a request runs. +- **Stripe reads are cached too.** `stripe.rs`: `wallet_cache` (key→wallet, 1h), + `customer_cache` (10m), `balance_cache` (10m TTL + **SWR** + background refresh + + coalescing). The credit check in `BilledLitActionApiKey::from_request` is a + memory read after warmup. +- **Charging is off the response path.** `stripe::charge()` reads the cached + balance, does an **optimistic in-memory decrement**, records the event, and + `spawn`s the real Stripe balance transaction fire-and-forget (there's a + `billing.charge.settlement_failed` metric for when it doesn't land). + `flush_unbilled_seconds` awaits only the cache ops, not the network. So + per-request charging costs microseconds, not a round trip. + +### The zero-latency gate + +The on-chain key record is **already read and cached** on the hot path (it's what +`canExecuteAction` returns). So we pack a single **`hasSpendingRules` bit** into +that record. Reading it costs **zero** extra network and zero extra cache lookup — +it rides along in data already in memory. Fetched in the **same multicall** as +`canExecuteAction` (trivial Solidity change — return multiple values), even a cold +cache miss adds no extra round trip. + +``` +guard (before execution): + (canExec, hasSpendingRules) = blockchain_cache.permissions(keyHash) # 1 cached multicall + if !hasSpendingRules: return Success # ← the 99% with no caps: ZERO added latency + else: enforce rules (all in-memory; see below) +``` + +### Where each kind of state lives + +The three things we store have different shapes (config vs. counter, write-rarely +vs. write-per-request), so they don't share one home: + +| Data | Shape | Home | Hot-path read | +|---|---|---|---| +| `hasSpendingRules` flag | 1 bit, toggled rarely | **On-chain** (opt 2), in the key record | Free — already in `BlockchainCache` | +| Cap + window, rate/burst, concurrency, IP/origin allowlist | Config, edited occasionally | **lit-payments DB** (opt 3), edited via its backend+frontend | New moka cache (TTL+SWR), **only read when flag set** | +| Per-key cumulative spend (rolling window) | High-write counter, durable | **lit-payments DB**, written on the existing **async** charge path | Optimistic in-memory decrement (mirrors `balance_cache`) | +| Rate-limit token buckets, concurrency counts, per-IP counters | High-write, ephemeral, rolling | **In-process memory** (per-node) | Native — already in memory | + +**Why this split:** + +- **Flag on-chain, not the cap value.** The flag is a permission (belongs with the + others) and is the one thing that must be free to read for *everyone*. Toggled + only when a key gains/loses its first rule (1 tx, rare). Keeping the cap *value* + off-chain means tuning caps in the lit-payments UI needs no gas/tx, and the DB + read is gated behind the flag so non-cap accounts never touch it. +- **Rule details + per-key usage in the DB, not Stripe metadata.** Stripe metadata + is per-*customer* (account), not per-key; writing it is an API call; it's the + wrong tool for rolling windows, rate config, or analytics. Keep Stripe for the + money relationship that already exists (opt 1 stays as-is). +- **Counters in memory, per-node.** Rate buckets / concurrency counts can't go + on-chain (per-request writes) or in Stripe. Per-node is acceptable because the + **durable spend cap is the real backstop**; per-node rate limiting yields an + effective cluster rate of ≈ limit × N nodes, which is fine for griefing control + (AWS API Gateway throttling is approximate too). Start per-node; add a shared + store (Redis-like) only if cluster-exact limits are ever required. + +### Request flow for an opted-in key + +``` +1. blockchain_cache: (canExecuteAction, hasSpendingRules) 1 cached multicall +2. flag set → rules_cache.get(keyHash) memory; DB read only on cold miss (SWR) +3. spend_cache.get(keyHash) vs rolling cap memory; reject 402 if over +4. rate bucket + concurrency counter memory; reject 429/503 if over +5. origin / IP allowlist check memory; reject 403 if not allowed +6. execute +7. POST-response, in the existing spawned settlement task: + - Stripe balance txn (already there) + - increment per-key rolling spend in DB + optimistic in-mem decrement +``` + +Steps 1–5 are memory reads (low microseconds). Step 7 is entirely off the response +path. An opted-in key pays one cold DB read per ~TTL window; a no-cap key pays +nothing. The staleness tolerance is the same bargain CPL-246 already accepted for +balances: a tiny possible overspend inside the TTL window in exchange for a fast +hot path. + +### Resolved design decisions + +1. **Cap semantics → rolling window** (match AWS Budgets / Lambda), not a lifetime + balance. The per-key spend counter resets on the window boundary. +2. **Rules-cache freshness → SWR** (short TTL + background refresh, no cross-service + invalidation plumbing), mirroring `balance_cache`. A cap edit takes effect + within the TTL window. +3. **Counter durability → per-key spend reloads from the DB on cache miss** + (durable across deploys/restarts); rate/concurrency buckets may reset on restart + (acceptable — the spend cap is the durable backstop). +4. **`hasSpendingRules` is fetched in the same contract multicall as + `canExecuteAction`** so a cold cache miss adds no extra round trip (a simple + Solidity change to return multiple values). +5. **"Turn rules on" ordering:** write the DB rule first, *then* flip the on-chain + bit. The bit going true is what activates enforcement, so this ordering avoids a + window where the flag is set but rules aren't loaded. + +## Work items, in priority order + +Priority = how much it bounds the worst case per dollar of effort. P0 items are +the ones that turn "drains the account" into "burns a number you chose." + +--- + +### P0.0 — `hasSpendingRules` flag + multicall (foundation for everything below) + +**What.** A single `hasSpendingRules` bit on the on-chain key record, returned in +the **same multicall** as `canExecuteAction`, surfaced through `BlockchainCache`, +and a request guard that short-circuits to "no enforcement" when it's false. + +**Why first.** This is the zero-latency gate. Every per-key control below +(spend cap, rate, concurrency, origin) reads it to decide whether to do *any* extra +work. Land it first so the rest can assume "if we got here, rules exist." It also +guarantees the headline property: **keys with no rules pay zero added latency.** + +**Build.** +- Solidity: extend the `canExecuteAction` read to also return `hasSpendingRules` + (decision 4 — just more return values). +- `accounts/blockchain_cache.rs`: cache the bit alongside the existing + `execute_action` / `execute_and_wallet` results (same generation-counter + invalidation, so flipping it bumps the generation and is picked up immediately). +- A request guard (sibling to `BilledLitActionApiKey` / `cpu_overload`) that reads + the cached bit and returns `Success` immediately when false. + +**Acceptance.** A key with no rules executes with no extra reads beyond today's +cached permission check; flipping the bit on-chain takes effect on the next request +(generation bump), no redeploy. + +--- + +### P0.1 — Per-key spend cap with auto-disable ⭐ highest leverage + +**What.** A usage key carries its own budget (e.g. credits or a cents cap over a +window). Each execution decrements it; when exhausted the key is rejected (and +flagged disabled), independent of the account's overall balance. + +**Why it's #1.** This is the single control that converts the failure mode from +"a leaked frontend key can spend the whole account" into "a leaked key can spend +*its* budget, then stops." It's the analog of an AWS per-function/per-budget cap. + +See [Architecture: where state lives](#architecture-where-state-lives--the-zero-latency-path) +for the storage split this builds on. The cap is a **rolling window** (decision 1): +a per-key spend-to-date counter that resets on the window boundary, checked against +a cap configured in the lit-payments DB, gated by the on-chain `hasSpendingRules` +flag (P0.0) so non-cap keys pay zero latency. + +**Build.** +- **On-chain:** the `hasSpendingRules` bit lands via P0.0 (fetched in the + `canExecuteAction` multicall). The existing no-op `balance` field on the key + (`add_usage_api_key` in `core/v1/models/request.rs`; hardcoded `10_000_000` in + `account_management.rs`, never read) can be repurposed as the flag or retired. +- **DB (lit-payments):** store the cap + window per key; track the per-key rolling + spend-to-date. New endpoints on the lit-payments backend to read/set them. +- **Hot path (`guards/billing.rs`):** when the flag is set, read the cached rules + + cached per-key spend (both in-memory; cold miss = one DB read, SWR) and reject + with **402** if the rolling spend would exceed the cap. Mark the key disabled for + the remainder of the window so subsequent calls short-circuit. +- **Async (off response path):** in the existing spawned settlement task that + already writes Stripe (`stripe::charge`), also increment the per-key rolling + spend in the DB and apply the optimistic in-memory decrement (mirror + `balance_cache`). Per-key spend reloads from the DB on cache miss (decision 3). + +**Acceptance.** A key with a $X/window cap, hammered in a loop, stops at ~$X spent +within the window and is rejected until the window rolls; the account's other keys +and overall balance are untouched; a key with no rules sees no added latency. + +--- + +### P0.2 — Per-key + per-IP rate limiting (rate + burst) + +**What.** Token-bucket throttle keyed on the API key, and a coarser one keyed on +client IP, on the `/core/v1/lit_action` path. Configurable rate + burst per key. + +**Why.** Today the only throttle is the **global** CPU-overload 429 — it protects +the node, not your wallet, and one abusive key can still spend fast within global +capacity. This is API Gateway's per-key/per-stage throttling. + +**Build.** +- A request guard (sits with the other `core/v1/guards/`) that runs before + execution, after key resolution. Reuse the existing 429 response plumbing from + CPL-202 so OpenAPI/docs stay consistent. +- Limits per key default to sane values, overridable per key (store next to + `balance`). +- Per-IP limit as a second bucket (defense against many anonymous callers on one + public key). + +**Acceptance.** A key exceeding its configured rate gets 429 with a `Retry-After`; +other keys unaffected; the global CPU guard still independently sheds load. + +**Decided.** Bucket store is **in-process, per-node** (decision in Architecture): +effective cluster rate ≈ limit × N nodes, acceptable for griefing control since +the spend cap (P0.1) is the durable backstop. Add a shared store (Redis-like) only +if cluster-exact limits are ever required. Buckets gated behind `hasSpendingRules` +(P0.0) so non-rule keys are untouched. + +--- + +### P1 — Per-key concurrency cap (reserved-concurrency analog) + +**What.** Cap simultaneous in-flight executions per key. + +**Why.** Rate limits bound requests/sec; a concurrency cap bounds *simultaneous* +spend and node load — the thing AWS reserved concurrency exists for. Cheap ins- +urance once P0.2 exists; together they tightly bound burn velocity. + +**Build.** A counter (incremented at execution start, decremented at end/timeout) +in the execution dispatch path (`actions/client/execution.rs` / the dispatch in +`lit-actions/server`), checked against the key's configured max. Over-cap → +429/503. + +**Acceptance.** A key with concurrency=N never has >N actions running at once; +the N+1th waits or is rejected per policy. + +--- + +### P2.1 — Per-key origin allowlist (replace wildcard CORS) + +**What.** Each key (esp. public ones) carries an allowed-origins list; the gateway +sets/validates CORS against it instead of today's wildcard. + +**Why.** Today CORS is `AllowedOrigins::all()` (`main.rs`). An origin allowlist is +standard hygiene for a browser-callable endpoint and stops casual cross-site +reuse. **Defense-in-depth only** — `Origin`/`Referer` are browser-enforced and +trivially spoofed by non-browser clients, so it rides *on top of* P0.1/P0.2, +never instead of them. + +**Build.** Add `allowed_origins` to the key model; per-request CORS reflection + +rejection in the Rocket CORS layer / a guard. Empty list = same-as-today (or +deny, for `public` keys — see P2.2). + +**Acceptance.** A public key configured for `app.example.com` rejects browser +calls from other origins; server-to-server calls still work (documented as not a +security boundary). + +--- + +### P2.2 — "Public / frontend-safe" key type + Dashboard guardrails + +**What.** A flag marking a key as intended for the browser. The Dashboard (and +API) then *require* the blast-radius controls to be set before issuing it: a spend +cap (P0.1), rate limits (P0.2), concurrency cap (P1), and an origin allowlist +(P2.1). + +**Why.** Makes the safe path the default path. Without this, someone ships an +unbounded key to the browser and we're back to square one. This is the DX glue +that makes the whole effort land. + +**Build.** A `public: bool` (or key class) on the key model; validation that +public keys have non-default caps; Dashboard UX that surfaces "this key is +frontend-safe; here's your max blast radius: $X/day, N rps, M concurrent." + +**Acceptance.** Creating a public key without caps is refused with a clear message; +the Dashboard shows the bounded worst case for a public key at a glance. + +--- + +### P3 — Optional hardening (post-parity) + +- **Alerting / budget alarms.** Spend-velocity alerts and a notification when a + key auto-disables (the CloudWatch-alarm analog). Parity = bounded worst case + **+ you find out**. +- **App-check / PoW / captcha** for *unauthenticated* public endpoints, to raise + the cost of scripted abuse before it hits the spend cap. +- **Per-IP WAF-style rules** (geo, known-bad ranges, anomaly) beyond the P0.2 + bucket. +- **Gateway authorizer hook** for teams that want auth enforced at the edge in + addition to in-action. + +## Suggested sequencing + +``` +P0.0 hasSpendingRules flag + multicall ← foundation; the zero-latency gate +P0.1 per-key spend cap (rolling) + auto-disable ← biggest blast-radius cut +P0.2 per-key + per-IP rate/burst ← parallel-able with P0.1, both gated by P0.0 + └─ P1 per-key concurrency cap ← small add once P0.2 lands +P2.1 per-key origin allowlist ← independent; defense-in-depth +P2.2 public key type + Dashboard caps ← depends on P0.0/P0.1/P0.2/P1/P2.1 existing +P3 alarms / app-check / WAF / authz ← post-parity hardening +``` + +After **P0.0 + P0.1 + P0.2 + P1 + P2.2**, a usage key is safe to ship in a frontend with +bounded, configurable blast radius — Lambda parity — and +[private-apps-backend.md](./private-apps-backend.md) can make its relay optional. + +## The honest caveat (same one AWS has) + +None of this makes a public endpoint un-griefable. A determined attacker can run +up cost *within the caps you set*. Parity is **bounded, configurable worst case + +alerting**, not zero risk. The caps (P0/P1) make the worst case a number you chose; +the alarms (P3) make sure you hear about it. That is exactly what AWS reserved +concurrency + Budgets + CloudWatch deliver — and what this plan brings to Chipotle. diff --git a/plans/private-apps-backend.md b/plans/private-apps-backend.md new file mode 100644 index 00000000..de610bed --- /dev/null +++ b/plans/private-apps-backend.md @@ -0,0 +1,416 @@ +# Private Apps on Lit — A Backend-as-Lit-Actions Framework + +Status: design proposal / RFC +Author: Chris (with Claude) +Date: 2026-06-04 + +## The pitch + +Let a developer host only a frontend (Vercel, Netlify, S3, IPFS — anywhere) and +have **every backend route run as a Lit Action**, with application data living in +Postgres where **row contents are encrypted at rest and only ever decrypted +inside the TEE**, on an as-needed basis. Index fields stay plaintext so the +database can still filter, sort, and join. The result: a "serverless backend" +where the people running the database and the relay **cannot read the data**, and +where the exact code that touches plaintext is auditable by its IPFS CID. + +This is the natural generalization of `examples/dark-pool/` — which already +proves the "encrypted Postgres + decrypt-and-compute-in-TEE" mechanic for one +narrow use case. This plan turns that mechanic into a reusable **framework** for +building arbitrary private CRUD apps. + +## The form-factor question (the honest answer up front) + +> "Is the best form factor a JS library people import into their web app and use +> as the backend instead of hosting a backend elsewhere?" + +**Almost — but a pure frontend-only library is not possible, and it's worth being +precise about why.** Running a Lit Action requires a **usage API key** that gates +execution at the gateway (see `examples/dark-pool/scripts/setup.js:217` +`createUsageApiKey`, scoped to `execute_in_groups`). That key is a secret. If you +ship it in the browser bundle, anyone can pull it and run your actions / drain +your account. So you cannot eliminate the server entirely. + +What you **can** do is shrink the server to a **stateless ~50-line relay** that: + +- holds the usage API key, +- maps a route name → an action CID, +- forwards `{ route, args, userAuth }` to `POST /core/v1/lit_action`, +- returns the response verbatim. + +The relay holds **no application logic, no database credentials, and never sees +plaintext**. All of that lives in Lit Actions and the TEE. So the *effective* +answer to the user is: "you write your backend as a library of route handlers, +and the only thing you deploy yourself is a trivial relay (or use ours)." + +**Therefore the recommended form factor is a framework, not a single library** — +made of four pieces: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 1. @lit/private-app in-action runtime (imported into the │ +│ action code via jsDelivr ESM): │ +│ router · encrypted-ORM · Neon client · │ +│ auth/session verification · decrypt- │ +│ DB-url helper │ +│ │ +│ 2. lit-app (CLI) bundle routes → action(s), compute │ +│ CIDs, mint vault PKP + group + usage │ +│ key, run migrations, write config, │ +│ deploy the relay. Wraps setup.js. │ +│ │ +│ 3. @lit/private-app/client frontend SDK: typed RPC to your routes │ +│ through the relay; user auth helpers │ +│ │ +│ 4. relay stateless edge function (Cloudflare / │ +│ Vercel) OR a tiny Express app — holds │ +│ the usage key, forwards calls │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +(Working name TBD; "Salsa" / "Burrito" fit the existing `chipotle` theme of the +API host `api.chipotle.litprotocol.com`. Use a placeholder `@lit/private-app` +below.) + +## Non-goals + +- **Replacing Postgres-the-database.** We use a SQL-over-HTTP provider (Neon, or + any HTTP-fronted Postgres). A Lit Action only has `fetch` — it cannot open a + raw TCP Postgres socket (see `examples/dark-pool/README.md:79`). +- **Full encrypted-query / homomorphic search.** Equality search on sensitive + columns is supported via blind indexes; range queries and full-text search on + encrypted columns are out of scope. +- **Hiding metadata.** Plaintext index columns, row counts, and timing are + visible to the DB operator — same caveat as the dark pool + (`examples/dark-pool/README.md:102` privacy table). We document this loudly, + we don't pretend to solve it. +- **A new persistence engine inside the TEE.** Actions stay stateless; all state + is in Postgres. + +## How a developer builds an app (target DX) + +The whole point is that this should *feel* like writing Next.js API routes or a +tiny Express app — the privacy is mechanical, not something the dev hand-rolls. + +### 1. Define models — declare which fields are encrypted vs. indexed + +```js +// app/models.js +import { defineModel, indexed, encrypted } from "@lit/private-app"; + +export const Note = defineModel("notes", { + id: indexed.uuid(), // plaintext PK + owner: indexed.address(), // plaintext — filterable / joinable + createdAt: indexed.timestamp(), // plaintext — sortable + title: encrypted.text(), // sealed at rest + body: encrypted.text(), // sealed at rest + tags: encrypted.json(), // sealed at rest + emailHash: indexed.blind(), // HMAC blind index — equality-searchable + // without exposing the email +}); +``` + +- `indexed.*` → a real plaintext column. You can `WHERE` / `ORDER BY` / `JOIN` + on it. The DB operator can read it. +- `encrypted.*` → folded into **one** `ciphertext` column per row (see Data + model below). Never queryable; only visible inside the TEE. +- `indexed.blind()` → stores `HMAC(secret, value)`. Lets you do exact-match + lookups (`where({ emailHash: blind(email) })`) on an otherwise-secret value + without ever storing it in the clear. The HMAC key is itself an encrypted + secret decrypted in-action. + +### 2. Write routes — plain async handlers + +```js +// app/routes/notes.js +import { route } from "@lit/private-app"; +import { Note } from "../models.js"; + +export const create = route(async (ctx, { title, body, tags }) => { + const user = await ctx.requireUser(); // verifies the session + return Note.insert({ + owner: user.address, title, body, tags, createdAt: ctx.now, + }); // auto-encrypts title/body/tags +}); + +export const list = route(async (ctx, { limit = 20, cursor }) => { + const user = await ctx.requireUser(); + // Filters on the PLAINTEXT `owner` index, paginates, then decrypts only the + // rows on this page — keeps decrypt count bounded (see Performance below). + return Note.where({ owner: user.address }) + .orderBy("createdAt", "desc") + .paginate({ limit, cursor }) + .all(); // auto-decrypts the page +}); + +export const remove = route(async (ctx, { id }) => { + const user = await ctx.requireUser(); + const note = await Note.find(id); + if (!note || note.owner !== user.address) throw ctx.forbidden(); + return Note.delete(id); +}); +``` + +### 3. Deploy + +```bash +npx lit-app deploy +``` + +The CLI (wrapping the `examples/dark-pool/scripts/setup.js` flow) does: + +1. Bundle each route file into Lit Action source (the `@lit/private-app` + in-action runtime is inlined via the existing SWC bundler / jsDelivr imports). +2. Compute each action's CID (`get_lit_action_ipfs_id`). +3. Mint the **vault PKP** (encrypts row contents + the DB URL + the blind-index + HMAC key) — `create_wallet`. +4. Create a **group**, add the PKP, **pin every route's CID** (no wildcard — + `cid_hashes_permitted: []` then explicit `add_action_to_group`, exactly as + dark-pool does at `setup.js:99-104`). +5. Mint a **usage API key** scoped to `execute_in_groups: [groupId]`. +6. Encrypt `DATABASE_URL` (and the HMAC key) against the vault PKP; store only + ciphertext. +7. Run migrations to create the plaintext index columns + the `ciphertext` + column for each model. +8. Write `.lit-app/config.json` (route → CID map, PKP id, encrypted DB url, + group id) and deploy/print the relay with the usage key as its only secret. + +### 4. Call it from the frontend + +```js +// web/api.js +import { createClient } from "@lit/private-app/client"; +export const api = createClient({ url: "/api" }); // points at the relay + +// anywhere in the app: +await api.notes.create({ title: "secret", body: "...", tags: ["x"] }); +const page = await api.notes.list({ limit: 20 }); +``` + +`api.notes.create` → `POST /api` `{ route: "notes.create", args, auth }` → relay +adds the usage key → `/core/v1/lit_action` with `js_params` → the `notes.create` +action runs in the TEE → returns the result. + +## Architecture + +``` + browser (frontend only) relay (stateless, ~50 LOC) + ┌────────────────────┐ POST /api ┌───────────────────────┐ + │ @lit/private-app/ │ {route,args,auth} │ holds usage API key │ + │ client ├─────────────────────►│ route → CID lookup │ + │ - typed RPC │ │ injects api key │ + │ - user auth (sign │ └──────────┬────────────┘ + │ in w/ wallet/JWT)│ │ POST /core/v1/lit_action + └────────────────────┘ │ {code|ipfs_id, js_params} + ▼ + ┌──────────────────────────────┐ + │ TEE: Lit Action (one per │ + │ route, pinned CID) │ + │ @lit/private-app runtime: │ + │ 1. verify ctx.user (auth) │ + │ 2. Decrypt(DB url) │ + │ 3. query Neon over HTTPS │ + │ (filter on plaintext idx) │ + │ 4. Encrypt on write / │ + │ Decrypt the page on read │ + │ 5. return result (no secrets)│ + └───────────────┬──────────────┘ + │ SQL over HTTPS + ▼ + ┌──────────────────────────────┐ + │ Neon Postgres │ + │ idx cols: plaintext │ + │ ciphertext col: opaque blob │ + └──────────────────────────────┘ +``` + +The relay operator and the DB operator each see only ciphertext + plaintext index +metadata. Plaintext app data exists **only** in TEE memory during a call. + +## Data model: one ciphertext per row + +The single most important design decision, driven by Lit's limits (below): +**bundle all `encrypted.*` columns of a row into one JSON blob and seal it as a +single ciphertext column.** Not one ciphertext per field. + +```sql +CREATE TABLE notes ( + id uuid PRIMARY KEY, + owner text NOT NULL, -- indexed.address + created_at timestamptz NOT NULL, -- indexed.timestamp + email_hash bytea, -- indexed.blind (HMAC) + ciphertext text NOT NULL -- Encrypt(JSON{title, body, tags}) +); +CREATE INDEX ON notes (owner, created_at DESC); +``` + +- **Write** = 1 `Lit.Actions.Encrypt` call per row. +- **Read** = 1 `Lit.Actions.Decrypt` call per row returned. + +This keeps the count of cryptographic remote ops equal to *rows touched*, not +*rows × encrypted-columns*. The ORM hides the (de)serialization; the dev just +sees fields. + +Cost of this choice: rotating or selectively disclosing a single encrypted field +means re-encrypting the whole row blob (acceptable for app data; documented). + +## Auth & per-user isolation + +Two layers, both needed: + +1. **Gateway** — the relay's usage key is required to run any action at all. + Random internet traffic without the relay can't execute routes. +2. **In-action user identity** — `ctx.requireUser()` verifies an end-user + credential *inside the action*. Options the runtime supports: + - **Wallet signature**: client signs a session challenge (nonce + deadline); + the action recovers it with `ethers.utils.verifyMessage` and exposes + `ctx.user.address` (the pattern in `docs/lit-actions/patterns.mdx:231` and + `examples/dark-pool` order auth). Also available: `Lit.Auth.authSigAddress`. + - **App JWT / session token**: action `fetch`es the app's auth endpoint to + verify, à la `docs/lit-actions/patterns.mdx:318`. + +Row ownership is then enforced in the handler (`note.owner === ctx.user.address`) +— the gateway proves *a* legitimate caller; the in-action check proves *which* +user and what they may touch. This mirrors dark-pool's "the usage key lets you +run the action, but per-record authority comes from a signature verified in the +enclave." + +**Stronger isolation (optional, phase 4+):** a **vault PKP per user** so each +user's rows are encrypted under a distinct key (`docs/lit-actions/patterns.mdx:291` +shows `user-alice-data-vault`). This gives crypto-level blast-radius isolation but +multiplies key/group management and runs into PKP-per-user scale; default to +**one app vault PKP** and gate by `owner`, offer per-user vaults as an advanced +mode. + +## Performance & limits (the real constraints) + +From `docs/lit-actions/limits.mdx`: + +| Limit | Default | Implication for the framework | +|---|---|---| +| Execution time | 15 min | fine for CRUD | +| Memory | 64 MB | bounds page size / payload | +| Outbound HTTP per action | **50** | each Neon query is 1 fetch; budget queries per route | +| Response payload | **1 MB** | hard cap on a page of decrypted rows | +| Console log | 100 KB | never log plaintext anyway | +| **Key/signature requests per action** | **10** | ⚠️ **see open question** | + +Two things shape the ORM: + +1. **Decrypt is a remote op.** Each `Decrypt`/`Encrypt` is a gRPC round-trip to + the node (`lit-actions/ext/bindings.rs`), and recent work explicitly targets + this cost (`a8a69edd perf: avoid deferred Lit Action remote ops`). So: filter + and paginate on **plaintext index columns first**, then decrypt only the rows + you actually return. Never "decrypt the table to filter it." + +2. **The per-action key/signature cap.** Docs say **10**. But `matchEpoch.js` + decrypts the DB URL **plus every order in a batch (up to 200)** in a single + action and works — so either `Decrypt` against a PKP-derived symmetric key + does **not** count toward that cap, or the cap is raised per account. **This is + load-bearing and must be confirmed** (see Open questions). The framework should + (a) minimize decrypts regardless, and (b) expose a `maxPageSize` the CLI can + tune to the account's real cap; if the cap genuinely binds at ~10, the default + page size shrinks and large lists paginate. + +Other notes: +- **No connection pooling** (stateless actions) — Neon HTTP is per-call; this is + inherent and acceptable for Neon's serverless driver. +- **Cold start** — first call to a freshly-bundled action pays bundle/CID + resolution; warm thereafter (the API server LRU-caches code by CID). + +## Security & trust model (state it plainly, like dark-pool does) + +- **You trust the TEE.** Plaintext exists in enclave memory during a call. + TEEs have known side-channel attacks (`examples/dark-pool/README.md:170`). +- **You trust the pinned action CIDs.** A permitted action *can* exfiltrate + plaintext if its code chooses to (`docs/lit-actions/secrets.mdx:18`). The + framework's value is that the in-action runtime is open and the deployed CIDs + are auditable — `lit-app deploy` prints them, and you can diff a CID against + the published `@lit/private-app` version. **The CLI must make "what code is + pinned" trivially verifiable**, or the privacy claim is hand-wavy. +- **Metadata leaks.** Index columns, row counts, and timing are visible to the + DB operator. `indexed.blind()` mitigates *value* exposure for equality columns + but not existence/count/timing. Document per-model what's plaintext. +- **The relay is untrusted for confidentiality** (sees only ciphertext-bound + traffic) but **is trusted for availability and rate-limiting** (it holds the + usage key; if compromised, an attacker can run your actions / burn your + account quota, but cannot read data). Recommend the relay also rate-limits and + optionally checks the user credential cheaply before forwarding. + +## Where this lives / build order + +Mirror how `dark-pool` proved the mechanic before generalizing: + +- **Phase 0 — Reference app (validates the whole pattern end-to-end).** + Add `examples/private-app-starter/` — a runnable private notes (or chat) app: + models, a few routes, the setup script (forked from dark-pool's), a tiny relay, + and a minimal frontend. No framework abstraction yet; hand-written. Proves + encrypt-on-write / decrypt-the-page / plaintext-index-filter / wallet-auth all + work together and surfaces the real decrypt-count behavior. + +- **Phase 1 — `@lit/private-app` in-action runtime.** + Extract from Phase 0: `defineModel`, `indexed`/`encrypted`/`blind` field types, + the query builder (`where/orderBy/paginate/insert/find/delete`), the Neon HTTP + client (lifted from `matchEpoch.js:253`), the row encrypt/decrypt codec, the + `route()` wrapper + `ctx` (`requireUser`, `now`, `forbidden`). Lives under + `lit-actions/packages/` next to `naga-la-types`. Ship TS types. + +- **Phase 2 — `lit-app` CLI.** + Generalize `dark-pool/scripts/setup.js` into a project-aware tool: discover + models + routes, bundle, compute CIDs, mint PKP/group/usage key, pin CIDs, run + migrations, write `.lit-app/config.json`, print/deploy the relay. Add + `lit-app migrate`, `lit-app verify` (re-derive and diff pinned CIDs), and + `lit-app dev` (local loop). + +- **Phase 3 — client SDK + relay templates.** + `@lit/private-app/client` (`createClient`, typed route proxies, wallet/JWT + sign-in). Relay templates for Cloudflare Workers, Vercel Edge, and a tiny + Express app (this repo is literally `lit-node-express` — an Express template is + the natural reference). + +- **Phase 4 — hardening / advanced.** + Per-user vault PKPs; blind-index range tricks; migration/rotation tooling; a + `lit-triggers`-driven background-job story (cron/webhook routes, since triggers + already exist in `examples/lit-triggers/`); request-level rate limiting in the + relay. + +## Open questions (resolve before/within Phase 0) + +1. **Does `Lit.Actions.Decrypt` count toward the per-action 10 key/signature + cap?** `matchEpoch.js` decrypting ~200 rows says no (or the cap is raised). + This single answer sets the default `maxPageSize` and whether large reads must + chunk across multiple action calls. Confirm with the runtime/limits owner; + measure in Phase 0. +2. **One mega-router action vs. one action per route.** Per-route CIDs give + finer audit + permission granularity (you can pin/unpin a single route) but + more CIDs to manage and re-pin on change; a single router action is simpler + but any route edit re-CIDs the whole backend. Lean **per-route** for + auditability; let the CLI bundle small apps into one action as an option. +3. **Blind-index HMAC key custody.** Store it as an encrypted secret under the + vault PKP (decrypted in-action) — confirm that's acceptable vs. a separate + vault, and define rotation (rotating re-derives every blind index → a + migration job). +4. **Migrations against an encrypted column.** Adding/removing an `encrypted.*` + field changes the row-blob shape. Define a backfill/migration story (lazy + re-encrypt on next write vs. a one-shot migration action that pages through + and re-seals rows — itself bounded by the decrypt cap). +5. **Where does user-auth verification belong** — purely in-action (max trust, + every call pays a `fetch` to the auth service) vs. partly in the relay (cheap + pre-check, but the relay becomes slightly trusted)? Default in-action; + document the relay pre-check as an optimization. +6. **Multi-tenant relay vs. self-hosted.** Offer a hosted relay (Lit-run) so devs + truly deploy "frontend only," with self-hosting as the escape hatch. Decide if + the hosted relay is in scope. + +## TL;DR recommendation + +Ship a **framework** (`@lit/private-app` in-action runtime + `lit-app` CLI + +client SDK + a stateless relay template), not a single browser library — because +the usage API key forces a thin server, but that server can be a logic-free, +plaintext-blind relay. Model data as **plaintext index columns + one sealed +ciphertext blob per row**, filter/paginate on the indexes and decrypt only the +returned page, and gate every route with a gateway usage key *plus* an in-action +user-identity check. Prove it first as `examples/private-app-starter/` (a private +notes app), then extract the framework. The dark pool already demonstrates every +primitive this needs; this plan is mostly about packaging them into a DX a +developer can adopt in an afternoon.