clone

a self-hosted sync node for lofi apps: jazz sync over iroh, ticket-gated, with no static ip or cloud needed

feat: HTTP-over-iroh proxy in tunnel + convergence fixtures pass

Dami committed 765ec19
Jazz leaves proxy their catalogue (schema/permissions) HTTP reads and
writes to their upstream, so the tunnel now carries plain HTTP alongside
the sync WebSocket: HELLO{kind:'http',method,path,headers} + body frame
up, {status,headers} + body back, one request per iroh conn.

Convergence fixtures (lofi's two-device offline-edit pattern from
access_sync_test): pass against a hosted daemon, an in-memory node, and
the full topology — device A on the root, device B on a leaf paired over
iroh, concurrent offline edits converge across the tunnel (~1s).

Also: UUID appIds (Jazz requires them), $createdBy permissions policy,
ws:// URL normalization, /health as the liveness probe, shutdown errors
logged instead of crashing the SIGINT handler.
commit
765ec197acb4203cd9efae6037bc4e9fda8b91e1
parent
19245e3cd8ab8601c74b73479709f420c90ded92
author
Dami <dami.etoile@gmail.com>
8 files changed, +757 -10
Mcli.ts6+-
Mdeno.json5+-
Mdeno.lock408++++++++++++++++++++++++++++++-
Msrc/config.ts2+-
Msrc/tunnel.ts118++++++++-
Mtesting/mod.ts2+-
Atests/convergence_test.ts224++++++++++++++++
Mtests/pair_test.ts2+-
M cli.ts +5 -1
M deno.json +4 -1
M deno.lock +406 -2
@@ -3,7 +3,8 @@
33 "specifiers": {44 "jsr:@std/assert@1": "1.0.19",55 "jsr:@std/internal@^1.0.12": "1.0.14",6- "npm:jazz-napi@2.0.0-alpha.53": "2.0.0-alpha.53"6+ "npm:jazz-napi@2.0.0-alpha.53": "2.0.0-alpha.53",7+ "npm:jazz-tools@2.0.0-alpha.53": "2.0.0-alpha.53"78 },89 "jsr": {910 "@std/assert@1.0.19": {
@@ -17,6 +18,139 @@
1718 }1819 },1920 "npm": {21+ "@babel/runtime@7.29.7": {22+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="23+ },24+ "@esbuild/aix-ppc64@0.27.7": {25+ "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",26+ "os": ["aix"],27+ "cpu": ["ppc64"]28+ },29+ "@esbuild/android-arm64@0.27.7": {30+ "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",31+ "os": ["android"],32+ "cpu": ["arm64"]33+ },34+ "@esbuild/android-arm@0.27.7": {35+ "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",36+ "os": ["android"],37+ "cpu": ["arm"]38+ },39+ "@esbuild/android-x64@0.27.7": {40+ "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",41+ "os": ["android"],42+ "cpu": ["x64"]43+ },44+ "@esbuild/darwin-arm64@0.27.7": {45+ "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",46+ "os": ["darwin"],47+ "cpu": ["arm64"]48+ },49+ "@esbuild/darwin-x64@0.27.7": {50+ "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",51+ "os": ["darwin"],52+ "cpu": ["x64"]53+ },54+ "@esbuild/freebsd-arm64@0.27.7": {55+ "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",56+ "os": ["freebsd"],57+ "cpu": ["arm64"]58+ },59+ "@esbuild/freebsd-x64@0.27.7": {60+ "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",61+ "os": ["freebsd"],62+ "cpu": ["x64"]63+ },64+ "@esbuild/linux-arm64@0.27.7": {65+ "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",66+ "os": ["linux"],67+ "cpu": ["arm64"]68+ },69+ "@esbuild/linux-arm@0.27.7": {70+ "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",71+ "os": ["linux"],72+ "cpu": ["arm"]73+ },74+ "@esbuild/linux-ia32@0.27.7": {75+ "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",76+ "os": ["linux"],77+ "cpu": ["ia32"]78+ },79+ "@esbuild/linux-loong64@0.27.7": {80+ "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",81+ "os": ["linux"],82+ "cpu": ["loong64"]83+ },84+ "@esbuild/linux-mips64el@0.27.7": {85+ "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",86+ "os": ["linux"],87+ "cpu": ["mips64el"]88+ },89+ "@esbuild/linux-ppc64@0.27.7": {90+ "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",91+ "os": ["linux"],92+ "cpu": ["ppc64"]93+ },94+ "@esbuild/linux-riscv64@0.27.7": {95+ "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",96+ "os": ["linux"],97+ "cpu": ["riscv64"]98+ },99+ "@esbuild/linux-s390x@0.27.7": {100+ "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",101+ "os": ["linux"],102+ "cpu": ["s390x"]103+ },104+ "@esbuild/linux-x64@0.27.7": {105+ "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",106+ "os": ["linux"],107+ "cpu": ["x64"]108+ },109+ "@esbuild/netbsd-arm64@0.27.7": {110+ "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",111+ "os": ["netbsd"],112+ "cpu": ["arm64"]113+ },114+ "@esbuild/netbsd-x64@0.27.7": {115+ "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",116+ "os": ["netbsd"],117+ "cpu": ["x64"]118+ },119+ "@esbuild/openbsd-arm64@0.27.7": {120+ "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",121+ "os": ["openbsd"],122+ "cpu": ["arm64"]123+ },124+ "@esbuild/openbsd-x64@0.27.7": {125+ "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",126+ "os": ["openbsd"],127+ "cpu": ["x64"]128+ },129+ "@esbuild/openharmony-arm64@0.27.7": {130+ "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",131+ "os": ["openharmony"],132+ "cpu": ["arm64"]133+ },134+ "@esbuild/sunos-x64@0.27.7": {135+ "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",136+ "os": ["sunos"],137+ "cpu": ["x64"]138+ },139+ "@esbuild/win32-arm64@0.27.7": {140+ "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",141+ "os": ["win32"],142+ "cpu": ["arm64"]143+ },144+ "@esbuild/win32-ia32@0.27.7": {145+ "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",146+ "os": ["win32"],147+ "cpu": ["ia32"]148+ },149+ "@esbuild/win32-x64@0.27.7": {150+ "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",151+ "os": ["win32"],152+ "cpu": ["x64"]153+ },20154 "@garden-co/jazz-napi-darwin-arm64@2.0.0-alpha.53": {21155 "integrity": "sha512-CqDtWl9+coVdCvMfhA5vQR8ZiqCztLj0QLG9NKg96nqZ/ZTB7Pt4JPT4ipfUf3COtzzkPY5MU6lv5zk92Kdp4Q==",22156 "os": ["darwin"],
@@ -37,6 +171,206 @@
37171 "os": ["win32"],38172 "cpu": ["x64"]39173 },174+ "@noble/hashes@2.2.0": {175+ "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="176+ },177+ "@opentelemetry/api-logs@0.216.0": {178+ "integrity": "sha512-KmGTgvxTJ0J01d4mOeX1wMV5NUTNf9HebIuOOGDfIn0a/IrnXIQbOnlylDyl9tkDv4h0DUpdI/GqCdLzfTkUXg==",179+ "dependencies": [180+ "@opentelemetry/api"181+ ]182+ },183+ "@opentelemetry/api@1.9.1": {184+ "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="185+ },186+ "@opentelemetry/core@2.7.1_@opentelemetry+api@1.9.1": {187+ "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==",188+ "dependencies": [189+ "@opentelemetry/api",190+ "@opentelemetry/semantic-conventions"191+ ]192+ },193+ "@opentelemetry/core@2.9.0_@opentelemetry+api@1.9.1": {194+ "integrity": "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==",195+ "dependencies": [196+ "@opentelemetry/api",197+ "@opentelemetry/semantic-conventions"198+ ]199+ },200+ "@opentelemetry/exporter-logs-otlp-http@0.216.0_@opentelemetry+api@1.9.1": {201+ "integrity": "sha512-8SUzQY/aExKkz6Ab3vOf6gu690Xk4wHH90dGwXinejQzazn5HCIRR7yPVU/2fEuiZ73R92MU4qI3djHfYP7NJg==",202+ "dependencies": [203+ "@opentelemetry/api",204+ "@opentelemetry/api-logs",205+ "@opentelemetry/core@2.7.1_@opentelemetry+api@1.9.1",206+ "@opentelemetry/otlp-exporter-base",207+ "@opentelemetry/otlp-transformer",208+ "@opentelemetry/sdk-logs"209+ ]210+ },211+ "@opentelemetry/exporter-trace-otlp-http@0.216.0_@opentelemetry+api@1.9.1": {212+ "integrity": "sha512-DhWjvj0PUPFwFnhOEivpum8sJzj6FTuyx88zff+oHVLUhfd6cLyw4AIai/F4j0PZqYZBFuMT/OTMUd9wdXnBEQ==",213+ "dependencies": [214+ "@opentelemetry/api",215+ "@opentelemetry/core@2.7.1_@opentelemetry+api@1.9.1",216+ "@opentelemetry/otlp-exporter-base",217+ "@opentelemetry/otlp-transformer",218+ "@opentelemetry/resources@2.7.1_@opentelemetry+api@1.9.1",219+ "@opentelemetry/sdk-trace-base"220+ ]221+ },222+ "@opentelemetry/otlp-exporter-base@0.216.0_@opentelemetry+api@1.9.1": {223+ "integrity": "sha512-sSnvb5f+FYa4mfYxj03rmmUh+aDwo3jok62dgIWUDw8ZCUPzEbgtv/YhZyKUSlKNNey7Uc5xmJgmtTLLIV6UDQ==",224+ "dependencies": [225+ "@opentelemetry/api",226+ "@opentelemetry/core@2.7.1_@opentelemetry+api@1.9.1",227+ "@opentelemetry/otlp-transformer"228+ ]229+ },230+ "@opentelemetry/otlp-transformer@0.216.0_@opentelemetry+api@1.9.1": {231+ "integrity": "sha512-g4Rb6sAsxQAo11eDjixfKxelruBsQFdJ8Wo23FCj7D6OXbidgXMu2xaRSYs4RdlomzAXSJuc86RcS3xmE8A6uA==",232+ "dependencies": [233+ "@opentelemetry/api",234+ "@opentelemetry/api-logs",235+ "@opentelemetry/core@2.7.1_@opentelemetry+api@1.9.1",236+ "@opentelemetry/resources@2.7.1_@opentelemetry+api@1.9.1",237+ "@opentelemetry/sdk-logs",238+ "@opentelemetry/sdk-metrics",239+ "@opentelemetry/sdk-trace-base",240+ "protobufjs"241+ ]242+ },243+ "@opentelemetry/resources@2.7.1_@opentelemetry+api@1.9.1": {244+ "integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==",245+ "dependencies": [246+ "@opentelemetry/api",247+ "@opentelemetry/core@2.7.1_@opentelemetry+api@1.9.1",248+ "@opentelemetry/semantic-conventions"249+ ]250+ },251+ "@opentelemetry/resources@2.9.0_@opentelemetry+api@1.9.1": {252+ "integrity": "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==",253+ "dependencies": [254+ "@opentelemetry/api",255+ "@opentelemetry/core@2.9.0_@opentelemetry+api@1.9.1",256+ "@opentelemetry/semantic-conventions"257+ ]258+ },259+ "@opentelemetry/sdk-logs@0.216.0_@opentelemetry+api@1.9.1": {260+ "integrity": "sha512-KB3rcwQuitq0JbbsCcNdqMhRJX3kArAYz/ovb0jGRaBQAIrt2roik3xQXuhYxS37zx0jSkUZcJu1z3Y2UCxbDA==",261+ "dependencies": [262+ "@opentelemetry/api",263+ "@opentelemetry/api-logs",264+ "@opentelemetry/core@2.7.1_@opentelemetry+api@1.9.1",265+ "@opentelemetry/resources@2.7.1_@opentelemetry+api@1.9.1",266+ "@opentelemetry/semantic-conventions"267+ ]268+ },269+ "@opentelemetry/sdk-metrics@2.7.1_@opentelemetry+api@1.9.1": {270+ "integrity": "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ==",271+ "dependencies": [272+ "@opentelemetry/api",273+ "@opentelemetry/core@2.7.1_@opentelemetry+api@1.9.1",274+ "@opentelemetry/resources@2.7.1_@opentelemetry+api@1.9.1"275+ ]276+ },277+ "@opentelemetry/sdk-trace-base@2.7.1_@opentelemetry+api@1.9.1": {278+ "integrity": "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==",279+ "dependencies": [280+ "@opentelemetry/api",281+ "@opentelemetry/core@2.7.1_@opentelemetry+api@1.9.1",282+ "@opentelemetry/resources@2.7.1_@opentelemetry+api@1.9.1",283+ "@opentelemetry/semantic-conventions"284+ ]285+ },286+ "@opentelemetry/semantic-conventions@1.43.0": {287+ "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="288+ },289+ "@protobufjs/aspromise@1.1.2": {290+ "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="291+ },292+ "@protobufjs/base64@1.1.2": {293+ "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="294+ },295+ "@protobufjs/codegen@2.0.5": {296+ "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="297+ },298+ "@protobufjs/eventemitter@1.1.1": {299+ "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="300+ },301+ "@protobufjs/fetch@1.1.1": {302+ "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",303+ "dependencies": [304+ "@protobufjs/aspromise"305+ ]306+ },307+ "@protobufjs/float@1.0.2": {308+ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="309+ },310+ "@protobufjs/inquire@1.1.2": {311+ "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw=="312+ },313+ "@protobufjs/path@1.1.2": {314+ "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="315+ },316+ "@protobufjs/pool@1.1.0": {317+ "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="318+ },319+ "@protobufjs/utf8@1.1.2": {320+ "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="321+ },322+ "@scure/base@2.2.0": {323+ "integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg=="324+ },325+ "@scure/bip39@2.2.0": {326+ "integrity": "sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A==",327+ "dependencies": [328+ "@noble/hashes",329+ "@scure/base"330+ ]331+ },332+ "@standard-schema/spec@1.1.0": {333+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="334+ },335+ "@types/node@26.1.1": {336+ "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",337+ "dependencies": [338+ "undici-types"339+ ]340+ },341+ "esbuild@0.27.7": {342+ "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",343+ "optionalDependencies": [344+ "@esbuild/aix-ppc64",345+ "@esbuild/android-arm",346+ "@esbuild/android-arm64",347+ "@esbuild/android-x64",348+ "@esbuild/darwin-arm64",349+ "@esbuild/darwin-x64",350+ "@esbuild/freebsd-arm64",351+ "@esbuild/freebsd-x64",352+ "@esbuild/linux-arm",353+ "@esbuild/linux-arm64",354+ "@esbuild/linux-ia32",355+ "@esbuild/linux-loong64",356+ "@esbuild/linux-mips64el",357+ "@esbuild/linux-ppc64",358+ "@esbuild/linux-riscv64",359+ "@esbuild/linux-s390x",360+ "@esbuild/linux-x64",361+ "@esbuild/netbsd-arm64",362+ "@esbuild/netbsd-x64",363+ "@esbuild/openbsd-arm64",364+ "@esbuild/openbsd-x64",365+ "@esbuild/openharmony-arm64",366+ "@esbuild/sunos-x64",367+ "@esbuild/win32-arm64",368+ "@esbuild/win32-ia32",369+ "@esbuild/win32-x64"370+ ],371+ "scripts": true,372+ "bin": true373+ },40374 "jazz-napi@2.0.0-alpha.53": {41375 "integrity": "sha512-5+EEUoahaNA2d0IqvYDlmrx9bjhLjpvkbt5Nab+C09EeKGnJcz7JJbofC0xuJDe++9I7/8cnYps7YcAYjdK3Ug==",42376 "optionalDependencies": [
@@ -45,11 +379,81 @@
45379 "@garden-co/jazz-napi-linux-x64-gnu",46380 "@garden-co/jazz-napi-win32-x64-msvc"47381 ]382+ },383+ "jazz-tools@2.0.0-alpha.53": {384+ "integrity": "sha512-GSAt9axXPaZY3g0bZxEPeAmq/q4IWfJRdVMWmNYxLnaKzFHD3vYQf1y4VK9/ZAGCdabcxS8Im3dreISRHXkHSg==",385+ "dependencies": [386+ "@opentelemetry/api",387+ "@opentelemetry/exporter-logs-otlp-http",388+ "@opentelemetry/exporter-trace-otlp-http",389+ "@opentelemetry/resources@2.9.0_@opentelemetry+api@1.9.1",390+ "@opentelemetry/sdk-logs",391+ "@opentelemetry/sdk-trace-base",392+ "@scure/bip39",393+ "@standard-schema/spec",394+ "esbuild",395+ "jazz-wasm",396+ "jose",397+ "json-schema-to-ts",398+ "pluralize-esm",399+ "web-streams-polyfill"400+ ],401+ "optionalDependencies": [402+ "jazz-napi"403+ ],404+ "bin": true405+ },406+ "jazz-wasm@2.0.0-alpha.53": {407+ "integrity": "sha512-0Aqnldh3pPvH582g4H2TtbycoFujBpnMpL4dcS0uhMRsBzr8ycIIi3GCWMP43Gn2DG5X5cutqpGqNKMzZbY6BQ=="408+ },409+ "jose@6.2.3": {410+ "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="411+ },412+ "json-schema-to-ts@3.1.1": {413+ "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",414+ "dependencies": [415+ "@babel/runtime",416+ "ts-algebra"417+ ]418+ },419+ "long@5.3.2": {420+ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="421+ },422+ "pluralize-esm@9.0.5": {423+ "integrity": "sha512-Kb2dcpMsIutFw2hYrN0EhsAXOUJTd6FVMIxvNAkZCMQLVt9NGZqQczvGpYDxNWCZeCWLHUPxQIBudWzt1h7VVA=="424+ },425+ "protobufjs@8.0.1": {426+ "integrity": "sha512-NWWCCscLjs+cOKF/s/XVNFRW7Yih0fdH+9brffR5NZCy8k42yRdl5KlWKMVXuI1vfCoy4o1z80XR/W/QUb3V3w==",427+ "dependencies": [428+ "@protobufjs/aspromise",429+ "@protobufjs/base64",430+ "@protobufjs/codegen",431+ "@protobufjs/eventemitter",432+ "@protobufjs/fetch",433+ "@protobufjs/float",434+ "@protobufjs/inquire",435+ "@protobufjs/path",436+ "@protobufjs/pool",437+ "@protobufjs/utf8",438+ "@types/node",439+ "long"440+ ],441+ "scripts": true442+ },443+ "ts-algebra@2.0.0": {444+ "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="445+ },446+ "undici-types@8.3.0": {447+ "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="448+ },449+ "web-streams-polyfill@4.3.0": {450+ "integrity": "sha512-/Gnggvj9oSrEvJbDyyPtAnxBt5fGQM2iWOKQNu7ie1OxDgK40iZpyV3TKaRiEzVj1oA1UxKnEy9XPXh6PW3eVw=="48451 }49452 },50453 "workspace": {51454 "dependencies": [52- "npm:jazz-napi@2.0.0-alpha.53"455+ "npm:jazz-napi@2.0.0-alpha.53",456+ "npm:jazz-tools@2.0.0-alpha.53"53457 ]54458 }55459 }
M src/config.ts +1 -1
M src/tunnel.ts +115 -3
@@ -56,9 +56,43 @@ function sanitizeCloseCode(code: number): number {
5656 return code;5757 }5858 59+/** kind "ws" (or absent, legacy): one WebSocket per conn. kind "http": one60+ * request/response per conn — HELLO{method,path,headers} + BIN body up,61+ * HELLO{status,headers} + BIN body back. Jazz leaves proxy their catalogue62+ * (schema/permissions) HTTP reads to their upstream, so the tunnel must carry63+ * plain HTTP as well as the sync WebSocket. */5964 interface Hello {65+ kind?: "ws" | "http";6066 path: string;61- protocol: string | null;67+ protocol?: string | null;68+ method?: string;69+ headers?: Record<string, string>;70+}71+72+interface HttpResponseHead {73+ status: number;74+ headers: Record<string, string>;75+}76+77+const HOP_BY_HOP = new Set([78+ "connection",79+ "keep-alive",80+ "transfer-encoding",81+ "upgrade",82+ "host",83+ "content-length",84+]);85+86+function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {87+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;88+}89+90+function forwardableHeaders(headers: Headers): Record<string, string> {91+ const out: Record<string, string> = {};92+ for (const [name, value] of headers) {93+ if (!HOP_BY_HOP.has(name.toLowerCase())) out[name] = value;94+ }95+ return out;6296 }6397 6498 /** Pump both directions between an OPEN WebSocket and an iroh conn until
@@ -150,11 +184,47 @@ export function startTunnelListener(
150184 peerAddr: Uint8Array,151185 options: { port?: number } = {},152186 ): TunnelListener {187+ async function proxyHttp(req: Request): Promise<Response> {188+ const url = new URL(req.url);189+ let conn: IrohConn;190+ try {191+ conn = await node.connect(peerAddr);192+ } catch {193+ return new Response("tunnel dial failed", { status: 502 });194+ }195+ try {196+ const hello: Hello = {197+ kind: "http",198+ method: req.method,199+ path: url.pathname + url.search,200+ headers: forwardableHeaders(req.headers),201+ };202+ conn.sendMsg(encodeFrame(FRAME_HELLO, encoder.encode(JSON.stringify(hello))));203+ conn.sendMsg(encodeFrame(FRAME_BIN, new Uint8Array(await req.arrayBuffer())));204+ const headFrame = await conn.recvMsg();205+ const bodyFrame = headFrame === null ? null : await conn.recvMsg();206+ if (headFrame === null || bodyFrame === null) {207+ return new Response("tunnel closed mid-response", { status: 502 });208+ }209+ const head = JSON.parse(decoder.decode(decodeFrame(headFrame).payload)) as HttpResponseHead;210+ debug("dialer: http", hello.method, hello.path, "→", head.status);211+ return new Response(toArrayBuffer(decodeFrame(bodyFrame).payload), {212+ status: head.status,213+ headers: head.headers,214+ });215+ } catch (e) {216+ debug("dialer: http proxy error", (e as Error).message);217+ return new Response("tunnel proxy error", { status: 502 });218+ } finally {219+ conn.close().catch(() => {});220+ }221+ }222+153223 const server = Deno.serve(154224 { hostname: "127.0.0.1", port: options.port ?? 0, onListen: () => {} },155225 (req) => {156226 if (req.headers.get("upgrade")?.toLowerCase() !== "websocket") {157- return new Response("lofi-node tunnel (WebSocket only)", { status: 426 });227+ return proxyHttp(req);158228 }159229 const requestedProtocol = req.headers.get("sec-websocket-protocol");160230 const { socket, response } = Deno.upgradeWebSocket(
@@ -170,7 +240,11 @@ export function startTunnelListener(
170240 socket.addEventListener("open", async () => {171241 try {172242 const conn = await node.connect(peerAddr);173- const hello: Hello = { path: url.pathname + url.search, protocol: requestedProtocol };243+ const hello: Hello = {244+ kind: "ws",245+ path: url.pathname + url.search,246+ protocol: requestedProtocol,247+ };174248 debug("dialer: connected, HELLO", hello);175249 conn.sendMsg(encodeFrame(FRAME_HELLO, encoder.encode(JSON.stringify(hello))));176250 socket.removeEventListener("message", buffer);
@@ -201,6 +275,40 @@ export interface TunnelAcceptor {
201275 /** Acceptor side: bridge every inbound iroh conn to the local Jazz server. */202276 export function runTunnelAcceptor(node: IrohNode, localWsUrl: string): TunnelAcceptor {203277 let stopped = false;278+ const localHttpUrl = localWsUrl.replace(/^ws/, "http");279+280+ async function handleHttp(conn: IrohConn, hello: Hello): Promise<void> {281+ const bodyFrame = await conn.recvMsg();282+ if (bodyFrame === null) {283+ await conn.close();284+ return;285+ }286+ const body = decodeFrame(bodyFrame).payload;287+ const method = hello.method ?? "GET";288+ let head: HttpResponseHead;289+ let responseBody: Uint8Array;290+ try {291+ const res = await fetch(new URL(hello.path || "/", localHttpUrl), {292+ method,293+ headers: hello.headers,294+ body: method === "GET" || method === "HEAD" ? undefined : toArrayBuffer(body),295+ });296+ head = { status: res.status, headers: forwardableHeaders(res.headers) };297+ responseBody = new Uint8Array(await res.arrayBuffer());298+ } catch (e) {299+ debug("acceptor: local http error", (e as Error).message);300+ head = { status: 502, headers: {} };301+ responseBody = encoder.encode("local fetch failed");302+ }303+ debug("acceptor: http", method, hello.path, "→", head.status);304+ try {305+ conn.sendMsg(encodeFrame(FRAME_HELLO, encoder.encode(JSON.stringify(head))));306+ conn.sendMsg(encodeFrame(FRAME_BIN, responseBody));307+ } catch {308+ // peer gone309+ }310+ await conn.close();311+ }204312 205313 async function handleInbound(conn: IrohConn): Promise<void> {206314 const first = await conn.recvMsg();
@@ -217,6 +325,10 @@ export function runTunnelAcceptor(node: IrohNode, localWsUrl: string): TunnelAcc
217325 await conn.close();218326 return;219327 }328+ if (hello.kind === "http") {329+ await handleHttp(conn, hello);330+ return;331+ }220332 const target = new URL(hello.path || "/", localWsUrl);221333 debug("acceptor: HELLO", hello, "→ dialing local", target.href);222334 const ws = hello.protocol
M testing/mod.ts +1 -1
A tests/convergence_test.ts +224 -0
@@ -0,0 +1,224 @@
1+// The convergence fixture (lofi's exit criterion for the sync-node spike):2+// two devices of ONE account (same secret, separate runtimes — lofi's3+// per-device sync model) make concurrent OFFLINE edits, reconnect, and must4+// both observe the union. Stages mirror lofi's convergence runner: prepare →5+// concurrent offline edits → reconnect → converge.6+//7+// Test 1 runs against a hosted daemon when LOFI_NODE_URL / LOFI_NODE_APP_ID /8+// LOFI_NODE_ADMIN_SECRET are set (else an in-memory node it spins itself).9+// Test 2 is the full topology: each device on its own node, nodes paired over10+// the iroh tunnel.11+12+import { assert, assertEquals } from "jsr:@std/assert@1";13+import { createDb, schema as s } from "jazz-tools";14+import { deploy } from "jazz-tools/testing";15+import { definePermissions } from "jazz-tools/permissions";16+import { createSyncNode, type SyncNode } from "../src/node.ts";17+import { resolveIrohLib } from "../src/native/loader.ts";18+19+const app = s.defineApp({20+ notes: s.table({ text: s.string() }),21+});22+23+// Per-account data: both devices share the account, so $createdBy policies24+// cover the whole multi-device fixture.25+const permissions = definePermissions(app, ({ policy, session }) => [26+ policy.notes.allowRead.where({ $createdBy: session.userId }),27+ policy.notes.allowInsert.where({ $createdBy: session.userId }),28+ policy.notes.allowUpdate29+ .whereOld({ $createdBy: session.userId })30+ .whereNew({ $createdBy: session.userId }),31+ policy.notes.allowDelete.where({ $createdBy: session.userId }),32+]);33+34+function accountSecret(fill: number): string {35+ let binary = "";36+ for (const byte of new Uint8Array(32).fill(fill)) binary += String.fromCharCode(byte);37+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");38+}39+40+async function within<T>(operation: Promise<T>, label: string, milliseconds = 15_000): Promise<T> {41+ let timer: ReturnType<typeof setTimeout> | undefined;42+ try {43+ return await Promise.race([44+ operation,45+ new Promise<never>((_resolve, reject) => {46+ timer = setTimeout(() => reject(new Error(`${label} timed out`)), milliseconds);47+ }),48+ ]);49+ } finally {50+ if (timer !== undefined) clearTimeout(timer);51+ }52+}53+54+type Db = Awaited<ReturnType<typeof createDb>>;55+56+async function pollUntil(57+ label: string,58+ predicate: () => Promise<boolean>,59+ milliseconds = 20_000,60+): Promise<void> {61+ const deadline = Date.now() + milliseconds;62+ while (Date.now() < deadline) {63+ if (await predicate()) return;64+ await new Promise((r) => setTimeout(r, 500));65+ }66+ throw new Error(`${label} did not become true within ${milliseconds}ms`);67+}68+69+/** The fixture proper: devices A and B already connected to their servers. */70+async function runConvergenceFixture(deviceA: Db, deviceB: Db): Promise<void> {71+ // Stage: prepare — a seeded row must reach both devices online.72+ const seed = await within(73+ deviceA.insert(app.notes, { text: "seed" }).wait({ tier: "global" }),74+ "seed global durability",75+ );76+ await pollUntil(77+ "device B observes the seed",78+ async () => (await deviceB.all(app.notes.where({ id: seed.id }), { tier: "global" })).length === 1,79+ );80+81+ // Stage: concurrent offline edits.82+ await deviceA.disconnect();83+ await deviceB.disconnect();84+ const fromA = deviceA.insert(app.notes, { text: "offline edit from device A" });85+ const fromB = deviceB.insert(app.notes, { text: "offline edit from device B" });86+ assertEquals(fromA.value.text, "offline edit from device A", "A's edit applied locally offline");87+ assertEquals(fromB.value.text, "offline edit from device B", "B's edit applied locally offline");88+89+ // Stage: reconnect.90+ await deviceA.reconnect();91+ await deviceB.reconnect();92+ await within(fromA.wait({ tier: "global" }), "A's edit global durability");93+ await within(fromB.wait({ tier: "global" }), "B's edit global durability");94+95+ // Stage: converge — both devices observe the union.96+ for (const [name, device] of [["A", deviceA], ["B", deviceB]] as const) {97+ await pollUntil(`device ${name} observes all three notes`, async () => {98+ const rows = await device.all(app.notes, { tier: "global" });99+ const texts = new Set(rows.map((r) => r.text));100+ return texts.has("seed") && texts.has("offline edit from device A") &&101+ texts.has("offline edit from device B");102+ });103+ }104+}105+106+interface Target {107+ appId: string;108+ serverUrl: string;109+ adminSecret: string;110+}111+112+async function connectDevices(a: Target, b: Target): Promise<[Db, Db]> {113+ const secret = accountSecret(7);114+ const deviceA = await createDb({115+ appId: a.appId,116+ serverUrl: a.serverUrl,117+ secret,118+ userBranch: "main",119+ driver: { type: "memory" },120+ });121+ const deviceB = await createDb({122+ appId: b.appId,123+ serverUrl: b.serverUrl,124+ secret,125+ userBranch: "main",126+ driver: { type: "memory" },127+ });128+ return [deviceA, deviceB];129+}130+131+Deno.test({132+ name: "convergence fixture: two devices through one lofi-node",133+ sanitizeOps: false,134+ sanitizeResources: false,135+ fn: async () => {136+ const hostedUrl = Deno.env.get("LOFI_NODE_URL");137+ let node: SyncNode | null = null;138+ let target: Target;139+ if (hostedUrl) {140+ target = {141+ serverUrl: hostedUrl,142+ appId: Deno.env.get("LOFI_NODE_APP_ID")!,143+ adminSecret: Deno.env.get("LOFI_NODE_ADMIN_SECRET")!,144+ };145+ console.log(` → running against hosted daemon at ${hostedUrl}`);146+ } else {147+ node = await createSyncNode({148+ appId: crypto.randomUUID(),149+ backendSecret: "lofi_backend_conv",150+ adminSecret: "lofi_admin_conv",151+ inMemory: true,152+ mesh: "off",153+ });154+ target = {155+ serverUrl: node.url.replace(/^ws/, "http"),156+ appId: node.appId,157+ adminSecret: "lofi_admin_conv",158+ };159+ }160+ await deploy({161+ appId: target.appId,162+ serverUrl: target.serverUrl,163+ adminSecret: target.adminSecret,164+ schema: app,165+ permissions,166+ });167+ const [deviceA, deviceB] = await connectDevices(target, target);168+ try {169+ await runConvergenceFixture(deviceA, deviceB);170+ } finally {171+ await Promise.allSettled([172+ within(deviceA.logout(), "device A cleanup", 3000),173+ within(deviceB.logout(), "device B cleanup", 3000),174+ ]);175+ await node?.stop();176+ }177+ },178+});179+180+Deno.test({181+ name: "convergence fixture: two devices on two nodes paired over iroh",182+ ignore: resolveIrohLib().status !== "ok",183+ sanitizeOps: false,184+ sanitizeResources: false,185+ fn: async () => {186+ const shared = {187+ appId: crypto.randomUUID(),188+ backendSecret: "lofi_backend_conv2",189+ adminSecret: "lofi_admin_conv2",190+ };191+ const root = await createSyncNode({ ...shared, inMemory: true });192+ const leaf = await createSyncNode({193+ ...shared,194+ inMemory: true,195+ upstream: { peer: root.ticket() },196+ });197+ const rootTarget: Target = {198+ serverUrl: root.url.replace(/^ws/, "http"),199+ appId: shared.appId,200+ adminSecret: shared.adminSecret,201+ };202+ const leafTarget: Target = {203+ serverUrl: leaf.url.replace(/^ws/, "http"),204+ appId: shared.appId,205+ adminSecret: shared.adminSecret,206+ };207+ await deploy({ ...rootTarget, schema: app, permissions });208+ await deploy({ ...leafTarget, schema: app, permissions });209+210+ // Device A on the root, device B on the leaf: convergence must cross the211+ // iroh tunnel.212+ const [deviceA, deviceB] = await connectDevices(rootTarget, leafTarget);213+ try {214+ await runConvergenceFixture(deviceA, deviceB);215+ } finally {216+ await Promise.allSettled([217+ within(deviceA.logout(), "device A cleanup", 3000),218+ within(deviceB.logout(), "device B cleanup", 3000),219+ ]);220+ await leaf.stop();221+ await root.stop();222+ }223+ },224+});
M tests/pair_test.ts +1 -1