Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
b86033e
docs: consolidate battle docs into a single protocol whitepaper
heyradcode Aug 5, 2026
e6bd361
feat(image-generator): point clients and metadata at the deployed ser…
heyradcode Aug 6, 2026
379b3bc
fix(image-generator): stop treating a missing R2 bucket as a cache miss
heyradcode Aug 6, 2026
2c33ca7
docs: rebrand README to CryptoPets and add a gallery screenshot
heyradcode Aug 6, 2026
1edb436
feat(backend): add a pet leaderboard query ranked on the merged battl…
heyradcode Aug 6, 2026
6bbdc37
feat(backend): rank players by their pets' combined battle record
heyradcode Aug 6, 2026
94f7f6b
feat(frontend): add pet and player leaderboard screens
heyradcode Aug 6, 2026
4fdc008
feat: show a player their own leaderboard rank
heyradcode Aug 6, 2026
aec7519
docs: record the shipped leaderboard and correct its frozen-counter p…
heyradcode Aug 6, 2026
04af54c
feat(backend): add married-pair private chat
heyradcode Aug 6, 2026
cd78ee8
feat(backend): push chat updates over a notification-only socket
heyradcode Aug 6, 2026
75e0a54
feat(frontend): add private chat for married-pet counterparts
heyradcode Aug 6, 2026
390807a
fix(indexer-go): index Solana at finalized, not confirmed
heyradcode Aug 6, 2026
d9428b3
feat(indexer-go): expose indexing freshness separately from liveness
heyradcode Aug 6, 2026
7d8a452
feat(indexer-go): reconcile the EVM roster on an interval
heyradcode Aug 6, 2026
7cbceae
fix(backend): enable RLS on the chat tables in their migration
heyradcode Aug 6, 2026
bdf2209
refactor: apply code-quality review to leaderboard and chat
heyradcode Aug 6, 2026
9050acc
fix(web): repair the app favicon and give the website one at all
heyradcode Aug 6, 2026
074d584
fix(backend): serve both WebSocket channels from one upgrade listener
heyradcode Aug 6, 2026
c264d58
fix(frontend): give the messaging panel real full-screen height
heyradcode Aug 6, 2026
233aba7
fix(frontend): give the leaderboard panel full-screen height
heyradcode Aug 6, 2026
6220aaf
feat(chat): show whether the counterpart is connected
heyradcode Aug 6, 2026
73548b2
feat(frontend): give Messages its own chat icon
heyradcode Aug 6, 2026
a532e07
docs: bring the chat socket docs in line
heyradcode Aug 6, 2026
7255664
fix(frontend): centre the loading spinner in the space it is given
heyradcode Aug 6, 2026
b564895
feat(frontend): ask for sign-in instead of failing with 401
heyradcode Aug 6, 2026
5f6df12
feat: sync chatbot knowledge base with the expanded site content
heyradcode Aug 6, 2026
76337ff
ui: round the pet art on rename, level-up and train
heyradcode Aug 6, 2026
f9ad2d5
fix(frontend): keep the message composer on the bottom edge in every …
heyradcode Aug 6, 2026
750cbeb
feat(frontend): show a spinner while pet art is still loading
heyradcode Aug 6, 2026
8eb77f4
fix(frontend): give the signed-out message and leaderboard panels ful…
heyradcode Aug 6, 2026
85c28bb
fix(frontend): give the standalone interaction panels full height
heyradcode Aug 6, 2026
e1020ed
feat(frontend): two-column layout for the level-up, train and breed p…
heyradcode Aug 6, 2026
c535dc1
fix(frontend): keep the level-up and train layout fixed as a pet is s…
heyradcode Aug 6, 2026
877c17b
feat(frontend): enlarge the pet art in the level-up and train columns
heyradcode Aug 6, 2026
875c27c
feat(frontend): replace the pet select with a neon pet picker
heyradcode Aug 6, 2026
c236c40
feat(frontend): replace the pet dropdown with visible pet tiles
heyradcode Aug 6, 2026
6a26a3c
feat(frontend): show pet art in the battle arena selectors
heyradcode Aug 6, 2026
fa59043
fix(frontend): keep the pet dropdown open while its own list scrolls
heyradcode Aug 6, 2026
036d1c6
fix(frontend): hold the rename pet slot with a placeholder
heyradcode Aug 6, 2026
ff86d85
feat(frontend): show pet art in the marriage selectors
heyradcode Aug 6, 2026
f570704
feat(frontend): show pet art in marriage cards and proposal rows
heyradcode Aug 6, 2026
8ea6f53
feat(chat): show each side's pet beside its messages
heyradcode Aug 7, 2026
a3e86a2
feat(chat): multi-line composer with Enter to send
heyradcode Aug 7, 2026
1872d9e
feat(chat): sent and seen receipts
heyradcode Aug 7, 2026
1dd52e3
fix(chat): move the read receipt outside the bubble
heyradcode Aug 7, 2026
5ceb339
fix(chat): put the time and receipt in the bubble's bottom-right corner
heyradcode Aug 7, 2026
7933080
fix(chat): left-align the text inside message bubbles
heyradcode Aug 7, 2026
ac4366a
fix(chat): put the reaction control beside the bubble
heyradcode Aug 7, 2026
ecd2806
feat(chat): widen the reaction set to forty
heyradcode Aug 7, 2026
dedff39
feat(chat): quick reaction row that expands, and closes on outside click
heyradcode Aug 7, 2026
0679558
fix(chat): more room in the bubbles and the panel
heyradcode Aug 7, 2026
a5b2cbe
ui(chat): move the messages page from magenta to violet
heyradcode Aug 7, 2026
1a756bb
fix(chat): round the single-emoji reaction chip
heyradcode Aug 7, 2026
89b0161
feat(chat): day marks and lazy history
heyradcode Aug 7, 2026
9d7e47e
fix(frontend): keep the leaderboard pager visible and jumpable
heyradcode Aug 7, 2026
f348169
feat(leaderboard): search pets by name and players by address
heyradcode Aug 7, 2026
b0c9aaa
feat(leaderboard): podium over ledger
heyradcode Aug 7, 2026
29f5cdf
feat(leaderboard): search by pet name or wallet address on both boards
heyradcode Aug 7, 2026
e784d69
refactor(backend): group the battle features under one directory
heyradcode Aug 7, 2026
2858c29
fix(battle): end a battle whose pipeline dead-letters, freeing its pets
heyradcode Aug 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 36 additions & 4 deletions CLAUDE.md

