Skip to content

Commit 60546cf

Browse files
fix: remove todo app
1 parent 259bdaf commit 60546cf

42 files changed

Lines changed: 112 additions & 2417 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,18 @@
1-
# PowerSync E2EE Monorepo
1+
# PowerSync E2EE Chat Monorepo
22

3-
This repo contains demo applications and shared libraries for building end-to-end encrypted experiences on top of PowerSync and Supabase.
3+
This repository focuses on the end-to-end encrypted chat demo plus the shared crypto libraries it depends on. Use it as a reference implementation for building client-encrypted PowerSync projects backed by Supabase.
44

5-
## Applications
5+
## Application
66

77
- [packages/e2ee-chat](packages/e2ee-chat) — End-to-end encrypted group chat with guest access, room invites, and local vault unlock flows. See the package README for setup instructions and an in-depth encryption/privacy overview.
88

99
![Screenshot of E2EE Chat](packages/e2ee-chat/screenshot.png)
1010

11-
- [packages/todo-raw-table](packages/todo-raw-table) — Todo list app showcasing encrypted raw-table storage paired with decrypted mirrors for UI queries.
12-
13-
![Screenshot of Todo (raw-table + encrypted mirrors)](packages/todo-raw-table/screenshot.png)
14-
1511
## Crypto packages
1612

1713
- [packages/crypto/interface](packages/crypto/interface) — Shared types and helpers (`CipherEnvelope`, base64 helpers, etc.) used by crypto providers.
1814
- [packages/crypto/encrypted-sqlite](packages/crypto/encrypted-sqlite) — Encrypted↔mirror runtime for SQLite/PowerSync: pair configs, `ensurePairsDDL`, mirror orchestration, and CRUD helpers.
1915
- [packages/crypto/password](packages/crypto/password) — Password-based crypto provider (PBKDF2 by default) implementing the `CryptoProvider` interface for wrapping/unwrapping DEKs.
2016
- [packages/crypto/webauthn](packages/crypto/webauthn) — WebAuthn-based crypto provider using PRF/hmac-secret extensions to derive a stable secret per credential (wrap/unwrap DEKs using passkeys).
2117

22-
Each application consumes these packages to keep ciphertext opaque to the PowerSync backend while presenting decrypted mirrors locally for the user interface.
18+
Each module keeps ciphertext opaque to the PowerSync backend while presenting decrypted mirrors locally for the user interface.

