A system for building static webapps
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add sync server

+2502 -39
+36 -13
deno.lock
··· 4 4 "jsr:@astral/astral@~0.5.5": "0.5.5", 5 5 "jsr:@b-fuze/deno-dom@~0.1.56": "0.1.56", 6 6 "jsr:@civility/store@0.3": "0.3.1", 7 + "jsr:@civility/sync@0.1": "0.1.1", 7 8 "jsr:@cliffy/ansi@1.0.0": "1.0.0", 8 9 "jsr:@cliffy/command@1.0.0": "1.0.0", 9 10 "jsr:@cliffy/flags@1.0.0": "1.0.0", ··· 70 71 "jsr:@zaubrik/djwt@^3.0.2": "3.0.2", 71 72 "jsr:@zip-js/zip-js@^2.7.52": "2.8.24", 72 73 "jsr:@zod/zod@^4.3.6": "4.3.6", 73 - "npm:@hono/zod-openapi@^1.2.2": "1.2.2_hono@4.12.5_zod@4.3.6", 74 + "npm:@hono/swagger-ui@~0.6.1": "0.6.1_hono@4.12.9", 75 + "npm:@hono/zod-openapi@^1.2.2": "1.2.4_hono@4.12.9_zod@4.3.6", 76 + "npm:@hono/zod-openapi@^1.2.4": "1.2.4_hono@4.12.9_zod@4.3.6", 74 77 "npm:@oslojs/crypto@^1.0.1": "1.0.1", 75 78 "npm:@oslojs/encoding@^1.1.0": "1.1.0", 76 - "npm:@scalar/hono-api-reference@0.10": "0.10.0_hono@4.12.5", 79 + "npm:@scalar/hono-api-reference@0.10": "0.10.0_hono@4.12.9", 77 80 "npm:@tauri-apps/plugin-store@^2.2.0": "2.4.2", 78 81 "npm:autoprefixer@^10.4.27": "10.4.27_postcss@8.5.8", 79 82 "npm:cheerio@^1.2.0": "1.2.0", ··· 81 84 "npm:esbuild@~0.27.3": "0.27.3", 82 85 "npm:fake-indexeddb@6.0.0": "6.0.0", 83 86 "npm:fast-json-patch@^3.1.1": "3.1.1", 87 + "npm:hono@^4.12.9": "4.12.9", 84 88 "npm:lit@^3.3.2": "3.3.2", 85 89 "npm:native-file-system-adapter@^3.0.1": "3.0.1", 86 90 "npm:postcss-import@^16.1.1": "16.1.1_postcss@8.5.8", ··· 110 114 "@civility/store@0.3.1": { 111 115 "integrity": "0438f2cdb16145a61a97f5be509cd0b34e7cbd9f71dc657feffe2a4dd7dd0ec3", 112 116 "dependencies": [ 117 + "jsr:@std/fs@^1.0.23", 113 118 "jsr:@std/semver" 114 119 ] 115 120 }, 121 + "@civility/sync@0.1.1": { 122 + "integrity": "9ef604671656316dffbeea4c0d8c01488c8a2f54269ddcc68c4d4731317174ad", 123 + "dependencies": [ 124 + "jsr:@civility/store", 125 + "jsr:@paulmillr/qr", 126 + "npm:native-file-system-adapter" 127 + ] 128 + }, 116 129 "@cliffy/ansi@1.0.0": { 117 130 "integrity": "987008f74e50aa72cc1517ffccc769711734a14927bc4599e052efe1b9a840e2", 118 131 "dependencies": [ ··· 370 383 } 371 384 }, 372 385 "npm": { 373 - "@asteasolutions/zod-to-openapi@8.4.3_zod@4.3.6": { 374 - "integrity": "sha512-lwfMTN7kDbFDwMniYZUebiGGHxVGBw9ZSI4IBYjm6Ey22Kd5z/fsQb2k+Okr8WMbCCC553vi/ZM9utl5/XcvuQ==", 386 + "@asteasolutions/zod-to-openapi@8.5.0_zod@4.3.6": { 387 + "integrity": "sha512-SABbKiObg5dLRiTFnqiW1WWwGcg1BJfmHtT2asIBnBHg6Smy/Ms2KHc650+JI4Hw7lSkdiNebEGXpwoxfben8Q==", 375 388 "dependencies": [ 376 389 "openapi3-ts", 377 390 "zod" ··· 507 520 "os": ["win32"], 508 521 "cpu": ["x64"] 509 522 }, 510 - "@hono/zod-openapi@1.2.2_hono@4.12.5_zod@4.3.6": { 511 - "integrity": "sha512-va6vsL23wCJ1d0Vd+vGL1XOt+wPwItxirYafuhlW9iC2MstYr2FvsI7mctb45eBTjZfkqB/3LYDJEppPjOEiHw==", 523 + "@hono/swagger-ui@0.6.1_hono@4.12.9": { 524 + "integrity": "sha512-sJTvldu1GPeEPfyeLG7gRj+W4vEuD+JDi+JjJ3TJs/DvMUtBLs0KJO5yokGegWWdy5qrbdnQGekbhgNRmPmYKQ==", 525 + "dependencies": [ 526 + "hono" 527 + ] 528 + }, 529 + "@hono/zod-openapi@1.2.4_hono@4.12.9_zod@4.3.6": { 530 + "integrity": "sha512-cZu71bpODTbtIDoUsIIYPrs58wJ565Tbg6FE+JshU0irBAd6KxrP+k62Amm/mjA7tTOQ3+ingODHKGFOnv+Ibw==", 512 531 "dependencies": [ 513 532 "@asteasolutions/zod-to-openapi", 514 533 "@hono/zod-validator", ··· 517 536 "zod" 518 537 ] 519 538 }, 520 - "@hono/zod-validator@0.7.6_hono@4.12.5_zod@4.3.6": { 539 + "@hono/zod-validator@0.7.6_hono@4.12.9_zod@4.3.6": { 521 540 "integrity": "sha512-Io1B6d011Gj1KknV4rXYz4le5+5EubcWEU/speUjuw9XMMIaP3n78yXLhjd2A3PXaXaUwEAluOiAyLqhBEJgsw==", 522 541 "dependencies": [ 523 542 "hono", ··· 561 580 "@scalar/helpers@0.3.0": { 562 581 "integrity": "sha512-lhQdehgighJC+PiSTJbbggM/SM3UydcRQil6Cfp/M4l539qklIh35pt4eh1+H+5Esa03gHnJwhTHF3TwglSOJw==" 563 582 }, 564 - "@scalar/hono-api-reference@0.10.0_hono@4.12.5": { 583 + "@scalar/hono-api-reference@0.10.0_hono@4.12.9": { 565 584 "integrity": "sha512-5QGNilMAnLRzSufMhh0Ni8DepWzL2UOJm+RQI+e/slrhhfhO+pSV2bcLE8dhBz6k+V70El6Pvl0m2cPaDvP4sw==", 566 585 "dependencies": [ 567 586 "@scalar/core", ··· 862 881 "function-bind" 863 882 ] 864 883 }, 865 - "hono@4.12.5": { 866 - "integrity": "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==" 884 + "hono@4.12.9": { 885 + "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==" 867 886 }, 868 887 "htmlparser2@10.1.0": { 869 888 "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", ··· 1302 1321 "whatwg-mimetype@4.0.0": { 1303 1322 "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==" 1304 1323 }, 1305 - "yaml@2.8.2": { 1306 - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", 1324 + "yaml@2.8.3": { 1325 + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", 1307 1326 "bin": true 1308 1327 }, 1309 1328 "zod@4.3.6": { ··· 1358 1377 "dependencies": [ 1359 1378 "jsr:@astral/astral@~0.5.5", 1360 1379 "jsr:@b-fuze/deno-dom@~0.1.56", 1380 + "jsr:@civility/store@^1.0.0-beta.1", 1361 1381 "jsr:@cliffy/ansi@1.0.0", 1362 1382 "jsr:@cliffy/command@1.0.0", 1363 1383 "jsr:@cliffy/prompt@1", ··· 1368 1388 "jsr:@std/fs@^1.0.23", 1369 1389 "jsr:@std/http@^1.0.25", 1370 1390 "jsr:@std/path@^1.1.4", 1391 + "npm:@hono/swagger-ui@~0.6.1", 1392 + "npm:@hono/zod-openapi@^1.2.4", 1371 1393 "npm:autoprefixer@^10.4.27", 1372 1394 "npm:cheerio@^1.2.0", 1373 - "npm:esbuild@~0.27.3" 1395 + "npm:esbuild@~0.27.3", 1396 + "npm:hono@^4.12.9" 1374 1397 ] 1375 1398 }, 1376 1399 "packages/store": {
+28
packages/cli/commands/sync.ts
··· 1 + import { Command } from '@cliffy/command' 2 + 3 + export const SyncCommand = new Command() 4 + .name('sync') 5 + .description('Civility Sync server') 6 + .command( 7 + 'start', 8 + new Command() 9 + .description('Start the sync server') 10 + .option('-p, --port <port:number>', 'Port to listen on', { 11 + default: 8081, 12 + }) 13 + .action(async (options) => { 14 + const { createSyncServer } = await import('../sync/mod.ts') 15 + const kv = await Deno.openKv() 16 + const app = createSyncServer(kv) 17 + 18 + console.log(` 19 + Civility Sync Server 20 + http://localhost:${options.port} 21 + 22 + API docs: http://localhost:${options.port}/api/v1/ui 23 + OpenAPI: http://localhost:${options.port}/api/v1/doc 24 + `) 25 + 26 + Deno.serve({ port: options.port }, app.fetch) 27 + }), 28 + )
+9 -3
packages/cli/deno.json
··· 1 1 { 2 2 "name": "@civility/cli", 3 - "version": "0.2.4", 3 + "version": "1.0.0-beta.1", 4 4 "exports": { 5 - ".": "./main.ts" 5 + ".": "./main.ts", 6 + "./sync": "./sync/mod.ts" 6 7 }, 7 8 "tasks": { 8 - "install": "deno install main.ts -Afg --name=civ --config ./deno.json" 9 + "install": "deno install main.ts -Afg --name=civ --unstable-kv --config ./deno.json" 9 10 }, 10 11 "imports": { 11 12 "@astral/astral": "jsr:@astral/astral@^0.5.5", ··· 14 15 "@cliffy/command": "jsr:@cliffy/command@1.0.0", 15 16 "@cliffy/prompt": "jsr:@cliffy/prompt@^1.0.0", 16 17 "@cliffy/table": "jsr:@cliffy/table@1.0.0", 18 + "@civility/store": "jsr:@civility/store@^1.0.0-beta.1", 19 + "@civility/store/deno-kv": "jsr:@civility/store@^1.0.0-beta.1/deno-kv", 20 + "@hono/hono": "npm:hono@^4.12.9", 21 + "@hono/swagger-ui": "npm:@hono/swagger-ui@^0.6.1", 22 + "@hono/zod-openapi": "npm:@hono/zod-openapi@^1.2.4", 17 23 "@luca/esbuild-deno-loader": "jsr:@luca/esbuild-deno-loader@^0.11.1", 18 24 "@rodney/parsedown": "jsr:@rodney/parsedown@^1.4.3", 19 25 "@std/front-matter": "jsr:@std/front-matter@^1.0.9",
+2
packages/cli/main.ts
··· 22 22 import { Start } from './commands/start.ts' 23 23 import { Init } from './commands/init.ts' 24 24 import { Static } from './commands/static.ts' 25 + import { SyncCommand } from './commands/sync.ts' 25 26 26 27 await loadConfig() 27 28 ··· 279 280 .command('start', Start) 280 281 .command('init', Init) 281 282 .command('static', Static) 283 + .command('sync', SyncCommand) 282 284 .command('init-config', ConfigInitCommand) 283 285 .parse(Deno.args)
+587
packages/cli/sync/__tests__/server.test.ts
··· 1 + /** 2 + * @module Civility Sync Server tests 3 + * 4 + * Tests the server via app.request() — no network needed. 5 + * Uses a fresh Deno KV instance per test for isolation. 6 + */ 7 + 8 + import { assertEquals, assertExists } from '@std/assert' 9 + import { createSyncServer } from '../mod.ts' 10 + 11 + // ── Helpers ───────────────────────────────────── 12 + 13 + function createApp(kv: Deno.Kv) { 14 + return createSyncServer(kv) 15 + } 16 + 17 + async function signup( 18 + app: ReturnType<typeof createApp>, 19 + email = 'test@test.com', 20 + password = 'password123', 21 + ) { 22 + const res = await app.request('/api/v1/signup', { 23 + method: 'POST', 24 + headers: { 'Content-Type': 'application/json' }, 25 + body: JSON.stringify({ email, password }), 26 + }) 27 + const body = await res.json() 28 + return { res, body, token: body.data?.token as string } 29 + } 30 + 31 + function authHeaders(token: string) { 32 + return { 33 + 'Content-Type': 'application/json', 34 + 'Authorization': `Bearer ${token}`, 35 + } 36 + } 37 + 38 + // ── Tests ─────────────────────────────────────── 39 + 40 + Deno.test('Health', async (t) => { 41 + const kv = await Deno.openKv(':memory:') 42 + 43 + try { 44 + const app = createApp(kv) 45 + 46 + await t.step('GET /api/v1/healthcheck returns healthy', async () => { 47 + const res = await app.request('/api/v1/healthcheck') 48 + assertEquals(res.status, 200) 49 + 50 + const body = await res.json() 51 + assertEquals(body.status, 'success') 52 + assertEquals(body.data.status, 'healthy') 53 + assertEquals(body.data.service, 'Civility Sync') 54 + }) 55 + } finally { 56 + kv.close() 57 + } 58 + }) 59 + 60 + Deno.test('Auth', async (t) => { 61 + const kv = await Deno.openKv(':memory:') 62 + 63 + try { 64 + const app = createApp(kv) 65 + 66 + await t.step( 67 + 'POST /api/v1/signup creates user and returns token', 68 + async () => { 69 + const { res, body } = await signup(app) 70 + assertEquals(res.status, 201) 71 + assertEquals(body.status, 'success') 72 + assertExists(body.data.token) 73 + assertExists(body.data.user.id) 74 + assertEquals(body.data.user.username, 'test') 75 + }, 76 + ) 77 + 78 + await t.step('POST /api/v1/signup rejects duplicate email', async () => { 79 + const res = await app.request('/api/v1/signup', { 80 + method: 'POST', 81 + headers: { 'Content-Type': 'application/json' }, 82 + body: JSON.stringify({ 83 + email: 'test@test.com', 84 + password: 'password123', 85 + }), 86 + }) 87 + assertEquals(res.status, 409) 88 + }) 89 + 90 + await t.step('POST /api/v1/login authenticates existing user', async () => { 91 + const res = await app.request('/api/v1/login', { 92 + method: 'POST', 93 + headers: { 'Content-Type': 'application/json' }, 94 + body: JSON.stringify({ 95 + email: 'test@test.com', 96 + password: 'password123', 97 + }), 98 + }) 99 + assertEquals(res.status, 200) 100 + 101 + const body = await res.json() 102 + assertEquals(body.status, 'success') 103 + assertExists(body.data.token) 104 + }) 105 + 106 + await t.step('POST /api/v1/login rejects wrong password', async () => { 107 + const res = await app.request('/api/v1/login', { 108 + method: 'POST', 109 + headers: { 'Content-Type': 'application/json' }, 110 + body: JSON.stringify({ email: 'test@test.com', password: 'wrong' }), 111 + }) 112 + assertEquals(res.status, 401) 113 + }) 114 + 115 + await t.step('GET /api/v1/verify validates token', async () => { 116 + const { token } = await signup(app, 'verify@test.com', 'password123') 117 + 118 + const res = await app.request(`/api/v1/verify?token=${token}`) 119 + assertEquals(res.status, 200) 120 + 121 + const body = await res.json() 122 + assertEquals(body.status, 'success') 123 + assertExists(body.data.userId) 124 + assertExists(body.data.sessionId) 125 + }) 126 + 127 + await t.step('GET /api/v1/verify rejects invalid token', async () => { 128 + const res = await app.request('/api/v1/verify?token=invalid-token') 129 + assertEquals(res.status, 401) 130 + }) 131 + 132 + await t.step('POST /api/v1/logout invalidates session', async () => { 133 + const { token } = await signup(app, 'logout@test.com', 'password123') 134 + 135 + const res = await app.request('/api/v1/logout', { 136 + method: 'POST', 137 + headers: authHeaders(token), 138 + }) 139 + assertEquals(res.status, 200) 140 + 141 + // Token should now be invalid 142 + const verifyRes = await app.request(`/api/v1/verify?token=${token}`) 143 + assertEquals(verifyRes.status, 401) 144 + }) 145 + } finally { 146 + kv.close() 147 + } 148 + }) 149 + 150 + Deno.test('Me', async (t) => { 151 + const kv = await Deno.openKv(':memory:') 152 + 153 + try { 154 + const app = createApp(kv) 155 + const { token } = await signup(app) 156 + 157 + await t.step('GET /api/v1/me returns user profile', async () => { 158 + const res = await app.request('/api/v1/me', { 159 + headers: authHeaders(token), 160 + }) 161 + assertEquals(res.status, 200) 162 + 163 + const body = await res.json() 164 + assertEquals(body.status, 'success') 165 + assertEquals(body.data.username, 'test') 166 + assertExists(body.data.id) 167 + }) 168 + 169 + await t.step('GET /api/v1/me rejects unauthenticated', async () => { 170 + const res = await app.request('/api/v1/me') 171 + assertEquals(res.status, 401) 172 + }) 173 + } finally { 174 + kv.close() 175 + } 176 + }) 177 + 178 + Deno.test('Apps CRUD', async (t) => { 179 + const kv = await Deno.openKv(':memory:') 180 + 181 + try { 182 + const app = createApp(kv) 183 + const { token } = await signup(app) 184 + let appId: string 185 + 186 + await t.step('POST /api/v1/apps creates an app', async () => { 187 + const res = await app.request('/api/v1/apps', { 188 + method: 'POST', 189 + headers: authHeaders(token), 190 + body: JSON.stringify({ name: 'Test App', description: 'A test app' }), 191 + }) 192 + assertEquals(res.status, 201) 193 + 194 + const body = await res.json() 195 + assertEquals(body.status, 'success') 196 + assertEquals(body.data.name, 'Test App') 197 + assertEquals(body.data.description, 'A test app') 198 + assertExists(body.data.id) 199 + appId = body.data.id 200 + }) 201 + 202 + await t.step('GET /api/v1/apps lists apps', async () => { 203 + const res = await app.request('/api/v1/apps', { 204 + headers: authHeaders(token), 205 + }) 206 + assertEquals(res.status, 200) 207 + 208 + const body = await res.json() 209 + assertEquals(body.data.length, 1) 210 + assertEquals(body.data[0].name, 'Test App') 211 + }) 212 + 213 + await t.step('GET /api/v1/apps/:id gets specific app', async () => { 214 + const res = await app.request(`/api/v1/apps/${appId}`, { 215 + headers: authHeaders(token), 216 + }) 217 + assertEquals(res.status, 200) 218 + 219 + const body = await res.json() 220 + assertEquals(body.data.id, appId) 221 + assertEquals(body.data.name, 'Test App') 222 + }) 223 + 224 + await t.step( 225 + 'GET /api/v1/apps/:id returns 404 for missing app', 226 + async () => { 227 + const res = await app.request( 228 + '/api/v1/apps/00000000-0000-0000-0000-000000000000', 229 + { headers: authHeaders(token) }, 230 + ) 231 + assertEquals(res.status, 404) 232 + }, 233 + ) 234 + 235 + await t.step('DELETE /api/v1/apps/:id deletes app', async () => { 236 + const res = await app.request(`/api/v1/apps/${appId}`, { 237 + method: 'DELETE', 238 + headers: authHeaders(token), 239 + }) 240 + assertEquals(res.status, 200) 241 + 242 + // Verify deleted 243 + const getRes = await app.request(`/api/v1/apps/${appId}`, { 244 + headers: authHeaders(token), 245 + }) 246 + assertEquals(getRes.status, 404) 247 + }) 248 + 249 + await t.step('all apps endpoints require auth', async () => { 250 + const r1 = await app.request('/api/v1/apps') 251 + assertEquals(r1.status, 401) 252 + 253 + const r2 = await app.request('/api/v1/apps', { 254 + method: 'POST', 255 + headers: { 'Content-Type': 'application/json' }, 256 + body: JSON.stringify({ name: 'No Auth' }), 257 + }) 258 + assertEquals(r2.status, 401) 259 + }) 260 + } finally { 261 + kv.close() 262 + } 263 + }) 264 + 265 + Deno.test('App Tokens', async (t) => { 266 + const kv = await Deno.openKv(':memory:') 267 + 268 + try { 269 + const app = createApp(kv) 270 + const { token } = await signup(app) 271 + 272 + // Create an app first 273 + const createRes = await app.request('/api/v1/apps', { 274 + method: 'POST', 275 + headers: authHeaders(token), 276 + body: JSON.stringify({ name: 'Token Test App' }), 277 + }) 278 + const appId = (await createRes.json()).data.id 279 + let tokenId: string 280 + 281 + await t.step('POST /api/v1/apps/:id/tokens creates a token', async () => { 282 + const res = await app.request(`/api/v1/apps/${appId}/tokens`, { 283 + method: 'POST', 284 + headers: authHeaders(token), 285 + body: JSON.stringify({ name: 'Test Token', permissions: 'read_write' }), 286 + }) 287 + assertEquals(res.status, 201) 288 + 289 + const body = await res.json() 290 + assertEquals(body.data.name, 'Test Token') 291 + assertEquals(body.data.permissions, 'read_write') 292 + assertExists(body.data.token) 293 + tokenId = body.data.id 294 + }) 295 + 296 + await t.step('GET /api/v1/apps/:id/tokens lists tokens', async () => { 297 + const res = await app.request(`/api/v1/apps/${appId}/tokens`, { 298 + headers: authHeaders(token), 299 + }) 300 + assertEquals(res.status, 200) 301 + 302 + const body = await res.json() 303 + assertEquals(body.data.length, 1) 304 + assertEquals(body.data[0].name, 'Test Token') 305 + }) 306 + 307 + await t.step( 308 + 'DELETE /api/v1/apps/:id/tokens/:tokenId deletes token', 309 + async () => { 310 + const res = await app.request( 311 + `/api/v1/apps/${appId}/tokens/${tokenId}`, 312 + { method: 'DELETE', headers: authHeaders(token) }, 313 + ) 314 + assertEquals(res.status, 200) 315 + 316 + // Verify deleted 317 + const listRes = await app.request(`/api/v1/apps/${appId}/tokens`, { 318 + headers: authHeaders(token), 319 + }) 320 + const body = await listRes.json() 321 + assertEquals(body.data.length, 0) 322 + }, 323 + ) 324 + } finally { 325 + kv.close() 326 + } 327 + }) 328 + 329 + Deno.test('Sync — register schema', async (t) => { 330 + const kv = await Deno.openKv(':memory:') 331 + 332 + try { 333 + const app = createApp(kv) 334 + const { token } = await signup(app) 335 + 336 + await t.step( 337 + 'POST /api/v1/apps/:id/register auto-creates app and stores schema', 338 + async () => { 339 + const appId = 'my-sync-app' 340 + const schema = { 341 + versions: [{ version: '1.0.0', schema: { type: 'object' } }], 342 + } 343 + 344 + const res = await app.request(`/api/v1/apps/${appId}/register`, { 345 + method: 'POST', 346 + headers: authHeaders(token), 347 + body: JSON.stringify({ schema }), 348 + }) 349 + assertEquals(res.status, 200) 350 + 351 + const body = await res.json() 352 + assertEquals(body.status, 'success') 353 + assertExists(body.data) 354 + assertEquals(JSON.parse(body.data.schema), schema) 355 + }, 356 + ) 357 + } finally { 358 + kv.close() 359 + } 360 + }) 361 + 362 + Deno.test('Sync — push and pull', async (t) => { 363 + const kv = await Deno.openKv(':memory:') 364 + 365 + try { 366 + const app = createApp(kv) 367 + const { token } = await signup(app) 368 + 369 + // Create app 370 + const createRes = await app.request('/api/v1/apps', { 371 + method: 'POST', 372 + headers: authHeaders(token), 373 + body: JSON.stringify({ name: 'Sync App' }), 374 + }) 375 + const appId = (await createRes.json()).data.id 376 + 377 + await t.step('POST /api/v1/apps/:id/push accepts changes', async () => { 378 + const changes = [ 379 + { 380 + id: 'change-1', 381 + documentId: 'todos/todo-1', 382 + patch: [{ op: 'add', path: '/title', value: 'Buy milk' }], 383 + inversePatch: [{ op: 'remove', path: '/title' }], 384 + hlc: '0000000000001-0000-client1', 385 + origin: 'client1', 386 + createdAt: '2024-01-01T00:00:00Z', 387 + synced: false, 388 + }, 389 + ] 390 + 391 + const res = await app.request(`/api/v1/apps/${appId}/push`, { 392 + method: 'POST', 393 + headers: authHeaders(token), 394 + body: JSON.stringify({ 395 + changes, 396 + clientHLC: '0000000000001-0000-client1', 397 + }), 398 + }) 399 + assertEquals(res.status, 200) 400 + 401 + const body = await res.json() 402 + assertEquals(body.status, 'success') 403 + assertEquals(body.data.accepted, ['change-1']) 404 + assertEquals(Array.isArray(body.data.remoteChanges), true) 405 + assertExists(body.data.serverHLC) 406 + }) 407 + 408 + await t.step( 409 + 'POST /api/v1/apps/:id/pull returns empty for up-to-date client', 410 + async () => { 411 + const res = await app.request(`/api/v1/apps/${appId}/pull`, { 412 + method: 'POST', 413 + headers: authHeaders(token), 414 + body: JSON.stringify({ 415 + sinceHLC: '9999999999999-9999-future', 416 + storeName: 'todos', 417 + }), 418 + }) 419 + assertEquals(res.status, 200) 420 + 421 + const body = await res.json() 422 + assertEquals(body.status, 'success') 423 + assertEquals(body.data.changes.length, 0) 424 + }, 425 + ) 426 + 427 + await t.step('push multiple changes across stores', async () => { 428 + const changes = [ 429 + { 430 + id: 'change-2', 431 + documentId: 'todos/todo-2', 432 + patch: [{ op: 'add', path: '/title', value: 'Walk dog' }], 433 + inversePatch: [{ op: 'remove', path: '/title' }], 434 + hlc: '0000000000002-0000-client1', 435 + origin: 'client1', 436 + createdAt: '2024-01-02T00:00:00Z', 437 + synced: false, 438 + }, 439 + { 440 + id: 'change-3', 441 + documentId: 'settings/prefs', 442 + patch: [{ op: 'add', path: '/theme', value: 'dark' }], 443 + inversePatch: [{ op: 'remove', path: '/theme' }], 444 + hlc: '0000000000003-0000-client1', 445 + origin: 'client1', 446 + createdAt: '2024-01-02T00:00:00Z', 447 + synced: false, 448 + }, 449 + ] 450 + 451 + const res = await app.request(`/api/v1/apps/${appId}/push`, { 452 + method: 'POST', 453 + headers: authHeaders(token), 454 + body: JSON.stringify({ 455 + changes, 456 + clientHLC: '0000000000003-0000-client1', 457 + }), 458 + }) 459 + assertEquals(res.status, 200) 460 + 461 + const body = await res.json() 462 + assertEquals(body.data.accepted.length, 2) 463 + assertEquals(body.data.accepted.includes('change-2'), true) 464 + assertEquals(body.data.accepted.includes('change-3'), true) 465 + }) 466 + 467 + await t.step('push/pull require auth', async () => { 468 + const r1 = await app.request(`/api/v1/apps/${appId}/push`, { 469 + method: 'POST', 470 + headers: { 'Content-Type': 'application/json' }, 471 + body: JSON.stringify({ changes: [], clientHLC: '' }), 472 + }) 473 + assertEquals(r1.status, 401) 474 + 475 + const r2 = await app.request(`/api/v1/apps/${appId}/pull`, { 476 + method: 'POST', 477 + headers: { 'Content-Type': 'application/json' }, 478 + body: JSON.stringify({ sinceHLC: '' }), 479 + }) 480 + assertEquals(r2.status, 401) 481 + }) 482 + 483 + await t.step('push to non-owned app returns 404', async () => { 484 + // Create second user 485 + const { token: token2 } = await signup( 486 + app, 487 + 'other@test.com', 488 + 'password123', 489 + ) 490 + 491 + const res = await app.request(`/api/v1/apps/${appId}/push`, { 492 + method: 'POST', 493 + headers: authHeaders(token2), 494 + body: JSON.stringify({ changes: [], clientHLC: '' }), 495 + }) 496 + assertEquals(res.status, 404) 497 + }) 498 + } finally { 499 + kv.close() 500 + } 501 + }) 502 + 503 + Deno.test('OpenAPI docs', async (t) => { 504 + const kv = await Deno.openKv(':memory:') 505 + 506 + try { 507 + const app = createApp(kv) 508 + 509 + await t.step('GET /api/v1/doc returns OpenAPI JSON', async () => { 510 + const res = await app.request('/api/v1/doc') 511 + assertEquals(res.status, 200) 512 + 513 + const body = await res.json() 514 + assertEquals(body.openapi, '3.0.0') 515 + assertEquals(body.info.title, 'Civility Sync API') 516 + assertExists(body.paths) 517 + 518 + // Verify key paths exist with /api/v1 prefix 519 + assertExists(body.paths['/api/v1/login']) 520 + assertExists(body.paths['/api/v1/signup']) 521 + assertExists(body.paths['/api/v1/healthcheck']) 522 + assertExists(body.paths['/api/v1/me']) 523 + assertExists(body.paths['/api/v1/apps']) 524 + assertExists(body.paths['/api/v1/apps/{id}/push']) 525 + assertExists(body.paths['/api/v1/apps/{id}/pull']) 526 + assertExists(body.paths['/api/v1/apps/{id}/register']) 527 + }) 528 + 529 + await t.step('GET /api/v1/ui returns Swagger UI HTML', async () => { 530 + const res = await app.request('/api/v1/ui') 531 + assertEquals(res.status, 200) 532 + const html = await res.text() 533 + assertEquals(html.includes('swagger'), true) 534 + }) 535 + 536 + await t.step('GET /api/docs redirects to /api/v1/ui', async () => { 537 + const res = await app.request('/api/docs', { redirect: 'manual' }) 538 + assertEquals(res.status, 302) 539 + assertEquals(res.headers.get('Location'), '/api/v1/ui') 540 + }) 541 + } finally { 542 + kv.close() 543 + } 544 + }) 545 + 546 + Deno.test('CORS', async (t) => { 547 + const kv = await Deno.openKv(':memory:') 548 + 549 + try { 550 + const app = createApp(kv) 551 + 552 + await t.step('OPTIONS returns CORS headers', async () => { 553 + const res = await app.request('/api/v1/healthcheck', { 554 + method: 'OPTIONS', 555 + }) 556 + assertEquals(res.status, 204) 557 + assertEquals(res.headers.get('Access-Control-Allow-Origin'), '*') 558 + assertExists(res.headers.get('Access-Control-Allow-Methods')) 559 + }) 560 + 561 + await t.step('responses include CORS headers', async () => { 562 + const res = await app.request('/api/v1/healthcheck') 563 + assertEquals(res.headers.get('Access-Control-Allow-Origin'), '*') 564 + }) 565 + } finally { 566 + kv.close() 567 + } 568 + }) 569 + 570 + Deno.test('404 handling', async (t) => { 571 + const kv = await Deno.openKv(':memory:') 572 + 573 + try { 574 + const app = createApp(kv) 575 + 576 + await t.step('unknown route returns structured 404', async () => { 577 + const res = await app.request('/api/v1/nonexistent') 578 + assertEquals(res.status, 404) 579 + 580 + const body = await res.json() 581 + assertEquals(body.status, 'error') 582 + assertEquals(body.message, 'Endpoint not found') 583 + }) 584 + } finally { 585 + kv.close() 586 + } 587 + })
+23
packages/cli/sync/main.ts
··· 1 + #!/usr/bin/env -S deno run -A --unstable-kv 2 + /** 3 + * @module Standalone sync server entry point 4 + * 5 + * Run directly: deno run -A --unstable-kv packages/cli/sync/main.ts 6 + * Or via CLI: civ sync start 7 + */ 8 + 9 + import { createSyncServer } from './mod.ts' 10 + 11 + const port = parseInt(Deno.env.get('PORT') || '8081') 12 + const kv = await Deno.openKv() 13 + const app = createSyncServer(kv) 14 + 15 + console.log(` 16 + Civility Sync Server 17 + http://localhost:${port} 18 + 19 + API docs: http://localhost:${port}/api/v1/ui 20 + OpenAPI: http://localhost:${port}/api/v1/doc 21 + `) 22 + 23 + Deno.serve({ port }, app.fetch)
+147
packages/cli/sync/mod.ts
··· 1 + /** 2 + * @module @civility/cli/sync — Sync server factory 3 + * 4 + * Creates a fully-configured Hono app for the Civility Sync server. 5 + * Can be used standalone or mounted into a larger Hono application. 6 + * 7 + * All API endpoints live under `/api/v1/...`. Swagger UI is at `/api/v1/ui` 8 + * with a convenience redirect from `/api/docs`. 9 + * 10 + * @example 11 + * ```ts 12 + * import { createSyncServer } from '@civility/cli/sync' 13 + * 14 + * const kv = await Deno.openKv() 15 + * const app = createSyncServer(kv) 16 + * Deno.serve({ port: 8081 }, app.fetch) 17 + * ``` 18 + */ 19 + 20 + import { OpenAPIHono } from '@hono/zod-openapi' 21 + import { swaggerUI } from '@hono/swagger-ui' 22 + import { Auth } from './utils/auth.ts' 23 + import { KVDatabase } from './utils/db.ts' 24 + import { StoreManager } from './utils/store_manager.ts' 25 + import { authMiddleware } from './utils/middleware.ts' 26 + import { createAuthRouter } from './routes/auth.ts' 27 + import { createHealthRouter } from './routes/health.ts' 28 + import { createMeRouter } from './routes/me.ts' 29 + import { createAppsRouter } from './routes/apps.ts' 30 + import { createSyncRouter } from './routes/sync.ts' 31 + import { 32 + createAppRoute, 33 + createAppTokenRoute, 34 + deleteAppRoute, 35 + deleteAppTokenRoute, 36 + getAppRoute, 37 + getAppsRoute, 38 + getAppTokensRoute, 39 + getMeRoute, 40 + healthCheckRoute, 41 + loginRoute, 42 + logoutRoute, 43 + openApiConfig, 44 + pullRoute, 45 + pushRoute, 46 + registerSchemaRoute, 47 + signupRoute, 48 + verifyRoute, 49 + } from './schemas.ts' 50 + 51 + export function createSyncServer(kv: Deno.Kv): OpenAPIHono { 52 + const auth = new Auth(kv) 53 + const db = new KVDatabase(kv) 54 + const storeManager = new StoreManager(kv) 55 + 56 + const app = new OpenAPIHono() 57 + 58 + // CORS 59 + app.use('*', async (c, next) => { 60 + c.header('Access-Control-Allow-Origin', '*') 61 + c.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') 62 + c.header('Access-Control-Allow-Headers', 'Content-Type, Authorization') 63 + c.header('Access-Control-Allow-Credentials', 'true') 64 + 65 + if (c.req.method === 'OPTIONS') { 66 + return new Response(null, { 67 + status: 204, 68 + headers: { 69 + 'Access-Control-Allow-Origin': '*', 70 + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', 71 + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', 72 + 'Access-Control-Allow-Credentials': 'true', 73 + }, 74 + }) 75 + } 76 + 77 + return await next() 78 + }) 79 + 80 + // Public routes (no auth required) 81 + app.route('/api/v1', createHealthRouter()) 82 + app.route('/api/v1', createAuthRouter(auth)) 83 + 84 + // Protected routes (auth required) 85 + app.use('/api/v1/me', authMiddleware(auth)) 86 + app.use('/api/v1/me/*', authMiddleware(auth)) 87 + app.use('/api/v1/apps', authMiddleware(auth)) 88 + app.use('/api/v1/apps/*', authMiddleware(auth)) 89 + app.route('/api/v1/me', createMeRouter()) 90 + app.route('/api/v1/apps', createAppsRouter(db)) 91 + app.route('/api/v1/apps', createSyncRouter(db, storeManager)) 92 + 93 + // Register all route definitions for OpenAPI docs 94 + // Paths in schemas already include /api/v1 prefix 95 + const routes = [ 96 + healthCheckRoute, 97 + loginRoute, 98 + signupRoute, 99 + logoutRoute, 100 + verifyRoute, 101 + getMeRoute, 102 + getAppsRoute, 103 + createAppRoute, 104 + getAppRoute, 105 + deleteAppRoute, 106 + getAppTokensRoute, 107 + createAppTokenRoute, 108 + deleteAppTokenRoute, 109 + registerSchemaRoute, 110 + pushRoute, 111 + pullRoute, 112 + ] 113 + for (const route of routes) { 114 + app.openAPIRegistry.registerPath(route) 115 + } 116 + 117 + // OpenAPI JSON + Swagger UI 118 + app.doc('/api/v1/doc', openApiConfig) 119 + app.get('/api/v1/ui', swaggerUI({ url: '/api/v1/doc' })) 120 + 121 + // Convenience redirect: /api/docs → latest version UI 122 + app.get('/api/docs', (c) => c.redirect('/api/v1/ui')) 123 + 124 + // Error handler 125 + app.onError((err, c) => { 126 + console.error('Server error:', err) 127 + return c.json( 128 + { status: 'error', message: 'Internal server error', data: null }, 129 + 500, 130 + ) 131 + }) 132 + 133 + app.notFound((c) => { 134 + return c.json( 135 + { status: 'error', message: 'Endpoint not found', data: null }, 136 + 404, 137 + ) 138 + }) 139 + 140 + return app 141 + } 142 + 143 + export { Auth } from './utils/auth.ts' 144 + export { KVDatabase } from './utils/db.ts' 145 + export { StoreManager } from './utils/store_manager.ts' 146 + export { authMiddleware } from './utils/middleware.ts' 147 + export type { AuthContext } from './utils/middleware.ts'
+106
packages/cli/sync/routes/apps.ts
··· 1 + /** 2 + * @module App routes — CRUD for apps and app tokens 3 + */ 4 + 5 + import { Hono } from '@hono/hono' 6 + import type { KVDatabase } from '../utils/db.ts' 7 + import { getAuthContext } from '../utils/middleware.ts' 8 + import { transformApp, transformAppToken } from '../utils/transformers.ts' 9 + 10 + function ok<T>(data: T, message = 'ok') { 11 + return { status: 'success' as const, message, data } 12 + } 13 + 14 + function err(message: string) { 15 + return { status: 'error' as const, message, data: null } 16 + } 17 + 18 + export function createAppsRouter(db: KVDatabase) { 19 + const router = new Hono() 20 + 21 + // List apps 22 + router.get('/', async (c) => { 23 + const { user } = getAuthContext(c) 24 + const apps = await db.getAppsByUserId(user.id) 25 + return c.json(ok(apps.map(transformApp), 'Apps retrieved')) 26 + }) 27 + 28 + // Create app 29 + router.post('/', async (c) => { 30 + const { user } = getAuthContext(c) 31 + const { name, description } = await c.req.json() 32 + const app = await db.createApp(user.id, name, description) 33 + return c.json(ok(transformApp(app), 'App created'), 201) 34 + }) 35 + 36 + // Get app 37 + router.get('/:id', async (c) => { 38 + const { user } = getAuthContext(c) 39 + const id = c.req.param('id') 40 + const app = await db.getApp(id) 41 + 42 + if (!app || app.user_id !== user.id) { 43 + return c.json(err('App not found'), 404) 44 + } 45 + 46 + return c.json(ok(transformApp(app), 'App retrieved')) 47 + }) 48 + 49 + // Delete app 50 + router.delete('/:id', async (c) => { 51 + const { user } = getAuthContext(c) 52 + const id = c.req.param('id') 53 + 54 + if (!await db.userOwnsApp(user.id, id)) { 55 + return c.json(err('App not found'), 404) 56 + } 57 + 58 + await db.deleteApp(id) 59 + return c.json(ok(null, 'App deleted')) 60 + }) 61 + 62 + // List tokens 63 + router.get('/:id/tokens', async (c) => { 64 + const { user } = getAuthContext(c) 65 + const id = c.req.param('id') 66 + 67 + if (!await db.userOwnsApp(user.id, id)) { 68 + return c.json(err('App not found'), 404) 69 + } 70 + 71 + const tokens = await db.getAppTokens(id) 72 + return c.json(ok(tokens.map(transformAppToken), 'Tokens retrieved')) 73 + }) 74 + 75 + // Create token 76 + router.post('/:id/tokens', async (c) => { 77 + const { user } = getAuthContext(c) 78 + const id = c.req.param('id') 79 + const { name, permissions } = await c.req.json() 80 + 81 + if (!await db.userOwnsApp(user.id, id)) { 82 + return c.json(err('App not found'), 404) 83 + } 84 + 85 + const token = await db.createAppToken(id, name, permissions) 86 + return c.json(ok(transformAppToken(token), 'Token created'), 201) 87 + }) 88 + 89 + // Delete token 90 + router.delete('/:id/tokens/:tokenId', async (c) => { 91 + const { user } = getAuthContext(c) 92 + const id = c.req.param('id') 93 + const tokenId = c.req.param('tokenId') 94 + 95 + if (!await db.userOwnsApp(user.id, id)) { 96 + return c.json(err('App not found'), 404) 97 + } 98 + 99 + const deleted = await db.deleteAppToken(tokenId) 100 + if (!deleted) return c.json(err('Token not found'), 404) 101 + 102 + return c.json(ok(null, 'Token deleted')) 103 + }) 104 + 105 + return router 106 + }
+91
packages/cli/sync/routes/auth.ts
··· 1 + /** 2 + * @module Auth routes — login, signup, logout, verify 3 + */ 4 + 5 + import { Hono } from '@hono/hono' 6 + import { Auth } from '../utils/auth.ts' 7 + import { transformUser } from '../utils/transformers.ts' 8 + 9 + function ok<T>(data: T, message = 'ok') { 10 + return { status: 'success' as const, message, data } 11 + } 12 + 13 + function err(message: string) { 14 + return { status: 'error' as const, message, data: null } 15 + } 16 + 17 + export function createAuthRouter(auth: Auth) { 18 + const router = new Hono() 19 + 20 + router.post('/login', async (c) => { 21 + const { email, password } = await c.req.json() 22 + 23 + let user = await auth.getUserByEmail(email) 24 + if (!user) user = await auth.getUserByUsername(email) 25 + 26 + if (!user) return c.json(err('Invalid credentials'), 401) 27 + 28 + const valid = await auth.verifyPassword(password, user.password_hash) 29 + if (!valid) return c.json(err('Invalid credentials'), 401) 30 + 31 + const session = await auth.createSession(user.id) 32 + auth.setSessionCookie(c, session.id, session.expires_at) 33 + 34 + return c.json( 35 + ok({ token: session.id, user: transformUser(user) }, 'Login successful'), 36 + ) 37 + }) 38 + 39 + router.post('/signup', async (c) => { 40 + const { email, password, name } = await c.req.json() 41 + const username = name || email.split('@')[0] 42 + 43 + try { 44 + const user = await auth.createUser(username, email, password) 45 + const session = await auth.createSession(user.id) 46 + auth.setSessionCookie(c, session.id, session.expires_at) 47 + 48 + return c.json( 49 + ok({ token: session.id, user: transformUser(user) }, 'User created'), 50 + 201, 51 + ) 52 + } catch (e) { 53 + if (e instanceof Error && e.message.includes('already exists')) { 54 + return c.json(err('Email already exists'), 409) 55 + } 56 + throw e 57 + } 58 + }) 59 + 60 + router.post('/logout', async (c) => { 61 + // Check cookie first, then Bearer token 62 + let sessionId = auth.getSessionIdFromCookie(c) 63 + if (!sessionId) { 64 + const authHeader = c.req.header('Authorization') 65 + if (authHeader?.startsWith('Bearer ')) { 66 + sessionId = authHeader.slice(7) 67 + } 68 + } 69 + if (sessionId) await auth.invalidateSession(sessionId) 70 + auth.deleteSessionCookie(c) 71 + return c.json(ok(null, 'Logged out')) 72 + }) 73 + 74 + router.get('/verify', async (c) => { 75 + const token = c.req.query('token') ?? 76 + c.req.header('Authorization')?.slice(7) 77 + 78 + if (!token) return c.json(err('Token required'), 400) 79 + 80 + const { session, user } = await auth.validateSessionId(token) 81 + if (!session || !user) return c.json(err('Token invalid or expired'), 401) 82 + 83 + return c.json(ok({ 84 + userId: user.id, 85 + sessionId: session.id, 86 + expiresAt: Math.floor(new Date(session.expires_at).getTime() / 1000), 87 + }, 'Token valid')) 88 + }) 89 + 90 + return router 91 + }
+24
packages/cli/sync/routes/health.ts
··· 1 + /** 2 + * @module Health route 3 + */ 4 + 5 + import { Hono } from '@hono/hono' 6 + 7 + export function createHealthRouter() { 8 + const router = new Hono() 9 + 10 + router.get('/healthcheck', (c) => { 11 + return c.json({ 12 + status: 'success', 13 + message: 'Healthy', 14 + data: { 15 + status: 'healthy', 16 + timestamp: new Date().toISOString(), 17 + service: 'Civility Sync', 18 + version: '1.0.0', 19 + }, 20 + }) 21 + }) 22 + 23 + return router 24 + }
+22
packages/cli/sync/routes/me.ts
··· 1 + /** 2 + * @module Me route — current user profile 3 + */ 4 + 5 + import { Hono } from '@hono/hono' 6 + import { getAuthContext } from '../utils/middleware.ts' 7 + import { transformUser } from '../utils/transformers.ts' 8 + 9 + export function createMeRouter() { 10 + const router = new Hono() 11 + 12 + router.get('/', (c) => { 13 + const { user } = getAuthContext(c) 14 + return c.json({ 15 + status: 'success', 16 + message: 'User profile', 17 + data: transformUser(user), 18 + }) 19 + }) 20 + 21 + return router 22 + }
+135
packages/cli/sync/routes/sync.ts
··· 1 + /** 2 + * @module Sync routes — push, pull, register schema 3 + * 4 + * These are the NEW change-based sync endpoints that leverage 5 + * @civility/store's ChangeEntry and merge logic. 6 + */ 7 + 8 + import { Hono } from '@hono/hono' 9 + import type { KVDatabase } from '../utils/db.ts' 10 + import type { StoreManager } from '../utils/store_manager.ts' 11 + import { getAuthContext } from '../utils/middleware.ts' 12 + import { transformApp } from '../utils/transformers.ts' 13 + import type { ChangeEntry } from '@civility/store' 14 + 15 + function ok<T>(data: T, message = 'ok') { 16 + return { status: 'success' as const, message, data } 17 + } 18 + 19 + function err(message: string) { 20 + return { status: 'error' as const, message, data: null } 21 + } 22 + 23 + export function createSyncRouter(db: KVDatabase, storeManager: StoreManager) { 24 + const router = new Hono() 25 + 26 + // Register schema 27 + router.post('/:id/register', async (c) => { 28 + const { user } = getAuthContext(c) 29 + const id = c.req.param('id') 30 + const { schema } = await c.req.json() 31 + 32 + // Auto-create the app if it doesn't exist 33 + if (!await db.userOwnsApp(user.id, id)) { 34 + const existing = await db.getApp(id) 35 + if (!existing) { 36 + await db.createAppWithId(id, user.id, id) 37 + } 38 + } 39 + 40 + const updated = await db.updateApp(id, { 41 + schema: schema ? JSON.stringify(schema) : undefined, 42 + }) 43 + 44 + return c.json( 45 + ok(updated ? transformApp(updated) : null, 'Schema registered'), 46 + ) 47 + }) 48 + 49 + // Push changes 50 + router.post('/:id/push', async (c) => { 51 + const { user } = getAuthContext(c) 52 + const id = c.req.param('id') 53 + const { changes, clientHLC } = await c.req.json() 54 + 55 + if (!await db.userOwnsApp(user.id, id)) { 56 + return c.json(err('App not found'), 404) 57 + } 58 + 59 + const accepted: string[] = [] 60 + const remoteChanges: ChangeEntry[] = [] 61 + 62 + // Group changes by store name (derived from documentId prefix) 63 + const byStore = new Map<string, ChangeEntry[]>() 64 + for (const change of changes) { 65 + const slashIdx = (change.documentId as string).indexOf('/') 66 + const storeName = slashIdx > 0 67 + ? (change.documentId as string).slice(0, slashIdx) 68 + : 'default' 69 + const group = byStore.get(storeName) ?? [] 70 + group.push(change as ChangeEntry) 71 + byStore.set(storeName, group) 72 + } 73 + 74 + for (const [storeName, storeChanges] of byStore) { 75 + const store = storeManager.getStore(user.id, id, storeName) 76 + 77 + await store.applyRemoteChanges(storeChanges) 78 + 79 + for (const change of storeChanges) { 80 + accepted.push(change.id) 81 + } 82 + 83 + // Return server-side changes the client hasn't seen 84 + const serverChanges = await store.getUnsyncedChanges() 85 + const clientChanges = serverChanges.filter( 86 + (sc: ChangeEntry) => sc.hlc > clientHLC, 87 + ) 88 + remoteChanges.push(...clientChanges) 89 + 90 + // Mark all incoming changes as synced on server 91 + await store.markSynced(storeChanges.map((c) => c.id)) 92 + } 93 + 94 + let serverHLC = clientHLC as string 95 + for (const change of [...changes, ...remoteChanges]) { 96 + if ((change as ChangeEntry).hlc > serverHLC) { 97 + serverHLC = (change as ChangeEntry).hlc 98 + } 99 + } 100 + 101 + return c.json(ok({ accepted, remoteChanges, serverHLC }, 'Changes pushed')) 102 + }) 103 + 104 + // Pull changes 105 + router.post('/:id/pull', async (c) => { 106 + const { user } = getAuthContext(c) 107 + const id = c.req.param('id') 108 + const { sinceHLC, storeName } = await c.req.json() 109 + 110 + if (!await db.userOwnsApp(user.id, id)) { 111 + return c.json(err('App not found'), 404) 112 + } 113 + 114 + const storeNames = storeName ? [storeName as string] : ['default'] 115 + const allChanges: ChangeEntry[] = [] 116 + let serverHLC = sinceHLC as string 117 + 118 + for (const name of storeNames) { 119 + const changes = await storeManager.getChangesSince( 120 + user.id, 121 + id, 122 + name, 123 + sinceHLC, 124 + ) 125 + allChanges.push(...changes) 126 + for (const change of changes) { 127 + if (change.hlc > serverHLC) serverHLC = change.hlc 128 + } 129 + } 130 + 131 + return c.json(ok({ changes: allChanges, serverHLC }, 'Changes pulled')) 132 + }) 133 + 134 + return router 135 + }
+559
packages/cli/sync/schemas.ts
··· 1 + /** 2 + * @module Zod schemas + OpenAPI route definitions for Civility Sync 3 + * 4 + * Uses @hono/zod-openapi for type-safe, self-documenting routes. 5 + * All paths use the full `/api/v1/...` prefix so Swagger UI 6 + * sends requests to the correct endpoints. 7 + */ 8 + 9 + import { createRoute, z } from '@hono/zod-openapi' 10 + 11 + // ── Base Schemas ─────────────────────────────── 12 + 13 + export const IdSchema = z.string().uuid().openapi({ 14 + example: '550e8400-e29b-41d4-a716-446655440000', 15 + }) 16 + export const TimestampSchema = z.string().datetime().openapi({ 17 + example: '2024-01-01T00:00:00.000Z', 18 + }) 19 + 20 + export const ApiResponseSchema = <T extends z.ZodType>(dataSchema: T) => 21 + z.object({ 22 + status: z.enum(['success', 'error']), 23 + message: z.string(), 24 + data: dataSchema.nullable(), 25 + }) 26 + 27 + export const ErrorResponseSchema = z.object({ 28 + status: z.literal('error'), 29 + message: z.string(), 30 + data: z.null(), 31 + }) 32 + 33 + // ── User Schemas ─────────────────────────────── 34 + 35 + export const UserProfileSchema = z 36 + .object({ 37 + id: IdSchema, 38 + username: z.string(), 39 + displayName: z.string().nullable(), 40 + profileImageUrl: z.string().nullable(), 41 + createdAt: TimestampSchema, 42 + updatedAt: TimestampSchema, 43 + }) 44 + .openapi('UserProfile') 45 + 46 + export const AuthDataSchema = z 47 + .object({ 48 + token: z.string(), 49 + user: UserProfileSchema, 50 + }) 51 + .openapi('AuthData') 52 + 53 + export const LoginRequestSchema = z 54 + .object({ 55 + email: z.string().email(), 56 + password: z.string().min(6), 57 + }) 58 + .openapi('LoginRequest') 59 + 60 + export const SignupRequestSchema = z 61 + .object({ 62 + email: z.string().email(), 63 + password: z.string().min(6), 64 + name: z.string().optional(), 65 + }) 66 + .openapi('SignupRequest') 67 + 68 + // ── App Schemas ──────────────────────────────── 69 + 70 + export const AppSchema = z 71 + .object({ 72 + id: IdSchema, 73 + userId: IdSchema, 74 + name: z.string().min(1).max(100), 75 + description: z.string().nullable(), 76 + schema: z.string().nullable(), 77 + createdAt: TimestampSchema, 78 + updatedAt: TimestampSchema, 79 + }) 80 + .openapi('App') 81 + 82 + export const CreateAppRequestSchema = z 83 + .object({ 84 + name: z.string().min(1).max(100), 85 + description: z.string().max(500).optional(), 86 + }) 87 + .openapi('CreateAppRequest') 88 + 89 + export const UpdateAppRequestSchema = z 90 + .object({ 91 + name: z.string().optional(), 92 + description: z.string().optional(), 93 + }) 94 + .openapi('UpdateAppRequest') 95 + 96 + // ── App Token Schemas ────────────────────────── 97 + 98 + export const AppTokenSchema = z 99 + .object({ 100 + id: IdSchema, 101 + appId: IdSchema, 102 + token: z.string(), 103 + name: z.string().min(1).max(100), 104 + permissions: z.string(), 105 + createdAt: TimestampSchema, 106 + lastUsedAt: z.string().nullable(), 107 + }) 108 + .openapi('AppToken') 109 + 110 + export const CreateAppTokenRequestSchema = z 111 + .object({ 112 + name: z.string().min(1).max(100), 113 + permissions: z.string().optional(), 114 + }) 115 + .openapi('CreateAppTokenRequest') 116 + 117 + // ── Sync Schemas ─────────────────────────────── 118 + 119 + export const JsonPatchOpSchema = z 120 + .object({ 121 + op: z.enum(['add', 'remove', 'replace', 'move', 'copy', 'test']), 122 + path: z.string(), 123 + from: z.string().optional(), 124 + value: z.unknown().optional(), 125 + }) 126 + .openapi('JsonPatchOp') 127 + 128 + export const ChangeEntrySchema = z 129 + .object({ 130 + id: z.string(), 131 + documentId: z.string(), 132 + patch: z.array(JsonPatchOpSchema), 133 + inversePatch: z.array(JsonPatchOpSchema), 134 + hlc: z.string(), 135 + origin: z.string(), 136 + createdAt: z.string(), 137 + synced: z.boolean(), 138 + }) 139 + .openapi('ChangeEntry') 140 + 141 + export const PushRequestSchema = z 142 + .object({ 143 + changes: z.array(ChangeEntrySchema), 144 + clientHLC: z.string(), 145 + }) 146 + .openapi('PushRequest') 147 + 148 + export const PushResultSchema = z 149 + .object({ 150 + accepted: z.array(z.string()), 151 + remoteChanges: z.array(ChangeEntrySchema), 152 + serverHLC: z.string(), 153 + }) 154 + .openapi('PushResult') 155 + 156 + export const PullRequestSchema = z 157 + .object({ 158 + sinceHLC: z.string(), 159 + storeName: z.string().optional(), 160 + }) 161 + .openapi('PullRequest') 162 + 163 + export const PullResultSchema = z 164 + .object({ 165 + changes: z.array(ChangeEntrySchema), 166 + serverHLC: z.string(), 167 + }) 168 + .openapi('PullResult') 169 + 170 + export const RegisterAppRequestSchema = z 171 + .object({ 172 + schema: z.unknown(), 173 + }) 174 + .openapi('RegisterAppRequest') 175 + 176 + // ── Health Schema ────────────────────────────── 177 + 178 + export const HealthDataSchema = z 179 + .object({ 180 + status: z.string(), 181 + timestamp: TimestampSchema, 182 + service: z.string(), 183 + version: z.string(), 184 + }) 185 + .openapi('HealthData') 186 + 187 + // ── OpenAPI Route Definitions ────────────────── 188 + // Paths include the full /api/v1 prefix so Swagger UI targets the correct URLs. 189 + 190 + // Auth routes 191 + 192 + export const loginRoute = createRoute({ 193 + method: 'post', 194 + path: '/api/v1/login', 195 + tags: ['Authentication'], 196 + summary: 'User login', 197 + description: 'Authenticate with email and password, returns session token', 198 + request: { 199 + body: { content: { 'application/json': { schema: LoginRequestSchema } } }, 200 + }, 201 + responses: { 202 + 200: { 203 + content: { 204 + 'application/json': { schema: ApiResponseSchema(AuthDataSchema) }, 205 + }, 206 + description: 'Login successful', 207 + }, 208 + 401: { 209 + content: { 'application/json': { schema: ErrorResponseSchema } }, 210 + description: 'Invalid credentials', 211 + }, 212 + }, 213 + }) 214 + 215 + export const signupRoute = createRoute({ 216 + method: 'post', 217 + path: '/api/v1/signup', 218 + tags: ['Authentication'], 219 + summary: 'User signup', 220 + description: 'Create a new user account', 221 + request: { 222 + body: { content: { 'application/json': { schema: SignupRequestSchema } } }, 223 + }, 224 + responses: { 225 + 201: { 226 + content: { 227 + 'application/json': { schema: ApiResponseSchema(AuthDataSchema) }, 228 + }, 229 + description: 'User created', 230 + }, 231 + 409: { 232 + content: { 'application/json': { schema: ErrorResponseSchema } }, 233 + description: 'Email already exists', 234 + }, 235 + }, 236 + }) 237 + 238 + export const logoutRoute = createRoute({ 239 + method: 'post', 240 + path: '/api/v1/logout', 241 + tags: ['Authentication'], 242 + summary: 'Logout', 243 + description: 'Invalidate the current session', 244 + responses: { 245 + 200: { 246 + content: { 'application/json': { schema: ApiResponseSchema(z.null()) } }, 247 + description: 'Logged out', 248 + }, 249 + }, 250 + }) 251 + 252 + export const verifyRoute = createRoute({ 253 + method: 'get', 254 + path: '/api/v1/verify', 255 + tags: ['Authentication'], 256 + summary: 'Verify token', 257 + description: 'Check if a session token is valid', 258 + request: { 259 + query: z.object({ token: z.string().optional() }), 260 + }, 261 + responses: { 262 + 200: { 263 + content: { 264 + 'application/json': { 265 + schema: ApiResponseSchema( 266 + z.object({ 267 + userId: IdSchema, 268 + sessionId: z.string(), 269 + expiresAt: z.number(), 270 + }), 271 + ), 272 + }, 273 + }, 274 + description: 'Token is valid', 275 + }, 276 + 401: { 277 + content: { 'application/json': { schema: ErrorResponseSchema } }, 278 + description: 'Token invalid or expired', 279 + }, 280 + }, 281 + }) 282 + 283 + // Health route 284 + 285 + export const healthCheckRoute = createRoute({ 286 + method: 'get', 287 + path: '/api/v1/healthcheck', 288 + tags: ['Health'], 289 + summary: 'Health check', 290 + description: 'Returns service health status', 291 + responses: { 292 + 200: { 293 + content: { 294 + 'application/json': { schema: ApiResponseSchema(HealthDataSchema) }, 295 + }, 296 + description: 'Healthy', 297 + }, 298 + }, 299 + }) 300 + 301 + // Me route 302 + 303 + export const getMeRoute = createRoute({ 304 + method: 'get', 305 + path: '/api/v1/me', 306 + tags: ['User'], 307 + summary: 'Get current user', 308 + description: 'Returns the authenticated user profile', 309 + security: [{ Bearer: [] }], 310 + responses: { 311 + 200: { 312 + content: { 313 + 'application/json': { schema: ApiResponseSchema(UserProfileSchema) }, 314 + }, 315 + description: 'User profile', 316 + }, 317 + 401: { 318 + content: { 'application/json': { schema: ErrorResponseSchema } }, 319 + description: 'Unauthorized', 320 + }, 321 + }, 322 + }) 323 + 324 + // App routes 325 + 326 + export const getAppsRoute = createRoute({ 327 + method: 'get', 328 + path: '/api/v1/apps', 329 + tags: ['Apps'], 330 + summary: 'List apps', 331 + description: 'Get all apps for the authenticated user', 332 + security: [{ Bearer: [] }], 333 + responses: { 334 + 200: { 335 + content: { 336 + 'application/json': { schema: ApiResponseSchema(z.array(AppSchema)) }, 337 + }, 338 + description: 'Apps list', 339 + }, 340 + }, 341 + }) 342 + 343 + export const createAppRoute = createRoute({ 344 + method: 'post', 345 + path: '/api/v1/apps', 346 + tags: ['Apps'], 347 + summary: 'Create app', 348 + description: 'Create a new app', 349 + security: [{ Bearer: [] }], 350 + request: { 351 + body: { 352 + content: { 'application/json': { schema: CreateAppRequestSchema } }, 353 + }, 354 + }, 355 + responses: { 356 + 201: { 357 + content: { 358 + 'application/json': { schema: ApiResponseSchema(AppSchema) }, 359 + }, 360 + description: 'App created', 361 + }, 362 + }, 363 + }) 364 + 365 + export const getAppRoute = createRoute({ 366 + method: 'get', 367 + path: '/api/v1/apps/{id}', 368 + tags: ['Apps'], 369 + summary: 'Get app', 370 + description: 'Get a specific app by ID', 371 + security: [{ Bearer: [] }], 372 + request: { 373 + params: z.object({ id: IdSchema }), 374 + }, 375 + responses: { 376 + 200: { 377 + content: { 378 + 'application/json': { schema: ApiResponseSchema(AppSchema) }, 379 + }, 380 + description: 'App details', 381 + }, 382 + 404: { 383 + content: { 'application/json': { schema: ErrorResponseSchema } }, 384 + description: 'Not found', 385 + }, 386 + }, 387 + }) 388 + 389 + export const deleteAppRoute = createRoute({ 390 + method: 'delete', 391 + path: '/api/v1/apps/{id}', 392 + tags: ['Apps'], 393 + summary: 'Delete app', 394 + description: 'Delete an app and all its tokens', 395 + security: [{ Bearer: [] }], 396 + request: { 397 + params: z.object({ id: IdSchema }), 398 + }, 399 + responses: { 400 + 200: { 401 + content: { 'application/json': { schema: ApiResponseSchema(z.null()) } }, 402 + description: 'App deleted', 403 + }, 404 + 404: { 405 + content: { 'application/json': { schema: ErrorResponseSchema } }, 406 + description: 'Not found', 407 + }, 408 + }, 409 + }) 410 + 411 + // App token routes 412 + 413 + export const getAppTokensRoute = createRoute({ 414 + method: 'get', 415 + path: '/api/v1/apps/{id}/tokens', 416 + tags: ['App Tokens'], 417 + summary: 'List app tokens', 418 + security: [{ Bearer: [] }], 419 + request: { 420 + params: z.object({ id: IdSchema }), 421 + }, 422 + responses: { 423 + 200: { 424 + content: { 425 + 'application/json': { 426 + schema: ApiResponseSchema(z.array(AppTokenSchema)), 427 + }, 428 + }, 429 + description: 'Token list', 430 + }, 431 + }, 432 + }) 433 + 434 + export const createAppTokenRoute = createRoute({ 435 + method: 'post', 436 + path: '/api/v1/apps/{id}/tokens', 437 + tags: ['App Tokens'], 438 + summary: 'Create app token', 439 + security: [{ Bearer: [] }], 440 + request: { 441 + params: z.object({ id: IdSchema }), 442 + body: { 443 + content: { 'application/json': { schema: CreateAppTokenRequestSchema } }, 444 + }, 445 + }, 446 + responses: { 447 + 201: { 448 + content: { 449 + 'application/json': { schema: ApiResponseSchema(AppTokenSchema) }, 450 + }, 451 + description: 'Token created', 452 + }, 453 + }, 454 + }) 455 + 456 + export const deleteAppTokenRoute = createRoute({ 457 + method: 'delete', 458 + path: '/api/v1/apps/{id}/tokens/{tokenId}', 459 + tags: ['App Tokens'], 460 + summary: 'Delete app token', 461 + security: [{ Bearer: [] }], 462 + request: { 463 + params: z.object({ id: IdSchema, tokenId: IdSchema }), 464 + }, 465 + responses: { 466 + 200: { 467 + content: { 'application/json': { schema: ApiResponseSchema(z.null()) } }, 468 + description: 'Token deleted', 469 + }, 470 + }, 471 + }) 472 + 473 + // Sync routes 474 + 475 + export const registerSchemaRoute = createRoute({ 476 + method: 'post', 477 + path: '/api/v1/apps/{id}/register', 478 + tags: ['Sync'], 479 + summary: 'Register app schema', 480 + description: 'Register or update the schema for an app', 481 + security: [{ Bearer: [] }], 482 + request: { 483 + params: z.object({ id: IdSchema }), 484 + body: { 485 + content: { 'application/json': { schema: RegisterAppRequestSchema } }, 486 + }, 487 + }, 488 + responses: { 489 + 200: { 490 + content: { 491 + 'application/json': { schema: ApiResponseSchema(AppSchema) }, 492 + }, 493 + description: 'Schema registered', 494 + }, 495 + }, 496 + }) 497 + 498 + export const pushRoute = createRoute({ 499 + method: 'post', 500 + path: '/api/v1/apps/{id}/push', 501 + tags: ['Sync'], 502 + summary: 'Push changes', 503 + description: 504 + 'Push local changes to the server. Returns accepted IDs and any remote changes.', 505 + security: [{ Bearer: [] }], 506 + request: { 507 + params: z.object({ id: IdSchema }), 508 + body: { content: { 'application/json': { schema: PushRequestSchema } } }, 509 + }, 510 + responses: { 511 + 200: { 512 + content: { 513 + 'application/json': { schema: ApiResponseSchema(PushResultSchema) }, 514 + }, 515 + description: 'Push result', 516 + }, 517 + }, 518 + }) 519 + 520 + export const pullRoute = createRoute({ 521 + method: 'post', 522 + path: '/api/v1/apps/{id}/pull', 523 + tags: ['Sync'], 524 + summary: 'Pull changes', 525 + description: 'Pull remote changes since a given HLC timestamp.', 526 + security: [{ Bearer: [] }], 527 + request: { 528 + params: z.object({ id: IdSchema }), 529 + body: { content: { 'application/json': { schema: PullRequestSchema } } }, 530 + }, 531 + responses: { 532 + 200: { 533 + content: { 534 + 'application/json': { schema: ApiResponseSchema(PullResultSchema) }, 535 + }, 536 + description: 'Pull result', 537 + }, 538 + }, 539 + }) 540 + 541 + // ── OpenAPI Config ───────────────────────────── 542 + 543 + export const openApiConfig = { 544 + openapi: '3.0.0', 545 + info: { 546 + title: 'Civility Sync API', 547 + version: '1.0.0', 548 + description: 'Change-based sync server for @civility/store', 549 + }, 550 + components: { 551 + securitySchemes: { 552 + Bearer: { 553 + type: 'http' as const, 554 + scheme: 'bearer', 555 + bearerFormat: 'Session Token', 556 + }, 557 + }, 558 + }, 559 + }
+273
packages/cli/sync/utils/auth.ts
··· 1 + /** 2 + * @module Auth — session-based authentication for Civility Sync 3 + * 4 + * Lucia-inspired session management with PBKDF2 password hashing. 5 + * Requires Deno KV for storage. 6 + */ 7 + 8 + // deno-lint-ignore no-explicit-any 9 + type HonoContext = any 10 + 11 + export interface AuthUser { 12 + id: string 13 + username: string 14 + email: string 15 + password_hash: string 16 + created_at: Date 17 + updated_at: Date 18 + } 19 + 20 + export interface Session { 21 + id: string 22 + user_id: string 23 + expires_at: Date 24 + created_at: Date 25 + } 26 + 27 + export interface SessionValidationResult { 28 + session: Session | null 29 + user: AuthUser | null 30 + } 31 + 32 + const SESSION_COOKIE_NAME = 'auth_token' 33 + 34 + function hexEncode(bytes: Uint8Array): string { 35 + return [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('') 36 + } 37 + 38 + function hexDecode(hex: string): Uint8Array { 39 + return new Uint8Array(hex.match(/.{1,2}/g)!.map((b) => parseInt(b, 16))) 40 + } 41 + 42 + function base32Encode(bytes: Uint8Array): string { 43 + const alphabet = 'abcdefghijklmnopqrstuvwxyz234567' 44 + let bits = 0 45 + let value = 0 46 + let output = '' 47 + for (const byte of bytes) { 48 + value = (value << 8) | byte 49 + bits += 8 50 + while (bits >= 5) { 51 + output += alphabet[(value >>> (bits - 5)) & 31] 52 + bits -= 5 53 + } 54 + } 55 + if (bits > 0) output += alphabet[(value << (5 - bits)) & 31] 56 + return output 57 + } 58 + 59 + export class Auth { 60 + #kv: Deno.Kv 61 + 62 + constructor(kv: Deno.Kv) { 63 + this.#kv = kv 64 + } 65 + 66 + generateSessionId(): string { 67 + const bytes = new Uint8Array(20) 68 + crypto.getRandomValues(bytes) 69 + return base32Encode(bytes) 70 + } 71 + 72 + async hashPassword(password: string): Promise<string> { 73 + const encoder = new TextEncoder() 74 + const salt = crypto.getRandomValues(new Uint8Array(16)) 75 + const keyMaterial = await crypto.subtle.importKey( 76 + 'raw', 77 + encoder.encode(password), 78 + { name: 'PBKDF2' }, 79 + false, 80 + ['deriveBits', 'deriveKey'], 81 + ) 82 + 83 + const key = await crypto.subtle.deriveKey( 84 + { 85 + name: 'PBKDF2', 86 + salt: salt.buffer as ArrayBuffer, 87 + iterations: 100000, 88 + hash: 'SHA-256', 89 + }, 90 + keyMaterial, 91 + { name: 'AES-GCM', length: 256 }, 92 + true, 93 + ['encrypt', 'decrypt'], 94 + ) 95 + 96 + const keyBytes = new Uint8Array(await crypto.subtle.exportKey('raw', key)) 97 + return `${hexEncode(salt)}:${hexEncode(keyBytes)}` 98 + } 99 + 100 + async verifyPassword(password: string, hash: string): Promise<boolean> { 101 + const [saltHex, keyHex] = hash.split(':') 102 + if (!saltHex || !keyHex) return false 103 + 104 + try { 105 + const salt = hexDecode(saltHex) 106 + const expectedKey = hexDecode(keyHex) 107 + 108 + const encoder = new TextEncoder() 109 + const keyMaterial = await crypto.subtle.importKey( 110 + 'raw', 111 + encoder.encode(password), 112 + { name: 'PBKDF2' }, 113 + false, 114 + ['deriveBits', 'deriveKey'], 115 + ) 116 + 117 + const key = await crypto.subtle.deriveKey( 118 + { 119 + name: 'PBKDF2', 120 + salt: salt.buffer as ArrayBuffer, 121 + iterations: 100000, 122 + hash: 'SHA-256', 123 + }, 124 + keyMaterial, 125 + { name: 'AES-GCM', length: 256 }, 126 + true, 127 + ['encrypt', 'decrypt'], 128 + ) 129 + 130 + const keyBytes = new Uint8Array( 131 + await crypto.subtle.exportKey('raw', key), 132 + ) 133 + 134 + if (keyBytes.length !== expectedKey.length) return false 135 + let result = 0 136 + for (let i = 0; i < keyBytes.length; i++) { 137 + result |= keyBytes[i] ^ expectedKey[i] 138 + } 139 + return result === 0 140 + } catch { 141 + return false 142 + } 143 + } 144 + 145 + async createUser( 146 + username: string, 147 + email: string, 148 + password: string, 149 + ): Promise<AuthUser> { 150 + const id = crypto.randomUUID() 151 + const passwordHash = await this.hashPassword(password) 152 + const now = new Date() 153 + 154 + const user: AuthUser = { 155 + id, 156 + username, 157 + email, 158 + password_hash: passwordHash, 159 + created_at: now, 160 + updated_at: now, 161 + } 162 + 163 + const result = await this.#kv.atomic() 164 + .check({ key: ['users', 'by_username', username], versionstamp: null }) 165 + .check({ key: ['users', 'by_email', email], versionstamp: null }) 166 + .set(['users', id], user) 167 + .set(['users', 'by_username', username], id) 168 + .set(['users', 'by_email', email], id) 169 + .commit() 170 + 171 + if (!result.ok) { 172 + throw new Error('Username or email already exists') 173 + } 174 + 175 + return user 176 + } 177 + 178 + async getUserByUsername(username: string): Promise<AuthUser | null> { 179 + const r = await this.#kv.get<string>(['users', 'by_username', username]) 180 + if (!r.value) return null 181 + return (await this.#kv.get<AuthUser>(['users', r.value])).value 182 + } 183 + 184 + async getUserByEmail(email: string): Promise<AuthUser | null> { 185 + const r = await this.#kv.get<string>(['users', 'by_email', email]) 186 + if (!r.value) return null 187 + return (await this.#kv.get<AuthUser>(['users', r.value])).value 188 + } 189 + 190 + async getUserById(id: string): Promise<AuthUser | null> { 191 + return (await this.#kv.get<AuthUser>(['users', id])).value 192 + } 193 + 194 + async createSession(userId: string): Promise<Session> { 195 + const session: Session = { 196 + id: this.generateSessionId(), 197 + user_id: userId, 198 + expires_at: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30), 199 + created_at: new Date(), 200 + } 201 + await this.#kv.set(['sessions', session.id], session) 202 + return session 203 + } 204 + 205 + async validateSessionId(sessionId: string): Promise<SessionValidationResult> { 206 + const r = await this.#kv.get<Session>(['sessions', sessionId]) 207 + if (!r.value) return { session: null, user: null } 208 + 209 + const session = r.value 210 + if (Date.now() >= new Date(session.expires_at).getTime()) { 211 + await this.invalidateSession(sessionId) 212 + return { session: null, user: null } 213 + } 214 + 215 + // Extend if within 15 days of expiry 216 + if ( 217 + Date.now() >= 218 + new Date(session.expires_at).getTime() - 1000 * 60 * 60 * 24 * 15 219 + ) { 220 + session.expires_at = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30) 221 + await this.#kv.set(['sessions', sessionId], session) 222 + } 223 + 224 + const user = await this.getUserById(session.user_id) 225 + return { session, user } 226 + } 227 + 228 + async invalidateSession(sessionId: string): Promise<void> { 229 + await this.#kv.delete(['sessions', sessionId]) 230 + } 231 + 232 + setSessionCookie(c: HonoContext, sessionId: string, expiresAt: Date): void { 233 + const parts = [ 234 + `${SESSION_COOKIE_NAME}=${sessionId}`, 235 + 'HttpOnly', 236 + 'Path=/', 237 + `Expires=${new Date(expiresAt).toUTCString()}`, 238 + 'SameSite=Lax', 239 + ] 240 + const url = new URL(c.req.url) 241 + if (url.protocol === 'https:') parts.push('Secure') 242 + c.header('Set-Cookie', parts.join('; ')) 243 + } 244 + 245 + deleteSessionCookie(c: HonoContext): void { 246 + const parts = [ 247 + `${SESSION_COOKIE_NAME}=`, 248 + 'HttpOnly', 249 + 'Path=/', 250 + 'Expires=Thu, 01 Jan 1970 00:00:00 GMT', 251 + 'SameSite=Lax', 252 + ] 253 + const url = new URL(c.req.url) 254 + if (url.protocol === 'https:') parts.push('Secure') 255 + c.header('Set-Cookie', parts.join('; ')) 256 + } 257 + 258 + getSessionIdFromCookie(c: HonoContext): string | null { 259 + const header = c.req.header('Cookie') 260 + if (!header) return null 261 + const match = header 262 + .split(';') 263 + .map((s: string) => s.trim()) 264 + .find((s: string) => s.startsWith(`${SESSION_COOKIE_NAME}=`)) 265 + return match?.split('=')[1] ?? null 266 + } 267 + 268 + validateSessionFromRequest(c: HonoContext): Promise<SessionValidationResult> { 269 + const sessionId = this.getSessionIdFromCookie(c) 270 + if (!sessionId) return Promise.resolve({ session: null, user: null }) 271 + return this.validateSessionId(sessionId) 272 + } 273 + }
+207
packages/cli/sync/utils/db.ts
··· 1 + /** 2 + * @module KVDatabase — Deno KV wrapper for app and token CRUD 3 + * 4 + * Manages apps and app tokens in Deno KV. Each app belongs to a user; 5 + * each app token belongs to an app. 6 + */ 7 + 8 + export interface DbApp { 9 + id: string 10 + user_id: string 11 + name: string 12 + description?: string 13 + schema?: string 14 + created_at: string 15 + updated_at: string 16 + } 17 + 18 + export interface DbAppToken { 19 + id: string 20 + app_id: string 21 + token: string 22 + name: string 23 + permissions: string 24 + last_used_at?: string 25 + created_at: string 26 + } 27 + 28 + export class KVDatabase { 29 + #kv: Deno.Kv 30 + 31 + constructor(kv: Deno.Kv) { 32 + this.#kv = kv 33 + } 34 + 35 + // ── Apps ────────────────────────────────────── 36 + 37 + async createApp( 38 + userId: string, 39 + name: string, 40 + description?: string, 41 + ): Promise<DbApp> { 42 + const id = crypto.randomUUID() 43 + const now = new Date().toISOString() 44 + 45 + const app: DbApp = { 46 + id, 47 + user_id: userId, 48 + name, 49 + description, 50 + created_at: now, 51 + updated_at: now, 52 + } 53 + 54 + await this.#kv.set(['apps', id], app) 55 + await this.#kv.set(['users', userId, 'apps', id], app) 56 + 57 + return app 58 + } 59 + 60 + async createAppWithId( 61 + id: string, 62 + userId: string, 63 + name: string, 64 + description?: string, 65 + ): Promise<DbApp> { 66 + const now = new Date().toISOString() 67 + const app: DbApp = { 68 + id, 69 + user_id: userId, 70 + name, 71 + description, 72 + created_at: now, 73 + updated_at: now, 74 + } 75 + await this.#kv.set(['apps', id], app) 76 + await this.#kv.set(['users', userId, 'apps', id], app) 77 + return app 78 + } 79 + 80 + async getApp(id: string): Promise<DbApp | null> { 81 + const result = await this.#kv.get<DbApp>(['apps', id]) 82 + return result.value 83 + } 84 + 85 + async getAppsByUserId(userId: string): Promise<DbApp[]> { 86 + const apps: DbApp[] = [] 87 + const iter = this.#kv.list<DbApp>({ prefix: ['users', userId, 'apps'] }) 88 + for await (const entry of iter) { 89 + apps.push(entry.value) 90 + } 91 + return apps 92 + } 93 + 94 + async updateApp( 95 + id: string, 96 + updates: Partial<Omit<DbApp, 'id' | 'user_id' | 'created_at'>>, 97 + ): Promise<DbApp | null> { 98 + const existing = await this.getApp(id) 99 + if (!existing) return null 100 + 101 + const updated: DbApp = { 102 + ...existing, 103 + ...updates, 104 + updated_at: new Date().toISOString(), 105 + } 106 + 107 + await this.#kv.set(['apps', id], updated) 108 + await this.#kv.set(['users', existing.user_id, 'apps', id], updated) 109 + 110 + return updated 111 + } 112 + 113 + async deleteApp(id: string): Promise<boolean> { 114 + const app = await this.getApp(id) 115 + if (!app) return false 116 + 117 + const tokens = await this.getAppTokens(id) 118 + for (const token of tokens) { 119 + await this.deleteAppToken(token.id) 120 + } 121 + 122 + await this.#kv.delete(['apps', id]) 123 + await this.#kv.delete(['users', app.user_id, 'apps', id]) 124 + 125 + return true 126 + } 127 + 128 + // ── App Tokens ──────────────────────────────── 129 + 130 + async createAppToken( 131 + appId: string, 132 + name: string, 133 + permissions = 'read', 134 + ): Promise<DbAppToken> { 135 + const id = crypto.randomUUID() 136 + const token = crypto.randomUUID() 137 + const now = new Date().toISOString() 138 + 139 + const appToken: DbAppToken = { 140 + id, 141 + app_id: appId, 142 + token, 143 + name, 144 + permissions, 145 + last_used_at: undefined, 146 + created_at: now, 147 + } 148 + 149 + await this.#kv.set(['app_tokens', id], appToken) 150 + await this.#kv.set(['apps', appId, 'tokens', id], appToken) 151 + await this.#kv.set(['token_lookup', token], appToken) 152 + 153 + return appToken 154 + } 155 + 156 + async getAppToken(id: string): Promise<DbAppToken | null> { 157 + const result = await this.#kv.get<DbAppToken>(['app_tokens', id]) 158 + return result.value 159 + } 160 + 161 + async getAppTokenByToken(token: string): Promise<DbAppToken | null> { 162 + const result = await this.#kv.get<DbAppToken>(['token_lookup', token]) 163 + return result.value 164 + } 165 + 166 + async getAppTokens(appId: string): Promise<DbAppToken[]> { 167 + const tokens: DbAppToken[] = [] 168 + const iter = this.#kv.list<DbAppToken>({ 169 + prefix: ['apps', appId, 'tokens'], 170 + }) 171 + for await (const entry of iter) { 172 + tokens.push(entry.value) 173 + } 174 + return tokens 175 + } 176 + 177 + async updateAppTokenLastUsed(id: string): Promise<void> { 178 + const token = await this.getAppToken(id) 179 + if (!token) return 180 + 181 + const updated = { ...token, last_used_at: new Date().toISOString() } 182 + 183 + await this.#kv.set(['app_tokens', id], updated) 184 + await this.#kv.set(['apps', token.app_id, 'tokens', id], updated) 185 + await this.#kv.set(['token_lookup', token.token], updated) 186 + } 187 + 188 + async deleteAppToken(id: string): Promise<boolean> { 189 + const token = await this.getAppToken(id) 190 + if (!token) return false 191 + 192 + await this.#kv.delete(['app_tokens', id]) 193 + await this.#kv.delete(['apps', token.app_id, 'tokens', id]) 194 + await this.#kv.delete(['token_lookup', token.token]) 195 + 196 + return true 197 + } 198 + 199 + async userOwnsApp(userId: string, appId: string): Promise<boolean> { 200 + const app = await this.getApp(appId) 201 + return app?.user_id === userId 202 + } 203 + 204 + close(): void { 205 + this.#kv.close() 206 + } 207 + }
+93
packages/cli/sync/utils/middleware.ts
··· 1 + /** 2 + * @module Auth middleware for Civility Sync server 3 + * 4 + * Validates session tokens from cookies or Authorization headers. 5 + * Uses duck typing for Hono Context to avoid JSR/npm version conflicts. 6 + */ 7 + 8 + import type { AuthUser, Session } from './auth.ts' 9 + import type { Auth } from './auth.ts' 10 + 11 + export interface AuthContext { 12 + user: AuthUser 13 + session: Session 14 + } 15 + 16 + // deno-lint-ignore no-explicit-any 17 + type HonoContext = any 18 + // deno-lint-ignore no-explicit-any 19 + type HonoNext = () => Promise<any> 20 + // deno-lint-ignore no-explicit-any 21 + type HonoMiddleware = (c: HonoContext, next: HonoNext) => Promise<any> 22 + 23 + export function authMiddleware(auth: Auth): HonoMiddleware { 24 + return async (c: HonoContext, next: HonoNext) => { 25 + if (c.req.method === 'OPTIONS') { 26 + await next() 27 + return 28 + } 29 + 30 + // Skip session auth for token-based requests 31 + const token = c.req.query('token') 32 + if (token) { 33 + await next() 34 + return 35 + } 36 + 37 + // Try cookie first 38 + let { session, user } = await auth.validateSessionFromRequest(c) 39 + 40 + // Fall back to Bearer token 41 + if (!session || !user) { 42 + const authHeader = c.req.header('Authorization') 43 + if (authHeader?.startsWith('Bearer ')) { 44 + const bearerToken = authHeader.slice(7) 45 + const result = await auth.validateSessionId(bearerToken) 46 + session = result.session 47 + user = result.user 48 + } 49 + } 50 + 51 + if (!session || !user) { 52 + return c.json( 53 + { status: 'error', message: 'Unauthorized', data: null }, 54 + 401, 55 + ) 56 + } 57 + 58 + c.set('authContext', { user, session }) 59 + await next() 60 + } 61 + } 62 + 63 + export function getAuthContext(c: HonoContext): AuthContext { 64 + const ctx = c.get('authContext') 65 + if (!ctx) { 66 + throw new Error( 67 + 'Auth context not found. Make sure authMiddleware is applied.', 68 + ) 69 + } 70 + return ctx as AuthContext 71 + } 72 + 73 + export function optionalAuthMiddleware(auth: Auth) { 74 + return async (c: HonoContext, next: HonoNext) => { 75 + let { session, user } = await auth.validateSessionFromRequest(c) 76 + 77 + if (!session || !user) { 78 + const authHeader = c.req.header('Authorization') 79 + if (authHeader?.startsWith('Bearer ')) { 80 + const bearerToken = authHeader.slice(7) 81 + const result = await auth.validateSessionId(bearerToken) 82 + session = result.session 83 + user = result.user 84 + } 85 + } 86 + 87 + if (session && user) { 88 + c.set('authContext', { user, session }) 89 + } 90 + 91 + await next() 92 + } 93 + }
+51
packages/cli/sync/utils/store_manager.ts
··· 1 + /** 2 + * @module StoreManager — per-user-app Store instance management 3 + * 4 + * Lazily creates and caches Store instances backed by DenoKvStorage. 5 + * Each user+app combination gets an isolated store via KV key prefix. 6 + */ 7 + 8 + import { Store } from '@civility/store' 9 + import { DenoKvStorage } from '@civility/store/deno-kv' 10 + import type { ChangeEntry } from '@civility/store' 11 + 12 + export class StoreManager { 13 + #kv: Deno.Kv 14 + #stores = new Map<string, Store<unknown>>() 15 + 16 + constructor(kv: Deno.Kv) { 17 + this.#kv = kv 18 + } 19 + 20 + /** 21 + * Get or create a Store for the given user+app+store combination. 22 + * Uses KV prefix `sync:{userId}:{appId}:{storeName}` for isolation. 23 + */ 24 + getStore(userId: string, appId: string, storeName: string): Store<unknown> { 25 + const key = `${userId}:${appId}:${storeName}` 26 + let store = this.#stores.get(key) 27 + if (!store) { 28 + const prefix = `sync:${key}` 29 + const storage = new DenoKvStorage<unknown>(this.#kv, { prefix }) 30 + store = new Store<unknown>(storage, { name: storeName }) 31 + this.#stores.set(key, store) 32 + } 33 + return store 34 + } 35 + 36 + /** 37 + * Get unsynced changes for a specific store, filtered since a given HLC. 38 + * Returns all changes with HLC > sinceHLC. 39 + */ 40 + async getChangesSince( 41 + userId: string, 42 + appId: string, 43 + storeName: string, 44 + sinceHLC: string, 45 + ): Promise<ChangeEntry[]> { 46 + const store = this.getStore(userId, appId, storeName) 47 + const allChanges = await store.getUnsyncedChanges() 48 + if (!sinceHLC) return allChanges 49 + return allChanges.filter((c) => c.hlc > sinceHLC) 50 + } 51 + }
+74
packages/cli/sync/utils/transformers.ts
··· 1 + /** 2 + * @module Data Transformers — snake_case DB → camelCase API 3 + */ 4 + 5 + import type { DbApp, DbAppToken } from './db.ts' 6 + import type { AuthUser } from './auth.ts' 7 + 8 + export interface ApiUser { 9 + id: string 10 + username: string 11 + displayName: string | null 12 + profileImageUrl: string | null 13 + createdAt: string 14 + updatedAt: string 15 + } 16 + 17 + export interface ApiApp { 18 + id: string 19 + userId: string 20 + name: string 21 + description: string | null 22 + schema: string | null 23 + createdAt: string 24 + updatedAt: string 25 + } 26 + 27 + export interface ApiAppToken { 28 + id: string 29 + appId: string 30 + token: string 31 + name: string 32 + permissions: string 33 + createdAt: string 34 + lastUsedAt: string | null 35 + } 36 + 37 + function dateStr(d: string | Date): string { 38 + return d instanceof Date ? d.toISOString() : d 39 + } 40 + 41 + export function transformUser(user: AuthUser): ApiUser { 42 + return { 43 + id: user.id, 44 + username: user.username, 45 + displayName: null, 46 + profileImageUrl: null, 47 + createdAt: dateStr(user.created_at), 48 + updatedAt: dateStr(user.updated_at), 49 + } 50 + } 51 + 52 + export function transformApp(app: DbApp): ApiApp { 53 + return { 54 + id: app.id, 55 + userId: app.user_id, 56 + name: app.name, 57 + description: app.description ?? null, 58 + schema: app.schema ?? null, 59 + createdAt: app.created_at, 60 + updatedAt: app.updated_at, 61 + } 62 + } 63 + 64 + export function transformAppToken(token: DbAppToken): ApiAppToken { 65 + return { 66 + id: token.id, 67 + appId: token.app_id, 68 + token: token.token, 69 + name: token.name, 70 + permissions: token.permissions, 71 + createdAt: token.created_at, 72 + lastUsedAt: token.last_used_at ?? null, 73 + } 74 + }
+31 -13
packages/sync/__tests__/synced.test.ts
··· 13 13 let currentHandler: Handler | null = null 14 14 15 15 function mockFetch(handler: Handler): void { 16 - currentHandler = handler 17 - // deno-lint-ignore no-explicit-any 18 - ;(globalThis as any).fetch = (url: string | URL | Request, init?: RequestInit) => { 19 - const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url 16 + currentHandler = handler // deno-lint-ignore no-explicit-any 17 + ;(globalThis as any).fetch = ( 18 + url: string | URL | Request, 19 + init?: RequestInit, 20 + ) => { 21 + const urlStr = typeof url === 'string' 22 + ? url 23 + : url instanceof URL 24 + ? url.toString() 25 + : url.url 20 26 return currentHandler!(urlStr, init) 21 27 } 22 28 } ··· 81 87 await t.step('connect sets connected state', () => { 82 88 const synced = createSynced([createStore()]) 83 89 let connectFired = false 84 - synced.addEventListener('connected', () => { connectFired = true }) 90 + synced.addEventListener('connected', () => { 91 + connectFired = true 92 + }) 85 93 86 94 synced.connect(SERVER) 87 95 assertEquals(synced.connected, true) ··· 111 119 assertEquals(synced.connected, true) 112 120 113 121 let disconnectFired = false 114 - synced.addEventListener('disconnected', () => { disconnectFired = true }) 122 + synced.addEventListener('disconnected', () => { 123 + disconnectFired = true 124 + }) 115 125 116 126 synced.disconnect() 117 127 assertEquals(synced.connected, false) ··· 138 148 mockFetch(() => authResponse()) 139 149 try { 140 150 let authFired = false 141 - synced.addEventListener('auth', () => { authFired = true }) 151 + synced.addEventListener('auth', () => { 152 + authFired = true 153 + }) 142 154 143 155 await synced.login('test@test.com', 'pass') 144 156 assertEquals(synced.authenticated, true) ··· 257 269 258 270 try { 259 271 let syncEvent: SyncEvent | null = null 260 - synced.addEventListener('sync', ((e: Event) => { 261 - syncEvent = e as SyncEvent 262 - }) as EventListener) 272 + synced.addEventListener( 273 + 'sync', 274 + ((e: Event) => { 275 + syncEvent = e as SyncEvent 276 + }) as EventListener, 277 + ) 263 278 264 279 await synced.sync() 265 280 ··· 433 448 434 449 try { 435 450 let errorEvent: SyncErrorEvent | null = null 436 - synced.addEventListener('error', ((e: Event) => { 437 - errorEvent = e as SyncErrorEvent 438 - }) as EventListener) 451 + synced.addEventListener( 452 + 'error', 453 + ((e: Event) => { 454 + errorEvent = e as SyncErrorEvent 455 + }) as EventListener, 456 + ) 439 457 440 458 await assertRejects(() => synced.sync(), Error) 441 459 assertEquals(errorEvent !== null, true)
+1 -1
packages/sync/mod.ts
··· 21 21 export { ApiError, SyncApi } from './api.ts' 22 22 export type { SyncApiConfig } from './api.ts' 23 23 24 - export { Synced, SyncEvent, SyncErrorEvent } from './synced.ts' 24 + export { Synced, SyncErrorEvent, SyncEvent } from './synced.ts' 25 25 export type { SyncableStore, SyncedConfig } from './synced.ts' 26 26 27 27 export { InitialSyncStrategy, serializeSchemaConfig } from './types.ts'
+3 -9
packages/sync/synced.ts
··· 207 207 } 208 208 209 209 // Kick off initial sync immediately 210 - this.sync().catch((err) => 211 - this.dispatchEvent(new SyncErrorEvent(err)) 212 - ) 210 + this.sync().catch((err) => this.dispatchEvent(new SyncErrorEvent(err))) 213 211 214 212 // Start periodic sync 215 213 this.#syncTimer = setInterval(() => { 216 - this.sync().catch((err) => 217 - this.dispatchEvent(new SyncErrorEvent(err)) 218 - ) 214 + this.sync().catch((err) => this.dispatchEvent(new SyncErrorEvent(err))) 219 215 }, this.#syncInterval) 220 216 } 221 217 ··· 386 382 } 387 383 this.#debounceTimer = setTimeout(() => { 388 384 this.#debounceTimer = null 389 - this.sync().catch((err) => 390 - this.dispatchEvent(new SyncErrorEvent(err)) 391 - ) 385 + this.sync().catch((err) => this.dispatchEvent(new SyncErrorEvent(err))) 392 386 }, this.#debounceDelay) 393 387 } 394 388