Large diffs are not rendered by default.

35 changes: 26 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,30 +1,41 @@
# do-not-stop 🚀
# CryptoPets 🚀

A continuously evolving Web3 playground built with modern technologies, designed to grow, adapt, and experiment with the latest advancements in Ethereum/Solana development and contemporary React patterns.
A Web3 pet-battling game running on both Ethereum and Solana. Breed, train, and
battle NFT pets whose art is generated from their on-chain DNA, with battle
outcomes resolved from a committed random beacon and published as signed
receipts anyone can replay.

**Live Demo:** https://cryptopets.vercel.app

![The CryptoPets gallery: a player's pets with stats, leaderboard, and daily quests](./docs/screenshot.png)

## 📁 Project Structure

This is a monorepo containing multiple interconnected projects:

### Applications
- **[Frontend](./frontend)** - React + Vite web application with wallet integration
- **[Backend](./backend)** - Node.js + Express API server
- **[Backend](./backend)** - Node.js + Express API server, battle authority, and settle keeper
- **[Mobile](./mobile)** - React Native cross-platform mobile app
- **[Website](./website)** - Next.js marketing/documentation site

### Smart Contracts & Blockchain
- **[Ethereum Contracts](./contracts/ethereum)** - Solidity smart contracts with Hardhat
- **[Solana Programs](./contracts/solana)** - Rust-based Solana programs with Anchor

### Services
- **[Indexer](./services/indexer-go)** - Go cross-chain indexer; the only writer of the pet roster
- **[Image Generator](./services/image-generator)** - Pet NFT art and ERC-721 metadata, from Cloudflare Workers AI

### Shared Code
- **[Shared Core](./shared)** - Common utilities and types used across projects
- **[Shared Core](./shared)** - Common utilities, types, and hooks used across clients
- **[Protocol](./protocol)** - The battle protocol: combat engine, canonical encodings, hashes, seed derivation
- **[Verifier](./verifier)** - Standalone verifier that replays a signed battle receipt against the protocol

## 📖 Documentation

See [docs/](./docs) for testing strategy and an index of all package-level docs.
The component map and data flow live in
See [docs/](./docs) for the battle protocol, testing strategy, and an index of
all package-level docs. The component map and data flow live in
[CLAUDE.md](./CLAUDE.md#architecture).

## 🛠️ Development
Expand Down Expand Up @@ -52,11 +63,13 @@ For detailed setup and commands, see [DEVELOPMENT.md](./DEVELOPMENT.md).

**Frontend:** React 19, TypeScript, Vite, Wagmi, Viem, TanStack Query

**Backend:** Node.js, Express.js, TypeScript, JWT, Ethers.js, TweetNaCl
**Backend:** Node.js, Express.js, TypeScript, Prisma, PostgreSQL, GraphQL, JWT, Ethers.js, TweetNaCl

**Mobile:** React Native, TypeScript

**Blockchain:**
**Services:** Go (indexer, gRPC), Cloudflare Workers AI + R2 (pet art)

**Blockchain:**
- Ethereum: Solidity, Hardhat
- Solana: Rust, Anchor

Expand All @@ -76,7 +89,11 @@ This monorepo uses two licenses depending on the package:
| Package(s) | License |
| --- | --- |
| `contracts/ethereum`, `contracts/solana`, `services/indexer-go`, `proto`, `protocol`, `verifier` | [MIT](./contracts/LICENSE) — fully permissive |
| `frontend`, `backend`, `mobile`, `website`, `shared` (and anything else) | [PolyForm Noncommercial 1.0.0](./LICENSE) — free for any noncommercial purpose; commercial use requires permission |
| `frontend`, `backend`, `mobile`, `website`, `shared`, `services/image-generator` (and anything else) | [PolyForm Noncommercial 1.0.0](./LICENSE) — free for any noncommercial purpose; commercial use requires permission |

`protocol` and `verifier` are MIT deliberately: a backend that decides battle
outcomes only holds up if outsiders can replay its receipts, which means the
verifier and everything it depends on has to be freely usable.

Each package's `package.json` / `go.mod` directory points at the license that
applies to it. For commercial licensing of the app layer, contact
Expand Down
113 changes: 110 additions & 3 deletions backend/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,48 @@ matchup UI degrades to "odds unavailable". Intended for a single confirmed
matchup, not per opponents row. Optional `samples` arg overrides the server
default (clamped to 10,000).

### Leaderboards

```graphql
query($chain: String!, $page: Int, $pageSize: Int) {
leaderboard(chain: $chain, page: $page, pageSize: $pageSize) {
entries { rank id chain owner name dna level rarity winCount lossCount asset }
total page pageSize
}
playerLeaderboard(chain: $chain, page: $page, pageSize: $pageSize) {
entries { rank owner winCount lossCount petCount }
total page pageSize
}
playerRank(chain: $chain) { rank owner winCount lossCount petCount }
}
```

Three read-only rankings over the **merged** battle record — `pet_battle_progress`
where a pet has fought a backend battle, the frozen `pet_roster` counters otherwise.
Ranking on the roster alone is not a simplification but a bug: those counters stopped
moving when battles left the chain (§L Phase 6), so on a deployment whose battles are
all backend-settled the roster-only ranking is empty.

Ordering is wins DESC, then losses ASC, then (pets only) level DESC, then the id or
owner key. The losses tiebreak *is* the win-rate tiebreak — among rows on equal wins,
fewer losses is a strictly higher rate — so nothing is ranked on a ratio drawn from a
handful of fights. Rows with no battles at all are excluded.

| Field | Type | Notes |
| --- | --- | --- |
| `rank` | Int | 1-based over the **full** ranking, not the page; page 2 continues where page 1 stopped |
| `owner` | String | grouping key on the player board: EVM addresses lowercased, Solana pubkeys untouched, matching `normalizeAccount` |
| `petCount` | Int | pets **with a battle record**, not pets owned |

`playerRank` reports the authenticated caller's own standing, so a client does not page
the whole board looking for itself. It takes no owner argument — whose rank it is comes
from the session — and returns **`null` for an unranked player** (no pet has fought)
rather than a zeroed row, which would be indistinguishable from genuine last place.

Neither board has a gRPC fast path, for the same reason `opponents` lost its own:
indexer-go's cache holds chain state and has no view of `pet_battle_progress`, a
backend-owned table, so it cannot answer these correctly. Both read Postgres directly.

### Battle data

`battle_history` carries `loserPetId, seed (0x-hex), rounds, winnerHpRemaining,
Expand All @@ -238,8 +280,7 @@ requests (the `requestX` → Pyth Entropy reveals → `settleX` flow) from a
backend-held wallet once entropy reveals, so the player only signs the request
transaction — `settleX` is permissionless and needed no special authorization,
it was just being sent from the player's wallet by default. Off unless
`KEEPER_ENABLED=true`. See `docs/plan-realtime-battle-ux.md` /
`docs/plan-realtime-battle-impl.md` for the design and threat model.
`KEEPER_ENABLED=true`.

Battles are **not** settled here any more (§L Phase 6). `requestBattle`/`settleBattle`
were removed from the contracts entirely, along with the Solana settle keeper and shadow
Expand All @@ -248,7 +289,7 @@ mode; battles run through the backend-authoritative path below.
### Backend-authoritative battles (v2)

`backend/src/routes/battle.ts` — the workflow described in
`docs/plan-backend-battle-architecture.md`. Submission and consent require a JWT
`docs/battle-protocol.md`. Submission and consent require a JWT
(the wallet signature inside the body is what actually authorizes the action,
per §D); the reads below require nothing, because every value they return is
either already public on chain or is itself a signed artifact anyone is meant
Expand All @@ -275,6 +316,31 @@ switching the mode off stops new battles, it does not retract receipts already i
| GET | `/api/battle/rulesets/:rulesetHash` | none | One ruleset's full bundle, for replaying against it. |
| POST | `/api/battle/verify-receipt` | none | Body `{ receiptHash }`. Checks the stored signature against a published key and that the payload is well-formed — §A's "operator signature, verified against a published key" row, nothing more. It does **not** re-run the fight, check the drand BLS signature, or recompute progression; that is the standalone verifier's job (§H), which runs with no backend access so its answer cannot depend on this process telling the truth. Passing this check is necessary, not sufficient. |

### Private chat (roadmap §2, v1)

| Method | Path | Auth | Purpose |
| --- | --- | --- | --- |
| GET | `/api/chat/threads` | JWT | The caller's currently-usable threads, one per married counterpart, with the pet pairs behind each. Creates a thread on first listing — a married pair always ends up with exactly one, so an explicit open call would add a round trip and a null state that resolves one way. |
| GET | `/api/chat/threads/:id/messages` | JWT | A page, oldest first within the page. `before=<messageId>` pages backwards (a chat is read from its end); `limit` defaults to 50, capped at 100. |
| POST | `/api/chat/threads/:id/messages` | JWT | Body `{ text }`, trimmed, 1-2000 characters. The author is the session wallet; a `sender` in the body is ignored. |

Access is **derived, never stored**: a thread answers only while the two wallets have a
married pet pair in `pet_roster.spouse_id`, rechecked on every request. A divorce closes
the conversation the moment the indexer sees it, with nothing to revoke. The thread row
survives — deleting it would destroy the history — it just stops answering.

Status codes carry a deliberate asymmetry. A non-participant gets **404**, the same as a
thread that does not exist, because 403 would confirm the id to anyone probing. A
participant whose marriage has ended gets **403** with a reason, since they already know
the thread exists.

**What v1 does not have**, each a product call flagged in the roadmap rather than an
oversight: no block or report, no profanity filtering, no read receipts or presence, no
edit or delete, and no retention policy. The abuse controls are a length cap and a rate
limit (20 sends/min per wallet, 120 reads) — volume controls, not content ones. This is
the first endpoint in the API that stores genuine user-authored text, which is what makes
moderation a real question here and not elsewhere.

### Battle room WebSocket (v2)

```
Expand Down Expand Up @@ -305,6 +371,47 @@ client-side by `(chainId, requestId)`. That socket was removed once battles
stopped being resolved from chain state, so there is no longer a second channel
and nothing left to filter.

### Chat WebSocket (roadmap §2)

```
ws(s)://<host>/ws/chat?threadId=<threadId>
```

Per-thread, and **authenticated**. Two frame shapes, neither carrying message text:

```json
{ "type": "thread-updated", "threadId": "c...", "messageId": 42 }
{ "type": "presence", "topic": "c...", "online": ["0xabc…"] }
```

`thread-updated` means "re-read this thread"; the text comes from
`GET /api/chat/threads/:id/messages`, which authenticates the caller and rechecks the
marriage. Missing a notification costs latency, never access. `presence` is the roster of
participants currently connected, which is what drives the online dot.

**Authentication.** The client offers two subprotocols, `cryptopets-auth` followed by the
JWT; the server echoes back only the marker. A subprotocol rather than a query parameter
because browsers cannot set headers on a WebSocket and a URL-borne token is recorded by
proxies and access logs. The upgrade then applies the same participation and live-marriage
gate as the HTTP routes, so a socket can never subscribe to a thread its holder could not
read. A connection with no token, a forged token, or a thread the caller is not in is
refused at the upgrade — it never becomes a subscriber, not even to the fact that the
thread changed.

This is stricter than the channel shipped with. It was unauthenticated at first, on the
argument that contentless frames made it safe; presence forced the change, because "is my
counterpart online" is a claim about identities and an anonymous socket has none. Counting
connections would have reported one person with two tabs open as two people. Closing the
activity-timing leak came along with it.

Presence counts identities, not sockets, so a second tab does not double a person and
closing one does not report them as gone. Authorization is checked at connect only: a
marriage that ends mid-session leaves the socket open until it drops, which costs nothing
because every frame is contentless and the read it prompts refuses immediately.

The battle-room channel above remains unauthenticated. It carries no content and has no
presence, so it has nothing an identity would protect.

### Public receipt corpus (v2)

`backend/src/routes/receipts.ts` — the paginated export §H item 3 calls for.
Expand Down
9 changes: 4 additions & 5 deletions backend/env.example
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,7 @@ DIRECT_URL="postgresql://postgres.<project-ref>:<password>@aws-1-<region>.pooler
# and mint only, which have no backend equivalent.
#
# Settles requestCreateFromDNA/requestMintStarter requests from this wallet once Pyth
# Entropy reveals, so the player only signs the request transaction
# (see docs/plan-realtime-battle-ux.md, docs/plan-realtime-battle-impl.md Phase 2).
# Entropy reveals, so the player only signs the request transaction.
# Off by default. All four of RPC_URL/PRIVATE_KEY/CHAIN_ID/GAME_LOGIC_ADDRESS are
# required once enabled; the keeper logs and no-ops (doesn't crash the server) if
# any are missing.
Expand All @@ -88,9 +87,9 @@ DIRECT_URL="postgresql://postgres.<project-ref>:<password>@aws-1-<region>.pooler
# enable against anything else, so this can't accidentally run on a real network.
# KEEPER_MOCK_REVEAL=true

# --- Backend-authoritative battles (docs/plan-backend-battle-architecture.md) ---
# Backend-authoritative battle mode (docs/plan-backend-battle-architecture.md §L Phase 3,
# operated per docs/runbook-backend-battles.md). Off by default, and a separate switch from
# --- Backend-authoritative battles (docs/battle-protocol.md) ---
# Backend-authoritative battle mode (docs/battle-protocol.md §L Phase 3,
# operated per its Appendix B). Off by default, and a separate switch from
# the on-chain path rather than a replacement: Phase 3 runs both side by side.
#
# Off: the write routes (POST /intents, /accept, /authorizations) return 503, the outbox
Expand Down
3 changes: 2 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"clean": "rm -rf dist",
"postinstall": "prisma generate",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:migrate": "prisma migrate deploy",
"prisma:migrate:dev": "prisma migrate dev",
"prisma:status": "prisma migrate status",
"prisma:deploy": "prisma migrate deploy",
"prisma:studio": "prisma studio",
Expand Down
35 changes: 34 additions & 1 deletion backend/prisma.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,39 @@
import "dotenv/config";
import { defineConfig } from "prisma/config";

/** Seconds the schema engine waits for a connection before reporting P1001. */
const CONNECT_TIMEOUT = "30";

/**
* The migration connection, with a connect timeout that survives TLS interception.
*
* Prisma's default is short enough that a proxy or antivirus doing TLS inspection makes
* every migration command fail as `P1001: Can't reach database server` — a message that
* points at the database, which is up and answering `pg` clients on the same URL at the
* same moment. Measured against the Supabase pooler behind such an interceptor: the
* handshake takes ~19s and the engine gives up at ~10s.
*
* Applied here rather than in `.env` so it holds for every developer and cannot be lost
* to an edit of a file that is not in the repository. Left alone if the URL already sets
* it. Note `sslmode=disable` also "fixes" this and must not be used: it works by putting
* the database password on the wire in clear text.
*/
function migrationUrl(): string | undefined {
const raw = process.env["DIRECT_URL"] ?? process.env["DATABASE_URL"];
if (!raw) return undefined;
try {
const url = new URL(raw);
if (!url.searchParams.has("connect_timeout")) {
url.searchParams.set("connect_timeout", CONNECT_TIMEOUT);
}
return url.toString();
} catch {
// Not a URL we can parse. Hand it back untouched: a malformed connection string is
// the engine's error to report, and it reports it far better than this would.
return raw;
}
}

export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
Expand All @@ -11,6 +44,6 @@ export default defineConfig({
datasource: {
// Migrations/introspection use a direct (session-mode) connection — the
// transaction-mode pooler in DATABASE_URL can't run DDL / advisory locks.
url: process.env["DIRECT_URL"] ?? process.env["DATABASE_URL"],
url: migrationUrl(),
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
-- CreateTable
CREATE TABLE "chat_thread" (
"id" TEXT NOT NULL,
"participant_a" TEXT NOT NULL,
"participant_b" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "chat_thread_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "chat_message" (
"id" SERIAL NOT NULL,
"thread_id" TEXT NOT NULL,
"sender" TEXT NOT NULL,
"text" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "chat_message_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "chat_thread_participant_a_participant_b_key" ON "chat_thread"("participant_a", "participant_b");

-- CreateIndex
CREATE INDEX "chat_thread_participant_a_idx" ON "chat_thread"("participant_a");

-- CreateIndex
CREATE INDEX "chat_thread_participant_b_idx" ON "chat_thread"("participant_b");

-- CreateIndex
CREATE INDEX "chat_message_thread_id_created_at_idx" ON "chat_message"("thread_id", "created_at");

-- AddForeignKey
ALTER TABLE "chat_message" ADD CONSTRAINT "chat_message_thread_id_fkey" FOREIGN KEY ("thread_id") REFERENCES "chat_thread"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- EnableRowLevelSecurity
--
-- Prisma emits no RLS statements, and on Supabase `ALTER DEFAULT PRIVILEGES` grants every
-- newly created table in `public` to `anon` and `authenticated` with ALL privileges —
-- including DELETE and TRUNCATE. So a table shipped without this line is readable and
-- writable by anyone holding the project's public anon key. For private messages that is
-- not a hardening gap, it is the whole confidentiality of the feature.
--
-- Enabled with no policies, matching every other table in this database: that denies all
-- access to the PostgREST roles, while the backend connects as the table owner
-- (`postgres`) and owners bypass RLS unless FORCE is set. Do NOT add FORCE here — it
-- would apply these policy-less tables to the owner too and deny the backend everything.
ALTER TABLE "chat_thread" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "chat_message" ENABLE ROW LEVEL SECURITY;
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- The message page reads `WHERE thread_id = ? [AND id < ?] ORDER BY id DESC LIMIT n`.
-- The (thread_id, created_at) index could locate a thread's rows but not satisfy that
-- ordering, so every read sorted the whole thread to return one page. `id` is a SERIAL
-- and is already the ordering key the code uses, so this is a swap, not an addition.
DROP INDEX IF EXISTS "chat_message_thread_id_created_at_idx";

CREATE INDEX "chat_message_thread_id_id_idx" ON "chat_message"("thread_id", "id");
Loading
Loading