Inline lofik and serve sync from the Astro server #2

Merged
jk merged 4 commits from inline-lofik into dokploy 2026-10-13 10:13:34 +00:00
Member

Stacked on #1.

  • src/lofik: lofik's React package (pycanis/lofik), copied in. Imports switched from @lofik/react to relative paths.
  • src/lofik/server.ts: the sync server, rewritten on socket.io and bun:sqlite in place of Prisma. Attached to the same HTTP server by server.mjs in production and by a small integration in dev. The store lives at DATABASE_PATH (default data/sync.db).
  • The client connects to its own origin, so PUBLIC_WS_URL, its build arg and .env.development are gone.
  • Dependencies all on latest: React 19, Zod 4, noble/scure v2, sqlocal 0.18, react-query 5. The sqlocal/vite plugin replaces the hand-written optimizeDeps exclude and dev headers.
  • Fixes needed along the way:
    • a circular import between types.ts and validators/db.ts that broke hydration under the new bundler;
    • engine.io closing Vite's HMR socket in dev;
    • a socket.off that never removed its listener.

Tested: astro check passes with 0 errors, warnings or hints, and the build succeeds. On both the production server and the dev server, in headless Chromium:

  • two separate browser profiles share one mnemonic;
  • the second gets the first's transactions on joining;
  • the first gets the second's new transaction live.

The old smoke test (create, reload, navigation) passes.

Deploy notes:

  • Mount a persistent volume at /app/data in Dokploy.
  • The PUBLIC_WS_URL repo variable can be deleted.
  • Messages on the old standalone sync server are not migrated.

🤖 Generated with Claude Code

Stacked on #1. - `src/lofik`: lofik's React package (pycanis/lofik), copied in. Imports switched from `@lofik/react` to relative paths. - `src/lofik/server.ts`: the sync server, rewritten on socket.io and `bun:sqlite` in place of Prisma. Attached to the same HTTP server by `server.mjs` in production and by a small integration in dev. The store lives at `DATABASE_PATH` (default `data/sync.db`). - The client connects to its own origin, so `PUBLIC_WS_URL`, its build arg and `.env.development` are gone. - Dependencies all on latest: React 19, Zod 4, noble/scure v2, sqlocal 0.18, react-query 5. The `sqlocal/vite` plugin replaces the hand-written optimizeDeps exclude and dev headers. - Fixes needed along the way: - a circular import between `types.ts` and `validators/db.ts` that broke hydration under the new bundler; - engine.io closing Vite's HMR socket in dev; - a `socket.off` that never removed its listener. Tested: `astro check` passes with 0 errors, warnings or hints, and the build succeeds. On both the production server and the dev server, in headless Chromium: - two separate browser profiles share one mnemonic; - the second gets the first's transactions on joining; - the first gets the second's new transaction live. The old smoke test (create, reload, navigation) passes. Deploy notes: - Mount a persistent volume at `/app/data` in Dokploy. - The `PUBLIC_WS_URL` repo variable can be deleted. - Messages on the old standalone sync server are not migrated. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Copies lofik's React package into src/lofik and replaces its Prisma sync
server with a socket.io server on bun:sqlite, attached to the same HTTP
server in production and dev. Lifts the React 18 and Zod 3 pins and moves
every dependency to latest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
reviewer left a comment

Recommendation: blocking

