diff --git a/src/routes/health.ts b/src/routes/health.ts
new file mode 100644
index 0000000..1d263c5
--- /dev/null
+++ b/src/routes/health.ts
@@ -0,0 +1,4 @@
+import { Hono } from 'hono'
+
+export const health = new Hono()
+health.get('/health', c => c.json({ ok: true }))
diff --git a/src/routes/miniapp.ts b/src/routes/miniapp.ts
new file mode 100644
index 0000000..d9e20ff
--- /dev/null
+++ b/src/routes/miniapp.ts
@@ -0,0 +1,12 @@
+import { Hono } from 'hono'
+
+const HTML = `
+
arcanesync
+
+
+managed by arcanegram client. you can close this window.
+
+`
+
+export const miniapp = new Hono()
+miniapp.get('/miniapp', c => c.html(HTML))
diff --git a/src/routes/sync.ts b/src/routes/sync.ts
new file mode 100644
index 0000000..e303d7f
--- /dev/null
+++ b/src/routes/sync.ts
@@ -0,0 +1,84 @@
+import type { AuthEnv } from '@/middleware/auth.js'
+import { Buffer } from 'node:buffer'
+import { eq, sql } from 'drizzle-orm'
+import { Hono } from 'hono'
+import { userSettings } from '@/db/schema.js'
+import { db } from '@/lib/db.js'
+import { authMiddleware } from '@/middleware/auth.js'
+import { config } from '@/shared/config.js'
+
+export const sync = new Hono()
+sync.use('*', authMiddleware)
+
+sync.get('/sync', async (c) => {
+ const userId = c.get('userId')
+ const [row] = await db.select().from(userSettings).where(eq(userSettings.userId, userId))
+ if (!row)
+ return c.json({ version: 0, data: {} })
+ return c.json({ version: row.version, data: row.data })
+})
+
+interface PutBody {
+ baseVersion: number
+ patch: Record
+}
+
+sync.put('/sync', async (c) => {
+ const raw = await c.req.text()
+ if (Buffer.byteLength(raw, 'utf8') > config.maxPayloadBytes)
+ return c.json({ error: 'payload-too-large' }, 413)
+
+ let body: PutBody
+ try {
+ body = JSON.parse(raw)
+ }
+ catch {
+ return c.json({ error: 'bad-json' }, 400)
+ }
+ if (typeof body.baseVersion !== 'number' || typeof body.patch !== 'object' || body.patch === null || Array.isArray(body.patch))
+ return c.json({ error: 'malformed' }, 400)
+
+ const userId = c.get('userId')
+
+ return db.transaction(async (tx) => {
+ const [row] = await tx
+ .select()
+ .from(userSettings)
+ .where(eq(userSettings.userId, userId))
+ .for('update')
+
+ if (!row) {
+ const merged = applyPatch({}, body.patch)
+ const [inserted] = await tx
+ .insert(userSettings)
+ .values({ userId, version: 1, data: merged })
+ .returning()
+ return c.json({ version: inserted.version, data: inserted.data })
+ }
+
+ if (body.baseVersion !== row.version)
+ return c.json({ version: row.version, data: row.data }, 409)
+
+ const merged = applyPatch(row.data, body.patch)
+ const [updated] = await tx
+ .update(userSettings)
+ .set({ version: row.version + 1, data: merged, updatedAt: sql`now()` })
+ .where(eq(userSettings.userId, userId))
+ .returning()
+ return c.json({ version: updated.version, data: updated.data })
+ })
+})
+
+function applyPatch(
+ base: Record,
+ patch: Record,
+): Record {
+ const out: Record = { ...base }
+ for (const [k, v] of Object.entries(patch)) {
+ if (v === null)
+ delete out[k]
+ else
+ out[k] = v
+ }
+ return out
+}