package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
{
2-
"name": "powersync-apps-e2ee",
2+
"name": "powersync-e2ee-chat",
33
"private": true,
44
"workspaces": [
55
"packages/crypto/*",
6-
"packages/todo-raw-table/frontend",
76
"packages/e2ee-chat/frontend"
87
],
98
"scripts": {

packages/crypto/encrypted-sqlite/README.md

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -43,23 +43,23 @@ Mirror table has: `id`, `user_id`, `bucket_id`, `updated_at`, plus your declared
4343
```ts
4444
import type { EncryptedPairConfig } from "@crypto/sqlite";
4545

46-
const TODOS_PAIR: EncryptedPairConfig<{ text: string; completed: boolean }> = {
47-
name: "todos",
48-
encryptedTable: "raw_items",
49-
mirrorTable: "raw_items_plain",
46+
const CHAT_MESSAGES_PAIR: EncryptedPairConfig<{ content: string; isEdited: boolean }> = {
47+
name: "chat_messages",
48+
encryptedTable: "chat_messages_cipher",
49+
mirrorTable: "chat_messages_plain",
5050
mirrorColumns: [
51-
{ name: "text", type: "TEXT", notNull: true, defaultExpr: "''" },
52-
{ name: "completed", type: "INTEGER", notNull: true, defaultExpr: "0" }
51+
{ name: "content", type: "TEXT", notNull: true, defaultExpr: "''" },
52+
{ name: "is_edited", type: "INTEGER", notNull: true, defaultExpr: "0" }
5353
],
5454
// Convert decrypted bytes -> mirror columns
5555
parsePlain: ({ plaintext }) => {
5656
const obj = JSON.parse(new TextDecoder().decode(plaintext));
57-
return { text: obj.text ?? "", completed: obj.completed ? 1 : 0 };
57+
return { content: obj.content ?? "", is_edited: obj.isEdited ? 1 : 0 };
5858
},
5959
// Optional: serialize domain object -> bytes (defaults to JSON if omitted)
6060
serializePlain: (obj) => ({
6161
plaintext: new TextEncoder().encode(JSON.stringify(obj)),
62-
aad: "todo-v1"
62+
aad: "chat-message-v1"
6363
})
6464
};
6565
```
@@ -69,7 +69,7 @@ const TODOS_PAIR: EncryptedPairConfig<{ text: string; completed: boolean }> = {
6969
```ts
7070
import { ensurePairsDDL } from "@crypto/sqlite";
7171

72-
await ensurePairsDDL(db, [TODOS_PAIR]);
72+
await ensurePairsDDL(db, [CHAT_MESSAGES_PAIR]);
7373
```
7474

7575
This creates both the encrypted and mirror tables and installs triggers that write to `powersync_crud` on insert/update/delete.
@@ -79,15 +79,15 @@ This creates both the encrypted and mirror tables and installs triggers that wri
7979
```ts
8080
import { installPairsOnSchema } from "@crypto/sqlite";
8181

82-
installPairsOnSchema(schema, [TODOS_PAIR]);
82+
installPairsOnSchema(schema, [CHAT_MESSAGES_PAIR]);
8383
```
8484

8585
### 4) Start the mirror replicator
8686

8787
```ts
8888
import { startEncryptedMirrors } from "@crypto/sqlite";
8989

90-
const stop = startEncryptedMirrors({ db, userId, crypto }, [TODOS_PAIR], { throttleMs: 150 });
90+
const stop = startEncryptedMirrors({ db, userId, crypto }, [CHAT_MESSAGES_PAIR], { throttleMs: 150 });
9191
// call stop() to dispose
9292
```
9393

@@ -100,17 +100,17 @@ import { insertEncrypted, updateEncrypted, deleteEncrypted } from "@crypto/sqlit
100100

101101
await insertEncrypted(
102102
{ db, userId, crypto },
103-
TODOS_PAIR,
104-
{ id: "a1", bucketId: null, object: { text: "Buy milk", completed: false } }
103+
CHAT_MESSAGES_PAIR,
104+
{ id: "m1", bucketId: "room-1", object: { content: "Hello", isEdited: false } }
105105
);
106106

107107
await updateEncrypted(
108108
{ db, userId, crypto },
109-
TODOS_PAIR,
110-
{ id: "a1", object: { text: "Buy oat milk", completed: true } }
109+
CHAT_MESSAGES_PAIR,
110+
{ id: "m1", bucketId: "room-1", object: { content: "Hello again", isEdited: true } }
111111
);
112112

113-
await deleteEncrypted({ db, userId, crypto }, TODOS_PAIR, { id: "a1" });
113+
await deleteEncrypted({ db, userId, crypto }, CHAT_MESSAGES_PAIR, { id: "m1" });
114114
```
115115

116116
### 6) Query from React
@@ -120,13 +120,13 @@ import { useQuery } from "@powersync/react";
120120

121121
const { data, isLoading, error } = useQuery(
122122
`
123-
SELECT id, user_id, bucket_id, updated_at, text, completed
124-
FROM raw_items_plain
123+
SELECT id, user_id, bucket_id, updated_at, content, is_edited
124+
FROM chat_messages_plain
125125
WHERE user_id = ?
126-
AND bucket_id IS NULL
126+
AND bucket_id = ?
127127
ORDER BY updated_at DESC
128128
`,
129-
[userId]
129+
[userId, roomId]
130130
);
131131
```
132132

packages/crypto/encrypted-sqlite/src/__tests__/mutations.spec.ts

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,18 @@ import { describe, it, expect } from "vitest";
22
import { insertEncrypted, updateEncrypted, deleteEncrypted } from "../mutations.js";
33
import { FakeDB, MockCrypto } from "./fakes";
44

5-
const PAIR = {
6-
name: "todos",
7-
encryptedTable: "raw_items",
8-
mirrorTable: "raw_items_plain",
5+
const CHAT_MESSAGES_PAIR = {
6+
name: "chat_messages",
7+
encryptedTable: "chat_messages_cipher",
8+
mirrorTable: "chat_messages_plain",
99
mirrorColumns: [
10-
{ name: "text", type: "TEXT" },
11-
{ name: "completed", type: "INTEGER" }
10+
{ name: "content", type: "TEXT" },
11+
{ name: "is_edited", type: "INTEGER" }
1212
],
1313
parsePlain: () => ({}),
14-
// Serialize domain object -> bytes (JSON as example)
1514
serializePlain: (obj: any) => {
1615
const bytes = new TextEncoder().encode(JSON.stringify(obj));
17-
return { plaintext: bytes, aad: "todo-v1" };
16+
return { plaintext: bytes, aad: "chat-message-v1" };
1817
}
1918
};
2019

@@ -23,32 +22,33 @@ describe("mutations", () => {
2322
const db = new FakeDB();
2423
const runtime = { db, userId: "u1", crypto: MockCrypto as any };
2524

26-
await insertEncrypted(runtime as any, PAIR, {
27-
id: "a1",
28-
bucketId: null,
29-
object: { text: "X", completed: false }
25+
await insertEncrypted(runtime as any, CHAT_MESSAGES_PAIR, {
26+
id: "m1",
27+
bucketId: "room-1",
28+
object: { content: "Hello", isEdited: false }
3029
});
3130

32-
let call = db.execCalls.find(c => (c.sql as string).startsWith("INSERT INTO raw_items"));
31+
let call = db.execCalls.find(c => (c.sql as string).startsWith("INSERT INTO chat_messages_cipher"));
3332
expect(call).toBeTruthy();
34-
expect(call!.params![0]).toBe("a1");
33+
expect(call!.params![0]).toBe("m1");
3534
expect(call!.params![1]).toBe("u1");
36-
expect(call!.params![3]).toBe("test/raw"); // alg
37-
expect(typeof call!.params![6]).toBe("string"); // cipher_b64
35+
expect(call!.params![2]).toBe("room-1");
36+
expect(call!.params![3]).toBe("test/raw");
37+
expect(typeof call!.params![6]).toBe("string");
3838

39-
await updateEncrypted(runtime as any, PAIR, {
40-
id: "a1",
41-
bucketId: null,
42-
object: { text: "Y", completed: true }
39+
await updateEncrypted(runtime as any, CHAT_MESSAGES_PAIR, {
40+
id: "m1",
41+
bucketId: "room-1",
42+
object: { content: "Hello again", isEdited: true }
4343
});
44-
call = db.execCalls.find(c => (c.sql as string).startsWith("UPDATE raw_items"));
44+
call = db.execCalls.find(c => (c.sql as string).startsWith("UPDATE chat_messages_cipher"));
4545
expect(call).toBeTruthy();
4646
expect(call!.params![0]).toBe("test/raw");
4747

48-
await deleteEncrypted(runtime as any, PAIR, { id: "a1" });
49-
call = db.execCalls.find(c => (c.sql as string).startsWith("DELETE FROM raw_items"));
48+
await deleteEncrypted(runtime as any, CHAT_MESSAGES_PAIR, { id: "m1" });
49+
call = db.execCalls.find(c => (c.sql as string).startsWith("DELETE FROM chat_messages_cipher"));
5050
expect(call).toBeTruthy();
51-
expect(call!.params![0]).toBe("a1");
51+
expect(call!.params![0]).toBe("m1");
5252
expect(call!.params![1]).toBe("u1");
5353
});
54-
});
54+
});

packages/crypto/encrypted-sqlite/src/__tests__/pairs.spec.ts

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,24 +8,24 @@ describe("ensurePairsDDL", () => {
88

99
await ensurePairsDDL(db as any, [
1010
{
11-
name: "todos",
12-
encryptedTable: "raw_items",
13-
mirrorTable: "raw_items_plain",
11+
name: "chat_messages",
12+
encryptedTable: "chat_messages_cipher",
13+
mirrorTable: "chat_messages_plain",
1414
mirrorColumns: [
15-
{ name: "text", type: "TEXT", notNull: true, defaultExpr: "''" },
16-
{ name: "completed", type: "INTEGER", notNull: true, defaultExpr: "0" }
15+
{ name: "content", type: "TEXT", notNull: true, defaultExpr: "''" },
16+
{ name: "is_edited", type: "INTEGER", notNull: true, defaultExpr: "0" }
1717
],
1818
parsePlain: () => ({})
1919
}
2020
]);
2121

2222
const ddl = db.execCalls.map(c => c.sql).join("\n---\n");
23-
expect(ddl).toContain("CREATE TABLE IF NOT EXISTS raw_items (");
24-
expect(ddl).toContain("CREATE TABLE IF NOT EXISTS raw_items_plain (");
25-
expect(ddl).toContain("text TEXT NOT NULL DEFAULT ''");
26-
expect(ddl).toContain("completed INTEGER NOT NULL DEFAULT 0");
27-
expect(ddl).toContain("CREATE TRIGGER IF NOT EXISTS raw_items_insert");
28-
expect(ddl).toContain("CREATE TRIGGER IF NOT EXISTS raw_items_update");
29-
expect(ddl).toContain("CREATE TRIGGER IF NOT EXISTS raw_items_delete");
23+
expect(ddl).toContain("CREATE TABLE IF NOT EXISTS chat_messages_cipher (");
24+
expect(ddl).toContain("CREATE TABLE IF NOT EXISTS chat_messages_plain (");
25+
expect(ddl).toContain("content TEXT NOT NULL DEFAULT ''");
26+
expect(ddl).toContain("is_edited INTEGER NOT NULL DEFAULT 0");
27+
expect(ddl).toContain("CREATE TRIGGER IF NOT EXISTS chat_messages_cipher_insert");
28+
expect(ddl).toContain("CREATE TRIGGER IF NOT EXISTS chat_messages_cipher_update");
29+
expect(ddl).toContain("CREATE TRIGGER IF NOT EXISTS chat_messages_cipher_delete");
3030
});
31-
});
31+
});

