Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place

privacy policy

+446 -11
+14 -6
apps/main-app/public/editor/editor.tsx
··· 551 551 </a> 552 552 </span> 553 553 </div> 554 - <a 555 - href="/acceptable-use" 556 - className="text-accent hover:text-accent/80 transition-colors" 557 - > 558 - Acceptable Use Policy 559 - </a> 554 + <div className="flex items-center gap-4"> 555 + <a 556 + href="/acceptable-use" 557 + className="text-accent hover:text-accent/80 transition-colors" 558 + > 559 + Acceptable Use Policy 560 + </a> 561 + <a 562 + href="/privacy" 563 + className="text-accent hover:text-accent/80 transition-colors" 564 + > 565 + Privacy Policy 566 + </a> 567 + </div> 560 568 </div> 561 569 </div> 562 570 </footer>
+1
apps/main-app/public/landingpage.html
··· 674 674 <p>Built by @nekomimi.pet</p> 675 675 <nav> 676 676 <a href="/acceptable-use">Acceptable Use</a> 677 + <a href="/privacy">Privacy Policy</a> 677 678 <a href="https://bsky.app/profile/wisp.place" target="_blank">Bluesky</a> 678 679 </nav> 679 680 </div>
+415
apps/main-app/public/privacy.html
··· 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>Privacy Policy - wisp.place</title> 7 + <link rel="icon" type="image/x-icon" href="./favicon.ico"> 8 + <link rel="preconnect" href="https://fonts.googleapis.com"> 9 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 10 + <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet"> 11 + <link rel="stylesheet" href="/wisp.css"> 12 + <style> 13 + .hero { 14 + background: linear-gradient(to bottom, var(--code-bg), var(--bg)); 15 + border-bottom: 1px solid var(--border-light); 16 + padding: 4rem 1rem; 17 + text-align: center; 18 + } 19 + 20 + .hero-content { max-width: 900px; margin: 0 auto; } 21 + 22 + .hero-icon { 23 + display: inline-flex; 24 + align-items: center; 25 + justify-content: center; 26 + width: 4rem; 27 + height: 4rem; 28 + border-radius: 50%; 29 + background: var(--code-bg); 30 + margin-bottom: 1.5rem; 31 + } 32 + 33 + .hero h1 { 34 + font-size: 2.5rem; 35 + } 36 + 37 + .meta { 38 + display: flex; 39 + align-items: center; 40 + justify-content: center; 41 + gap: 1.5rem; 42 + font-size: 0.875rem; 43 + color: var(--text-muted); 44 + } 45 + 46 + .meta-divider { 47 + width: 1px; 48 + height: 1rem; 49 + background: var(--border-light); 50 + } 51 + 52 + article { margin-bottom: 3rem; } 53 + section { margin-bottom: 3rem; } 54 + .card { margin-bottom: 2rem; } 55 + 56 + h2 { 57 + display: flex; 58 + align-items: center; 59 + gap: 0.75rem; 60 + } 61 + 62 + li { 63 + display: flex; 64 + align-items: start; 65 + gap: 0.75rem; 66 + margin-bottom: 0.75rem; 67 + color: var(--text-muted); 68 + } 69 + 70 + .alert p { margin: 0; } 71 + 72 + table { 73 + width: 100%; 74 + border-collapse: collapse; 75 + font-size: 0.9rem; 76 + } 77 + 78 + th { 79 + text-align: left; 80 + padding: 0.6rem 0.75rem; 81 + border-bottom: 1px solid var(--border-light); 82 + color: var(--text-muted); 83 + font-weight: 600; 84 + } 85 + 86 + td { 87 + padding: 0.6rem 0.75rem; 88 + border-bottom: 1px solid var(--border-light); 89 + color: var(--text-muted); 90 + vertical-align: top; 91 + } 92 + 93 + tr:last-child td { border-bottom: none; } 94 + 95 + @media (max-width: 768px) { 96 + .hero h1 { font-size: 2rem; } 97 + .meta { flex-direction: column; gap: 0.5rem; } 98 + .meta-divider { display: none; } 99 + } 100 + </style> 101 + </head> 102 + <body> 103 + <header> 104 + <div class="header-inner"> 105 + <div class="logo"> 106 + <img src="/transparent-full-size-ico.png" alt="wisp.place"> 107 + <span>wisp.place</span> 108 + </div> 109 + <a href="/editor" class="back-link">← Back to Dashboard</a> 110 + </div> 111 + </header> 112 + 113 + <div class="hero"> 114 + <div class="hero-content"> 115 + <div class="hero-icon">🔒</div> 116 + <h1>Privacy Policy</h1> 117 + <div class="meta"> 118 + <div><strong>Effective:</strong> February 28, 2026</div> 119 + <div class="meta-divider"></div> 120 + <div><strong>Last Updated:</strong> February 28, 2026</div> 121 + </div> 122 + </div> 123 + </div> 124 + 125 + <div class="container"> 126 + <article> 127 + <section> 128 + <h2>Our Principles</h2> 129 + <p> 130 + Your privacy is critically important to me. A few fundamental principles: 131 + </p> 132 + <ul> 133 + <li><span class="bullet accent">•</span><span>I collect your personal information only when I need it to provide the service.</span></li> 134 + <li><span class="bullet accent">•</span><span>I don't share your personal information except to comply with the law or protect my rights.</span></li> 135 + <li><span class="bullet accent">•</span><span>I don't store personal information unless required for the ongoing operation of the service.</span></li> 136 + <li><span class="bullet accent">•</span><span>I make every effort to protect your privacy using secure technology and self-controlled infrastructure wherever possible.</span></li> 137 + </ul> 138 + </section> 139 + 140 + <section> 141 + <h2>How I Collect Information</h2> 142 + <p> 143 + wisp.place uses the AT Protocol for authentication. When you log in, you authenticate directly with your Personal Data Server (PDS) — I never see your password. What I receive and store is your <strong>Decentralized Identifier (DID)</strong>, a public pseudonymous identifier that is the foundation of your AT Protocol identity. I do not collect email addresses or phone numbers. 144 + </p> 145 + <p> 146 + During the login process, your <strong>AT Protocol handle</strong> (e.g. <code>you.bsky.social</code>) may appear in application logs. It is not stored in the database. 147 + </p> 148 + <p> 149 + When you publish a site, I store metadata about it: the site name, display name, your wisp.place subdomain, any custom domains you configure, content hashes, timestamps, and your site settings. The actual files you upload live on your PDS as blobs — I hold a derived cache to serve them to visitors. 150 + </p> 151 + <p> 152 + I retain server logs and collect non-personally-identifying operational data — request paths, HTTP methods, status codes, and response durations — to monitor service health and diagnose issues. <strong>I do not log IP addresses, User-Agent strings, or Referer headers.</strong> 153 + </p> 154 + <p> 155 + Request counts are aggregated into periodic hit totals per site. These aggregate statistics contain no personally identifying information — no IP addresses, no geographic data, no visitor identifiers of any kind — and are made available to site owners who support the project. 156 + </p> 157 + <p> 158 + I use a signed session cookie to keep you logged into your account. Session records are stored in the database and expire after 30 days. 159 + </p> 160 + </section> 161 + 162 + <section> 163 + <h2>How I Use Information</h2> 164 + <ul> 165 + <li><span class="bullet accent">•</span><span>Authenticate you and associate you with your sites and domains</span></li> 166 + <li><span class="bullet accent">•</span><span>Serve your published sites to visitors</span></li> 167 + <li><span class="bullet accent">•</span><span>Resolve your custom domains and subdomains correctly</span></li> 168 + <li><span class="bullet accent">•</span><span>Monitor service health, diagnose errors, and measure performance</span></li> 169 + <li><span class="bullet accent">•</span><span>Enforce usage limits and prevent abuse</span></li> 170 + <li><span class="bullet accent">•</span><span>Provide site owners who support the project with aggregate hit statistics for their own sites</span></li> 171 + </ul> 172 + </section> 173 + 174 + <section> 175 + <h2>Infrastructure and Third Parties</h2> 176 + <p> 177 + wisp.place runs on infrastructure I own and operate directly. There are no advertising networks, analytics platforms, or data brokers involved. 178 + </p> 179 + 180 + <div class="card thick"> 181 + <table> 182 + <tr> 183 + <th>Service</th> 184 + <th>Provider</th> 185 + <th>Location</th> 186 + <th>Purpose</th> 187 + </tr> 188 + <tr> 189 + <td>Web servers &amp; application</td> 190 + <td>Netcup</td> 191 + <td>Germany</td> 192 + <td>Serving sites to visitors, main application</td> 193 + </tr> 194 + <tr> 195 + <td>Web servers &amp; application</td> 196 + <td>UpCloud</td> 197 + <td>California, Singapore</td> 198 + <td>Serving sites to visitors, main application</td> 199 + </tr> 200 + <tr> 201 + <td>Web servers, application &amp; monitoring</td> 202 + <td>Oracle Cloud</td> 203 + <td>Virginia, US</td> 204 + <td>Serving sites to visitors, main application, VictoriaLogs, VictoriaMetrics</td> 205 + </tr> 206 + <tr> 207 + <td>Object storage</td> 208 + <td>Hetzner</td> 209 + <td>Finland</td> 210 + <td>Cold-tier site file cache</td> 211 + </tr> 212 + <tr> 213 + <td>DNS</td> 214 + <td>Bunny</td> 215 + <td>Global CDN</td> 216 + <td>Domain resolution</td> 217 + </tr> 218 + </table> 219 + </div> 220 + 221 + <p> 222 + Application logs and request metrics are collected by VictoriaLogs and VictoriaMetrics running on an Oracle Cloud VPS I operate. This data does not leave my infrastructure. 223 + </p> 224 + <p> 225 + DNS queries for wisp.place domains pass through Bunny's network. See <a href="https://bunny.net/privacy" target="_blank">Bunny's privacy policy</a> for details. 226 + </p> 227 + <p> 228 + Your site content is fetched from your AT Protocol PDS. I do not control the data practices of your PDS provider. 229 + </p> 230 + </section> 231 + 232 + <section> 233 + <h2>How I Protect Your Information</h2> 234 + <p> 235 + Session cookies are <code>httpOnly</code>, <code>Secure</code>, and use <code>SameSite=Lax</code>. OAuth authorization states expire after one hour. Signing keys are rotated every six months. 236 + </p> 237 + <p> 238 + No method of transmission or storage is 100% secure. While I strive to protect your information, I cannot guarantee its absolute security. 239 + </p> 240 + </section> 241 + 242 + <section> 243 + <h2>Data Retention</h2> 244 + <p> 245 + Session records expire automatically after 30 days. Site and domain records persist until you delete your site. When you delete your <code>place.wisp.fs</code> record from your PDS, the hosting service receives that event via the AT Protocol firehose and automatically removes the corresponding cache and database entries. For complete removal of your data from my systems, contact <a href="mailto:privacy@wisp.place">privacy@wisp.place</a> in addition to deleting your PDS records. 246 + </p> 247 + <p> 248 + In-memory log buffers, error traces, and metrics are rolling windows and are not persisted beyond the monitoring stack. 249 + </p> 250 + </section> 251 + 252 + <div class="card info"> 253 + <h2 style="margin-top: 0;">The AT Protocol and Public Data</h2> 254 + <p style="margin-bottom: 0;"> 255 + wisp.place is built on the AT Protocol. Your sites, their files, and the records describing them are public on your PDS. Anyone operating a compatible service can read your published site records and blobs — this is inherent to how the protocol works and is not specific to wisp.place. 256 + </p> 257 + </div> 258 + 259 + <section> 260 + <h2>The CLI Tool</h2> 261 + <p> 262 + The wisp.place CLI operates entirely on your local machine. It communicates directly with your PDS and sends no usage data or telemetry to me. OAuth sessions are stored locally in a directory of your choice. 263 + </p> 264 + </section> 265 + 266 + <section> 267 + <h2>Your Data Rights</h2> 268 + <p> 269 + You may request a copy of all personal data I store about you, ask me to correct any inaccurate data, or request deletion of all your personal data from my systems. Email <a href="mailto:privacy@wisp.place">privacy@wisp.place</a> and I'll get back to you within 48 hours. 270 + </p> 271 + </section> 272 + 273 + <section> 274 + <h2>European General Data Protection Regulation (GDPR)</h2> 275 + <p> 276 + If you are located in the EEA, UK, or Switzerland, the following applies. The data controller for wisp.place is reachable at <a href="mailto:privacy@wisp.place">privacy@wisp.place</a>. 277 + </p> 278 + 279 + <div class="card thick"> 280 + <table> 281 + <tr> 282 + <th>Processing activity</th> 283 + <th>Legal basis</th> 284 + </tr> 285 + <tr> 286 + <td>Authenticating you and maintaining your session</td> 287 + <td>Performance of a contract (Art. 6(1)(b))</td> 288 + </tr> 289 + <tr> 290 + <td>Storing your DID, site records, and domain assignments</td> 291 + <td>Performance of a contract (Art. 6(1)(b))</td> 292 + </tr> 293 + <tr> 294 + <td>Application logs, error traces, and request metrics</td> 295 + <td>Legitimate interests (Art. 6(1)(f)) — operating and securing the service</td> 296 + </tr> 297 + <tr> 298 + <td>VictoriaLogs / VictoriaMetrics on self-operated infrastructure</td> 299 + <td>Legitimate interests (Art. 6(1)(f)) — service monitoring on infrastructure I control</td> 300 + </tr> 301 + </table> 302 + </div> 303 + 304 + <p> 305 + I do not process special category data and do not use your data for automated decision-making or profiling. 306 + </p> 307 + 308 + <p>You have the right to:</p> 309 + <ul> 310 + <li><span class="bullet accent">•</span><span><strong>Access</strong> — request a copy of the personal data I hold about you</span></li> 311 + <li><span class="bullet accent">•</span><span><strong>Rectification</strong> — ask me to correct inaccurate data</span></li> 312 + <li><span class="bullet accent">•</span><span><strong>Erasure</strong> — ask me to delete your data (see retention note above)</span></li> 313 + <li><span class="bullet accent">•</span><span><strong>Restriction</strong> — ask me to limit how I process your data</span></li> 314 + <li><span class="bullet accent">•</span><span><strong>Portability</strong> — receive your data in a structured, machine-readable format</span></li> 315 + <li><span class="bullet accent">•</span><span><strong>Object</strong> — object to processing based on legitimate interests</span></li> 316 + <li><span class="bullet accent">•</span><span><strong>Lodge a complaint</strong> — with your national data protection supervisory authority</span></li> 317 + </ul> 318 + 319 + <div class="alert"> 320 + <p> 321 + <strong>International transfers:</strong> Your data may be processed on servers outside the EEA, including the United States and Singapore. Where this occurs, I rely on Standard Contractual Clauses to ensure an equivalent level of protection. 322 + </p> 323 + </div> 324 + 325 + <p> 326 + To exercise your rights, email <a href="mailto:privacy@wisp.place">privacy@wisp.place</a>. I will respond within 30 days. 327 + </p> 328 + </section> 329 + 330 + <section> 331 + <h2>California Consumer Privacy Act (CCPA / CPRA)</h2> 332 + <p>In the last 12 months, I collected the following categories of personal information from California residents:</p> 333 + 334 + <div class="card thick"> 335 + <table> 336 + <tr> 337 + <th>Category</th> 338 + <th>Examples</th> 339 + <th>Collected</th> 340 + </tr> 341 + <tr> 342 + <td>Identifiers</td> 343 + <td>AT Protocol DID, wisp.place subdomain, custom domain names</td> 344 + <td>Yes</td> 345 + </tr> 346 + <tr> 347 + <td>Internet or network activity</td> 348 + <td>Request paths, HTTP methods, status codes, response durations</td> 349 + <td>Yes</td> 350 + </tr> 351 + <tr> 352 + <td>Sensitive personal information</td> 353 + <td>—</td> 354 + <td>No</td> 355 + </tr> 356 + </table> 357 + </div> 358 + 359 + <p> 360 + <strong>I do not sell or share your personal information</strong> for cross-context behavioral advertising. 361 + </p> 362 + 363 + <p>California residents have the right to:</p> 364 + <ul> 365 + <li><span class="bullet accent">•</span><span>Know what personal information I collect, its sources, the purposes for collection, and any third parties with whom I share it</span></li> 366 + <li><span class="bullet accent">•</span><span>Request deletion of personal information I have collected</span></li> 367 + <li><span class="bullet accent">•</span><span>Request correction of inaccurate personal information</span></li> 368 + <li><span class="bullet accent">•</span><span>Not receive discriminatory treatment for exercising these rights</span></li> 369 + </ul> 370 + 371 + <p> 372 + To make a request, email <a href="mailto:privacy@wisp.place">privacy@wisp.place</a>. I will verify your identity before disclosing or deleting anything, and will respond within 45 days. You may designate an authorized agent by providing written authorization. 373 + </p> 374 + </section> 375 + 376 + <section> 377 + <h2>COPPA</h2> 378 + <p> 379 + wisp.place is directed to people who are at least 13 years old. If you are under 13, do not use this site. 380 + </p> 381 + </section> 382 + 383 + <section> 384 + <h2>Changes to This Privacy Policy</h2> 385 + <p> 386 + I may update this policy from time to time. Significant changes will be noted in the changelog below. 387 + </p> 388 + </section> 389 + 390 + <section> 391 + <h2>Contact</h2> 392 + <p> 393 + Questions or concerns? Email <a href="mailto:privacy@wisp.place">privacy@wisp.place</a>. 394 + </p> 395 + </section> 396 + 397 + <div class="card thick"> 398 + <p style="font-weight: 600; margin-bottom: 0.5rem;">Changelog</p> 399 + <p style="color: var(--text-muted); margin: 0;">February 28, 2026 — Initial version published</p> 400 + </div> 401 + </article> 402 + </div> 403 + 404 + <footer class="simple"> 405 + <p> 406 + Built by <a href="https://bsky.app/profile/nekomimi.pet" target="_blank">@nekomimi.pet</a> • 407 + Contact: <a href="mailto:contact@wisp.place">contact@wisp.place</a> • 408 + Legal/DMCA: <a href="mailto:legal@wisp.place">legal@wisp.place</a> 409 + </p> 410 + <p> 411 + <a href="/editor">Back to Dashboard</a> 412 + </p> 413 + </footer> 414 + </body> 415 + </html>
+4
apps/main-app/src/index.ts
··· 295 295 set.headers['Content-Type'] = 'text/html; charset=utf-8' 296 296 return await Bun.file('./apps/main-app/public/editor/acceptable-use.html').text() 297 297 }) 298 + .get('/privacy', async ({ set }) => { 299 + set.headers['Content-Type'] = 'text/html; charset=utf-8' 300 + return await Bun.file('./apps/main-app/public/privacy.html').text() 301 + }) 298 302 .get('/onboarding', async ({ set }) => { 299 303 set.headers['Content-Type'] = 'text/html; charset=utf-8' 300 304 return await Bun.file('./apps/main-app/public/editor/onboarding.html').text()
+10 -5
cli/commands/deploy.ts
··· 37 37 spa?: boolean; 38 38 yes?: boolean; 39 39 concurrency?: number; 40 + forceGzip?: boolean; 40 41 } 41 42 42 43 interface FileInfo { ··· 168 169 files: FileInfo[], 169 170 existingBlobMap: Map<string, { blobRef: BlobRef; cid: string }>, 170 171 concurrency: number, 171 - useBase64 = false 172 + useBase64 = false, 173 + forceGzip = false 172 174 ): Promise<{ uploadedFiles: UploadedFile[]; uploadResults: FileUploadResult[]; filePaths: string[] }> { 173 175 const spinner = createSpinner(`Processing ${files.length} files...`).start(); 174 176 ··· 195 197 await Promise.all(batch.map(async (file) => { 196 198 const content = readFileSync(file.path); 197 199 const mimeType = lookup(file.relativePath) || 'application/octet-stream'; 198 - const shouldCompress = shouldCompressFile(mimeType, file.relativePath); 200 + const shouldCompress = forceGzip || shouldCompressFile(mimeType, file.relativePath); 199 201 200 202 let processedContent: Buffer; 201 203 let encoding: 'gzip' | undefined; ··· 203 205 204 206 if (shouldCompress) { 205 207 const compressed = compressFile(content); 206 - if (useBase64 && isTextMimeType(mimeType)) { 208 + if (!forceGzip && useBase64 && isTextMimeType(mimeType)) { 207 209 // Fallback: base64-encode compressed text files for PDSes that reject them otherwise 208 210 processedContent = Buffer.from(compressed.toString('base64'), 'binary'); 209 211 base64Encoded = true; ··· 515 517 516 518 // 4. Process and upload files 517 519 const concurrency = options.concurrency ?? DEFAULT_CONCURRENT_UPLOADS; 520 + const forceGzip = options.forceGzip ?? false; 518 521 let { uploadedFiles, uploadResults, filePaths } = await processAndUploadFiles( 519 522 agent, 520 523 files, 521 524 existingBlobMap, 522 - concurrency 525 + concurrency, 526 + false, 527 + forceGzip 523 528 ); 524 529 525 530 // 5. Build directory structure ··· 542 547 const mimeType = lookup(f.relativePath) || 'application/octet-stream'; 543 548 return shouldCompressFile(mimeType, f.relativePath) && isTextMimeType(mimeType); 544 549 }); 545 - const retryResult = await processAndUploadFiles(agent, textFiles, existingBlobMap, concurrency, true); 550 + const retryResult = await processAndUploadFiles(agent, textFiles, existingBlobMap, concurrency, true, forceGzip); 546 551 547 552 // Merge updated results back: replace matching paths 548 553 const retryMap = new Map(retryResult.filePaths.map((p, i) => [p, retryResult.uploadResults[i]!]));
+2
cli/index.ts
··· 159 159 .option('--directory', 'Enable directory listing') 160 160 .option('--spa', 'Enable SPA mode (serve index.html for all routes)') 161 161 .option('-c, --concurrency <n>', 'Number of concurrent uploads (backs off to 2 on rate limit)', '3') 162 + .option('--force-gzip', 'Force gzip compression for all files regardless of type') 162 163 .option('--password <password>', 'App password for headless authentication') 163 164 .option('--store <path>', 'OAuth session store path') 164 165 .option('-y, --yes', 'Skip confirmation prompts') ··· 223 224 spa: options.spa, 224 225 yes: options.yes, 225 226 concurrency: parseInt(options.concurrency, 10), 227 + forceGzip: options.forceGzip, 226 228 }); 227 229 228 230 const handleUrl = `https://sites.wisp.place/${resolvedHandle}/${resolvedSite}`;