Skip to content

Commit f5bda98

Browse files
authored
Merge pull request #1653 from EpicenterHQ/pr/5-bulk-ops-and-api-fixes
feat(workspace): add bulkSet/bulkDelete to TableHelper, fix API OAuth cookie handling
2 parents c63599e + badfa20 commit f5bda98

13 files changed

Lines changed: 586 additions & 93 deletions

‎apps/api/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
"hono-openapi": "catalog:",
5252
"lib0": "catalog:",
5353
"pg": "^8.20.0",
54+
"nanoid": "catalog:",
5455
"wellcrafted": "catalog:",
5556
"y-protocols": "catalog:",
5657
"yjs": "catalog:"

‎apps/api/src/asset-routes.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,14 @@
99
* through this Worker, which sets security headers and supports ETag/range.
1010
*/
1111

12-
import { generateGuid } from '@epicenter/workspace';
12+
import { customAlphabet } from 'nanoid';
13+
14+
/**
15+
* 15-char alphanumeric ID generator—same spec as `generateGuid` in @epicenter/workspace.
16+
* Inlined here to avoid pulling workspace (and its Yjs dependency tree) into the
17+
* Cloudflare Worker bundle, where wrangler can't resolve it.
18+
*/
19+
const generateGuid = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789', 15);
1320
import { and, desc, eq, sql } from 'drizzle-orm';
1421
import { Hono } from 'hono';
1522
import { bodyLimit } from 'hono/body-limit';