packages/crypto/encrypted-sqlite/src/__tests__/replicator.spec.ts

Lines changed: 33 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,83 +2,87 @@ import { describe, it, expect } from "vitest";
22
import { startEncryptedMirrors } from "../replicator.js";
33
import { FakeDB, MockCrypto } from "./fakes.js";
44

5-
const PAIR = {
6-
name: "todos",
7-
encryptedTable: "raw_items",
8-
mirrorTable: "raw_items_plain",
5+
const CHAT_MESSAGES_PAIR = {
6+
name: "chat_messages",
7+
encryptedTable: "chat_messages_cipher",
8+
mirrorTable: "chat_messages_plain",
99
mirrorColumns: [
10-
{ name: "text", type: "TEXT", notNull: true, defaultExpr: "''" },
11-
{ name: "completed", type: "INTEGER", notNull: true, defaultExpr: "0" }
10+
{ name: "content", type: "TEXT", notNull: true, defaultExpr: "''" },
11+
{ name: "is_edited", type: "INTEGER", notNull: true, defaultExpr: "0" }
1212
],
1313
parsePlain: ({ plaintext }: any) => {
1414
const obj = JSON.parse(new TextDecoder().decode(plaintext));
15-
return { text: obj.text ?? "", completed: obj.completed ? 1 : 0 };
15+
return { content: obj.content ?? "", is_edited: obj.isEdited ? 1 : 0 };
1616
}
1717
};
1818

1919
describe("replicator", () => {
2020
it("decrypts, parses, and upserts into mirror columns", async () => {
21-
const db = new FakeDB() ;
21+
const db = new FakeDB();
2222

2323
const stop = startEncryptedMirrors(
2424
{ db: db as any, userId: "u1", crypto: MockCrypto as any },
25-
[PAIR],
25+
[CHAT_MESSAGES_PAIR],
2626
{ throttleMs: 0 }
2727
);
2828

29-
const reg = db.queries.find(q => q.sql.includes("FROM raw_items"));
29+
const reg = db.queries.find(q => q.sql.includes("FROM chat_messages_cipher"));
3030
expect(reg).toBeTruthy();
3131

32-
// added
3332
await reg!.instance.emit({
3433
added: [{
35-
id: "a1",
34+
id: "m1",
3635
user_id: "u1",
37-
bucket_id: null,
36+
bucket_id: "room-1",
3837
alg: "test/raw",
3938
aad: null,
4039
nonce_b64: "N",
41-
cipher_b64: Buffer.from(JSON.stringify({ text: "A", completed: false }), "utf8").toString("base64"),
40+
cipher_b64: Buffer.from(
41+
JSON.stringify({ content: "Hello", isEdited: false }),
42+
"utf8"
43+
).toString("base64"),
4244
kdf_salt_b64: "",
4345
updated_at: "2025-01-01T00:00:00.000Z"
4446
}]
4547
});
4648

47-
const ins = db.lastTx!.calls.find(c => (c.sql as string).includes(`INSERT INTO ${PAIR.mirrorTable}`));
49+
const ins = db.lastTx!.calls.find(c => (c.sql as string).includes(`INSERT INTO ${CHAT_MESSAGES_PAIR.mirrorTable}`));
4850
expect(ins).toBeTruthy();
49-
// order: id,user_id,bucket_id,updated_at,text,completed
50-
expect(ins!.params![0]).toBe("a1");
51-
expect(ins!.params![4]).toBe("A");
51+
expect(ins!.params![0]).toBe("m1");
52+
expect(ins!.params![1]).toBe("u1");
53+
expect(ins!.params![2]).toBe("room-1");
54+
expect(ins!.params![4]).toBe("Hello");
5255
expect(ins!.params![5]).toBe(0);
5356

54-
// updated
5557
db.lastTx = null;
5658
await reg!.instance.emit({
5759
updated: [{
58-
id: "a1",
60+
id: "m1",
5961
user_id: "u1",
60-
bucket_id: null,
62+
bucket_id: "room-1",
6163
alg: "test/raw",
6264
aad: null,
6365
nonce_b64: "N",
64-
cipher_b64: Buffer.from(JSON.stringify({ text: "A+", completed: true }), "utf8").toString("base64"),
66+
cipher_b64: Buffer.from(
67+
JSON.stringify({ content: "Hello again", isEdited: true }),
68+
"utf8"
69+
).toString("base64"),
6570
kdf_salt_b64: "",
6671
updated_at: "2025-01-01T01:00:00.000Z"
6772
}]
6873
});
69-
const updDelete = db.lastTx!.calls.find(c => (c.sql as string).startsWith(`DELETE FROM ${PAIR.mirrorTable}`));
70-
const updInsert = db.lastTx!.calls.find(c => (c.sql as string).includes(`INSERT INTO ${PAIR.mirrorTable}`));
74+
const updDelete = db.lastTx!.calls.find(c => (c.sql as string).startsWith(`DELETE FROM ${CHAT_MESSAGES_PAIR.mirrorTable}`));
75+
const updInsert = db.lastTx!.calls.find(c => (c.sql as string).includes(`INSERT INTO ${CHAT_MESSAGES_PAIR.mirrorTable}`));
7176
expect(updDelete).toBeTruthy();
7277
expect(updInsert).toBeTruthy();
73-
expect(updInsert!.params![4]).toBe("A+");
78+
expect(updInsert!.params![4]).toBe("Hello again");
7479
expect(updInsert!.params![5]).toBe(1);
7580

76-
// removed
7781
db.lastTx = null;
78-
await reg!.instance.emit({ removed: [{ id: "a1" }] });
79-
const del = db.lastTx!.calls.find(c => (c.sql as string).startsWith(`DELETE FROM ${PAIR.mirrorTable}`));
82+
await reg!.instance.emit({ removed: [{ id: "m1" }] });
83+
const del = db.lastTx!.calls.find(c => (c.sql as string).startsWith(`DELETE FROM ${CHAT_MESSAGES_PAIR.mirrorTable}`));
8084
expect(del).toBeTruthy();
8185

8286
stop();
8387
});
84-
});
88+
});

packages/crypto/password/tests/roundtrip.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ describe('password crypto roundtrip', () => {
55
it('encrypts and decrypts', async () => {
66
const crypto = createPasswordCrypto({ password: 'correct horse battery staple' });
77
const msg = new TextEncoder().encode('hello secret world');
8-
const env = await crypto.encrypt(msg, 'todo-v1');
8+
const env = await crypto.encrypt(msg, 'chat-message-v1');
99
const out = await crypto.decrypt(env);
1010
expect(new TextDecoder().decode(out)).toBe('hello secret world');
1111
});

0 commit comments

Comments
 (0)