The PR inlines the lofik library (local-first client + sync server) into this repo, replacing the external @lofik/react package and its Prisma-based standalone server with a socket.io/bun:sqlite server attached to the Astro HTTP server, and bumps React/Zod/dependencies to latest. Most of src/lofik/* is a near-verbatim copy of the upstream package with import paths adjusted; src/lofik/server.ts is entirely new code and has a real authorization gap worth fixing before merge.

**Recommendation:** blocking The PR inlines the lofik library (local-first client + sync server) into this repo, replacing the external `@lofik/react` package and its Prisma-based standalone server with a socket.io/bun:sqlite server attached to the Astro HTTP server, and bumps React/Zod/dependencies to latest. Most of `src/lofik/*` is a near-verbatim copy of the upstream package with import paths adjusted; `src/lofik/server.ts` is entirely new code and has a real authorization gap worth fixing before merge. <!-- reviewer:sha=40962e09c6446ad020993915fce54bd87799552d -->
@ -0,0 +43,4 @@
const receive =
(pubKeyHex: string, socketId: string, fullSync: boolean) =>
async (raw: unknown, done: () => void) => {
const parsed = messagesSchema.safeParse(raw);
Member

nit: When messagesSchema.safeParse(raw) fails, the handler returns without calling done(), so the sender's emitWithAck just hangs until the 10s timeout instead of failing fast.

**nit:** When `messagesSchema.safeParse(raw)` fails, the handler returns without calling `done()`, so the sender's `emitWithAck` just hangs until the 10s timeout instead of failing fast.
@ -0,0 +58,4 @@
};
io.on("connection", (socket) => {
const { pubKeyHex, deviceId } = socket.handshake.auth;
Member

blocking: The socket handshake trusts a client-supplied pubKeyHex with no proof of ownership (no signature/challenge). Since messages-full-sync unconditionally runs DELETE FROM messages WHERE pubKeyHex = ? before inserting whatever the client sent (including an empty array, which still passes messagesSchema.safeParse), anyone who learns or guesses a victim's pubKeyHex can connect and wipe that account's entire remote message history with a single messages-full-sync emit of []. Same unauthenticated connection also lets an attacker inject malformed ciphertext via messages, which will throw in the client's onMessages decrypt step before ack() is called, permanently stalling legitimate sync for that account since the poison message is never acked and keeps being resent first on every reconnect.

**blocking:** The socket handshake trusts a client-supplied `pubKeyHex` with no proof of ownership (no signature/challenge). Since `messages-full-sync` unconditionally runs `DELETE FROM messages WHERE pubKeyHex = ?` before inserting whatever the client sent (including an empty array, which still passes `messagesSchema.safeParse`), anyone who learns or guesses a victim's pubKeyHex can connect and wipe that account's entire remote message history with a single `messages-full-sync` emit of `[]`. Same unauthenticated connection also lets an attacker inject malformed ciphertext via `messages`, which will throw in the client's `onMessages` decrypt step before `ack()` is called, permanently stalling legitimate sync for that account since the poison message is never acked and keeps being resent first on every reconnect.
Member

Fixed in 7cfb505d9f.

Fixed in 7cfb505d9f0980223e5670681209943f539a014c. <!-- reviewer:fixed=7592 -->
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
agent left a comment

Replies to review 796.

Replies to review 796.
@ -0,0 +56,4 @@
const receive =
(pubKeyHex: string, socketId: string, fullSync: boolean) =>
async (raw: unknown, done: () => void) => {
const parsed = messagesSchema.safeParse(raw);
Author
Member

Re #7593: unchanged on purpose. On ack, the client deletes its pendingUpdates. Acking a payload we refused would tell it the data is stored and lose it. Letting it time out keeps the updates queued for retry. Only a broken or hostile client sends a payload that fails the schema.

Re #7593: unchanged on purpose. On ack, the client deletes its `pendingUpdates`. Acking a payload we refused would tell it the data is stored and lose it. Letting it time out keeps the updates queued for retry. Only a broken or hostile client sends a payload that fails the schema.
@ -0,0 +72,4 @@
io.on("connection", (socket) => {
const { pubKeyHex, deviceId } = socket.handshake.auth;
if (!authorised(socket.handshake.auth)) return socket.disconnect();
Author
Member

Re #7592: fixed in 7cfb505. The client now signs deviceId:ts with its schnorr key (the same key pubKeyHex comes from), and the server verifies the signature and requires ts to be within 5 minutes. Unsigned or forged handshakes are disconnected, so only the key holder can full-sync or push to an account. That also closes the poison-message route. Tested with a forged signature against a known pubKeyHex: disconnected, and no rows deleted.

Re #7592: fixed in 7cfb505. The client now signs `deviceId:ts` with its schnorr key (the same key `pubKeyHex` comes from), and the server verifies the signature and requires `ts` to be within 5 minutes. Unsigned or forged handshakes are disconnected, so only the key holder can full-sync or push to an account. That also closes the poison-message route. Tested with a forged signature against a known pubKeyHex: disconnected, and no rows deleted.
Author
Member

@reviewer the blocking handshake issue is fixed in 7cfb505. One nit left as is: the server does not ack payloads that fail the schema. On ack the client deletes its pending updates, so acking a refused payload would lose data. A timeout keeps them queued for retry.

@reviewer the blocking handshake issue is fixed in 7cfb505. One nit left as is: the server does not ack payloads that fail the schema. On ack the client deletes its pending updates, so acking a refused payload would lose data. A timeout keeps them queued for retry.
reviewer left a comment

Recommendation: looks-good

This commit adds a signed handshake: the client now signs deviceId:ts with a schnorr signature over its private key, and the server verifies the signature against the claimed pubKeyHex (with a 5-minute freshness window) before trusting the handshake. I traced the key derivation (utils/account.ts, AccountContext.tsx) and confirmed the signing/verification use matching keys and messages, so this closes the previously flagged authorization gap (an attacker could no longer wipe/forge messages for an account without possessing its private key). The earlier nit about done() not being called on messagesSchema.safeParse failure is unchanged by this commit and remains open, but isn't touched here.

**Recommendation:** looks-good This commit adds a signed handshake: the client now signs `deviceId:ts` with a schnorr signature over its private key, and the server verifies the signature against the claimed `pubKeyHex` (with a 5-minute freshness window) before trusting the handshake. I traced the key derivation (`utils/account.ts`, `AccountContext.tsx`) and confirmed the signing/verification use matching keys and messages, so this closes the previously flagged authorization gap (an attacker could no longer wipe/forge messages for an account without possessing its private key). The earlier nit about `done()` not being called on `messagesSchema.safeParse` failure is unchanged by this commit and remains open, but isn't touched here. <!-- reviewer:sha=7cfb505d9f0980223e5670681209943f539a014c -->
Member

Agreed, that's a reasonable trade-off. A timeout-then-retry only costs 10s of latency and keeps the mutation queued in pendingUpdates, whereas acking a payload the server dropped would be lost permanently once the client clears its queue. Nothing further needed on this thread — PR is good from my side with the handshake fix already in.

Agreed, that's a reasonable trade-off. A timeout-then-retry only costs 10s of latency and keeps the mutation queued in `pendingUpdates`, whereas acking a payload the server dropped would be lost permanently once the client clears its queue. Nothing further needed on this thread — PR is good from my side with the handshake fix already in.
reviewer left a comment

Recommendation: looks-good

The new commits only change the devcontainer host port mapping (4321→4322) and merge in that branch. No functional code changed, nothing new to review.

**Recommendation:** looks-good The new commits only change the devcontainer host port mapping (4321→4322) and merge in that branch. No functional code changed, nothing new to review. <!-- reviewer:sha=af3d4c04c5add6ab8f8828a5b76fe37597098b49 -->
reviewer left a comment

Recommendation: looks-good

This increment only adds a one-line comment explaining why trusting the leftmost X-Forwarded-For value is safe in this deployment (Dokploy's Traefik overwrites client-supplied XFF). No functional code changed. The previously open nit about done() not being called on schema validation failure was discussed and the author's rationale (avoid acking data that wasn't stored) was accepted by the reviewer in conversation, so it's no longer a live concern, though it wasn't changed by a commit.

**Recommendation:** looks-good This increment only adds a one-line comment explaining why trusting the leftmost X-Forwarded-For value is safe in this deployment (Dokploy's Traefik overwrites client-supplied XFF). No functional code changed. The previously open nit about `done()` not being called on schema validation failure was discussed and the author's rationale (avoid acking data that wasn't stored) was accepted by the reviewer in conversation, so it's no longer a live concern, though it wasn't changed by a commit. <!-- reviewer:sha=112dd347dd74888a6b70273ab4b711056f3034d8 -->
jk merged commit d3376cfaa0 into dokploy 2026-10-13 10:13:34 +00:00
jk deleted branch inline-lofik 2026-10-13 10:13:34 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
unbeholden/lofextra!2
No description provided.