‎apps/api/src/auth/create-auth.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,39 @@ export function createAuth({
6363
strategy: 'jwe',
6464
},
6565
},
66+
// Cross-origin cookie config for OAuth and sessions.
67+
//
68+
// The auth server (api.epicenter.so) serves multiple client apps:
69+
// - Production subdomains: fuji.epicenter.so, opensidian.com
70+
// - Desktop: tauri://localhost
71+
// - Dev: localhost:5173, localhost:5174, etc.
72+
//
73+
// OAuth state cookies are set during a cross-origin POST (client → API),
74+
// then read back on a top-level GET (Google → API callback). With the
75+
// default SameSite=lax, browsers may drop cookies set via cross-origin
76+
// POST responses, causing "state_mismatch" errors on the callback.
77+
//
78+
// SameSite=none tells the browser to send cookies on all cross-origin
79+
// requests. This trades browser-level CSRF protection for app-level
80+
// protection (trustedOrigins + origin header checking, which Better Auth
81+
// already enforces on every request). Standard practice for auth servers
82+
// on a separate domain—same model as Auth0, Clerk, and Supabase Auth.
83+
//
84+
// NOTE: We intentionally omit `partitioned: true` (CHIPS). Partitioned
85+
// cookies are keyed by the top-level site at creation time. During OAuth,
86+
// the top-level site changes mid-flow (client → Google → API callback),
87+
// so the cookie becomes invisible at the callback step. Partitioned is
88+
// designed for embedded iframes/subresources, not redirect-based OAuth.
89+
advanced: {
90+
crossSubDomainCookies: {
91+
enabled: true,
92+
domain: '.epicenter.so',
93+
},
94+
defaultCookieAttributes: {
95+
sameSite: 'none',
96+
secure: true,
97+
},
98+
},
6699
databaseHooks: {
67100
user: {
68101
create: {
@@ -144,6 +177,10 @@ export function createAuth({
144177
consentPage: '/consent',
145178
requirePKCE: true,
146179
allowDynamicClientRegistration: false,
180+
// The plugin warns that /.well-known/oauth-authorization-server/auth must exist
181+
// because basePath is /auth (not /), so it can't auto-mount at the root.
182+
// We already mount both discovery endpoints manually in app.ts.
183+
silenceWarnings: { oauthAuthServerConfig: true, openidConfig: true },
147184
trustedClients: [
148185
{
149186
clientId: 'epicenter-desktop',

‎apps/fuji/package.json‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,18 +38,19 @@
3838
"@epicenter/workspace": "workspace:*",
3939
"@tanstack/svelte-table": "catalog:",
4040
"@tanstack/table-core": "9.0.0-alpha.10",
41-
"prosemirror-commands": "^1.6.0",
42-
"prosemirror-inputrules": "^1.4.0",
43-
"prosemirror-keymap": "^1.2.0",
44-
"prosemirror-schema-basic": "^1.2.0",
45-
"prosemirror-schema-list": "^1.4.0",
4641
"arktype": "catalog:",
4742
"bits-ui": "catalog:",
4843
"date-fns": "catalog:",
4944
"nanoid": "catalog:",
45+
"prosemirror-commands": "^1.6.0",
46+
"prosemirror-inputrules": "^1.4.0",
47+
"prosemirror-keymap": "^1.2.0",
5048
"prosemirror-model": "^1.25.0",
49+
"prosemirror-schema-basic": "^1.2.0",
50+
"prosemirror-schema-list": "^1.4.0",
5151
"prosemirror-state": "^1.4.0",
5252
"prosemirror-view": "^1.41.0",
53+
"typebox": "catalog:",
5354
"wellcrafted": "catalog:",
5455
"y-prosemirror": "^1.3.7",
5556
"yjs": "catalog:"

‎bun.lock‎

Lines changed: 10 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/workspace/src/shared/y-keyvalue/y-keyvalue-lww-encrypted.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,9 +101,11 @@ type EncryptionState = {
101101
*/
102102
export type EncryptedYKeyValueLww<T> = {
103103
set(key: string, val: T): void;
104+
bulkSet(entries: Array<{ key: string; val: T }>): void;
104105
get(key: string): T | undefined;
105106
has(key: string): boolean;
106107
delete(key: string): void;
108+
bulkDelete(keys: string[]): void;
107109
entries(): IterableIterator<[string, YKeyValueLwwEntry<T>]>;
108110
observe(handler: EncryptedKvObserver<T>): void;
109111
unobserve(handler: EncryptedKvObserver<T>): void;
@@ -345,6 +347,24 @@ export function createEncryptedYkvLww<T>(
345347
),
346348
);
347349
},
350+
bulkSet(entries) {
351+
if (!encryption) {
352+
inner.bulkSet(entries);
353+
return;
354+
}
355+
356+
inner.bulkSet(
357+
entries.map(({ key, val }) => ({
358+
key,
359+
val: encryptValue(
360+
JSON.stringify(val),
361+
encryption.currentKey,
362+
textEncoder.encode(key),
363+
encryption.currentVersion,
364+
),
365+
})),
366+
);
367+
},
348368
/**
349369
* Get a decrypted value by key. Reads from the inner store and decrypts
350370
* on the fly (~0.01ms for XChaCha20-Poly1305 on a small JSON blob).
@@ -362,6 +382,9 @@ export function createEncryptedYkvLww<T>(
362382
delete(key) {
363383
inner.delete(key);
364384
},
385+
bulkDelete(keys) {
386+
inner.bulkDelete(keys);
387+
},
365388
*entries() {
366389
yield* iterateDecrypted(inner.entries());
367390
},

‎packages/workspace/src/shared/y-keyvalue/y-keyvalue-lww.test.ts‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,49 @@ describe('YKeyValueLww', () => {
3838
expect(kv.get('foo')).toBe('second');
3939
});
4040

41+
test('bulkSet inserts all entries', () => {
42+
const ydoc = new Y.Doc({ guid: 'test' });
43+
const yarray = ydoc.getArray<YKeyValueLwwEntry<string>>('data');
44+
const kv = new YKeyValueLww(yarray);
45+
46+
kv.bulkSet([
47+
{ key: 'foo', val: 'bar' },
48+
{ key: 'baz', val: 'qux' },
49+
{ key: 'zap', val: 'zip' },
50+
]);
51+
52+
expect(kv.get('foo')).toBe('bar');
53+
expect(kv.get('baz')).toBe('qux');
54+
expect(kv.get('zap')).toBe('zip');
55+
expect(Array.from(kv.entries())).toHaveLength(3);
56+
});
57+
58+
test('bulkSet updates existing entries', () => {
59+
const ydoc = new Y.Doc({ guid: 'test' });
60+
const yarray = ydoc.getArray<YKeyValueLwwEntry<string>>('data');
61+
const kv = new YKeyValueLww(yarray);
62+
63+
kv.set('foo', 'first');
64+
kv.bulkSet([
65+
{ key: 'foo', val: 'second' },
66+
{ key: 'bar', val: 'third' },
67+
]);
68+
69+
expect(kv.get('foo')).toBe('second');
70+
expect(kv.get('bar')).toBe('third');
71+
expect(
72+
Array.from(kv.entries())
73+
.map(([key]) => key)
74+
.sort(),
75+
).toEqual(['bar', 'foo']);
76+
expect(
77+
yarray
78+
.toArray()
79+
.map((entry) => entry.key)
80+
.sort(),
81+
).toEqual(['bar', 'foo']);
82+
});
83+
4184
test('delete removes value', () => {
4285
const ydoc = new Y.Doc({ guid: 'test' });
4386
const yarray = ydoc.getArray<YKeyValueLwwEntry<string>>('data');
@@ -49,6 +92,42 @@ describe('YKeyValueLww', () => {
4992
expect(kv.has('foo')).toBe(false);
5093
});
5194

95+
test('bulkDelete removes all specified keys', () => {
96+
const ydoc = new Y.Doc({ guid: 'test' });
97+
const yarray = ydoc.getArray<YKeyValueLwwEntry<string>>('data');
98+
const kv = new YKeyValueLww(yarray);
99+
100+
kv.bulkSet([
101+
{ key: 'foo', val: 'bar' },
102+
{ key: 'baz', val: 'qux' },
103+
{ key: 'zap', val: 'zip' },
104+
]);
105+
kv.bulkDelete(['foo', 'zap']);
106+
107+
expect(kv.get('foo')).toBeUndefined();
108+
expect(kv.get('zap')).toBeUndefined();
109+
expect(kv.get('baz')).toBe('qux');
110+
expect(Array.from(kv.entries()).map(([key]) => key)).toEqual(['baz']);
111+
});
112+
113+
test('bulkDelete is a no-op for missing keys', () => {
114+
const ydoc = new Y.Doc({ guid: 'test' });
115+
const yarray = ydoc.getArray<YKeyValueLwwEntry<string>>('data');
116+
const kv = new YKeyValueLww(yarray);
117+
118+
kv.bulkSet([
119+
{ key: 'foo', val: 'bar' },
120+
{ key: 'baz', val: 'qux' },
121+
]);
122+
const before = yarray.toArray();
123+
124+
kv.bulkDelete(['missing', 'still-missing']);
125+
126+
expect(kv.get('foo')).toBe('bar');
127+
expect(kv.get('baz')).toBe('qux');
128+
expect(yarray.toArray()).toEqual(before);
129+
});
130+
52131
test('entries have timestamp field', () => {
53132
const ydoc = new Y.Doc({ guid: 'test' });
54133
const yarray = ydoc.getArray<YKeyValueLwwEntry<string>>('data');

0 commit comments

Comments
 (0)