| 1 |
<!DOCTYPE html> |
| 2 |
<html lang="en"> |
| 3 |
<head> |
| 4 |
<meta charset="UTF-8"> |
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| 6 |
<title>MNW SyncKit — Pitch</title> |
| 7 |
<style> |
| 8 |
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Lato:wght@300;400;700&family=Young+Serif&display=swap'); |
| 9 |
|
| 10 |
:root { |
| 11 |
--bg: #ede8e1; |
| 12 |
--bg-warm: #e4ddd3; |
| 13 |
--bg-deep: #d8cfc3; |
| 14 |
--bg-code: #2d2a26; |
| 15 |
--text: #3d3530; |
| 16 |
--text-secondary: #5d524a; |
| 17 |
--text-muted: #8a7e74; |
| 18 |
--accent: #6c5ce7; |
| 19 |
--accent-light: #8577ed; |
| 20 |
--green: #4caf50; |
| 21 |
--red: #c0392b; |
| 22 |
--border: #c9c0b4; |
| 23 |
--border-dark: #a89d91; |
| 24 |
--code-text: #e8e0d6; |
| 25 |
--code-keyword: #c792ea; |
| 26 |
--code-string: #c3e88d; |
| 27 |
--code-comment: #7a7062; |
| 28 |
--code-type: #ffcb6b; |
| 29 |
--code-fn: #82aaff; |
| 30 |
} |
| 31 |
|
| 32 |
* { margin: 0; padding: 0; box-sizing: border-box; } |
| 33 |
|
| 34 |
@page { size: letter; margin: 0; } |
| 35 |
|
| 36 |
body { |
| 37 |
font-family: 'Lato', sans-serif; |
| 38 |
color: var(--text); |
| 39 |
background: var(--bg); |
| 40 |
line-height: 1.6; |
| 41 |
-webkit-print-color-adjust: exact; |
| 42 |
print-color-adjust: exact; |
| 43 |
} |
| 44 |
|
| 45 |
.page { |
| 46 |
width: 8.5in; |
| 47 |
min-height: 11in; |
| 48 |
margin: 0 auto; |
| 49 |
padding: 0.55in 0.75in; |
| 50 |
background: var(--bg); |
| 51 |
page-break-after: always; |
| 52 |
position: relative; |
| 53 |
} |
| 54 |
|
| 55 |
.page:last-child { page-break-after: auto; } |
| 56 |
|
| 57 |
|
| 58 |
|
| 59 |
.hero { |
| 60 |
text-align: center; |
| 61 |
padding: 0.25in 0 0.2in; |
| 62 |
border-bottom: 2px solid var(--border); |
| 63 |
margin-bottom: 0.22in; |
| 64 |
} |
| 65 |
|
| 66 |
.hero h1 { |
| 67 |
font-family: 'Young Serif', serif; |
| 68 |
font-size: 42px; |
| 69 |
font-weight: 400; |
| 70 |
color: var(--text); |
| 71 |
margin-bottom: 2px; |
| 72 |
letter-spacing: -0.5px; |
| 73 |
} |
| 74 |
|
| 75 |
.hero h1 .mark { color: var(--accent); } |
| 76 |
|
| 77 |
.hero .tagline { |
| 78 |
font-family: 'IBM Plex Mono', monospace; |
| 79 |
font-size: 13.5px; |
| 80 |
color: var(--text-secondary); |
| 81 |
letter-spacing: 0.2px; |
| 82 |
} |
| 83 |
|
| 84 |
.hero .sub { |
| 85 |
font-size: 12px; |
| 86 |
color: var(--text-muted); |
| 87 |
margin-top: 6px; |
| 88 |
} |
| 89 |
|
| 90 |
|
| 91 |
|
| 92 |
h2 { |
| 93 |
font-family: 'IBM Plex Mono', monospace; |
| 94 |
font-size: 13.5px; |
| 95 |
font-weight: 600; |
| 96 |
text-transform: uppercase; |
| 97 |
letter-spacing: 1.5px; |
| 98 |
color: var(--accent); |
| 99 |
margin-bottom: 10px; |
| 100 |
padding-bottom: 4px; |
| 101 |
border-bottom: 1px solid var(--border); |
| 102 |
} |
| 103 |
|
| 104 |
h3 { |
| 105 |
font-family: 'IBM Plex Mono', monospace; |
| 106 |
font-size: 12px; |
| 107 |
font-weight: 600; |
| 108 |
color: var(--text); |
| 109 |
margin-bottom: 4px; |
| 110 |
margin-top: 12px; |
| 111 |
} |
| 112 |
|
| 113 |
h3:first-child { margin-top: 0; } |
| 114 |
|
| 115 |
.intro { |
| 116 |
font-size: 14px; |
| 117 |
line-height: 1.7; |
| 118 |
margin-bottom: 0.2in; |
| 119 |
} |
| 120 |
|
| 121 |
|
| 122 |
|
| 123 |
.stats { |
| 124 |
display: grid; |
| 125 |
grid-template-columns: repeat(4, 1fr); |
| 126 |
gap: 10px; |
| 127 |
margin-bottom: 0.22in; |
| 128 |
} |
| 129 |
|
| 130 |
.stat { |
| 131 |
text-align: center; |
| 132 |
background: var(--bg-warm); |
| 133 |
border: 1px solid var(--border); |
| 134 |
border-radius: 6px; |
| 135 |
padding: 10px 6px; |
| 136 |
} |
| 137 |
|
| 138 |
.stat .num { |
| 139 |
font-family: 'Young Serif', serif; |
| 140 |
font-size: 26px; |
| 141 |
color: var(--accent); |
| 142 |
display: block; |
| 143 |
line-height: 1; |
| 144 |
margin-bottom: 3px; |
| 145 |
} |
| 146 |
|
| 147 |
.stat .label { |
| 148 |
font-family: 'IBM Plex Mono', monospace; |
| 149 |
font-size: 9px; |
| 150 |
color: var(--text-muted); |
| 151 |
text-transform: uppercase; |
| 152 |
letter-spacing: 0.4px; |
| 153 |
} |
| 154 |
|
| 155 |
|
| 156 |
|
| 157 |
.code-block { |
| 158 |
background: var(--bg-code); |
| 159 |
border-radius: 6px; |
| 160 |
padding: 14px 16px; |
| 161 |
margin-bottom: 0.2in; |
| 162 |
overflow-x: auto; |
| 163 |
border: 1px solid #444; |
| 164 |
} |
| 165 |
|
| 166 |
.code-block pre { |
| 167 |
font-family: 'IBM Plex Mono', monospace; |
| 168 |
font-size: 10.5px; |
| 169 |
line-height: 1.55; |
| 170 |
color: var(--code-text); |
| 171 |
white-space: pre; |
| 172 |
margin: 0; |
| 173 |
} |
| 174 |
|
| 175 |
.code-block .kw { color: var(--code-keyword); } |
| 176 |
.code-block .str { color: var(--code-string); } |
| 177 |
.code-block .cmt { color: var(--code-comment); } |
| 178 |
.code-block .ty { color: var(--code-type); } |
| 179 |
.code-block .fn { color: var(--code-fn); } |
| 180 |
|
| 181 |
.code-label { |
| 182 |
font-family: 'IBM Plex Mono', monospace; |
| 183 |
font-size: 10px; |
| 184 |
color: var(--text-muted); |
| 185 |
text-transform: uppercase; |
| 186 |
letter-spacing: 0.5px; |
| 187 |
margin-bottom: 4px; |
| 188 |
display: block; |
| 189 |
} |
| 190 |
|
| 191 |
|
| 192 |
|
| 193 |
.features { |
| 194 |
display: grid; |
| 195 |
grid-template-columns: 1fr 1fr; |
| 196 |
gap: 12px; |
| 197 |
margin-bottom: 0.2in; |
| 198 |
} |
| 199 |
|
| 200 |
.feature-card { |
| 201 |
background: var(--bg-warm); |
| 202 |
border: 1px solid var(--border); |
| 203 |
border-radius: 6px; |
| 204 |
padding: 11px 13px; |
| 205 |
} |
| 206 |
|
| 207 |
.feature-card h3 { |
| 208 |
margin: 0 0 4px 0; |
| 209 |
font-size: 11.5px; |
| 210 |
color: var(--accent); |
| 211 |
} |
| 212 |
|
| 213 |
.feature-card ul { list-style: none; padding: 0; } |
| 214 |
|
| 215 |
.feature-card li { |
| 216 |
font-size: 11px; |
| 217 |
line-height: 1.4; |
| 218 |
color: var(--text-secondary); |
| 219 |
padding: 1.5px 0 1.5px 12px; |
| 220 |
position: relative; |
| 221 |
} |
| 222 |
|
| 223 |
.feature-card li::before { |
| 224 |
content: ''; |
| 225 |
position: absolute; |
| 226 |
left: 0; |
| 227 |
top: 7px; |
| 228 |
width: 5px; |
| 229 |
height: 5px; |
| 230 |
background: var(--accent); |
| 231 |
border-radius: 50%; |
| 232 |
} |
| 233 |
|
| 234 |
|
| 235 |
|
| 236 |
.feature-section { margin-bottom: 0.18in; } |
| 237 |
|
| 238 |
.feature-section ul { |
| 239 |
list-style: none; |
| 240 |
padding: 0; |
| 241 |
columns: 2; |
| 242 |
column-gap: 22px; |
| 243 |
} |
| 244 |
|
| 245 |
.feature-section li { |
| 246 |
font-size: 11.5px; |
| 247 |
line-height: 1.4; |
| 248 |
color: var(--text-secondary); |
| 249 |
padding: 2px 0 2px 12px; |
| 250 |
position: relative; |
| 251 |
break-inside: avoid; |
| 252 |
} |
| 253 |
|
| 254 |
.feature-section li::before { |
| 255 |
content: ''; |
| 256 |
position: absolute; |
| 257 |
left: 0; |
| 258 |
top: 7.5px; |
| 259 |
width: 5px; |
| 260 |
height: 5px; |
| 261 |
background: var(--accent); |
| 262 |
border-radius: 50%; |
| 263 |
} |
| 264 |
|
| 265 |
|
| 266 |
|
| 267 |
.highlight-box { |
| 268 |
background: var(--bg-warm); |
| 269 |
border-left: 3px solid var(--accent); |
| 270 |
padding: 10px 14px; |
| 271 |
margin-bottom: 0.18in; |
| 272 |
border-radius: 0 6px 6px 0; |
| 273 |
} |
| 274 |
|
| 275 |
.highlight-box p { |
| 276 |
font-size: 12px; |
| 277 |
line-height: 1.55; |
| 278 |
color: var(--text-secondary); |
| 279 |
} |
| 280 |
|
| 281 |
.highlight-box strong { color: var(--text); } |
| 282 |
|
| 283 |
|
| 284 |
|
| 285 |
.api-table { |
| 286 |
width: 100%; |
| 287 |
border-collapse: collapse; |
| 288 |
margin-bottom: 0.18in; |
| 289 |
font-size: 10.5px; |
| 290 |
} |
| 291 |
|
| 292 |
.api-table th { |
| 293 |
font-family: 'IBM Plex Mono', monospace; |
| 294 |
font-size: 9.5px; |
| 295 |
font-weight: 600; |
| 296 |
text-align: left; |
| 297 |
text-transform: uppercase; |
| 298 |
letter-spacing: 0.5px; |
| 299 |
padding: 6px 7px; |
| 300 |
background: var(--bg-deep); |
| 301 |
color: var(--text); |
| 302 |
border-bottom: 1px solid var(--border-dark); |
| 303 |
} |
| 304 |
|
| 305 |
.api-table td { |
| 306 |
padding: 4px 7px; |
| 307 |
border-bottom: 1px solid var(--border); |
| 308 |
color: var(--text-secondary); |
| 309 |
vertical-align: top; |
| 310 |
} |
| 311 |
|
| 312 |
.api-table tr:last-child td { border-bottom: none; } |
| 313 |
|
| 314 |
.api-table code { |
| 315 |
font-family: 'IBM Plex Mono', monospace; |
| 316 |
font-size: 10px; |
| 317 |
color: var(--text); |
| 318 |
background: var(--bg-deep); |
| 319 |
padding: 1px 4px; |
| 320 |
border-radius: 3px; |
| 321 |
} |
| 322 |
|
| 323 |
|
| 324 |
|
| 325 |
.comparison { |
| 326 |
width: 100%; |
| 327 |
border-collapse: collapse; |
| 328 |
margin-bottom: 0.18in; |
| 329 |
font-size: 11px; |
| 330 |
} |
| 331 |
|
| 332 |
.comparison th { |
| 333 |
font-family: 'IBM Plex Mono', monospace; |
| 334 |
font-size: 10px; |
| 335 |
font-weight: 600; |
| 336 |
text-align: left; |
| 337 |
padding: 6px 7px; |
| 338 |
background: var(--bg-deep); |
| 339 |
color: var(--text); |
| 340 |
border-bottom: 1px solid var(--border-dark); |
| 341 |
} |
| 342 |
|
| 343 |
.comparison td { |
| 344 |
padding: 5px 7px; |
| 345 |
border-bottom: 1px solid var(--border); |
| 346 |
color: var(--text-secondary); |
| 347 |
vertical-align: top; |
| 348 |
} |
| 349 |
|
| 350 |
.comparison .check { color: var(--green); font-weight: 700; font-size: 12px; } |
| 351 |
.comparison .dash { color: var(--text-muted); } |
| 352 |
.comparison tr:last-child td { border-bottom: none; } |
| 353 |
|
| 354 |
|
| 355 |
|
| 356 |
.crypto-flow { |
| 357 |
display: grid; |
| 358 |
grid-template-columns: repeat(3, 1fr); |
| 359 |
gap: 10px; |
| 360 |
margin-bottom: 0.15in; |
| 361 |
} |
| 362 |
|
| 363 |
.crypto-step { |
| 364 |
background: var(--bg-warm); |
| 365 |
border: 1px solid var(--border); |
| 366 |
border-radius: 6px; |
| 367 |
padding: 10px 12px; |
| 368 |
text-align: center; |
| 369 |
} |
| 370 |
|
| 371 |
.crypto-step .step-num { |
| 372 |
font-family: 'IBM Plex Mono', monospace; |
| 373 |
font-size: 9px; |
| 374 |
color: var(--accent); |
| 375 |
text-transform: uppercase; |
| 376 |
letter-spacing: 0.5px; |
| 377 |
display: block; |
| 378 |
margin-bottom: 3px; |
| 379 |
} |
| 380 |
|
| 381 |
.crypto-step .step-title { |
| 382 |
font-family: 'IBM Plex Mono', monospace; |
| 383 |
font-size: 11px; |
| 384 |
font-weight: 600; |
| 385 |
color: var(--text); |
| 386 |
display: block; |
| 387 |
margin-bottom: 4px; |
| 388 |
} |
| 389 |
|
| 390 |
.crypto-step .step-detail { |
| 391 |
font-size: 10px; |
| 392 |
color: var(--text-muted); |
| 393 |
line-height: 1.35; |
| 394 |
} |
| 395 |
|
| 396 |
|
| 397 |
|
| 398 |
.pricing { |
| 399 |
display: grid; |
| 400 |
grid-template-columns: repeat(4, 1fr); |
| 401 |
gap: 10px; |
| 402 |
margin-bottom: 0.18in; |
| 403 |
} |
| 404 |
|
| 405 |
.tier { |
| 406 |
background: var(--bg-warm); |
| 407 |
border: 1px solid var(--border); |
| 408 |
border-radius: 6px; |
| 409 |
padding: 12px 10px; |
| 410 |
text-align: center; |
| 411 |
} |
| 412 |
|
| 413 |
.tier.featured { |
| 414 |
border-color: var(--accent); |
| 415 |
border-width: 2px; |
| 416 |
} |
| 417 |
|
| 418 |
.tier .tier-name { |
| 419 |
font-family: 'IBM Plex Mono', monospace; |
| 420 |
font-size: 10px; |
| 421 |
font-weight: 600; |
| 422 |
color: var(--text); |
| 423 |
text-transform: uppercase; |
| 424 |
letter-spacing: 0.5px; |
| 425 |
display: block; |
| 426 |
margin-bottom: 4px; |
| 427 |
} |
| 428 |
|
| 429 |
.tier .tier-price { |
| 430 |
font-family: 'Young Serif', serif; |
| 431 |
font-size: 24px; |
| 432 |
color: var(--accent); |
| 433 |
display: block; |
| 434 |
line-height: 1; |
| 435 |
margin-bottom: 4px; |
| 436 |
} |
| 437 |
|
| 438 |
.tier .tier-period { |
| 439 |
font-size: 10px; |
| 440 |
color: var(--text-muted); |
| 441 |
display: block; |
| 442 |
margin-bottom: 6px; |
| 443 |
} |
| 444 |
|
| 445 |
.tier .tier-desc { |
| 446 |
font-size: 9.5px; |
| 447 |
color: var(--text-secondary); |
| 448 |
line-height: 1.35; |
| 449 |
} |
| 450 |
|
| 451 |
|
| 452 |
|
| 453 |
.two-col { |
| 454 |
display: grid; |
| 455 |
grid-template-columns: 1fr 1fr; |
| 456 |
gap: 12px; |
| 457 |
margin-bottom: 0.18in; |
| 458 |
} |
| 459 |
|
| 460 |
.two-col .col { |
| 461 |
background: var(--bg-warm); |
| 462 |
border: 1px solid var(--border); |
| 463 |
border-radius: 6px; |
| 464 |
padding: 11px 13px; |
| 465 |
} |
| 466 |
|
| 467 |
.two-col .col h3 { |
| 468 |
margin: 0 0 4px 0; |
| 469 |
font-size: 11.5px; |
| 470 |
color: var(--accent); |
| 471 |
} |
| 472 |
|
| 473 |
.two-col .col p { |
| 474 |
font-size: 11px; |
| 475 |
line-height: 1.45; |
| 476 |
color: var(--text-secondary); |
| 477 |
} |
| 478 |
|
| 479 |
|
| 480 |
|
| 481 |
.footer { |
| 482 |
position: absolute; |
| 483 |
bottom: 0.4in; |
| 484 |
left: 0.75in; |
| 485 |
right: 0.75in; |
| 486 |
display: flex; |
| 487 |
justify-content: space-between; |
| 488 |
align-items: center; |
| 489 |
padding-top: 8px; |
| 490 |
border-top: 1px solid var(--border); |
| 491 |
} |
| 492 |
|
| 493 |
.footer .left, .footer .right { |
| 494 |
font-family: 'IBM Plex Mono', monospace; |
| 495 |
font-size: 10px; |
| 496 |
color: var(--text-muted); |
| 497 |
} |
| 498 |
|
| 499 |
.footer-inline { |
| 500 |
display: flex; |
| 501 |
justify-content: space-between; |
| 502 |
align-items: center; |
| 503 |
padding-top: 8px; |
| 504 |
border-top: 1px solid var(--border); |
| 505 |
margin-top: auto; |
| 506 |
} |
| 507 |
|
| 508 |
.footer-inline .left, .footer-inline .right { |
| 509 |
font-family: 'IBM Plex Mono', monospace; |
| 510 |
font-size: 10px; |
| 511 |
color: var(--text-muted); |
| 512 |
} |
| 513 |
|
| 514 |
@media print { body { background: white; } .page { box-shadow: none; } } |
| 515 |
@media screen { body { background: #ccc; padding: 20px 0; } .page { box-shadow: 0 2px 20px rgba(0,0,0,0.15); margin-bottom: 20px; } } |
| 516 |
</style> |
| 517 |
</head> |
| 518 |
<body> |
| 519 |
|
| 520 |
|
| 521 |
|
| 522 |
|
| 523 |
<div class="page"> |
| 524 |
|
| 525 |
<div class="hero"> |
| 526 |
<h1>MNW SyncKit<span class="mark">.</span></h1> |
| 527 |
<div class="tagline">E2E encrypted cloud sync for indie apps. Ship it in an afternoon.</div> |
| 528 |
<div class="sub">A Rust SDK + hosted API. Cursor-based changelog sync, blob storage, device management. Zero-knowledge server.</div> |
| 529 |
</div> |
| 530 |
|
| 531 |
<div class="stats"> |
| 532 |
<div class="stat"> |
| 533 |
<span class="num">20+</span> |
| 534 |
<span class="label">SDK methods</span> |
| 535 |
</div> |
| 536 |
<div class="stat"> |
| 537 |
<span class="num">E2E</span> |
| 538 |
<span class="label">Encrypted by default</span> |
| 539 |
</div> |
| 540 |
<div class="stat"> |
| 541 |
<span class="num">3</span> |
| 542 |
<span class="label">Apps shipping today</span> |
| 543 |
</div> |
| 544 |
<div class="stat"> |
| 545 |
<span class="num">0</span> |
| 546 |
<span class="label">Plaintext on server</span> |
| 547 |
</div> |
| 548 |
</div> |
| 549 |
|
| 550 |
<div class="intro"> |
| 551 |
SyncKit is developer infrastructure for cloud sync. You bring your own schema — table names, row IDs, and data shapes are opaque to the server. Every payload is encrypted client-side with XChaCha20-Poly1305 before it leaves the device. The server stores ciphertext. No schema migrations on our end, no data access requests to worry about, no plaintext in your database logs. |
| 552 |
</div> |
| 553 |
|
| 554 |
<span class="code-label">Five lines to sync</span> |
| 555 |
<div class="code-block"><pre><span class="kw">let</span> client = <span class="ty">SyncKitClient</span>::<span class="fn">new</span>(<span class="ty">SyncKitConfig</span> { |
| 556 |
server_url: <span class="str">"https://makenot.work"</span>.into(), |
| 557 |
api_key: <span class="str">"your-api-key"</span>.into(), |
| 558 |
}); |
| 559 |
<span class="kw">let</span> (user_id, app_id) = client.<span class="fn">authenticate</span>(<span class="str">"user@example.com"</span>, <span class="str">"pass"</span>).<span class="kw">await</span>?; |
| 560 |
client.<span class="fn">setup_encryption_new</span>(<span class="str">"pass"</span>).<span class="kw">await</span>?; <span class="cmt">// generates + wraps master key</span> |
| 561 |
<span class="kw">let</span> device = client.<span class="fn">register_device</span>(<span class="str">"MacBook"</span>, <span class="str">"macos"</span>).<span class="kw">await</span>?; |
| 562 |
<span class="kw">let</span> cursor = client.<span class="fn">push</span>(device.id, changes).<span class="kw">await</span>?; <span class="cmt">// encrypted, retried, done</span></pre></div> |
| 563 |
|
| 564 |
<h2>What You Get</h2> |
| 565 |
|
| 566 |
<div class="features"> |
| 567 |
<div class="feature-card"> |
| 568 |
<h3>Changelog Sync</h3> |
| 569 |
<ul> |
| 570 |
<li>Push/pull with cursor-based pagination</li> |
| 571 |
<li>INSERT, UPDATE, DELETE operations</li> |
| 572 |
<li>Any JSON payload — server is schema-agnostic</li> |
| 573 |
<li>500 changes per push, 500 per pull page</li> |
| 574 |
<li>Monotonic sequence numbers, deterministic ordering</li> |
| 575 |
</ul> |
| 576 |
</div> |
| 577 |
<div class="feature-card"> |
| 578 |
<h3>E2E Encryption</h3> |
| 579 |
<ul> |
| 580 |
<li>XChaCha20-Poly1305 (256-bit, AEAD)</li> |
| 581 |
<li>Argon2id key derivation (OWASP parameters)</li> |
| 582 |
<li>Random 24-byte nonce per entry (no reuse risk)</li> |
| 583 |
<li>40 bytes overhead per encrypted payload</li> |
| 584 |
<li>Server never sees plaintext data or master key</li> |
| 585 |
</ul> |
| 586 |
</div> |
| 587 |
<div class="feature-card"> |
| 588 |
<h3>Blob Storage</h3> |
| 589 |
<ul> |
| 590 |
<li>Encrypted file upload/download via presigned S3 URLs</li> |
| 591 |
<li>Content-addressed deduplication (SHA-256 hash)</li> |
| 592 |
<li>3-step flow: request URL, upload, confirm</li> |
| 593 |
<li>Up to 500 MB per blob</li> |
| 594 |
<li>Binary encryption (no base64 overhead on wire)</li> |
| 595 |
</ul> |
| 596 |
</div> |
| 597 |
<div class="feature-card"> |
| 598 |
<h3>Device & Account Management</h3> |
| 599 |
<ul> |
| 600 |
<li>Register devices by name + platform (upsert semantics)</li> |
| 601 |
<li>Per-device cursor tracking on client side</li> |
| 602 |
<li>List and delete devices via API</li> |
| 603 |
<li>Change password with automatic key re-wrapping</li> |
| 604 |
<li>App registration for OAuth client credentials</li> |
| 605 |
</ul> |
| 606 |
</div> |
| 607 |
</div> |
| 608 |
|
| 609 |
<h2>Encryption Model</h2> |
| 610 |
|
| 611 |
<div class="crypto-flow"> |
| 612 |
<div class="crypto-step"> |
| 613 |
<span class="step-num">Step 1</span> |
| 614 |
<span class="step-title">Key Derivation</span> |
| 615 |
<div class="step-detail">User password + random 32-byte salt. Argon2id (64 MB, 3 iterations). Produces a 256-bit wrapping key.</div> |
| 616 |
</div> |
| 617 |
<div class="crypto-step"> |
| 618 |
<span class="step-num">Step 2</span> |
| 619 |
<span class="step-title">Key Wrapping</span> |
| 620 |
<div class="step-detail">Random 256-bit master key encrypted with the wrapping key. Envelope (salt + nonce + ciphertext) stored on server. New salt per wrap.</div> |
| 621 |
</div> |
| 622 |
<div class="crypto-step"> |
| 623 |
<span class="step-num">Step 3</span> |
| 624 |
<span class="step-title">Data Encryption</span> |
| 625 |
<div class="step-detail">Each changelog entry encrypted with master key. Fresh random nonce per entry. Cached in OS keychain after first unlock.</div> |
| 626 |
</div> |
| 627 |
</div> |
| 628 |
|
| 629 |
<div class="highlight-box"> |
| 630 |
<p><strong>Second device?</strong> Call <code>setup_encryption_existing(password)</code>. The SDK downloads the encrypted envelope from the server, derives the wrapping key from the password, unwraps the master key, and caches it in the OS keychain. One password prompt, then it's automatic.</p> |
| 631 |
</div> |
| 632 |
|
| 633 |
<div class="footer"> |
| 634 |
<div class="left">MNW SyncKit — v0.3.1</div> |
| 635 |
<div class="right">makenot.work</div> |
| 636 |
</div> |
| 637 |
|
| 638 |
</div> |
| 639 |
|
| 640 |
|
| 641 |
|
| 642 |
|
| 643 |
<div class="page"> |
| 644 |
|
| 645 |
<h2>Server API</h2> |
| 646 |
|
| 647 |
<table class="api-table"> |
| 648 |
<tr> |
| 649 |
<th style="width:12%">Method</th> |
| 650 |
<th style="width:35%">Endpoint</th> |
| 651 |
<th style="width:13%">Auth</th> |
| 652 |
<th style="width:40%">Description</th> |
| 653 |
</tr> |
| 654 |
<tr> |
| 655 |
<td><code>POST</code></td> |
| 656 |
<td><code>/api/sync/auth</code></td> |
| 657 |
<td>Public</td> |
| 658 |
<td>Authenticate with MNW credentials + API key. Returns JWT (7-day expiry).</td> |
| 659 |
</tr> |
| 660 |
<tr> |
| 661 |
<td><code>POST</code></td> |
| 662 |
<td><code>/api/sync/push</code></td> |
| 663 |
<td>JWT</td> |
| 664 |
<td>Push up to 500 encrypted changelog entries. Returns new cursor.</td> |
| 665 |
</tr> |
| 666 |
<tr> |
| 667 |
<td><code>POST</code></td> |
| 668 |
<td><code>/api/sync/pull</code></td> |
| 669 |
<td>JWT</td> |
| 670 |
<td>Pull changes since cursor. Returns entries + new cursor + has_more flag.</td> |
| 671 |
</tr> |
| 672 |
<tr> |
| 673 |
<td><code>GET</code></td> |
| 674 |
<td><code>/api/sync/status</code></td> |
| 675 |
<td>JWT</td> |
| 676 |
<td>Total change count and latest cursor for this user/app.</td> |
| 677 |
</tr> |
| 678 |
<tr> |
| 679 |
<td><code>POST</code></td> |
| 680 |
<td><code>/api/sync/devices</code></td> |
| 681 |
<td>JWT</td> |
| 682 |
<td>Register or update a device. Upsert by (app, user, name).</td> |
| 683 |
</tr> |
| 684 |
<tr> |
| 685 |
<td><code>GET</code></td> |
| 686 |
<td><code>/api/sync/devices</code></td> |
| 687 |
<td>JWT</td> |
| 688 |
<td>List all devices for the authenticated user.</td> |
| 689 |
</tr> |
| 690 |
<tr> |
| 691 |
<td><code>PUT</code></td> |
| 692 |
<td><code>/api/sync/keys</code></td> |
| 693 |
<td>JWT</td> |
| 694 |
<td>Store encrypted master key envelope (max 4 KB).</td> |
| 695 |
</tr> |
| 696 |
<tr> |
| 697 |
<td><code>GET</code></td> |
| 698 |
<td><code>/api/sync/keys</code></td> |
| 699 |
<td>JWT</td> |
| 700 |
<td>Retrieve encrypted key envelope + version number.</td> |
| 701 |
</tr> |
| 702 |
<tr> |
| 703 |
<td><code>POST</code></td> |
| 704 |
<td><code>/api/sync/blobs/upload</code></td> |
| 705 |
<td>JWT</td> |
| 706 |
<td>Request presigned S3 upload URL. Returns already_exists flag.</td> |
| 707 |
</tr> |
| 708 |
<tr> |
| 709 |
<td><code>POST</code></td> |
| 710 |
<td><code>/api/sync/blobs/confirm</code></td> |
| 711 |
<td>JWT</td> |
| 712 |
<td>Confirm blob upload. Idempotent.</td> |
| 713 |
</tr> |
| 714 |
<tr> |
| 715 |
<td><code>POST</code></td> |
| 716 |
<td><code>/api/sync/blobs/download</code></td> |
| 717 |
<td>JWT</td> |
| 718 |
<td>Request presigned S3 download URL by content hash.</td> |
| 719 |
</tr> |
| 720 |
</table> |
| 721 |
|
| 722 |
<h2>SDK Surface</h2> |
| 723 |
|
| 724 |
<div class="two-col"> |
| 725 |
<div class="col"> |
| 726 |
<h3>Core Types</h3> |
| 727 |
<p><code>SyncKitClient</code> — thread-safe (<code>Send + Sync</code>), share via <code>Arc</code>. All methods take <code>&self</code>. Internal <code>parking_lot::RwLock</code> for session and master key. Single <code>reqwest::Client</code> with connection pooling.</p> |
| 728 |
</div> |
| 729 |
<div class="col"> |
| 730 |
<h3>Change Model</h3> |
| 731 |
<p><code>ChangeEntry { table, op, row_id, timestamp, data }</code> — your app defines table names and row IDs. <code>ChangeOp</code>: Insert, Update, Delete. Data is <code>Option<serde_json::Value></code>, automatically encrypted before push.</p> |
| 732 |
</div> |
| 733 |
</div> |
| 734 |
|
| 735 |
<span class="code-label">Pull, process, push</span> |
| 736 |
<div class="code-block"><pre><span class="cmt">// Pull remote changes (auto-decrypted)</span> |
| 737 |
<span class="kw">let</span> (changes, new_cursor, has_more) = client.<span class="fn">pull</span>(device.id, cursor).<span class="kw">await</span>?; |
| 738 |
<span class="kw">for</span> change <span class="kw">in</span> &changes { |
| 739 |
<span class="kw">match</span> change.op { |
| 740 |
<span class="ty">ChangeOp</span>::Insert => db.<span class="fn">upsert</span>(&change.table, &change.row_id, &change.data), |
| 741 |
<span class="ty">ChangeOp</span>::Update => db.<span class="fn">upsert</span>(&change.table, &change.row_id, &change.data), |
| 742 |
<span class="ty">ChangeOp</span>::Delete => db.<span class="fn">delete</span>(&change.table, &change.row_id), |
| 743 |
} |
| 744 |
} |
| 745 |
|
| 746 |
<span class="cmt">// Push local changes (auto-encrypted)</span> |
| 747 |
<span class="kw">let</span> local_changes = db.<span class="fn">pending_changes_since</span>(last_push); |
| 748 |
<span class="kw">let</span> cursor = client.<span class="fn">push</span>(device.id, local_changes).<span class="kw">await</span>?;</pre></div> |
| 749 |
|
| 750 |
<h2>Resilience</h2> |
| 751 |
|
| 752 |
<div class="feature-section"> |
| 753 |
<ul> |
| 754 |
<li>Automatic retry with exponential backoff (1s, 2s, 4s) for transient failures</li> |
| 755 |
<li>Transient: network errors, 5xx, 429 rate limits</li> |
| 756 |
<li>Permanent: 4xx client errors, auth failures, crypto errors — returned immediately</li> |
| 757 |
<li>30-second JWT expiry buffer (proactive re-auth before token expires mid-request)</li> |
| 758 |
<li>HTTP connection pooling (5 idle per host, 90s pool timeout)</li> |
| 759 |
<li>OS keychain integration (macOS Keychain, Linux secret-service, Windows Credential Manager)</li> |
| 760 |
<li>Master key zeroized on drop (volatile memory writes)</li> |
| 761 |
</ul> |
| 762 |
</div> |
| 763 |
|
| 764 |
<h2>Blob Workflow</h2> |
| 765 |
|
| 766 |
<div class="code-block"><pre><span class="cmt">// Upload: hash locally, request URL, upload encrypted, confirm</span> |
| 767 |
<span class="kw">let</span> resp = client.<span class="fn">blob_upload_url</span>(&hash, size).<span class="kw">await</span>?; |
| 768 |
<span class="kw">if</span> !resp.already_exists { |
| 769 |
client.<span class="fn">blob_upload</span>(&resp.upload_url, data).<span class="kw">await</span>?; <span class="cmt">// encrypted on wire</span> |
| 770 |
} |
| 771 |
client.<span class="fn">blob_confirm</span>(&hash, size).<span class="kw">await</span>?; |
| 772 |
|
| 773 |
<span class="cmt">// Download: request URL, download + decrypt</span> |
| 774 |
<span class="kw">let</span> url = client.<span class="fn">blob_download_url</span>(&hash).<span class="kw">await</span>?; |
| 775 |
<span class="kw">let</span> plaintext = client.<span class="fn">blob_download</span>(&url).<span class="kw">await</span>?;</pre></div> |
| 776 |
|
| 777 |
<div class="highlight-box"> |
| 778 |
<p>Blobs are content-addressed by SHA-256 hash. If <code>already_exists</code> returns true, the file is already on the server — skip the upload. Binary encryption uses raw bytes (no base64 wrapping), so overhead is exactly 40 bytes per file regardless of size.</p> |
| 779 |
</div> |
| 780 |
|
| 781 |
<div class="footer"> |
| 782 |
<div class="left">MNW SyncKit — v0.3.1</div> |
| 783 |
<div class="right">makenot.work</div> |
| 784 |
</div> |
| 785 |
|
| 786 |
</div> |
| 787 |
|
| 788 |
|
| 789 |
|
| 790 |
|
| 791 |
<div class="page" style="display: flex; flex-direction: column;"> |
| 792 |
|
| 793 |
<h2>Integration Pattern</h2> |
| 794 |
|
| 795 |
<span class="code-label">App startup (session restore)</span> |
| 796 |
<div class="code-block"><pre><span class="cmt">// Try keychain first (no password prompt needed)</span> |
| 797 |
<span class="kw">if</span> client.<span class="fn">try_load_key_from_keychain</span>().<span class="fn">is_ok</span>() { |
| 798 |
<span class="cmt">// Master key cached from previous session — ready to sync</span> |
| 799 |
} <span class="kw">else if</span> client.<span class="fn">has_server_key</span>().<span class="kw">await</span>? { |
| 800 |
<span class="cmt">// Existing account on new device — ask for password once</span> |
| 801 |
client.<span class="fn">setup_encryption_existing</span>(&password).<span class="kw">await</span>?; |
| 802 |
} <span class="kw">else</span> { |
| 803 |
<span class="cmt">// First device ever — generate master key, wrap with password</span> |
| 804 |
client.<span class="fn">setup_encryption_new</span>(&password).<span class="kw">await</span>?; |
| 805 |
} |
| 806 |
|
| 807 |
<span class="cmt">// OAuth2 PKCE also supported (browser-based auth)</span> |
| 808 |
<span class="kw">let</span> url = client.<span class="fn">build_authorize_url</span>(port, state, challenge); |
| 809 |
<span class="cmt">// ... redirect user, receive code ...</span> |
| 810 |
<span class="kw">let</span> (user_id, app_id) = client.<span class="fn">authenticate_with_code</span>(&code, &verifier, port).<span class="kw">await</span>?;</pre></div> |
| 811 |
|
| 812 |
<h2>What Ships Today</h2> |
| 813 |
|
| 814 |
<div class="features"> |
| 815 |
<div class="feature-card"> |
| 816 |
<h3>Three Apps Shipping</h3> |
| 817 |
<ul> |
| 818 |
<li>GoingsOn: tasks, projects, events, contacts, email accounts</li> |
| 819 |
<li>Balanced Breakfast: feeds, read/star state, preferences</li> |
| 820 |
<li>audiofiles: sample metadata, VFS trees, tags, collections</li> |
| 821 |
</ul> |
| 822 |
</div> |
| 823 |
<div class="feature-card"> |
| 824 |
<h3>Developer Experience</h3> |
| 825 |
<ul> |
| 826 |
<li>Typical integration: under a day from zero to syncing</li> |
| 827 |
<li>Schema-agnostic — no server-side migrations needed</li> |
| 828 |
<li>OAuth2 PKCE for browser-based auth (desktop + mobile)</li> |
| 829 |
<li>Password auth also supported for headless / CLI apps</li> |
| 830 |
</ul> |
| 831 |
</div> |
| 832 |
</div> |
| 833 |
|
| 834 |
<h2>How It Compares</h2> |
| 835 |
|
| 836 |
<table class="comparison"> |
| 837 |
<tr> |
| 838 |
<th style="width:24%">Capability</th> |
| 839 |
<th style="width:15.2%">SyncKit</th> |
| 840 |
<th style="width:15.2%">Firebase</th> |
| 841 |
<th style="width:15.2%">Supabase</th> |
| 842 |
<th style="width:15.2%">iCloud</th> |
| 843 |
<th style="width:15.2%">Custom</th> |
| 844 |
</tr> |
| 845 |
<tr> |
| 846 |
<td>E2E encrypted</td> |
| 847 |
<td><span class="check">Yes</span></td> |
| 848 |
<td><span class="dash">—</span></td> |
| 849 |
<td><span class="dash">—</span></td> |
| 850 |
<td>Partial</td> |
| 851 |
<td>You build it</td> |
| 852 |
</tr> |
| 853 |
<tr> |
| 854 |
<td>Zero-knowledge server</td> |
| 855 |
<td><span class="check">Yes</span></td> |
| 856 |
<td><span class="dash">—</span></td> |
| 857 |
<td><span class="dash">—</span></td> |
| 858 |
<td><span class="dash">—</span></td> |
| 859 |
<td>You build it</td> |
| 860 |
</tr> |
| 861 |
<tr> |
| 862 |
<td>Schema-agnostic</td> |
| 863 |
<td><span class="check">Yes</span></td> |
| 864 |
<td>doc-store schema (Firestore enforces document model)</td> |
| 865 |
<td>Postgres</td> |
| 866 |
<td>CloudKit</td> |
| 867 |
<td>You build it</td> |
| 868 |
</tr> |
| 869 |
<tr> |
| 870 |
<td>Blob storage</td> |
| 871 |
<td><span class="check">S3</span></td> |
| 872 |
<td><span class="check">Yes</span></td> |
| 873 |
<td><span class="check">Yes</span></td> |
| 874 |
<td><span class="check">Yes</span></td> |
| 875 |
<td>You build it</td> |
| 876 |
</tr> |
| 877 |
<tr> |
| 878 |
<td>Rust SDK</td> |
| 879 |
<td><span class="check">Yes</span></td> |
| 880 |
<td><span class="dash">—</span></td> |
| 881 |
<td><span class="dash">—</span></td> |
| 882 |
<td><span class="dash">—</span></td> |
| 883 |
<td>You build it</td> |
| 884 |
</tr> |
| 885 |
<tr> |
| 886 |
<td>Cross-platform</td> |
| 887 |
<td><span class="check">Yes</span></td> |
| 888 |
<td>Mobile focus</td> |
| 889 |
<td><span class="check">Yes</span></td> |
| 890 |
<td>Apple only</td> |
| 891 |
<td>You build it</td> |
| 892 |
</tr> |
| 893 |
<tr> |
| 894 |
<td>No vendor lock-in</td> |
| 895 |
<td><span class="check">Yes</span></td> |
| 896 |
<td><span class="dash">—</span></td> |
| 897 |
<td><span class="check">Yes</span></td> |
| 898 |
<td><span class="dash">—</span></td> |
| 899 |
<td><span class="check">Yes</span></td> |
| 900 |
</tr> |
| 901 |
<tr> |
| 902 |
<td>Source-available</td> |
| 903 |
<td><span class="check">Yes</span></td> |
| 904 |
<td><span class="dash">—</span></td> |
| 905 |
<td><span class="check">Yes</span></td> |
| 906 |
<td><span class="dash">—</span></td> |
| 907 |
<td>Yours</td> |
| 908 |
</tr> |
| 909 |
<tr> |
| 910 |
<td>No per-user pricing</td> |
| 911 |
<td><span class="check">Yes</span></td> |
| 912 |
<td>Pay per use</td> |
| 913 |
<td>Pay per use</td> |
| 914 |
<td>Free (Apple)</td> |
| 915 |
<td>Your infra</td> |
| 916 |
</tr> |
| 917 |
<tr> |
| 918 |
<td>Time to integrate</td> |
| 919 |
<td>Hours</td> |
| 920 |
<td>Hours</td> |
| 921 |
<td>Hours</td> |
| 922 |
<td>Days</td> |
| 923 |
<td>Weeks</td> |
| 924 |
</tr> |
| 925 |
</table> |
| 926 |
|
| 927 |
<h2>Pricing</h2> |
| 928 |
|
| 929 |
<p style="margin-bottom: 16px;">SyncKit is a B2B service: customers are app developers, not end users. Pricing is usage-based on storage and transfer — no per-user fees, no per-seat licenses. Your end users authenticate with their own MNW accounts at no cost to you.</p> |
| 930 |
|
| 931 |
<p style="margin-bottom: 20px;"><strong>Monthly cost = (GB stored × $0.15) + (burst multiplier × GB stored × $0.03)</strong></p> |
| 932 |
|
| 933 |
<p style="margin-bottom: 16px;"><em>Burst</em> is the transfer budget relative to storage. <strong>Simple mode</strong> uses a default burst of 5×; <strong>Builder mode</strong> lets you choose any multiplier. Same billing engine for both.</p> |
| 934 |
|
| 935 |
<div class="pricing"> |
| 936 |
<div class="tier"> |
| 937 |
<span class="tier-name">Config / state sync</span> |
| 938 |
<span class="tier-price">$0.30</span> |
| 939 |
<span class="tier-period">/ month</span> |
| 940 |
<div class="tier-desc">Example: 1 GB stored, 5× burst. Settings, read state, light metadata.</div> |
| 941 |
</div> |
| 942 |
<div class="tier featured"> |
| 943 |
<span class="tier-name">Productivity app</span> |
| 944 |
<span class="tier-price">$3.00</span> |
| 945 |
<span class="tier-period">/ month</span> |
| 946 |
<div class="tier-desc">Example: 10 GB stored, 5× burst. Tasks, contacts, light files.</div> |
| 947 |
</div> |
| 948 |
<div class="tier"> |
| 949 |
<span class="tier-name">Media metadata</span> |
| 950 |
<span class="tier-price">$15.00</span> |
| 951 |
<span class="tier-period">/ month</span> |
| 952 |
<div class="tier-desc">Example: 50 GB stored, 5× burst. Sample libraries, playlists.</div> |
| 953 |
</div> |
| 954 |
<div class="tier"> |
| 955 |
<span class="tier-name">Large file sync</span> |
| 956 |
<span class="tier-price">$60.00</span> |
| 957 |
<span class="tier-period">/ month</span> |
| 958 |
<div class="tier-desc">Example: 200 GB stored, 5× burst. Audio, video, courses.</div> |
| 959 |
</div> |
| 960 |
</div> |
| 961 |
|
| 962 |
<div class="highlight-box"> |
| 963 |
<p><strong>No per-user fees. No per-request fees. No egress charges.</strong> Your end users authenticate with their own MNW accounts. Pricing scales with what you actually store and transfer — not with how many users or devices sync through your app. No overages: the bill is known before the billing period starts; at limits, sync degrades but the bill does not change. Source-available under PolyForm Noncommercial 1.0.0.</p> |
| 964 |
</div> |
| 965 |
|
| 966 |
<div class="footer-inline" style="margin-top: 12px;"> |
| 967 |
<div class="left">MNW SyncKit — v0.3.1 — Rust SDK + hosted API</div> |
| 968 |
<div class="right">makenot.work</div> |
| 969 |
</div> |
| 970 |
|
| 971 |
</div> |
| 972 |
|
| 973 |
</body> |
| 974 |
</html> |
| 975 |
|