Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"hono-openapi": "catalog:",
"lib0": "catalog:",
"pg": "^8.20.0",
"nanoid": "catalog:",
"wellcrafted": "catalog:",
"y-protocols": "catalog:",
"yjs": "catalog:"
Expand Down
9 changes: 8 additions & 1 deletion apps/api/src/asset-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,14 @@
* through this Worker, which sets security headers and supports ETag/range.
*/

import { generateGuid } from '@epicenter/workspace';
import { customAlphabet } from 'nanoid';

/**
* 15-char alphanumeric ID generator—same spec as `generateGuid` in @epicenter/workspace.
* Inlined here to avoid pulling workspace (and its Yjs dependency tree) into the
* Cloudflare Worker bundle, where wrangler can't resolve it.
*/
const generateGuid = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789', 15);
import { and, desc, eq, sql } from 'drizzle-orm';
import { Hono } from 'hono';
import { bodyLimit } from 'hono/body-limit';
Expand Down
37 changes: 37 additions & 0 deletions apps/api/src/auth/create-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,39 @@ export function createAuth({
strategy: 'jwe',
},
},
// Cross-origin cookie config for OAuth and sessions.
//
// The auth server (api.epicenter.so) serves multiple client apps:
// - Production subdomains: fuji.epicenter.so, opensidian.com
// - Desktop: tauri://localhost
// - Dev: localhost:5173, localhost:5174, etc.
//
// OAuth state cookies are set during a cross-origin POST (client → API),
// then read back on a top-level GET (Google → API callback). With the
// default SameSite=lax, browsers may drop cookies set via cross-origin
// POST responses, causing "state_mismatch" errors on the callback.
//
// SameSite=none tells the browser to send cookies on all cross-origin
// requests. This trades browser-level CSRF protection for app-level
// protection (trustedOrigins + origin header checking, which Better Auth
// already enforces on every request). Standard practice for auth servers
// on a separate domain—same model as Auth0, Clerk, and Supabase Auth.
//
// NOTE: We intentionally omit `partitioned: true` (CHIPS). Partitioned
// cookies are keyed by the top-level site at creation time. During OAuth,
// the top-level site changes mid-flow (client → Google → API callback),
// so the cookie becomes invisible at the callback step. Partitioned is
// designed for embedded iframes/subresources, not redirect-based OAuth.
advanced: {
crossSubDomainCookies: {
enabled: true,
domain: '.epicenter.so',
},
defaultCookieAttributes: {
sameSite: 'none',
secure: true,
},
},
databaseHooks: {
user: {
create: {
Expand Down Expand Up @@ -144,6 +177,10 @@ export function createAuth({
consentPage: '/consent',
requirePKCE: true,
allowDynamicClientRegistration: false,
// The plugin warns that /.well-known/oauth-authorization-server/auth must exist
// because basePath is /auth (not /), so it can't auto-mount at the root.
// We already mount both discovery endpoints manually in app.ts.
silenceWarnings: { oauthAuthServerConfig: true, openidConfig: true },
trustedClients: [
{
clientId: 'epicenter-desktop',
Expand Down
11 changes: 6 additions & 5 deletions apps/fuji/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,18 +38,19 @@
"@epicenter/workspace": "workspace:*",
"@tanstack/svelte-table": "catalog:",
"@tanstack/table-core": "9.0.0-alpha.10",
"prosemirror-commands": "^1.6.0",
"prosemirror-inputrules": "^1.4.0",
"prosemirror-keymap": "^1.2.0",
"prosemirror-schema-basic": "^1.2.0",
"prosemirror-schema-list": "^1.4.0",
"arktype": "catalog:",
"bits-ui": "catalog:",
"date-fns": "catalog:",
"nanoid": "catalog:",
"prosemirror-commands": "^1.6.0",
"prosemirror-inputrules": "^1.4.0",
"prosemirror-keymap": "^1.2.0",
"prosemirror-model": "^1.25.0",
"prosemirror-schema-basic": "^1.2.0",
"prosemirror-schema-list": "^1.4.0",
"prosemirror-state": "^1.4.0",
"prosemirror-view": "^1.41.0",
"typebox": "catalog:",
"wellcrafted": "catalog:",
"y-prosemirror": "^1.3.7",
"yjs": "catalog:"
Expand Down
12 changes: 10 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,11 @@ type EncryptionState = {
*/
export type EncryptedYKeyValueLww<T> = {
set(key: string, val: T): void;
bulkSet(entries: Array<{ key: string; val: T }>): void;
get(key: string): T | undefined;
has(key: string): boolean;
delete(key: string): void;
bulkDelete(keys: string[]): void;
entries(): IterableIterator<[string, YKeyValueLwwEntry<T>]>;
observe(handler: EncryptedKvObserver<T>): void;
unobserve(handler: EncryptedKvObserver<T>): void;
Expand Down Expand Up @@ -345,6 +347,24 @@ export function createEncryptedYkvLww<T>(
),
);
},
bulkSet(entries) {
if (!encryption) {
inner.bulkSet(entries);
return;
}

inner.bulkSet(
entries.map(({ key, val }) => ({
key,
val: encryptValue(
JSON.stringify(val),
encryption.currentKey,
textEncoder.encode(key),
encryption.currentVersion,
),
})),
);
},
/**
* Get a decrypted value by key. Reads from the inner store and decrypts
* on the fly (~0.01ms for XChaCha20-Poly1305 on a small JSON blob).
Expand All @@ -362,6 +382,9 @@ export function createEncryptedYkvLww<T>(
delete(key) {
inner.delete(key);
},
bulkDelete(keys) {
inner.bulkDelete(keys);
},
*entries() {
yield* iterateDecrypted(inner.entries());
},
Expand Down
79 changes: 79 additions & 0 deletions packages/workspace/src/shared/y-keyvalue/y-keyvalue-lww.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,49 @@ describe('YKeyValueLww', () => {
expect(kv.get('foo')).toBe('second');
});

test('bulkSet inserts all entries', () => {
const ydoc = new Y.Doc({ guid: 'test' });
const yarray = ydoc.getArray<YKeyValueLwwEntry<string>>('data');
const kv = new YKeyValueLww(yarray);

kv.bulkSet([
{ key: 'foo', val: 'bar' },
{ key: 'baz', val: 'qux' },
{ key: 'zap', val: 'zip' },
]);

expect(kv.get('foo')).toBe('bar');
expect(kv.get('baz')).toBe('qux');
expect(kv.get('zap')).toBe('zip');
expect(Array.from(kv.entries())).toHaveLength(3);
});

test('bulkSet updates existing entries', () => {
const ydoc = new Y.Doc({ guid: 'test' });
const yarray = ydoc.getArray<YKeyValueLwwEntry<string>>('data');
const kv = new YKeyValueLww(yarray);

kv.set('foo', 'first');
kv.bulkSet([
{ key: 'foo', val: 'second' },
{ key: 'bar', val: 'third' },
]);

expect(kv.get('foo')).toBe('second');
expect(kv.get('bar')).toBe('third');
expect(
Array.from(kv.entries())
.map(([key]) => key)
.sort(),
).toEqual(['bar', 'foo']);
expect(
yarray
.toArray()
.map((entry) => entry.key)
.sort(),
).toEqual(['bar', 'foo']);
});

test('delete removes value', () => {
const ydoc = new Y.Doc({ guid: 'test' });
const yarray = ydoc.getArray<YKeyValueLwwEntry<string>>('data');
Expand All @@ -49,6 +92,42 @@ describe('YKeyValueLww', () => {
expect(kv.has('foo')).toBe(false);
});

test('bulkDelete removes all specified keys', () => {
const ydoc = new Y.Doc({ guid: 'test' });
const yarray = ydoc.getArray<YKeyValueLwwEntry<string>>('data');
const kv = new YKeyValueLww(yarray);

kv.bulkSet([
{ key: 'foo', val: 'bar' },
{ key: 'baz', val: 'qux' },
{ key: 'zap', val: 'zip' },
]);
kv.bulkDelete(['foo', 'zap']);

expect(kv.get('foo')).toBeUndefined();
expect(kv.get('zap')).toBeUndefined();
expect(kv.get('baz')).toBe('qux');
expect(Array.from(kv.entries()).map(([key]) => key)).toEqual(['baz']);
});

test('bulkDelete is a no-op for missing keys', () => {
const ydoc = new Y.Doc({ guid: 'test' });
const yarray = ydoc.getArray<YKeyValueLwwEntry<string>>('data');
const kv = new YKeyValueLww(yarray);

kv.bulkSet([
{ key: 'foo', val: 'bar' },
{ key: 'baz', val: 'qux' },
]);
const before = yarray.toArray();

kv.bulkDelete(['missing', 'still-missing']);

expect(kv.get('foo')).toBe('bar');
expect(kv.get('baz')).toBe('qux');
expect(yarray.toArray()).toEqual(before);
});

test('entries have timestamp field', () => {
const ydoc = new Y.Doc({ guid: 'test' });
const yarray = ydoc.getArray<YKeyValueLwwEntry<string>>('data');
Expand Down
Loading
Loading