polls on atproto pollz.waow.tech
atproto zig
0
fork

Configure Feed

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

rewrite frontend in sveltekit, add oauth + jetstream to backend

backend:
- replace tap (node firehose consumer) with native jetstream client
- add full oauth flow (DPoP, PKCE, session management)
- add poll deletion endpoint (DELETE /api/polls/{uri})
- add DELETE to CORS allowed methods
- add voter handle resolution + profile caching

frontend:
- rewrite from vanilla vite/ts to sveltekit with static adapter
- fix vote switch detection (per-option count comparison, not totals)
- add inline vote feedback (voting.../voted/failed)
- add poll deletion (owner only, with confirmation)
- add VotersTooltip cache invalidation after voting
- add mobile tap support for voter tooltips
- link handles to bsky.app profiles
- add timestamp hover (full localized datetime)
- 44px min touch targets on option buttons
- add favicon (◉)
- defensive JSON parsing in api helper

cleanup:
- remove old vanilla frontend (src/, dist/, functions/, public/)
- remove tap/ (replaced by jetstream.zig)
- remove docs/ (outdated architecture docs)
- move lexicons to tech.waow.pollz namespace
- rewrite readme

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+4507 -2498
+1
.gitignore
··· 2 2 dist 3 3 .vite 4 4 .wrangler 5 + .svelte-kit 5 6 .zig-cache 6 7 zig-out 7 8 *.db
+24 -5
backend/build.zig
··· 4 4 const target = b.standardTargetOptions(.{}); 5 5 const optimize = b.standardOptimizeOption(.{}); 6 6 7 - const websocket = b.dependency("websocket", .{ 7 + const zat = b.dependency("zat", .{ 8 8 .target = target, 9 9 .optimize = optimize, 10 10 }); ··· 14 14 .optimize = optimize, 15 15 }); 16 16 17 + const imports: []const std.Build.Module.Import = &.{ 18 + .{ .name = "zat", .module = zat.module("zat") }, 19 + .{ .name = "zqlite", .module = zqlite.module("zqlite") }, 20 + }; 21 + 17 22 const exe = b.addExecutable(.{ 18 23 .name = "pollz", 19 24 .root_module = b.createModule(.{ 20 25 .root_source_file = b.path("src/main.zig"), 21 26 .target = target, 22 27 .optimize = optimize, 23 - .imports = &.{ 24 - .{ .name = "websocket", .module = websocket.module("websocket") }, 25 - .{ .name = "zqlite", .module = zqlite.module("zqlite") }, 26 - }, 28 + .imports = imports, 27 29 }), 28 30 }); 29 31 ··· 37 39 38 40 const run_step = b.step("run", "Run the server"); 39 41 run_step.dependOn(&run_cmd.step); 42 + 43 + // tests 44 + const test_step = b.step("test", "Run unit tests"); 45 + const test_files = .{ 46 + "src/oauth.zig", 47 + }; 48 + inline for (test_files) |file| { 49 + const t = b.addTest(.{ 50 + .root_module = b.createModule(.{ 51 + .root_source_file = b.path(file), 52 + .target = target, 53 + .optimize = optimize, 54 + .imports = imports, 55 + }), 56 + }); 57 + test_step.dependOn(&b.addRunArtifact(t).step); 58 + } 40 59 }
+3 -3
backend/build.zig.zon
··· 4 4 .fingerprint = 0x855197507943a911, 5 5 .minimum_zig_version = "0.15.0", 6 6 .dependencies = .{ 7 - .websocket = .{ 8 - .url = "https://github.com/karlseguin/websocket.zig/archive/refs/heads/master.tar.gz", 9 - .hash = "websocket-0.1.0-ZPISdRNzAwAGszh62EpRtoQxu8wb1MSMVI6Ow0o2dmyJ", 7 + .zat = .{ 8 + .url = "https://tangled.org/zat.dev/zat/archive/v0.2.16.tar.gz", 9 + .hash = "zat-0.2.16-5PuC7tjwBADbnwV5y8ztKUHhGHMJHh2HouvoYImnZ7y5", 10 10 }, 11 11 .zqlite = .{ 12 12 .url = "git+https://github.com/karlseguin/zqlite.zig?ref=master#e041f81c6b11b7381b6358030d57ca95dcd54d30",
+4 -3
backend/fly.toml
··· 4 4 [build] 5 5 6 6 [env] 7 - TAP_HOST = 'pollz-tap.internal' 8 - TAP_PORT = '2480' 7 + FRONTEND_ORIGIN = 'https://pollz.waow.tech' 8 + OAUTH_CLIENT_ID = 'https://api.pollz.waow.tech/oauth-client-metadata.json' 9 + OAUTH_REDIRECT_URI = 'https://api.pollz.waow.tech/oauth/callback' 9 10 10 11 [http_service] 11 12 internal_port = 3000 ··· 16 17 processes = ['app'] 17 18 18 19 [[vm]] 19 - memory = '256mb' 20 + memory = '512mb' 20 21 cpu_kind = 'shared' 21 22 cpus = 1 22 23
+303
backend/src/db.zig
··· 70 70 return err; 71 71 }; 72 72 73 + // Profiles cache 74 + conn.execNoArgs( 75 + \\CREATE TABLE IF NOT EXISTS profiles ( 76 + \\ did TEXT PRIMARY KEY, 77 + \\ handle TEXT, 78 + \\ avatar_url TEXT, 79 + \\ fetched_at INTEGER NOT NULL 80 + \\) 81 + ) catch |err| { 82 + std.debug.print("failed to create profiles table: {}\n", .{err}); 83 + return err; 84 + }; 85 + 86 + // OAuth tables 87 + conn.execNoArgs( 88 + \\CREATE TABLE IF NOT EXISTS oauth_auth_request ( 89 + \\ state TEXT PRIMARY KEY, 90 + \\ authserver_iss TEXT NOT NULL, 91 + \\ did TEXT NOT NULL, 92 + \\ handle TEXT NOT NULL, 93 + \\ pds_url TEXT NOT NULL, 94 + \\ pkce_verifier TEXT NOT NULL, 95 + \\ scope TEXT NOT NULL, 96 + \\ dpop_authserver_nonce TEXT NOT NULL DEFAULT '', 97 + \\ dpop_private_key BLOB NOT NULL, 98 + \\ created_at INTEGER NOT NULL 99 + \\) 100 + ) catch |err| { 101 + std.debug.print("failed to create oauth_auth_request table: {}\n", .{err}); 102 + return err; 103 + }; 104 + 105 + conn.execNoArgs( 106 + \\CREATE TABLE IF NOT EXISTS oauth_session ( 107 + \\ did TEXT PRIMARY KEY, 108 + \\ handle TEXT NOT NULL, 109 + \\ pds_url TEXT NOT NULL, 110 + \\ authserver_iss TEXT NOT NULL, 111 + \\ access_token TEXT NOT NULL, 112 + \\ refresh_token TEXT NOT NULL, 113 + \\ dpop_authserver_nonce TEXT NOT NULL DEFAULT '', 114 + \\ dpop_pds_nonce TEXT NOT NULL DEFAULT '', 115 + \\ dpop_private_key BLOB NOT NULL, 116 + \\ created_at INTEGER NOT NULL 117 + \\) 118 + ) catch |err| { 119 + std.debug.print("failed to create oauth_session table: {}\n", .{err}); 120 + return err; 121 + }; 122 + 73 123 std.debug.print("database schema initialized\n", .{}); 74 124 } 75 125 ··· 129 179 std.debug.print("db delete vote error: {}\n", .{err}); 130 180 }; 131 181 } 182 + 183 + // --- OAuth --- 184 + 185 + pub fn insertAuthRequest( 186 + state: []const u8, 187 + authserver_iss: []const u8, 188 + did: []const u8, 189 + handle: []const u8, 190 + pds_url: []const u8, 191 + pkce_verifier: []const u8, 192 + scope: []const u8, 193 + dpop_nonce: []const u8, 194 + dpop_private_key: []const u8, 195 + ) !void { 196 + mutex.lock(); 197 + defer mutex.unlock(); 198 + 199 + conn.exec( 200 + \\INSERT INTO oauth_auth_request 201 + \\ (state, authserver_iss, did, handle, pds_url, pkce_verifier, scope, dpop_authserver_nonce, dpop_private_key, created_at) 202 + \\VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 203 + , .{ 204 + state, 205 + authserver_iss, 206 + did, 207 + handle, 208 + pds_url, 209 + pkce_verifier, 210 + scope, 211 + dpop_nonce, 212 + dpop_private_key, 213 + @as(i64, @intCast(std.time.timestamp())), 214 + }) catch |err| { 215 + std.debug.print("db insert auth request error: {}\n", .{err}); 216 + return err; 217 + }; 218 + } 219 + 220 + pub const AuthRequest = struct { 221 + state: []const u8, 222 + authserver_iss: []const u8, 223 + did: []const u8, 224 + handle: []const u8, 225 + pds_url: []const u8, 226 + pkce_verifier: []const u8, 227 + scope: []const u8, 228 + dpop_authserver_nonce: []const u8, 229 + dpop_private_key: []const u8, 230 + }; 231 + 232 + pub fn getAuthRequest(state: []const u8) ?AuthRequest { 233 + mutex.lock(); 234 + defer mutex.unlock(); 235 + 236 + const row = conn.row( 237 + "SELECT state, authserver_iss, did, handle, pds_url, pkce_verifier, scope, dpop_authserver_nonce, dpop_private_key FROM oauth_auth_request WHERE state = ?", 238 + .{state}, 239 + ) catch return null; 240 + if (row == null) return null; 241 + 242 + return .{ 243 + .state = row.?.text(0), 244 + .authserver_iss = row.?.text(1), 245 + .did = row.?.text(2), 246 + .handle = row.?.text(3), 247 + .pds_url = row.?.text(4), 248 + .pkce_verifier = row.?.text(5), 249 + .scope = row.?.text(6), 250 + .dpop_authserver_nonce = row.?.text(7), 251 + .dpop_private_key = row.?.text(8), 252 + }; 253 + } 254 + 255 + pub fn deleteAuthRequest(state: []const u8) void { 256 + mutex.lock(); 257 + defer mutex.unlock(); 258 + 259 + conn.exec("DELETE FROM oauth_auth_request WHERE state = ?", .{state}) catch |err| { 260 + std.debug.print("db delete auth request error: {}\n", .{err}); 261 + }; 262 + } 263 + 264 + pub fn upsertSession( 265 + did: []const u8, 266 + handle: []const u8, 267 + pds_url: []const u8, 268 + authserver_iss: []const u8, 269 + access_token: []const u8, 270 + refresh_token: []const u8, 271 + dpop_authserver_nonce: []const u8, 272 + dpop_pds_nonce: []const u8, 273 + dpop_private_key: []const u8, 274 + ) !void { 275 + mutex.lock(); 276 + defer mutex.unlock(); 277 + 278 + conn.exec( 279 + \\INSERT INTO oauth_session 280 + \\ (did, handle, pds_url, authserver_iss, access_token, refresh_token, 281 + \\ dpop_authserver_nonce, dpop_pds_nonce, dpop_private_key, created_at) 282 + \\VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 283 + \\ON CONFLICT(did) DO UPDATE SET 284 + \\ handle = excluded.handle, 285 + \\ access_token = excluded.access_token, 286 + \\ refresh_token = excluded.refresh_token, 287 + \\ dpop_authserver_nonce = excluded.dpop_authserver_nonce, 288 + \\ dpop_pds_nonce = excluded.dpop_pds_nonce, 289 + \\ created_at = excluded.created_at 290 + , .{ 291 + did, 292 + handle, 293 + pds_url, 294 + authserver_iss, 295 + access_token, 296 + refresh_token, 297 + dpop_authserver_nonce, 298 + dpop_pds_nonce, 299 + dpop_private_key, 300 + @as(i64, @intCast(std.time.timestamp())), 301 + }) catch |err| { 302 + std.debug.print("db upsert session error: {}\n", .{err}); 303 + return err; 304 + }; 305 + } 306 + 307 + pub const Session = struct { 308 + did: []const u8, 309 + handle: []const u8, 310 + pds_url: []const u8, 311 + authserver_iss: []const u8, 312 + access_token: []const u8, 313 + refresh_token: []const u8, 314 + dpop_authserver_nonce: []const u8, 315 + dpop_pds_nonce: []const u8, 316 + dpop_private_key: []const u8, 317 + }; 318 + 319 + pub fn getSession(did: []const u8) ?Session { 320 + mutex.lock(); 321 + defer mutex.unlock(); 322 + 323 + const row = conn.row( 324 + \\SELECT did, handle, pds_url, authserver_iss, access_token, refresh_token, 325 + \\ dpop_authserver_nonce, dpop_pds_nonce, dpop_private_key 326 + \\FROM oauth_session WHERE did = ? 327 + , .{did}) catch return null; 328 + if (row == null) return null; 329 + 330 + return .{ 331 + .did = row.?.text(0), 332 + .handle = row.?.text(1), 333 + .pds_url = row.?.text(2), 334 + .authserver_iss = row.?.text(3), 335 + .access_token = row.?.text(4), 336 + .refresh_token = row.?.text(5), 337 + .dpop_authserver_nonce = row.?.text(6), 338 + .dpop_pds_nonce = row.?.text(7), 339 + .dpop_private_key = row.?.text(8), 340 + }; 341 + } 342 + 343 + pub fn deleteSession(did: []const u8) void { 344 + mutex.lock(); 345 + defer mutex.unlock(); 346 + 347 + conn.exec("DELETE FROM oauth_session WHERE did = ?", .{did}) catch |err| { 348 + std.debug.print("db delete session error: {}\n", .{err}); 349 + }; 350 + } 351 + 352 + pub fn updateSessionNonce(did: []const u8, field: enum { authserver, pds }, nonce: []const u8) void { 353 + mutex.lock(); 354 + defer mutex.unlock(); 355 + 356 + switch (field) { 357 + .authserver => conn.exec("UPDATE oauth_session SET dpop_authserver_nonce = ? WHERE did = ?", .{ nonce, did }) catch {}, 358 + .pds => conn.exec("UPDATE oauth_session SET dpop_pds_nonce = ? WHERE did = ?", .{ nonce, did }) catch {}, 359 + } 360 + } 361 + 362 + pub fn updateSessionTokens(did: []const u8, access_token: []const u8, refresh_token: []const u8) void { 363 + mutex.lock(); 364 + defer mutex.unlock(); 365 + 366 + conn.exec("UPDATE oauth_session SET access_token = ?, refresh_token = ? WHERE did = ?", .{ access_token, refresh_token, did }) catch {}; 367 + } 368 + 369 + pub fn cleanupExpiredAuthRequests() void { 370 + mutex.lock(); 371 + defer mutex.unlock(); 372 + 373 + // delete auth requests older than 10 minutes 374 + const cutoff = @as(i64, @intCast(std.time.timestamp())) - 600; 375 + conn.exec("DELETE FROM oauth_auth_request WHERE created_at < ?", .{cutoff}) catch {}; 376 + } 377 + 378 + // --- Profiles cache --- 379 + 380 + pub const Profile = struct { 381 + did: []const u8, 382 + handle: []const u8, 383 + avatar_url: []const u8, 384 + fetched_at: i64, 385 + }; 386 + 387 + pub fn getProfile(did: []const u8) ?Profile { 388 + mutex.lock(); 389 + defer mutex.unlock(); 390 + 391 + const row = conn.row( 392 + "SELECT did, handle, avatar_url, fetched_at FROM profiles WHERE did = ?", 393 + .{did}, 394 + ) catch return null; 395 + if (row == null) return null; 396 + 397 + return .{ 398 + .did = row.?.text(0), 399 + .handle = row.?.text(1), 400 + .avatar_url = row.?.text(2), 401 + .fetched_at = row.?.int(3), 402 + }; 403 + } 404 + 405 + pub fn upsertProfile(did: []const u8, handle: []const u8, avatar_url: []const u8) void { 406 + mutex.lock(); 407 + defer mutex.unlock(); 408 + 409 + conn.exec( 410 + \\INSERT INTO profiles (did, handle, avatar_url, fetched_at) 411 + \\VALUES (?, ?, ?, ?) 412 + \\ON CONFLICT(did) DO UPDATE SET 413 + \\ handle = excluded.handle, 414 + \\ avatar_url = excluded.avatar_url, 415 + \\ fetched_at = excluded.fetched_at 416 + , .{ 417 + did, 418 + handle, 419 + avatar_url, 420 + @as(i64, @intCast(std.time.timestamp())), 421 + }) catch |err| { 422 + std.debug.print("db upsert profile error: {}\n", .{err}); 423 + }; 424 + } 425 + 426 + /// get handle for a DID from cache (no locking - caller must hold mutex or accept races) 427 + pub fn getHandleForDid(did: []const u8) ?[]const u8 { 428 + const row = conn.row( 429 + "SELECT handle FROM profiles WHERE did = ?", 430 + .{did}, 431 + ) catch return null; 432 + if (row == null) return null; 433 + return row.?.text(0); 434 + }
+1283 -52
backend/src/http.zig
··· 3 3 const http = std.http; 4 4 const mem = std.mem; 5 5 const json = std.json; 6 + const crypto = std.crypto; 6 7 const db = @import("db.zig"); 8 + const oauth = @import("oauth.zig"); 9 + const zat = @import("zat"); 7 10 8 - pub fn handleConnection(conn: net.Server.Connection) void { 9 - defer conn.stream.close(); 11 + const SCOPE = "atproto repo:tech.waow.pollz.poll repo:tech.waow.pollz.vote"; 12 + 13 + fn getClientId() []const u8 { 14 + return std.posix.getenv("OAUTH_CLIENT_ID") orelse "https://api.pollz.waow.tech/oauth-client-metadata.json"; 15 + } 16 + 17 + fn getRedirectUri() []const u8 { 18 + return std.posix.getenv("OAUTH_REDIRECT_URI") orelse "https://api.pollz.waow.tech/oauth/callback"; 19 + } 20 + 21 + fn getFrontendOrigin() []const u8 { 22 + return std.posix.getenv("FRONTEND_ORIGIN") orelse "https://pollz.waow.tech"; 23 + } 24 + 25 + fn getClientOrigin() []const u8 { 26 + // derive origin from client_id: e.g. "https://api.pollz.waow.tech/oauth/client-metadata" → "https://api.pollz.waow.tech" 27 + const client_id = getClientId(); 28 + const scheme_end = mem.indexOf(u8, client_id, "://") orelse return client_id; 29 + const after_scheme = client_id[scheme_end + 3 ..]; 30 + const path_start = mem.indexOf(u8, after_scheme, "/") orelse return client_id; 31 + return client_id[0 .. scheme_end + 3 + path_start]; 32 + } 33 + 34 + fn getClientKeypair() !zat.Keypair { 35 + const key_hex = std.posix.getenv("OAUTH_CLIENT_SECRET_KEY") orelse return error.MissingClientKey; 36 + if (key_hex.len != 64) return error.InvalidClientKey; 37 + var key_bytes: [32]u8 = undefined; 38 + _ = std.fmt.hexToBytes(&key_bytes, key_hex) catch return error.InvalidClientKey; 39 + return zat.Keypair.fromSecretKey(.p256, key_bytes); 40 + } 41 + 42 + pub fn handleConnection(conn_: net.Server.Connection) void { 43 + defer conn_.stream.close(); 10 44 11 45 var read_buffer: [8192]u8 = undefined; 12 46 var write_buffer: [8192]u8 = undefined; 13 47 14 - var reader = conn.stream.reader(&read_buffer); 15 - var writer = conn.stream.writer(&write_buffer); 48 + var reader = conn_.stream.reader(&read_buffer); 49 + var writer = conn_.stream.writer(&write_buffer); 16 50 17 51 var server = http.Server.init(reader.interface(), &writer.interface); 18 52 19 53 while (true) { 20 54 var request = server.receiveHead() catch |err| { 21 - // this is expected for idle connections 22 55 if (err != error.HttpConnectionClosing and err != error.EndOfStream) { 23 56 std.debug.print("http receive error: {}\n", .{err}); 24 57 } ··· 42 75 return; 43 76 } 44 77 45 - if (mem.startsWith(u8, target, "/api/polls")) { 46 - if (mem.eql(u8, target, "/api/polls")) { 47 - try handleGetPolls(request); 48 - } else if (mem.indexOf(u8, target, "/votes")) |votes_idx| { 49 - // /api/polls/:uri/votes 50 - const uri_encoded = target["/api/polls/".len..votes_idx]; 51 - try handleGetVotes(request, uri_encoded); 52 - } else if (mem.startsWith(u8, target, "/api/polls/")) { 53 - const uri_encoded = target["/api/polls/".len..]; 54 - try handleGetPoll(request, uri_encoded); 78 + // OAuth client metadata (served at root path per AT Protocol convention) 79 + if (mem.eql(u8, target, "/oauth-client-metadata.json")) { 80 + try handleClientMetadata(request); 81 + return; 82 + } 83 + 84 + // OAuth endpoints 85 + if (mem.startsWith(u8, target, "/oauth/")) { 86 + if (mem.eql(u8, target, "/oauth/jwks")) { 87 + try handleJwks(request); 88 + } else if (mem.startsWith(u8, target, "/oauth/login")) { 89 + try handleLogin(request); 90 + } else if (mem.startsWith(u8, target, "/oauth/callback")) { 91 + try handleCallback(request); 92 + } else { 93 + try sendNotFound(request); 94 + } 95 + return; 96 + } 97 + 98 + // API endpoints 99 + if (mem.startsWith(u8, target, "/api/")) { 100 + if (mem.eql(u8, target, "/api/me")) { 101 + try handleMe(request); 102 + } else if (mem.startsWith(u8, target, "/api/polls")) { 103 + if (request.head.method == .POST) { 104 + if (mem.eql(u8, target, "/api/polls")) { 105 + try handleCreatePoll(request); 106 + } else if (mem.endsWith(u8, target, "/vote")) { 107 + try handleVote(request); 108 + } else { 109 + try sendNotFound(request); 110 + } 111 + } else if (request.head.method == .DELETE) { 112 + if (mem.startsWith(u8, target, "/api/polls/")) { 113 + const uri_encoded = target["/api/polls/".len..]; 114 + try handleDeletePoll(request, uri_encoded); 115 + } else { 116 + try sendNotFound(request); 117 + } 118 + } else { 119 + // GET routes 120 + if (mem.eql(u8, target, "/api/polls")) { 121 + try handleGetPolls(request); 122 + } else if (mem.indexOf(u8, target, "/votes")) |votes_idx| { 123 + const uri_encoded = target["/api/polls/".len..votes_idx]; 124 + try handleGetVotes(request, uri_encoded); 125 + } else if (mem.startsWith(u8, target, "/api/polls/")) { 126 + const uri_encoded = target["/api/polls/".len..]; 127 + try handleGetPoll(request, uri_encoded); 128 + } 129 + } 130 + } else if (mem.eql(u8, target, "/api/logout") and request.head.method == .POST) { 131 + try handleLogout(request); 132 + } else { 133 + try sendNotFound(request); 55 134 } 56 - } else if (mem.eql(u8, target, "/health")) { 135 + return; 136 + } 137 + 138 + if (mem.eql(u8, target, "/health")) { 57 139 try sendJson(request, "{\"status\":\"ok\"}"); 58 140 } else { 59 141 try sendNotFound(request); 60 142 } 61 143 } 62 144 145 + // --- OAuth endpoints --- 146 + 147 + fn handleClientMetadata(request: *http.Server.Request) !void { 148 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 149 + defer arena.deinit(); 150 + const alloc = arena.allocator(); 151 + 152 + const client_id = getClientId(); 153 + const redirect_uri = getRedirectUri(); 154 + 155 + const keypair = getClientKeypair() catch { 156 + try sendError(request, .internal_server_error, "server configuration error"); 157 + return; 158 + }; 159 + 160 + const jwk = oauth.jwkPublicKey(alloc, &keypair) catch { 161 + try sendError(request, .internal_server_error, "key error"); 162 + return; 163 + }; 164 + 165 + var body: std.ArrayList(u8) = .{}; 166 + defer body.deinit(alloc); 167 + 168 + try body.print(alloc, 169 + \\{{ 170 + \\ "client_id": "{s}", 171 + \\ "client_name": "pollz", 172 + \\ "client_uri": "{s}", 173 + \\ "application_type": "web", 174 + \\ "grant_types": ["authorization_code", "refresh_token"], 175 + \\ "response_types": ["code"], 176 + \\ "redirect_uris": ["{s}"], 177 + \\ "token_endpoint_auth_method": "private_key_jwt", 178 + \\ "token_endpoint_auth_signing_alg": "ES256", 179 + \\ "scope": "{s}", 180 + \\ "dpop_bound_access_tokens": true, 181 + \\ "jwks": {{"keys": [{s}]}} 182 + \\}} 183 + , .{ client_id, getClientOrigin(), redirect_uri, SCOPE, jwk }); 184 + 185 + try sendJson(request, body.items); 186 + } 187 + 188 + fn handleJwks(request: *http.Server.Request) !void { 189 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 190 + defer arena.deinit(); 191 + const alloc = arena.allocator(); 192 + 193 + const keypair = getClientKeypair() catch { 194 + try sendError(request, .internal_server_error, "server configuration error"); 195 + return; 196 + }; 197 + 198 + const jwks = oauth.jwksJson(alloc, &keypair) catch { 199 + try sendError(request, .internal_server_error, "key error"); 200 + return; 201 + }; 202 + 203 + try sendJson(request, jwks); 204 + } 205 + 206 + fn handleLogin(request: *http.Server.Request) !void { 207 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 208 + defer arena.deinit(); 209 + const alloc = arena.allocator(); 210 + 211 + // parse ?handle= from query string 212 + const target = request.head.target; 213 + const handle_str = extractQueryParam(target, "handle") orelse { 214 + try sendError(request, .bad_request, "missing handle parameter"); 215 + return; 216 + }; 217 + 218 + // resolve handle → DID → PDS → auth server 219 + var handle_resolver = zat.HandleResolver.init(alloc); 220 + defer handle_resolver.deinit(); 221 + 222 + const did = handle_resolver.resolve(zat.Handle.parse(handle_str) orelse { 223 + try sendError(request, .bad_request, "invalid handle"); 224 + return; 225 + }) catch |err| { 226 + std.debug.print("handle resolution failed for '{s}': {}\n", .{ handle_str, err }); 227 + try sendError(request, .bad_request, "could not resolve handle"); 228 + return; 229 + }; 230 + 231 + var did_resolver = zat.DidResolver.init(alloc); 232 + defer did_resolver.deinit(); 233 + 234 + var did_doc = did_resolver.resolve(zat.Did.parse(did) orelse { 235 + try sendError(request, .bad_request, "invalid DID"); 236 + return; 237 + }) catch { 238 + try sendError(request, .bad_request, "could not resolve DID"); 239 + return; 240 + }; 241 + defer did_doc.deinit(); 242 + 243 + const pds_url = did_doc.pdsEndpoint() orelse { 244 + try sendError(request, .bad_request, "no PDS endpoint found"); 245 + return; 246 + }; 247 + 248 + // fetch PDS OAuth protected resource metadata 249 + const authserver_url = fetchAuthServerUrl(alloc, pds_url) catch { 250 + try sendError(request, .bad_request, "could not discover auth server"); 251 + return; 252 + }; 253 + 254 + // fetch auth server metadata 255 + var authserver_meta = fetchAuthServerMeta(alloc, authserver_url) catch { 256 + try sendError(request, .bad_request, "could not fetch auth server metadata"); 257 + return; 258 + }; 259 + defer authserver_meta.deinit(); 260 + 261 + // use the issuer from metadata (not the discovered URL) for iss verification 262 + const authserver_iss = jsonGetString(authserver_meta.value, "issuer") orelse { 263 + try sendError(request, .bad_request, "auth server missing issuer"); 264 + return; 265 + }; 266 + 267 + const par_url = jsonGetString(authserver_meta.value, "pushed_authorization_request_endpoint") orelse { 268 + try sendError(request, .bad_request, "auth server missing PAR endpoint"); 269 + return; 270 + }; 271 + 272 + const authorization_endpoint = jsonGetString(authserver_meta.value, "authorization_endpoint") orelse { 273 + try sendError(request, .bad_request, "auth server missing authorization endpoint"); 274 + return; 275 + }; 276 + 277 + // generate PKCE + state + per-session DPoP key 278 + const pkce_verifier = try oauth.generatePkceVerifier(alloc); 279 + const pkce_challenge = try oauth.generatePkceChallenge(alloc, pkce_verifier); 280 + const state = try oauth.generateState(alloc); 281 + 282 + // generate a per-session DPoP keypair (separate from client secret key) 283 + var dpop_key_bytes: [32]u8 = undefined; 284 + crypto.random.bytes(&dpop_key_bytes); 285 + const dpop_keypair = zat.Keypair.fromSecretKey(.p256, dpop_key_bytes) catch { 286 + // extremely unlikely — retry with new random bytes 287 + try sendError(request, .internal_server_error, "key generation failed"); 288 + return; 289 + }; 290 + 291 + const client_keypair = getClientKeypair() catch { 292 + try sendError(request, .internal_server_error, "server configuration error"); 293 + return; 294 + }; 295 + 296 + const client_id = getClientId(); 297 + const redirect_uri = getRedirectUri(); 298 + 299 + // send PAR request 300 + const par_result = sendParRequest(alloc, .{ 301 + .par_url = par_url, 302 + .authserver_url = authserver_iss, 303 + .client_id = client_id, 304 + .redirect_uri = redirect_uri, 305 + .scope = SCOPE, 306 + .state = state, 307 + .pkce_challenge = pkce_challenge, 308 + .handle = handle_str, 309 + .client_keypair = &client_keypair, 310 + .dpop_keypair = &dpop_keypair, 311 + }) catch { 312 + try sendError(request, .bad_gateway, "PAR request failed"); 313 + return; 314 + }; 315 + 316 + const request_uri = par_result.request_uri; 317 + 318 + // store auth request in DB 319 + const hex_buf = std.fmt.bytesToHex(dpop_key_bytes, .lower); 320 + db.insertAuthRequest( 321 + state, 322 + authserver_iss, 323 + did, 324 + handle_str, 325 + pds_url, 326 + pkce_verifier, 327 + SCOPE, 328 + par_result.dpop_nonce, 329 + &hex_buf, 330 + ) catch { 331 + try sendError(request, .internal_server_error, "could not store auth request"); 332 + return; 333 + }; 334 + 335 + // redirect to auth server 336 + var redirect_url: std.ArrayList(u8) = .{}; 337 + defer redirect_url.deinit(alloc); 338 + try redirect_url.print( 339 + alloc, 340 + "{s}?request_uri={s}&client_id={s}&state={s}", 341 + .{ authorization_endpoint, request_uri, client_id, state }, 342 + ); 343 + 344 + try sendRedirect(request, redirect_url.items); 345 + } 346 + 347 + fn handleCallback(request: *http.Server.Request) !void { 348 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 349 + defer arena.deinit(); 350 + const alloc = arena.allocator(); 351 + 352 + const target = request.head.target; 353 + std.debug.print("callback target: {s}\n", .{target}); 354 + 355 + const code = extractQueryParam(target, "code") orelse { 356 + std.debug.print("callback missing code param\n", .{}); 357 + try sendError(request, .bad_request, "missing code"); 358 + return; 359 + }; 360 + const state = extractQueryParam(target, "state") orelse { 361 + std.debug.print("callback missing state param\n", .{}); 362 + try sendError(request, .bad_request, "missing state"); 363 + return; 364 + }; 365 + const iss_raw = extractQueryParam(target, "iss"); 366 + const iss = if (iss_raw) |raw| blk: { 367 + const buf = try alloc.dupe(u8, raw); 368 + break :blk std.Uri.percentDecodeBackwards(buf, buf); 369 + } else null; 370 + 371 + // look up auth request 372 + const auth_req = db.getAuthRequest(state) orelse { 373 + try sendError(request, .bad_request, "unknown state — login may have expired"); 374 + return; 375 + }; 376 + 377 + // verify issuer matches 378 + if (iss) |issuer| { 379 + if (!mem.eql(u8, issuer, auth_req.authserver_iss)) { 380 + std.debug.print("issuer mismatch: callback iss='{s}', stored='{s}'\n", .{ issuer, auth_req.authserver_iss }); 381 + try sendError(request, .bad_request, "issuer mismatch"); 382 + return; 383 + } 384 + } 385 + 386 + // reconstruct DPoP keypair from stored hex key 387 + const dpop_keypair = keypairFromHex(auth_req.dpop_private_key) catch { 388 + try sendError(request, .internal_server_error, "invalid stored key"); 389 + return; 390 + }; 391 + 392 + const client_keypair = getClientKeypair() catch { 393 + try sendError(request, .internal_server_error, "server configuration error"); 394 + return; 395 + }; 396 + 397 + // re-fetch auth server metadata for token endpoint 398 + var authserver_meta = fetchAuthServerMeta(alloc, auth_req.authserver_iss) catch { 399 + try sendError(request, .bad_gateway, "could not fetch auth server metadata"); 400 + return; 401 + }; 402 + defer authserver_meta.deinit(); 403 + 404 + const token_url = jsonGetString(authserver_meta.value, "token_endpoint") orelse { 405 + try sendError(request, .bad_gateway, "auth server missing token endpoint"); 406 + return; 407 + }; 408 + 409 + const client_id = getClientId(); 410 + const redirect_uri = getRedirectUri(); 411 + 412 + // exchange code for tokens 413 + const token_result = sendTokenRequest(alloc, .{ 414 + .token_url = token_url, 415 + .authserver_url = auth_req.authserver_iss, 416 + .client_id = client_id, 417 + .redirect_uri = redirect_uri, 418 + .code = code, 419 + .pkce_verifier = auth_req.pkce_verifier, 420 + .client_keypair = &client_keypair, 421 + .dpop_keypair = &dpop_keypair, 422 + .dpop_nonce = auth_req.dpop_authserver_nonce, 423 + }) catch { 424 + try sendError(request, .bad_gateway, "token exchange failed"); 425 + return; 426 + }; 427 + 428 + // verify sub matches expected DID 429 + if (!mem.eql(u8, token_result.sub, auth_req.did)) { 430 + try sendError(request, .bad_request, "token subject does not match expected DID"); 431 + return; 432 + } 433 + 434 + // store session 435 + db.upsertSession( 436 + auth_req.did, 437 + auth_req.handle, 438 + auth_req.pds_url, 439 + auth_req.authserver_iss, 440 + token_result.access_token, 441 + token_result.refresh_token, 442 + token_result.dpop_nonce, 443 + "", // PDS nonce not yet known 444 + auth_req.dpop_private_key, 445 + ) catch { 446 + try sendError(request, .internal_server_error, "could not store session"); 447 + return; 448 + }; 449 + 450 + // clean up auth request 451 + db.deleteAuthRequest(state); 452 + 453 + // redirect to frontend with session cookie 454 + var cookie_buf: [512]u8 = undefined; 455 + const cookie = std.fmt.bufPrint( 456 + &cookie_buf, 457 + "pollz_session={s}; HttpOnly; Secure; SameSite=Lax; Domain=pollz.waow.tech; Path=/; Max-Age=2592000", 458 + .{auth_req.did}, 459 + ) catch { 460 + try sendError(request, .internal_server_error, "cookie error"); 461 + return; 462 + }; 463 + 464 + try request.respond("", .{ 465 + .status = .found, 466 + .extra_headers = &.{ 467 + .{ .name = "location", .value = getFrontendOrigin() }, 468 + .{ .name = "set-cookie", .value = cookie }, 469 + .{ .name = "access-control-allow-origin", .value = getFrontendOrigin() }, 470 + .{ .name = "access-control-allow-credentials", .value = "true" }, 471 + }, 472 + }); 473 + } 474 + 475 + fn handleMe(request: *http.Server.Request) !void { 476 + const session_did = getSessionDid(request) orelse { 477 + try sendError(request, .unauthorized, "not logged in"); 478 + return; 479 + }; 480 + 481 + const session = db.getSession(session_did) orelse { 482 + try sendError(request, .unauthorized, "session not found"); 483 + return; 484 + }; 485 + 486 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 487 + defer arena.deinit(); 488 + const alloc = arena.allocator(); 489 + 490 + var body: std.ArrayList(u8) = .{}; 491 + defer body.deinit(alloc); 492 + 493 + try body.print(alloc, 494 + \\{{"did":"{s}","handle":"{s}"}} 495 + , .{ session.did, session.handle }); 496 + 497 + try sendJsonWithCredentials(request, body.items); 498 + } 499 + 500 + fn handleLogout(request: *http.Server.Request) !void { 501 + const session_did = getSessionDid(request); 502 + if (session_did) |did| { 503 + db.deleteSession(did); 504 + } 505 + 506 + try request.respond("{\"ok\":true}", .{ 507 + .status = .ok, 508 + .extra_headers = &.{ 509 + .{ .name = "content-type", .value = "application/json" }, 510 + .{ .name = "set-cookie", .value = "pollz_session=; HttpOnly; Secure; SameSite=Lax; Domain=pollz.waow.tech; Path=/; Max-Age=0" }, 511 + .{ .name = "access-control-allow-origin", .value = getFrontendOrigin() }, 512 + .{ .name = "access-control-allow-credentials", .value = "true" }, 513 + }, 514 + }); 515 + } 516 + 517 + // --- BFF proxy endpoints --- 518 + 519 + fn handleCreatePoll(request: *http.Server.Request) !void { 520 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 521 + defer arena.deinit(); 522 + const alloc = arena.allocator(); 523 + 524 + const session_did = getSessionDid(request) orelse { 525 + try sendError(request, .unauthorized, "not logged in"); 526 + return; 527 + }; 528 + 529 + const session = db.getSession(session_did) orelse { 530 + try sendError(request, .unauthorized, "session not found"); 531 + return; 532 + }; 533 + 534 + // read request body 535 + const body = readRequestBody(alloc, request) orelse { 536 + try sendError(request, .bad_request, "missing body"); 537 + return; 538 + }; 539 + 540 + // parse the poll creation request 541 + const parsed = json.parseFromSlice(json.Value, alloc, body, .{}) catch { 542 + try sendError(request, .bad_request, "invalid JSON"); 543 + return; 544 + }; 545 + defer parsed.deinit(); 546 + 547 + const text = jsonGetString(parsed.value, "text") orelse { 548 + try sendError(request, .bad_request, "missing text"); 549 + return; 550 + }; 551 + 552 + const options_val = jsonGetPath(parsed.value, "options") orelse { 553 + try sendError(request, .bad_request, "missing options"); 554 + return; 555 + }; 556 + if (options_val != .array) { 557 + try sendError(request, .bad_request, "options must be an array"); 558 + return; 559 + } 560 + 561 + // build record for PDS 562 + const now = try formatTimestamp(alloc); 563 + 564 + var record: std.ArrayList(u8) = .{}; 565 + defer record.deinit(alloc); 566 + 567 + try record.print(alloc, 568 + \\{{"$type":"tech.waow.pollz.poll","text":"{s}","options":{f},"createdAt":"{s}"}} 569 + , .{ text, json.fmt(options_val, .{}), now }); 570 + 571 + var xrpc_body: std.ArrayList(u8) = .{}; 572 + defer xrpc_body.deinit(alloc); 573 + 574 + try xrpc_body.print(alloc, 575 + \\{{"repo":"{s}","collection":"tech.waow.pollz.poll","record":{s}}} 576 + , .{ session.did, record.items }); 577 + 578 + // proxy to PDS 579 + const result = pdsAuthedRequest(alloc, session, "POST", "/xrpc/com.atproto.repo.createRecord", xrpc_body.items) catch { 580 + try sendError(request, .bad_gateway, "PDS request failed"); 581 + return; 582 + }; 583 + 584 + try sendJsonWithCredentials(request, result); 585 + } 586 + 587 + fn handleVote(request: *http.Server.Request) !void { 588 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 589 + defer arena.deinit(); 590 + const alloc = arena.allocator(); 591 + 592 + const session_did = getSessionDid(request) orelse { 593 + try sendError(request, .unauthorized, "not logged in"); 594 + return; 595 + }; 596 + 597 + const session = db.getSession(session_did) orelse { 598 + try sendError(request, .unauthorized, "session not found"); 599 + return; 600 + }; 601 + 602 + // read request body 603 + const body = readRequestBody(alloc, request) orelse { 604 + try sendError(request, .bad_request, "missing body"); 605 + return; 606 + }; 607 + 608 + const parsed = json.parseFromSlice(json.Value, alloc, body, .{}) catch { 609 + try sendError(request, .bad_request, "invalid JSON"); 610 + return; 611 + }; 612 + defer parsed.deinit(); 613 + 614 + const subject = jsonGetString(parsed.value, "subject") orelse { 615 + try sendError(request, .bad_request, "missing subject"); 616 + return; 617 + }; 618 + 619 + const option = jsonGetInt(parsed.value, "option") orelse { 620 + try sendError(request, .bad_request, "missing option"); 621 + return; 622 + }; 623 + 624 + const now = try formatTimestamp(alloc); 625 + 626 + var record: std.ArrayList(u8) = .{}; 627 + defer record.deinit(alloc); 628 + 629 + try record.print(alloc, 630 + \\{{"$type":"tech.waow.pollz.vote","subject":"{s}","option":{d},"createdAt":"{s}"}} 631 + , .{ subject, option, now }); 632 + 633 + var xrpc_body: std.ArrayList(u8) = .{}; 634 + defer xrpc_body.deinit(alloc); 635 + 636 + try xrpc_body.print(alloc, 637 + \\{{"repo":"{s}","collection":"tech.waow.pollz.vote","record":{s}}} 638 + , .{ session.did, record.items }); 639 + 640 + const result = pdsAuthedRequest(alloc, session, "POST", "/xrpc/com.atproto.repo.createRecord", xrpc_body.items) catch { 641 + try sendError(request, .bad_gateway, "PDS request failed"); 642 + return; 643 + }; 644 + 645 + try sendJsonWithCredentials(request, result); 646 + } 647 + 648 + fn handleDeletePoll(request: *http.Server.Request, uri_encoded: []const u8) !void { 649 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 650 + defer arena.deinit(); 651 + const alloc = arena.allocator(); 652 + 653 + const session_did = getSessionDid(request) orelse { 654 + try sendError(request, .unauthorized, "not logged in"); 655 + return; 656 + }; 657 + 658 + const session = db.getSession(session_did) orelse { 659 + try sendError(request, .unauthorized, "session not found"); 660 + return; 661 + }; 662 + 663 + const uri_buf = try alloc.dupe(u8, uri_encoded); 664 + const poll_uri = std.Uri.percentDecodeInPlace(uri_buf); 665 + 666 + // parse AT URI: at://did/collection/rkey 667 + if (!mem.startsWith(u8, poll_uri, "at://")) { 668 + try sendError(request, .bad_request, "invalid AT URI"); 669 + return; 670 + } 671 + const after_scheme = poll_uri["at://".len..]; 672 + const first_slash = mem.indexOf(u8, after_scheme, "/") orelse { 673 + try sendError(request, .bad_request, "invalid AT URI"); 674 + return; 675 + }; 676 + const repo = after_scheme[0..first_slash]; 677 + const after_repo = after_scheme[first_slash + 1 ..]; 678 + const second_slash = mem.indexOf(u8, after_repo, "/") orelse { 679 + try sendError(request, .bad_request, "invalid AT URI"); 680 + return; 681 + }; 682 + const collection = after_repo[0..second_slash]; 683 + const rkey = after_repo[second_slash + 1 ..]; 684 + 685 + // only the poll author can delete 686 + if (!mem.eql(u8, repo, session.did)) { 687 + try sendError(request, .forbidden, "you can only delete your own polls"); 688 + return; 689 + } 690 + 691 + // deleteRecord is a POST to the PDS 692 + var xrpc_body: std.ArrayList(u8) = .{}; 693 + defer xrpc_body.deinit(alloc); 694 + 695 + try xrpc_body.print(alloc, 696 + \\{{"repo":"{s}","collection":"{s}","rkey":"{s}"}} 697 + , .{ repo, collection, rkey }); 698 + 699 + _ = pdsAuthedRequest(alloc, session, "POST", "/xrpc/com.atproto.repo.deleteRecord", xrpc_body.items) catch { 700 + try sendError(request, .bad_gateway, "PDS request failed"); 701 + return; 702 + }; 703 + 704 + // also delete from local DB 705 + db.deletePoll(poll_uri); 706 + 707 + try sendJsonWithCredentials(request, "{\"ok\":true}"); 708 + } 709 + 710 + // --- profile resolution --- 711 + 712 + const PROFILE_CACHE_SECS: i64 = 3600; // 1 hour 713 + 714 + fn fetchAndCacheProfile(alloc: std.mem.Allocator, did: []const u8) void { 715 + const url = std.fmt.allocPrint(alloc, "https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor={s}", .{did}) catch return; 716 + defer alloc.free(url); 717 + 718 + const body = httpGet(alloc, url) catch return; 719 + defer alloc.free(body); 720 + 721 + const parsed = json.parseFromSlice(json.Value, alloc, body, .{}) catch return; 722 + defer parsed.deinit(); 723 + 724 + const handle = if (parsed.value.object.get("handle")) |v| switch (v) { 725 + .string => |s| s, 726 + else => did, 727 + } else did; 728 + 729 + const avatar = if (parsed.value.object.get("avatar")) |v| switch (v) { 730 + .string => |s| s, 731 + else => "", 732 + } else ""; 733 + 734 + db.upsertProfile(did, handle, avatar); 735 + } 736 + 737 + fn getOrFetchProfile(alloc: std.mem.Allocator, did: []const u8) db.Profile { 738 + const now = @as(i64, @intCast(std.time.timestamp())); 739 + 740 + if (db.getProfile(did)) |profile| { 741 + if (now - profile.fetched_at < PROFILE_CACHE_SECS) { 742 + return profile; 743 + } 744 + // stale — serve it but refresh in background would be nice 745 + // for now just return stale data, fetch will happen on next miss 746 + return profile; 747 + } 748 + 749 + // cache miss — fetch synchronously 750 + fetchAndCacheProfile(alloc, did); 751 + 752 + // re-read from db 753 + return db.getProfile(did) orelse .{ 754 + .did = did, 755 + .handle = did, 756 + .avatar_url = "", 757 + .fetched_at = now, 758 + }; 759 + } 760 + 761 + // --- existing poll/vote read endpoints --- 762 + 63 763 fn handleGetPolls(request: *http.Server.Request) !void { 64 764 var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 65 765 defer arena.deinit(); ··· 81 781 return; 82 782 }; 83 783 defer rows.deinit(); 784 + 785 + // collect poll data first so we can release db rows 786 + const PollRow = struct { uri: []const u8, repo: []const u8, rkey: []const u8, text_json: []const u8, options_json: []const u8, created_at: []const u8 }; 787 + var poll_list: std.ArrayList(PollRow) = .{}; 788 + defer poll_list.deinit(alloc); 789 + 790 + while (rows.next()) |row| { 791 + try poll_list.append(alloc, .{ 792 + .uri = try alloc.dupe(u8, row.text(0)), 793 + .repo = try alloc.dupe(u8, row.text(1)), 794 + .rkey = try alloc.dupe(u8, row.text(2)), 795 + .text_json = try alloc.dupe(u8, row.text(3)), 796 + .options_json = try alloc.dupe(u8, row.text(4)), 797 + .created_at = try alloc.dupe(u8, row.text(5)), 798 + }); 799 + } 800 + 801 + if (rows.err) |err| { 802 + std.debug.print("rows error: {}\n", .{err}); 803 + } 84 804 85 805 var first = true; 86 - while (rows.next()) |row| { 806 + for (poll_list.items) |p| { 87 807 if (!first) try response.appendSlice(alloc, ","); 88 808 first = false; 89 809 90 - const uri = row.text(0); 91 - const repo = row.text(1); 92 - const rkey = row.text(2); 93 - const text_json = row.text(3); 94 - const options_json = row.text(4); 95 - const created_at = row.text(5); 810 + // parse options array and get per-option counts 811 + const parsed = json.parseFromSlice(json.Value, alloc, p.options_json, .{}) catch { 812 + try response.print(alloc, 813 + \\{{"uri":"{s}","repo":"{s}","rkey":"{s}","text":{s},"options":[],"createdAt":"{s}"}} 814 + , .{ p.uri, p.repo, p.rkey, p.text_json, p.created_at }); 815 + continue; 816 + }; 817 + defer parsed.deinit(); 818 + 819 + if (parsed.value != .array) { 820 + try response.print(alloc, 821 + \\{{"uri":"{s}","repo":"{s}","rkey":"{s}","text":{s},"options":[],"createdAt":"{s}"}} 822 + , .{ p.uri, p.repo, p.rkey, p.text_json, p.created_at }); 823 + continue; 824 + } 825 + 826 + const options = parsed.value.array.items; 827 + 828 + // build options with counts 829 + var opts_json: std.ArrayList(u8) = .{}; 830 + defer opts_json.deinit(alloc); 831 + try opts_json.appendSlice(alloc, "["); 832 + 833 + for (options, 0..) |opt, i| { 834 + if (i > 0) try opts_json.appendSlice(alloc, ","); 835 + 836 + const count: i64 = blk: { 837 + const vrow = db.conn.row("SELECT COUNT(*) FROM votes WHERE subject = ? AND option = ?", .{ p.uri, @as(i32, @intCast(i)) }) catch break :blk 0; 838 + if (vrow) |r| { 839 + defer r.deinit(); 840 + break :blk r.int(0); 841 + } 842 + break :blk 0; 843 + }; 844 + 845 + try opts_json.print(alloc, 846 + \\{{"text":{f},"count":{d}}} 847 + , .{ json.fmt(opt, .{}), count }); 848 + } 849 + try opts_json.appendSlice(alloc, "]"); 850 + 851 + // resolve author profile (release db mutex briefly for network fetch) 852 + db.mutex.unlock(); 853 + const profile = getOrFetchProfile(alloc, p.repo); 854 + db.mutex.lock(); 96 855 97 - // count votes for this poll 98 - const vote_count: i64 = blk: { 99 - const vrow = db.conn.row("SELECT COUNT(*) FROM votes WHERE subject = ?", .{uri}) catch break :blk 0; 100 - if (vrow) |r| { 101 - defer r.deinit(); 102 - break :blk r.int(0); 103 - } 104 - break :blk 0; 105 - }; 856 + // escape avatar_url for JSON 857 + var avatar_json: std.ArrayList(u8) = .{}; 858 + defer avatar_json.deinit(alloc); 859 + if (profile.avatar_url.len > 0) { 860 + try avatar_json.appendSlice(alloc, "\""); 861 + try avatar_json.appendSlice(alloc, profile.avatar_url); 862 + try avatar_json.appendSlice(alloc, "\""); 863 + } else { 864 + try avatar_json.appendSlice(alloc, "null"); 865 + } 106 866 107 867 try response.print(alloc, 108 - \\{{"uri":"{s}","repo":"{s}","rkey":"{s}","text":{s},"options":{s},"createdAt":"{s}","voteCount":{d}}} 109 - , .{ uri, repo, rkey, text_json, options_json, created_at, vote_count }); 110 - } 111 - 112 - if (rows.err) |err| { 113 - std.debug.print("rows error: {}\n", .{err}); 868 + \\{{"uri":"{s}","repo":"{s}","rkey":"{s}","text":{s},"options":{s},"createdAt":"{s}","author":{{"did":"{s}","handle":"{s}","avatar":{s}}}}} 869 + , .{ p.uri, p.repo, p.rkey, p.text_json, opts_json.items, p.created_at, profile.did, profile.handle, avatar_json.items }); 114 870 } 115 871 116 872 try response.appendSlice(alloc, "]"); ··· 122 878 defer arena.deinit(); 123 879 const alloc = arena.allocator(); 124 880 125 - // decode uri 126 881 const uri_buf = try alloc.dupe(u8, uri_encoded); 127 882 const uri = std.Uri.percentDecodeInPlace(uri_buf); 128 883 ··· 146 901 const options_json = row.?.text(4); 147 902 const created_at = row.?.text(5); 148 903 149 - // parse options array to get count 150 904 const parsed = json.parseFromSlice(json.Value, alloc, options_json, .{}) catch { 151 905 try sendNotFound(request); 152 906 return; ··· 160 914 161 915 const options = parsed.value.array.items; 162 916 163 - // build response 164 917 var response: std.ArrayList(u8) = .{}; 165 918 defer response.deinit(alloc); 166 919 ··· 180 933 break :blk 0; 181 934 }; 182 935 183 - // use json.fmt to properly escape quotes and special chars 184 936 try response.print(alloc, 185 937 \\{{"text":{f},"count":{d}}} 186 938 , .{ json.fmt(opt, .{}), count }); ··· 198 950 defer arena.deinit(); 199 951 const alloc = arena.allocator(); 200 952 201 - // decode uri 202 953 const uri_buf = try alloc.dupe(u8, uri_encoded); 203 954 const uri = std.Uri.percentDecodeInPlace(uri_buf); 204 955 ··· 211 962 try response.appendSlice(alloc, "["); 212 963 213 964 var rows = db.conn.rows( 214 - "SELECT voter, option, uri, created_at FROM votes WHERE subject = ?", 965 + \\SELECT v.voter, v.option, v.uri, v.created_at, p.handle 966 + \\FROM votes v LEFT JOIN profiles p ON v.voter = p.did 967 + \\WHERE v.subject = ? 968 + , 215 969 .{uri}, 216 970 ) catch { 217 971 try sendJson(request, "[]"); ··· 228 982 const option = row.int(1); 229 983 const vote_uri = row.text(2); 230 984 const created_at = row.text(3); 985 + const handle = row.text(4); 986 + const has_handle = handle.len > 0; 231 987 232 - try response.print(alloc, 233 - \\{{"voter":"{s}","option":{d},"uri":"{s}","createdAt":"{s}"}} 234 - , .{ voter, option, vote_uri, created_at }); 988 + if (has_handle) { 989 + try response.print(alloc, 990 + \\{{"voter":"{s}","option":{d},"uri":"{s}","createdAt":"{s}","handle":"{s}"}} 991 + , .{ voter, option, vote_uri, created_at, handle }); 992 + } else { 993 + try response.print(alloc, 994 + \\{{"voter":"{s}","option":{d},"uri":"{s}","createdAt":"{s}"}} 995 + , .{ voter, option, vote_uri, created_at }); 996 + } 235 997 } 236 998 237 999 if (rows.err) |err| { ··· 242 1004 try sendJson(request, response.items); 243 1005 } 244 1006 1007 + // --- HTTP helpers --- 1008 + 245 1009 fn sendJson(request: *http.Server.Request, body: []const u8) !void { 246 1010 try request.respond(body, .{ 247 1011 .status = .ok, 248 1012 .extra_headers = &.{ 249 1013 .{ .name = "content-type", .value = "application/json" }, 250 - .{ .name = "access-control-allow-origin", .value = "*" }, 251 - .{ .name = "access-control-allow-methods", .value = "GET, OPTIONS" }, 1014 + .{ .name = "access-control-allow-origin", .value = getFrontendOrigin() }, 1015 + .{ .name = "access-control-allow-credentials", .value = "true" }, 1016 + .{ .name = "access-control-allow-methods", .value = "GET, POST, DELETE, OPTIONS" }, 252 1017 .{ .name = "access-control-allow-headers", .value = "content-type" }, 253 1018 }, 254 1019 }); 1020 + } 1021 + 1022 + fn sendJsonWithCredentials(request: *http.Server.Request, body: []const u8) !void { 1023 + try sendJson(request, body); 255 1024 } 256 1025 257 1026 fn sendCorsHeaders(request: *http.Server.Request, body: []const u8) !void { 258 1027 try request.respond(body, .{ 259 1028 .status = .no_content, 260 1029 .extra_headers = &.{ 261 - .{ .name = "access-control-allow-origin", .value = "*" }, 262 - .{ .name = "access-control-allow-methods", .value = "GET, OPTIONS" }, 1030 + .{ .name = "access-control-allow-origin", .value = getFrontendOrigin() }, 1031 + .{ .name = "access-control-allow-credentials", .value = "true" }, 1032 + .{ .name = "access-control-allow-methods", .value = "GET, POST, DELETE, OPTIONS" }, 263 1033 .{ .name = "access-control-allow-headers", .value = "content-type" }, 264 1034 }, 265 1035 }); ··· 270 1040 .status = .not_found, 271 1041 .extra_headers = &.{ 272 1042 .{ .name = "content-type", .value = "application/json" }, 273 - .{ .name = "access-control-allow-origin", .value = "*" }, 1043 + .{ .name = "access-control-allow-origin", .value = getFrontendOrigin() }, 1044 + .{ .name = "access-control-allow-credentials", .value = "true" }, 1045 + }, 1046 + }); 1047 + } 1048 + 1049 + fn sendError(request: *http.Server.Request, status: http.Status, message: []const u8) !void { 1050 + var buf: [512]u8 = undefined; 1051 + const body = std.fmt.bufPrint(&buf, "{{\"error\":\"{s}\"}}", .{message}) catch "{\"error\":\"internal error\"}"; 1052 + try request.respond(body, .{ 1053 + .status = status, 1054 + .extra_headers = &.{ 1055 + .{ .name = "content-type", .value = "application/json" }, 1056 + .{ .name = "access-control-allow-origin", .value = getFrontendOrigin() }, 1057 + .{ .name = "access-control-allow-credentials", .value = "true" }, 1058 + }, 1059 + }); 1060 + } 1061 + 1062 + fn sendRedirect(request: *http.Server.Request, location: []const u8) !void { 1063 + try request.respond("", .{ 1064 + .status = .found, 1065 + .extra_headers = &.{ 1066 + .{ .name = "location", .value = location }, 1067 + }, 1068 + }); 1069 + } 1070 + 1071 + // --- session/cookie helpers --- 1072 + 1073 + fn getSessionDid(request: *http.Server.Request) ?[]const u8 { 1074 + // parse cookie header for pollz_session=<did> 1075 + var it = request.iterateHeaders(); 1076 + while (it.next()) |h| { 1077 + if (std.ascii.eqlIgnoreCase(h.name, "cookie")) { 1078 + return parseCookieValue(h.value, "pollz_session"); 1079 + } 1080 + } 1081 + return null; 1082 + } 1083 + 1084 + fn parseCookieValue(cookie_header: []const u8, name: []const u8) ?[]const u8 { 1085 + var iter = mem.splitSequence(u8, cookie_header, "; "); 1086 + while (iter.next()) |pair| { 1087 + if (mem.startsWith(u8, pair, name)) { 1088 + if (pair.len > name.len and pair[name.len] == '=') { 1089 + return pair[name.len + 1 ..]; 1090 + } 1091 + } 1092 + } 1093 + return null; 1094 + } 1095 + 1096 + fn extractQueryParam(target: []const u8, name: []const u8) ?[]const u8 { 1097 + const q_idx = mem.indexOf(u8, target, "?") orelse return null; 1098 + const query = target[q_idx + 1 ..]; 1099 + var iter = mem.splitScalar(u8, query, '&'); 1100 + while (iter.next()) |pair| { 1101 + const eq_idx = mem.indexOf(u8, pair, "=") orelse continue; 1102 + if (mem.eql(u8, pair[0..eq_idx], name)) { 1103 + return pair[eq_idx + 1 ..]; 1104 + } 1105 + } 1106 + return null; 1107 + } 1108 + 1109 + fn readRequestBody(alloc: std.mem.Allocator, request: *http.Server.Request) ?[]u8 { 1110 + const content_length = request.head.content_length orelse return null; 1111 + if (content_length > 1024 * 64) return null; 1112 + request.head.expect = null; 1113 + var buf: [8192]u8 = undefined; 1114 + const reader = request.readerExpectNone(&buf); 1115 + return reader.readAlloc(alloc, @intCast(content_length)) catch null; 1116 + } 1117 + 1118 + fn keypairFromHex(hex: []const u8) !zat.Keypair { 1119 + if (hex.len != 64) return error.InvalidKeyHex; 1120 + var key_bytes: [32]u8 = undefined; 1121 + _ = std.fmt.hexToBytes(&key_bytes, hex) catch return error.InvalidKeyHex; 1122 + return zat.Keypair.fromSecretKey(.p256, key_bytes); 1123 + } 1124 + 1125 + // --- OAuth HTTP client helpers --- 1126 + 1127 + /// simple HTTP GET that returns response body as owned slice 1128 + fn httpGet(alloc: std.mem.Allocator, url: []const u8) ![]u8 { 1129 + var client: std.http.Client = .{ .allocator = alloc }; 1130 + defer client.deinit(); 1131 + 1132 + var aw: std.Io.Writer.Allocating = .init(alloc); 1133 + 1134 + const result = client.fetch(.{ 1135 + .location = .{ .url = url }, 1136 + .response_writer = &aw.writer, 1137 + .headers = .{ 1138 + .accept_encoding = .{ .override = "identity" }, 1139 + }, 1140 + }) catch return error.FetchFailed; 1141 + 1142 + if (result.status != .ok) { 1143 + aw.deinit(); 1144 + return error.FetchFailed; 1145 + } 1146 + 1147 + return aw.toOwnedSlice() catch error.FetchFailed; 1148 + } 1149 + 1150 + fn fetchAuthServerUrl(alloc: std.mem.Allocator, pds_url: []const u8) ![]const u8 { 1151 + const url = try std.fmt.allocPrint(alloc, "{s}/.well-known/oauth-protected-resource", .{pds_url}); 1152 + defer alloc.free(url); 1153 + 1154 + const body = try httpGet(alloc, url); 1155 + defer alloc.free(body); 1156 + 1157 + const parsed = try json.parseFromSlice(json.Value, alloc, body, .{}); 1158 + defer parsed.deinit(); 1159 + 1160 + const servers = parsed.value.object.get("authorization_servers") orelse return error.NoAuthServers; 1161 + if (servers != .array or servers.array.items.len == 0) return error.NoAuthServers; 1162 + 1163 + const first = servers.array.items[0]; 1164 + if (first != .string) return error.NoAuthServers; 1165 + 1166 + return alloc.dupe(u8, first.string); 1167 + } 1168 + 1169 + fn fetchAuthServerMeta(alloc: std.mem.Allocator, authserver_url: []const u8) !json.Parsed(json.Value) { 1170 + const url = try std.fmt.allocPrint(alloc, "{s}/.well-known/oauth-authorization-server", .{authserver_url}); 1171 + defer alloc.free(url); 1172 + 1173 + const body = try httpGet(alloc, url); 1174 + 1175 + return json.parseFromSlice(json.Value, alloc, body, .{}); 1176 + } 1177 + 1178 + const ParResult = struct { 1179 + request_uri: []const u8, 1180 + dpop_nonce: []const u8, 1181 + }; 1182 + 1183 + const ParParams = struct { 1184 + par_url: []const u8, 1185 + authserver_url: []const u8, 1186 + client_id: []const u8, 1187 + redirect_uri: []const u8, 1188 + scope: []const u8, 1189 + state: []const u8, 1190 + pkce_challenge: []const u8, 1191 + handle: []const u8, 1192 + client_keypair: *const zat.Keypair, 1193 + dpop_keypair: *const zat.Keypair, 1194 + }; 1195 + 1196 + fn sendParRequest(alloc: std.mem.Allocator, params: ParParams) !ParResult { 1197 + const client_assertion = try oauth.createClientAssertion(alloc, params.client_keypair, params.client_id, params.authserver_url); 1198 + defer alloc.free(client_assertion); 1199 + 1200 + const dpop_proof = try oauth.createDpopProof(alloc, params.dpop_keypair, "POST", params.par_url, null, null); 1201 + defer alloc.free(dpop_proof); 1202 + 1203 + const form_params = [_][2][]const u8{ 1204 + .{ "response_type", "code" }, 1205 + .{ "code_challenge", params.pkce_challenge }, 1206 + .{ "code_challenge_method", "S256" }, 1207 + .{ "redirect_uri", params.redirect_uri }, 1208 + .{ "scope", params.scope }, 1209 + .{ "state", params.state }, 1210 + .{ "login_hint", params.handle }, 1211 + .{ "client_id", params.client_id }, 1212 + .{ "client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" }, 1213 + .{ "client_assertion", client_assertion }, 1214 + }; 1215 + 1216 + const form_body = try oauth.formEncode(alloc, &form_params); 1217 + defer alloc.free(form_body); 1218 + 1219 + // first attempt 1220 + var result = try doPost(alloc, params.par_url, form_body, &.{ 1221 + .{ .name = "DPoP", .value = dpop_proof }, 1222 + }); 1223 + 1224 + // handle DPoP nonce requirement (retry once) 1225 + if (isDpopNonceError(result.status, result.body)) { 1226 + const new_nonce = result.dpop_nonce orelse return error.MissingDpopNonce; 1227 + 1228 + alloc.free(result.body); 1229 + 1230 + const dpop_proof2 = try oauth.createDpopProof(alloc, params.dpop_keypair, "POST", params.par_url, new_nonce, null); 1231 + defer alloc.free(dpop_proof2); 1232 + 1233 + result = try doPost(alloc, params.par_url, form_body, &.{ 1234 + .{ .name = "DPoP", .value = dpop_proof2 }, 1235 + }); 1236 + } 1237 + 1238 + defer alloc.free(result.body); 1239 + 1240 + if (result.status != .ok and result.status != .created) { 1241 + std.debug.print("PAR error: {s}\n", .{result.body}); 1242 + return error.ParFailed; 1243 + } 1244 + 1245 + const parsed = try json.parseFromSlice(json.Value, alloc, result.body, .{}); 1246 + defer parsed.deinit(); 1247 + 1248 + const request_uri = jsonGetString(parsed.value, "request_uri") orelse return error.MissingRequestUri; 1249 + 1250 + return .{ 1251 + .request_uri = try alloc.dupe(u8, request_uri), 1252 + .dpop_nonce = if (result.dpop_nonce) |n| try alloc.dupe(u8, n) else try alloc.dupe(u8, ""), 1253 + }; 1254 + } 1255 + 1256 + const HttpResult = struct { 1257 + status: http.Status, 1258 + body: []u8, 1259 + dpop_nonce: ?[]const u8, 1260 + }; 1261 + 1262 + fn doPost(alloc: std.mem.Allocator, url: []const u8, payload: []const u8, extra_headers: []const http.Header) !HttpResult { 1263 + var client: std.http.Client = .{ .allocator = alloc }; 1264 + defer client.deinit(); 1265 + 1266 + var req = try client.request(.POST, try std.Uri.parse(url), .{ 1267 + .extra_headers = extra_headers, 1268 + .headers = .{ 1269 + .content_type = .{ .override = "application/x-www-form-urlencoded" }, 1270 + .accept_encoding = .{ .override = "identity" }, 1271 + }, 1272 + }); 1273 + defer req.deinit(); 1274 + 1275 + req.transfer_encoding = .{ .content_length = payload.len }; 1276 + var body_writer = try req.sendBodyUnflushed(&.{}); 1277 + try body_writer.writer.writeAll(payload); 1278 + try body_writer.end(); 1279 + try req.connection.?.flush(); 1280 + 1281 + var redirect_buf: [1]u8 = undefined; 1282 + var response = req.receiveHead(&redirect_buf) catch return error.FetchFailed; 1283 + 1284 + // extract DPoP-Nonce from response headers 1285 + var dpop_nonce: ?[]const u8 = null; 1286 + var header_iter = response.head.iterateHeaders(); 1287 + while (header_iter.next()) |header| { 1288 + if (std.ascii.eqlIgnoreCase(header.name, "dpop-nonce")) { 1289 + dpop_nonce = try alloc.dupe(u8, header.value); 1290 + break; 1291 + } 1292 + } 1293 + 1294 + var aw: std.Io.Writer.Allocating = .init(alloc); 1295 + const reader = response.reader(&.{}); 1296 + _ = reader.streamRemaining(&aw.writer) catch { 1297 + aw.deinit(); 1298 + return error.FetchFailed; 1299 + }; 1300 + 1301 + const resp_body = aw.toOwnedSlice() catch { 1302 + return error.FetchFailed; 1303 + }; 1304 + 1305 + return .{ 1306 + .status = response.head.status, 1307 + .body = resp_body, 1308 + .dpop_nonce = dpop_nonce, 1309 + }; 1310 + } 1311 + 1312 + const TokenResult = struct { 1313 + access_token: []const u8, 1314 + refresh_token: []const u8, 1315 + sub: []const u8, 1316 + dpop_nonce: []const u8, 1317 + }; 1318 + 1319 + const TokenParams = struct { 1320 + token_url: []const u8, 1321 + authserver_url: []const u8, 1322 + client_id: []const u8, 1323 + redirect_uri: []const u8, 1324 + code: []const u8, 1325 + pkce_verifier: []const u8, 1326 + client_keypair: *const zat.Keypair, 1327 + dpop_keypair: *const zat.Keypair, 1328 + dpop_nonce: []const u8, 1329 + }; 1330 + 1331 + fn sendTokenRequest(alloc: std.mem.Allocator, params: TokenParams) !TokenResult { 1332 + const client_assertion = try oauth.createClientAssertion(alloc, params.client_keypair, params.client_id, params.authserver_url); 1333 + defer alloc.free(client_assertion); 1334 + 1335 + const dpop_proof = try oauth.createDpopProof(alloc, params.dpop_keypair, "POST", params.token_url, if (params.dpop_nonce.len > 0) params.dpop_nonce else null, null); 1336 + defer alloc.free(dpop_proof); 1337 + 1338 + const form_params = [_][2][]const u8{ 1339 + .{ "grant_type", "authorization_code" }, 1340 + .{ "code", params.code }, 1341 + .{ "redirect_uri", params.redirect_uri }, 1342 + .{ "code_verifier", params.pkce_verifier }, 1343 + .{ "client_id", params.client_id }, 1344 + .{ "client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" }, 1345 + .{ "client_assertion", client_assertion }, 1346 + }; 1347 + 1348 + const form_body = try oauth.formEncode(alloc, &form_params); 1349 + defer alloc.free(form_body); 1350 + 1351 + var result = try doPost(alloc, params.token_url, form_body, &.{ 1352 + .{ .name = "DPoP", .value = dpop_proof }, 1353 + }); 1354 + 1355 + // handle DPoP nonce retry 1356 + if (isDpopNonceError(result.status, result.body)) { 1357 + const new_nonce = result.dpop_nonce orelse return error.MissingDpopNonce; 1358 + alloc.free(result.body); 1359 + 1360 + const dpop_proof2 = try oauth.createDpopProof(alloc, params.dpop_keypair, "POST", params.token_url, new_nonce, null); 1361 + defer alloc.free(dpop_proof2); 1362 + 1363 + result = try doPost(alloc, params.token_url, form_body, &.{ 1364 + .{ .name = "DPoP", .value = dpop_proof2 }, 1365 + }); 1366 + } 1367 + 1368 + defer alloc.free(result.body); 1369 + 1370 + if (result.status != .ok) { 1371 + std.debug.print("token exchange error: {s}\n", .{result.body}); 1372 + return error.TokenExchangeFailed; 1373 + } 1374 + 1375 + const parsed = try json.parseFromSlice(json.Value, alloc, result.body, .{}); 1376 + defer parsed.deinit(); 1377 + 1378 + return .{ 1379 + .access_token = try alloc.dupe(u8, jsonGetString(parsed.value, "access_token") orelse return error.MissingAccessToken), 1380 + .refresh_token = try alloc.dupe(u8, jsonGetString(parsed.value, "refresh_token") orelse return error.MissingRefreshToken), 1381 + .sub = try alloc.dupe(u8, jsonGetString(parsed.value, "sub") orelse return error.MissingSub), 1382 + .dpop_nonce = if (result.dpop_nonce) |n| try alloc.dupe(u8, n) else try alloc.dupe(u8, ""), 1383 + }; 1384 + } 1385 + 1386 + fn pdsAuthedRequest(alloc: std.mem.Allocator, session: db.Session, method_str: []const u8, path: []const u8, body: ?[]const u8) ![]u8 { 1387 + const dpop_keypair = keypairFromHex(session.dpop_private_key) catch return error.InvalidSessionKey; 1388 + 1389 + const url = try std.fmt.allocPrint(alloc, "{s}{s}", .{ session.pds_url, path }); 1390 + defer alloc.free(url); 1391 + 1392 + const ath = try oauth.accessTokenHash(alloc, session.access_token); 1393 + defer alloc.free(ath); 1394 + 1395 + const dpop_proof = try oauth.createDpopProof( 1396 + alloc, 1397 + &dpop_keypair, 1398 + method_str, 1399 + url, 1400 + if (session.dpop_pds_nonce.len > 0) session.dpop_pds_nonce else null, 1401 + ath, 1402 + ); 1403 + defer alloc.free(dpop_proof); 1404 + 1405 + var auth_header_buf: [4096]u8 = undefined; 1406 + const auth_header = std.fmt.bufPrint(&auth_header_buf, "DPoP {s}", .{session.access_token}) catch return error.AuthHeaderTooLong; 1407 + 1408 + const http_method: http.Method = if (mem.eql(u8, method_str, "POST")) .POST else .GET; 1409 + 1410 + var client: std.http.Client = .{ .allocator = alloc }; 1411 + defer client.deinit(); 1412 + 1413 + var req = try client.request(http_method, try std.Uri.parse(url), .{ 1414 + .extra_headers = &.{ 1415 + .{ .name = "Authorization", .value = auth_header }, 1416 + .{ .name = "DPoP", .value = dpop_proof }, 274 1417 }, 1418 + .headers = .{ 1419 + .content_type = .{ .override = "application/json" }, 1420 + }, 1421 + }); 1422 + defer req.deinit(); 1423 + 1424 + if (body) |b| { 1425 + req.transfer_encoding = .{ .content_length = b.len }; 1426 + var body_writer = try req.sendBodyUnflushed(&.{}); 1427 + try body_writer.writer.writeAll(b); 1428 + try body_writer.end(); 1429 + try req.connection.?.flush(); 1430 + } else { 1431 + try req.sendBodiless(); 1432 + } 1433 + 1434 + var redirect_buf: [1]u8 = undefined; 1435 + var response = req.receiveHead(&redirect_buf) catch return error.FetchFailed; 1436 + 1437 + // extract DPoP-Nonce from response headers 1438 + var header_iter = response.head.iterateHeaders(); 1439 + while (header_iter.next()) |header| { 1440 + if (std.ascii.eqlIgnoreCase(header.name, "dpop-nonce")) { 1441 + db.updateSessionNonce(session.did, .pds, header.value); 1442 + 1443 + // handle DPoP nonce retry 1444 + if (isDpopNonceErrorStatus(response.head.status)) { 1445 + var updated_session = session; 1446 + updated_session.dpop_pds_nonce = header.value; 1447 + return pdsAuthedRequest(alloc, updated_session, method_str, path, body); 1448 + } 1449 + break; 1450 + } 1451 + } 1452 + 1453 + var aw: std.Io.Writer.Allocating = .init(alloc); 1454 + const reader = response.reader(&.{}); 1455 + _ = reader.streamRemaining(&aw.writer) catch { 1456 + aw.deinit(); 1457 + return error.FetchFailed; 1458 + }; 1459 + 1460 + return aw.toOwnedSlice() catch error.FetchFailed; 1461 + } 1462 + 1463 + fn isDpopNonceError(status: http.Status, body: []const u8) bool { 1464 + if (status != .bad_request and status != .unauthorized) return false; 1465 + return mem.indexOf(u8, body, "use_dpop_nonce") != null; 1466 + } 1467 + 1468 + fn isDpopNonceErrorStatus(status: http.Status) bool { 1469 + return status == .bad_request or status == .unauthorized; 1470 + } 1471 + 1472 + // --- JSON helpers --- 1473 + 1474 + fn jsonGetString(value: json.Value, key: []const u8) ?[]const u8 { 1475 + if (value != .object) return null; 1476 + const v = value.object.get(key) orelse return null; 1477 + if (v != .string) return null; 1478 + return v.string; 1479 + } 1480 + 1481 + fn jsonGetInt(value: json.Value, key: []const u8) ?i64 { 1482 + if (value != .object) return null; 1483 + const v = value.object.get(key) orelse return null; 1484 + if (v != .integer) return null; 1485 + return v.integer; 1486 + } 1487 + 1488 + fn jsonGetPath(value: json.Value, key: []const u8) ?json.Value { 1489 + if (value != .object) return null; 1490 + return value.object.get(key); 1491 + } 1492 + 1493 + fn formatTimestamp(alloc: std.mem.Allocator) ![]u8 { 1494 + const now = std.time.timestamp(); 1495 + const epoch_secs: std.time.epoch.EpochSeconds = .{ .secs = @intCast(now) }; 1496 + const day = epoch_secs.getDaySeconds(); 1497 + const year_day = epoch_secs.getEpochDay().calculateYearDay(); 1498 + const md = year_day.calculateMonthDay(); 1499 + return std.fmt.allocPrint(alloc, "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}.000Z", .{ 1500 + year_day.year, 1501 + @intFromEnum(md.month), 1502 + md.day_index + 1, 1503 + day.getHoursIntoDay(), 1504 + day.getMinutesIntoHour(), 1505 + day.getSecondsIntoMinute(), 275 1506 }); 276 1507 }
+113
backend/src/jetstream.zig
··· 1 + const std = @import("std"); 2 + const mem = std.mem; 3 + const json = std.json; 4 + const Allocator = mem.Allocator; 5 + const zat = @import("zat"); 6 + const db = @import("db.zig"); 7 + 8 + const POLL_COLLECTION = "tech.waow.pollz.poll"; 9 + const VOTE_COLLECTION = "tech.waow.pollz.vote"; 10 + 11 + const Handler = struct { 12 + allocator: Allocator, 13 + msg_count: usize = 0, 14 + 15 + pub fn onEvent(self: *Handler, event: zat.JetstreamEvent) void { 16 + self.msg_count += 1; 17 + if (self.msg_count % 100 == 1) { 18 + std.debug.print("jetstream: processed {} events\n", .{self.msg_count}); 19 + } 20 + 21 + switch (event) { 22 + .commit => |commit| processCommit(self.allocator, commit) catch |err| { 23 + std.debug.print("commit processing error: {}\n", .{err}); 24 + }, 25 + else => {}, 26 + } 27 + } 28 + 29 + pub fn onConnect(_: *Handler, host: []const u8) void { 30 + std.debug.print("jetstream connected to {s}\n", .{host}); 31 + } 32 + 33 + pub fn onError(_: *Handler, err: anyerror) void { 34 + std.debug.print("jetstream error: {s}\n", .{@errorName(err)}); 35 + } 36 + }; 37 + 38 + fn processCommit(allocator: Allocator, commit: zat.jetstream.CommitEvent) !void { 39 + const collection = commit.collection; 40 + const is_poll = mem.eql(u8, collection, POLL_COLLECTION); 41 + const is_vote = mem.eql(u8, collection, VOTE_COLLECTION); 42 + if (!is_poll and !is_vote) return; 43 + 44 + const uri = try std.fmt.allocPrint(allocator, "at://{s}/{s}/{s}", .{ commit.did, collection, commit.rkey }); 45 + defer allocator.free(uri); 46 + 47 + switch (commit.operation) { 48 + .create, .update => { 49 + const record = commit.record orelse return; 50 + if (record != .object) return; 51 + 52 + if (is_poll) { 53 + try processPoll(allocator, uri, commit.did, commit.rkey, record.object); 54 + } else { 55 + try processVote(uri, commit.did, record.object); 56 + } 57 + }, 58 + .delete => { 59 + if (is_poll) { 60 + db.deletePoll(uri); 61 + std.debug.print("deleted poll: {s}\n", .{uri}); 62 + } else { 63 + db.deleteVote(uri); 64 + std.debug.print("deleted vote: {s}\n", .{uri}); 65 + } 66 + }, 67 + } 68 + } 69 + 70 + fn processPoll(allocator: Allocator, uri: []const u8, did: []const u8, rkey: []const u8, record: json.ObjectMap) !void { 71 + const text_val = record.get("text") orelse return; 72 + if (text_val != .string) return; 73 + 74 + const options_val = record.get("options") orelse return; 75 + if (options_val != .array) return; 76 + 77 + const created_at_val = record.get("createdAt") orelse return; 78 + if (created_at_val != .string) return; 79 + 80 + var options_buf: std.ArrayList(u8) = .{}; 81 + defer options_buf.deinit(allocator); 82 + try options_buf.print(allocator, "{f}", .{json.fmt(options_val, .{})}); 83 + 84 + var text_buf: std.ArrayList(u8) = .{}; 85 + defer text_buf.deinit(allocator); 86 + try text_buf.print(allocator, "{f}", .{json.fmt(text_val, .{})}); 87 + 88 + try db.insertPoll(uri, did, rkey, text_buf.items, options_buf.items, created_at_val.string); 89 + std.debug.print("indexed poll: {s}\n", .{uri}); 90 + } 91 + 92 + fn processVote(uri: []const u8, did: []const u8, record: json.ObjectMap) !void { 93 + const subject_val = record.get("subject") orelse return; 94 + if (subject_val != .string) return; 95 + 96 + const option_val = record.get("option") orelse return; 97 + if (option_val != .integer) return; 98 + 99 + const created_at: ?[]const u8 = if (record.get("createdAt")) |v| (if (v == .string) v.string else null) else null; 100 + 101 + try db.insertVote(uri, subject_val.string, @as(i32, @intCast(option_val.integer)), did, created_at); 102 + std.debug.print("indexed vote: {s} -> {s}\n", .{ uri, subject_val.string }); 103 + } 104 + 105 + pub fn start(allocator: Allocator) void { 106 + var client = zat.JetstreamClient.init(allocator, .{ 107 + .wanted_collections = &.{ POLL_COLLECTION, VOTE_COLLECTION }, 108 + }); 109 + defer client.deinit(); 110 + 111 + var handler = Handler{ .allocator = allocator }; 112 + client.subscribe(&handler); 113 + }
+4 -4
backend/src/main.zig
··· 4 4 const Thread = std.Thread; 5 5 const db = @import("db.zig"); 6 6 const http_server = @import("http.zig"); 7 - const tap = @import("tap.zig"); 7 + const jetstream = @import("jetstream.zig"); 8 8 9 9 // max concurrent http connections (prevents resource exhaustion) 10 10 const MAX_HTTP_WORKERS = 16; ··· 22 22 try db.init(db_path); 23 23 defer db.close(); 24 24 25 - // start tap consumer in background 26 - const tap_thread = try Thread.spawn(.{}, tap.consumer, .{allocator}); 27 - defer tap_thread.join(); 25 + // start jetstream consumer in background 26 + const js_thread = try Thread.spawn(.{}, jetstream.start, .{allocator}); 27 + defer js_thread.join(); 28 28 29 29 // init thread pool for http connections 30 30 var pool: Thread.Pool = undefined;
+434
backend/src/oauth.zig
··· 1 + const std = @import("std"); 2 + const mem = std.mem; 3 + const json = std.json; 4 + const crypto = std.crypto; 5 + const Allocator = mem.Allocator; 6 + const zat = @import("zat"); 7 + 8 + const base64url = std.base64.url_safe_no_pad; 9 + 10 + // --- JWT creation --- 11 + 12 + /// create a signed JWT from header and payload JSON strings. 13 + /// caller owns returned slice. 14 + pub fn createJwt(allocator: Allocator, header_json: []const u8, payload_json: []const u8, keypair: *const zat.Keypair) ![]u8 { 15 + const header_b64 = try base64urlEncode(allocator, header_json); 16 + defer allocator.free(header_b64); 17 + 18 + const payload_b64 = try base64urlEncode(allocator, payload_json); 19 + defer allocator.free(payload_b64); 20 + 21 + // signing input: header.payload 22 + const signing_input = try std.fmt.allocPrint(allocator, "{s}.{s}", .{ header_b64, payload_b64 }); 23 + defer allocator.free(signing_input); 24 + 25 + const sig = try keypair.sign(signing_input); 26 + const sig_b64 = try base64urlEncodeBytes(allocator, &sig.bytes); 27 + defer allocator.free(sig_b64); 28 + 29 + return std.fmt.allocPrint(allocator, "{s}.{s}", .{ signing_input, sig_b64 }); 30 + } 31 + 32 + // --- DPoP proof --- 33 + 34 + /// create a DPoP proof JWT per RFC 9449. 35 + /// htm: HTTP method (e.g. "POST"), htu: target URI, nonce: server-provided DPoP-Nonce, 36 + /// ath: optional access token hash (base64url-encoded SHA-256 of the access token). 37 + pub fn createDpopProof( 38 + allocator: Allocator, 39 + keypair: *const zat.Keypair, 40 + htm: []const u8, 41 + htu: []const u8, 42 + nonce: ?[]const u8, 43 + ath: ?[]const u8, 44 + ) ![]u8 { 45 + const jwk = try jwkPublicKey(allocator, keypair); 46 + defer allocator.free(jwk); 47 + 48 + const jti = try generateJti(allocator); 49 + defer allocator.free(jti); 50 + 51 + const now = std.time.timestamp(); 52 + 53 + // header: {"typ":"dpop+jwt","alg":"ES256","jwk":{...}} 54 + const header = try std.fmt.allocPrint(allocator, 55 + \\{{"typ":"dpop+jwt","alg":"ES256","jwk":{s}}} 56 + , .{jwk}); 57 + defer allocator.free(header); 58 + 59 + // payload 60 + var payload_buf: std.ArrayList(u8) = .{}; 61 + defer payload_buf.deinit(allocator); 62 + 63 + try payload_buf.appendSlice(allocator, "{"); 64 + try payload_buf.print(allocator, 65 + \\"jti":"{s}","htm":"{s}","htu":"{s}","iat":{d} 66 + , .{ jti, htm, htu, now }); 67 + 68 + if (nonce) |n| { 69 + try payload_buf.print(allocator, ",\"nonce\":\"{s}\"", .{n}); 70 + } 71 + if (ath) |a| { 72 + try payload_buf.print(allocator, ",\"ath\":\"{s}\"", .{a}); 73 + } 74 + 75 + try payload_buf.appendSlice(allocator, "}"); 76 + 77 + return createJwt(allocator, header, payload_buf.items, keypair); 78 + } 79 + 80 + // --- client assertion --- 81 + 82 + /// compute the JWK thumbprint (kid) for a keypair. 83 + /// caller owns returned slice. 84 + pub fn jwkThumbprint(allocator: Allocator, keypair: *const zat.Keypair) ![]u8 { 85 + const Scheme = crypto.sign.ecdsa.EcdsaP256Sha256; 86 + const sk = Scheme.SecretKey.fromBytes(keypair.secret_key) catch return error.InvalidSecretKey; 87 + const kp = Scheme.KeyPair.fromSecretKey(sk) catch return error.InvalidSecretKey; 88 + const uncompressed = kp.public_key.toUncompressedSec1(); 89 + 90 + const x_b64 = try base64urlEncodeBytes(allocator, uncompressed[1..33]); 91 + defer allocator.free(x_b64); 92 + const y_b64 = try base64urlEncodeBytes(allocator, uncompressed[33..65]); 93 + defer allocator.free(y_b64); 94 + 95 + const input = try std.fmt.allocPrint(allocator, 96 + \\{{"crv":"P-256","kty":"EC","x":"{s}","y":"{s}"}} 97 + , .{ x_b64, y_b64 }); 98 + defer allocator.free(input); 99 + 100 + var hash: [32]u8 = undefined; 101 + crypto.hash.sha2.Sha256.hash(input, &hash, .{}); 102 + return base64urlEncodeBytes(allocator, &hash); 103 + } 104 + 105 + /// create a `private_key_jwt` client assertion for token endpoint auth. 106 + /// client_id: the OAuth client ID, aud: the token endpoint URL. 107 + pub fn createClientAssertion( 108 + allocator: Allocator, 109 + keypair: *const zat.Keypair, 110 + client_id: []const u8, 111 + aud: []const u8, 112 + ) ![]u8 { 113 + const jti = try generateJti(allocator); 114 + defer allocator.free(jti); 115 + 116 + const kid = try jwkThumbprint(allocator, keypair); 117 + defer allocator.free(kid); 118 + 119 + const now = std.time.timestamp(); 120 + 121 + const header = try std.fmt.allocPrint(allocator, 122 + \\{{"typ":"JWT","alg":"ES256","kid":"{s}"}} 123 + , .{kid}); 124 + defer allocator.free(header); 125 + 126 + const payload = try std.fmt.allocPrint(allocator, 127 + \\{{"iss":"{s}","sub":"{s}","aud":"{s}","jti":"{s}","iat":{d},"exp":{d}}} 128 + , .{ client_id, client_id, aud, jti, now, now + 120 }); 129 + defer allocator.free(payload); 130 + 131 + return createJwt(allocator, header, payload, keypair); 132 + } 133 + 134 + // --- PKCE S256 --- 135 + 136 + /// generate a PKCE code challenge from a code verifier using S256. 137 + /// caller owns returned slice. 138 + pub fn generatePkceChallenge(allocator: Allocator, verifier: []const u8) ![]u8 { 139 + var hash: [32]u8 = undefined; 140 + crypto.hash.sha2.Sha256.hash(verifier, &hash, .{}); 141 + return base64urlEncodeBytes(allocator, &hash); 142 + } 143 + 144 + /// generate a random PKCE code verifier (43 chars, base64url-encoded 32 random bytes). 145 + /// caller owns returned slice. 146 + pub fn generatePkceVerifier(allocator: Allocator) ![]u8 { 147 + var random_bytes: [32]u8 = undefined; 148 + crypto.random.bytes(&random_bytes); 149 + return base64urlEncodeBytes(allocator, &random_bytes); 150 + } 151 + 152 + /// generate a random state parameter. 153 + /// caller owns returned slice. 154 + pub fn generateState(allocator: Allocator) ![]u8 { 155 + var random_bytes: [16]u8 = undefined; 156 + crypto.random.bytes(&random_bytes); 157 + return base64urlEncodeBytes(allocator, &random_bytes); 158 + } 159 + 160 + // --- JWK --- 161 + 162 + /// generate a JWK JSON string for the public key of a P-256 keypair. 163 + /// includes kid (JWK thumbprint per RFC 7638), use, and alg fields. 164 + /// caller owns returned slice. 165 + pub fn jwkPublicKey(allocator: Allocator, keypair: *const zat.Keypair) ![]u8 { 166 + const Scheme = crypto.sign.ecdsa.EcdsaP256Sha256; 167 + const sk = Scheme.SecretKey.fromBytes(keypair.secret_key) catch return error.InvalidSecretKey; 168 + const kp = Scheme.KeyPair.fromSecretKey(sk) catch return error.InvalidSecretKey; 169 + const uncompressed = kp.public_key.toUncompressedSec1(); 170 + 171 + // uncompressed format: 0x04 || x[32] || y[32] 172 + const x = uncompressed[1..33]; 173 + const y = uncompressed[33..65]; 174 + 175 + const x_b64 = try base64urlEncodeBytes(allocator, x); 176 + defer allocator.free(x_b64); 177 + 178 + const y_b64 = try base64urlEncodeBytes(allocator, y); 179 + defer allocator.free(y_b64); 180 + 181 + // JWK thumbprint (RFC 7638): SHA-256 of canonical JSON with members in lexicographic order 182 + const thumbprint_input = try std.fmt.allocPrint(allocator, 183 + \\{{"crv":"P-256","kty":"EC","x":"{s}","y":"{s}"}} 184 + , .{ x_b64, y_b64 }); 185 + defer allocator.free(thumbprint_input); 186 + 187 + var thumbprint_hash: [32]u8 = undefined; 188 + crypto.hash.sha2.Sha256.hash(thumbprint_input, &thumbprint_hash, .{}); 189 + const kid = try base64urlEncodeBytes(allocator, &thumbprint_hash); 190 + defer allocator.free(kid); 191 + 192 + return std.fmt.allocPrint(allocator, 193 + \\{{"kty":"EC","crv":"P-256","x":"{s}","y":"{s}","kid":"{s}","use":"sig","alg":"ES256"}} 194 + , .{ x_b64, y_b64, kid }); 195 + } 196 + 197 + /// generate a JWKS JSON containing the public key. 198 + /// caller owns returned slice. 199 + pub fn jwksJson(allocator: Allocator, keypair: *const zat.Keypair) ![]u8 { 200 + const jwk = try jwkPublicKey(allocator, keypair); 201 + defer allocator.free(jwk); 202 + 203 + return std.fmt.allocPrint(allocator, 204 + \\{{"keys":[{s}]}} 205 + , .{jwk}); 206 + } 207 + 208 + // --- access token hash --- 209 + 210 + /// compute the `ath` claim for DPoP: base64url(SHA-256(access_token)). 211 + /// caller owns returned slice. 212 + pub fn accessTokenHash(allocator: Allocator, access_token: []const u8) ![]u8 { 213 + var hash: [32]u8 = undefined; 214 + crypto.hash.sha2.Sha256.hash(access_token, &hash, .{}); 215 + return base64urlEncodeBytes(allocator, &hash); 216 + } 217 + 218 + // --- form URL encoding --- 219 + 220 + /// encode key-value pairs as application/x-www-form-urlencoded. 221 + /// caller owns returned slice. 222 + pub fn formEncode(allocator: Allocator, params: []const [2][]const u8) ![]u8 { 223 + var buf: std.ArrayList(u8) = .{}; 224 + errdefer buf.deinit(allocator); 225 + 226 + for (params, 0..) |kv, i| { 227 + if (i > 0) try buf.appendSlice(allocator, "&"); 228 + try percentEncode(allocator, &buf, kv[0]); 229 + try buf.appendSlice(allocator, "="); 230 + try percentEncode(allocator, &buf, kv[1]); 231 + } 232 + 233 + return buf.toOwnedSlice(allocator); 234 + } 235 + 236 + // --- helpers --- 237 + 238 + fn base64urlEncode(allocator: Allocator, data: []const u8) ![]u8 { 239 + const len = base64url.Encoder.calcSize(data.len); 240 + const buf = try allocator.alloc(u8, len); 241 + _ = base64url.Encoder.encode(buf, data); 242 + return buf; 243 + } 244 + 245 + fn base64urlEncodeBytes(allocator: Allocator, data: []const u8) ![]u8 { 246 + return base64urlEncode(allocator, data); 247 + } 248 + 249 + fn generateJti(allocator: Allocator) ![]u8 { 250 + var random_bytes: [16]u8 = undefined; 251 + crypto.random.bytes(&random_bytes); 252 + return base64urlEncodeBytes(allocator, &random_bytes); 253 + } 254 + 255 + fn percentEncode(allocator: Allocator, buf: *std.ArrayList(u8), input: []const u8) !void { 256 + for (input) |c| { 257 + if (isUnreserved(c)) { 258 + try buf.append(allocator, c); 259 + } else { 260 + try buf.print(allocator, "%{X:0>2}", .{c}); 261 + } 262 + } 263 + } 264 + 265 + fn isUnreserved(c: u8) bool { 266 + return switch (c) { 267 + 'A'...'Z', 'a'...'z', '0'...'9', '-', '_', '.', '~' => true, 268 + else => false, 269 + }; 270 + } 271 + 272 + // --- tests --- 273 + 274 + test "PKCE S256 challenge" { 275 + const allocator = std.testing.allocator; 276 + 277 + // RFC 7636 example: verifier "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" 278 + const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; 279 + const challenge = try generatePkceChallenge(allocator, verifier); 280 + defer allocator.free(challenge); 281 + 282 + // expected: E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM 283 + try std.testing.expectEqualStrings("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", challenge); 284 + } 285 + 286 + test "PKCE verifier generation" { 287 + const allocator = std.testing.allocator; 288 + const verifier = try generatePkceVerifier(allocator); 289 + defer allocator.free(verifier); 290 + 291 + // 32 bytes → 43 base64url chars 292 + try std.testing.expectEqual(@as(usize, 43), verifier.len); 293 + } 294 + 295 + test "form URL encoding" { 296 + const allocator = std.testing.allocator; 297 + 298 + const params = [_][2][]const u8{ 299 + .{ "grant_type", "authorization_code" }, 300 + .{ "code", "abc123" }, 301 + .{ "redirect_uri", "https://example.com/callback" }, 302 + }; 303 + 304 + const encoded = try formEncode(allocator, &params); 305 + defer allocator.free(encoded); 306 + 307 + try std.testing.expectEqualStrings( 308 + "grant_type=authorization_code&code=abc123&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback", 309 + encoded, 310 + ); 311 + } 312 + 313 + test "JWK public key generation" { 314 + const allocator = std.testing.allocator; 315 + 316 + const keypair = try zat.Keypair.fromSecretKey(.p256, .{ 317 + 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 318 + 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 319 + 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 320 + 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40, 321 + }); 322 + 323 + const jwk = try jwkPublicKey(allocator, &keypair); 324 + defer allocator.free(jwk); 325 + 326 + // verify it's valid JSON with the right fields 327 + const parsed = try json.parseFromSlice(json.Value, allocator, jwk, .{}); 328 + defer parsed.deinit(); 329 + 330 + const obj = parsed.value.object; 331 + try std.testing.expectEqualStrings("EC", obj.get("kty").?.string); 332 + try std.testing.expectEqualStrings("P-256", obj.get("crv").?.string); 333 + try std.testing.expect(obj.get("x") != null); 334 + try std.testing.expect(obj.get("y") != null); 335 + try std.testing.expectEqualStrings("sig", obj.get("use").?.string); 336 + try std.testing.expectEqualStrings("ES256", obj.get("alg").?.string); 337 + try std.testing.expect(obj.get("kid") != null); 338 + } 339 + 340 + test "JWT creation and structure" { 341 + const allocator = std.testing.allocator; 342 + 343 + const keypair = try zat.Keypair.fromSecretKey(.p256, .{ 344 + 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 345 + 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 346 + 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 347 + 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40, 348 + }); 349 + 350 + const header = 351 + \\{"alg":"ES256","typ":"JWT"} 352 + ; 353 + const payload = 354 + \\{"sub":"test","iat":1700000000} 355 + ; 356 + 357 + const token = try createJwt(allocator, header, payload, &keypair); 358 + defer allocator.free(token); 359 + 360 + // JWT should have 3 dot-separated parts 361 + var parts: usize = 0; 362 + var iter = mem.splitScalar(u8, token, '.'); 363 + while (iter.next()) |_| parts += 1; 364 + try std.testing.expectEqual(@as(usize, 3), parts); 365 + } 366 + 367 + test "DPoP proof creation" { 368 + const allocator = std.testing.allocator; 369 + 370 + const keypair = try zat.Keypair.fromSecretKey(.p256, .{ 371 + 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 372 + 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 373 + 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 374 + 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40, 375 + }); 376 + 377 + const proof = try createDpopProof(allocator, &keypair, "POST", "https://auth.example.com/token", "server-nonce", null); 378 + defer allocator.free(proof); 379 + 380 + // should be a valid 3-part JWT 381 + var parts: usize = 0; 382 + var iter = mem.splitScalar(u8, proof, '.'); 383 + while (iter.next()) |_| parts += 1; 384 + try std.testing.expectEqual(@as(usize, 3), parts); 385 + 386 + // decode header to verify it contains typ: dpop+jwt and jwk 387 + var iter2 = mem.splitScalar(u8, proof, '.'); 388 + const header_b64 = iter2.next().?; 389 + var header_buf: [4096]u8 = undefined; 390 + const header_len = try base64url.Decoder.calcSizeForSlice(header_b64); 391 + try base64url.Decoder.decode(header_buf[0..header_len], header_b64); 392 + const header_parsed = try json.parseFromSlice(json.Value, allocator, header_buf[0..header_len], .{}); 393 + defer header_parsed.deinit(); 394 + 395 + try std.testing.expectEqualStrings("dpop+jwt", header_parsed.value.object.get("typ").?.string); 396 + try std.testing.expectEqualStrings("ES256", header_parsed.value.object.get("alg").?.string); 397 + try std.testing.expect(header_parsed.value.object.get("jwk") != null); 398 + } 399 + 400 + test "client assertion creation" { 401 + const allocator = std.testing.allocator; 402 + 403 + const keypair = try zat.Keypair.fromSecretKey(.p256, .{ 404 + 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 405 + 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 406 + 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 407 + 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40, 408 + }); 409 + 410 + const assertion = try createClientAssertion(allocator, &keypair, "https://pollz.waow.tech/oauth/client-metadata", "https://bsky.social/oauth/token"); 411 + defer allocator.free(assertion); 412 + 413 + // decode payload to verify claims 414 + var iter = mem.splitScalar(u8, assertion, '.'); 415 + _ = iter.next(); // skip header 416 + const payload_b64 = iter.next().?; 417 + var payload_buf: [4096]u8 = undefined; 418 + const payload_len = try base64url.Decoder.calcSizeForSlice(payload_b64); 419 + try base64url.Decoder.decode(payload_buf[0..payload_len], payload_b64); 420 + const payload_parsed = try json.parseFromSlice(json.Value, allocator, payload_buf[0..payload_len], .{}); 421 + defer payload_parsed.deinit(); 422 + 423 + const obj = payload_parsed.value.object; 424 + try std.testing.expectEqualStrings("https://pollz.waow.tech/oauth/client-metadata", obj.get("iss").?.string); 425 + try std.testing.expectEqualStrings("https://pollz.waow.tech/oauth/client-metadata", obj.get("sub").?.string); 426 + try std.testing.expectEqualStrings("https://bsky.social/oauth/token", obj.get("aud").?.string); 427 + } 428 + 429 + test "access token hash" { 430 + const allocator = std.testing.allocator; 431 + const ath = try accessTokenHash(allocator, "test-access-token"); 432 + defer allocator.free(ath); 433 + try std.testing.expect(ath.len > 0); 434 + }
-190
backend/src/tap.zig
··· 1 - const std = @import("std"); 2 - const mem = std.mem; 3 - const json = std.json; 4 - const posix = std.posix; 5 - const Allocator = mem.Allocator; 6 - const websocket = @import("websocket"); 7 - const db = @import("db.zig"); 8 - 9 - const POLL_COLLECTION = "tech.waow.poll"; 10 - const VOTE_COLLECTION = "tech.waow.vote"; 11 - 12 - // tap url from env or default to fly.io internal network 13 - fn getTapHost() []const u8 { 14 - return std.posix.getenv("TAP_HOST") orelse "pollz-tap.fly.dev"; 15 - } 16 - 17 - fn getTapPort() u16 { 18 - const port_str = std.posix.getenv("TAP_PORT") orelse "443"; 19 - return std.fmt.parseInt(u16, port_str, 10) catch 443; 20 - } 21 - 22 - fn useTls() bool { 23 - const port = getTapPort(); 24 - return port == 443; 25 - } 26 - 27 - pub fn consumer(allocator: Allocator) void { 28 - // exponential backoff: 1s -> 2s -> 4s -> ... -> 60s cap 29 - var backoff: u64 = 1; 30 - const max_backoff: u64 = 60; 31 - 32 - while (true) { 33 - connect(allocator) catch |err| { 34 - std.debug.print("tap error: {}, reconnecting in {}s...\n", .{ err, backoff }); 35 - }; 36 - posix.nanosleep(backoff, 0); 37 - backoff = @min(backoff * 2, max_backoff); 38 - } 39 - } 40 - 41 - const Handler = struct { 42 - allocator: Allocator, 43 - msg_count: usize = 0, 44 - 45 - pub fn serverMessage(self: *Handler, data: []const u8) !void { 46 - self.msg_count += 1; 47 - if (self.msg_count % 100 == 1) { 48 - std.debug.print("tap: received {} messages\n", .{self.msg_count}); 49 - } 50 - processMessage(self.allocator, data) catch |err| { 51 - std.debug.print("message processing error: {}\n", .{err}); 52 - }; 53 - } 54 - 55 - pub fn close(_: *Handler) void { 56 - std.debug.print("tap connection closed\n", .{}); 57 - } 58 - }; 59 - 60 - fn connect(allocator: Allocator) !void { 61 - const host = getTapHost(); 62 - const port = getTapPort(); 63 - const tls = useTls(); 64 - 65 - const path = "/channel"; 66 - 67 - std.debug.print("connecting to {s}://{s}:{d}{s}\n", .{ if (tls) "wss" else "ws", host, port, path }); 68 - 69 - var client = websocket.Client.init(allocator, .{ 70 - .host = host, 71 - .port = port, 72 - .tls = tls, 73 - }) catch |err| { 74 - std.debug.print("websocket client init failed: {}\n", .{err}); 75 - return err; 76 - }; 77 - defer client.deinit(); 78 - 79 - std.debug.print("tcp connected, starting handshake...\n", .{}); 80 - 81 - var host_header_buf: [256]u8 = undefined; 82 - const host_header = std.fmt.bufPrint(&host_header_buf, "Host: {s}\r\n", .{host}) catch "Host: pollz-tap.fly.dev\r\n"; 83 - 84 - client.handshake(path, .{ .headers = host_header }) catch |err| { 85 - std.debug.print("websocket handshake failed: {}\n", .{err}); 86 - return err; 87 - }; 88 - 89 - std.debug.print("tap connected!\n", .{}); 90 - 91 - var handler = Handler{ .allocator = allocator }; 92 - client.readLoop(&handler) catch |err| { 93 - std.debug.print("websocket read loop error: {}\n", .{err}); 94 - return err; 95 - }; 96 - } 97 - 98 - fn processMessage(allocator: Allocator, payload: []const u8) !void { 99 - // parse tap event 100 - const parsed = json.parseFromSlice(json.Value, allocator, payload, .{}) catch return; 101 - defer parsed.deinit(); 102 - 103 - const root = parsed.value.object; 104 - 105 - // tap format: { "id": 123, "type": "record", "record": { ... } } 106 - const msg_type = root.get("type") orelse return; 107 - if (msg_type != .string) return; 108 - 109 - if (!mem.eql(u8, msg_type.string, "record")) return; 110 - 111 - const record_wrapper = root.get("record") orelse return; 112 - if (record_wrapper != .object) return; 113 - 114 - const rec = record_wrapper.object; 115 - 116 - const collection = rec.get("collection") orelse return; 117 - if (collection != .string) return; 118 - 119 - const action = rec.get("action") orelse return; 120 - if (action != .string) return; 121 - 122 - const did = rec.get("did") orelse return; 123 - if (did != .string) return; 124 - 125 - const rkey = rec.get("rkey") orelse return; 126 - if (rkey != .string) return; 127 - 128 - const uri_str = try std.fmt.allocPrint(allocator, "at://{s}/{s}/{s}", .{ did.string, collection.string, rkey.string }); 129 - defer allocator.free(uri_str); 130 - 131 - if (mem.eql(u8, action.string, "create") or mem.eql(u8, action.string, "update")) { 132 - const record = rec.get("record") orelse return; 133 - if (record != .object) return; 134 - 135 - if (mem.eql(u8, collection.string, POLL_COLLECTION)) { 136 - processPoll(allocator, uri_str, did.string, rkey.string, record.object) catch |err| { 137 - std.debug.print("poll processing error: {}\n", .{err}); 138 - }; 139 - } else if (mem.eql(u8, collection.string, VOTE_COLLECTION)) { 140 - processVote(uri_str, did.string, record.object) catch |err| { 141 - std.debug.print("vote processing error: {}\n", .{err}); 142 - }; 143 - } 144 - } else if (mem.eql(u8, action.string, "delete")) { 145 - if (mem.eql(u8, collection.string, POLL_COLLECTION)) { 146 - db.deletePoll(uri_str); 147 - std.debug.print("deleted poll: {s}\n", .{uri_str}); 148 - } else if (mem.eql(u8, collection.string, VOTE_COLLECTION)) { 149 - db.deleteVote(uri_str); 150 - std.debug.print("deleted vote: {s}\n", .{uri_str}); 151 - } 152 - } 153 - } 154 - 155 - pub fn processPoll(allocator: Allocator, uri: []const u8, did: []const u8, rkey: []const u8, record: json.ObjectMap) !void { 156 - const text_val = record.get("text") orelse return; 157 - if (text_val != .string) return; 158 - 159 - const options_val = record.get("options") orelse return; 160 - if (options_val != .array) return; 161 - 162 - const created_at_val = record.get("createdAt") orelse return; 163 - if (created_at_val != .string) return; 164 - 165 - // serialize options as json 166 - var options_buf: std.ArrayList(u8) = .{}; 167 - defer options_buf.deinit(allocator); 168 - try options_buf.print(allocator, "{f}", .{json.fmt(options_val, .{})}); 169 - 170 - // serialize text as json (to escape quotes properly) 171 - var text_buf: std.ArrayList(u8) = .{}; 172 - defer text_buf.deinit(allocator); 173 - try text_buf.print(allocator, "{f}", .{json.fmt(text_val, .{})}); 174 - 175 - try db.insertPoll(uri, did, rkey, text_buf.items, options_buf.items, created_at_val.string); 176 - std.debug.print("indexed poll: {s}\n", .{uri}); 177 - } 178 - 179 - pub fn processVote(uri: []const u8, did: []const u8, record: json.ObjectMap) !void { 180 - const subject_val = record.get("subject") orelse return; 181 - if (subject_val != .string) return; 182 - 183 - const option_val = record.get("option") orelse return; 184 - if (option_val != .integer) return; 185 - 186 - const created_at: ?[]const u8 = if (record.get("createdAt")) |v| (if (v == .string) v.string else null) else null; 187 - 188 - try db.insertVote(uri, subject_val.string, @as(i32, @intCast(option_val.integer)), did, created_at); 189 - std.debug.print("indexed vote: {s} -> {s}\n", .{ uri, subject_val.string }); 190 - }
-126
docs/architecture.md
··· 1 - # pollz architecture 2 - 3 - ## overview 4 - 5 - pollz is a polling app built on atproto. users create polls and vote using their bluesky accounts. 6 - 7 - ``` 8 - ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ 9 - │ frontend │────▶│ backend │◀────│ tap │ 10 - │ (vite/ts) │ │ (zig) │ │ (go) │ 11 - │ cloudflare │ │ fly.io │ │ fly.io │ 12 - └─────────────┘ └─────────────┘ └─────────────┘ 13 - │ │ │ 14 - │ ▼ │ 15 - │ ┌─────────────┐ │ 16 - │ │ sqlite │ │ 17 - │ │ (fly vol) │ │ 18 - │ └─────────────┘ │ 19 - │ │ 20 - ▼ ▼ 21 - ┌─────────────┐ ┌─────────────┐ 22 - │ user PDS │ │ firehose │ 23 - │ (bsky.social) │ (relay) │ 24 - └─────────────┘ └─────────────┘ 25 - ``` 26 - 27 - ## components 28 - 29 - ### frontend (src/) 30 - - vanilla typescript with vite 31 - - oauth via @atcute/oauth-browser-client 32 - - writes polls/votes directly to user's PDS 33 - - fetches poll data from backend API 34 - 35 - ### backend (backend/) 36 - - zig http server 37 - - sqlite for persistence 38 - - consumes events from tap via websocket 39 - - serves REST API for frontend 40 - 41 - ### tap (tap/) 42 - - bluesky's official atproto sync utility 43 - - handles firehose connection, backfill, cursor management 44 - - filters for `tech.waow.poll` and `tech.waow.vote` collections 45 - - delivers events to backend via websocket 46 - 47 - ## data flow 48 - 49 - ### creating a poll 50 - 1. user logs in via oauth 51 - 2. frontend calls `com.atproto.repo.createRecord` on user's PDS 52 - 3. PDS broadcasts to relay/firehose 53 - 4. tap receives event, forwards to backend 54 - 5. backend inserts poll into sqlite 55 - 56 - ### voting 57 - 1. frontend checks if user has existing vote on this poll 58 - 2. if exists: `com.atproto.repo.putRecord` (update) 59 - 3. if not: `com.atproto.repo.createRecord` (create) 60 - 4. tap receives event, forwards to backend 61 - 5. backend upserts vote (one vote per user per poll) 62 - 63 - ### reading polls 64 - 1. frontend fetches `/api/polls` from backend 65 - 2. backend queries sqlite, returns polls with vote counts 66 - 3. frontend renders poll list 67 - 68 - ## lexicons 69 - 70 - ### tech.waow.poll 71 - ```json 72 - { 73 - "$type": "tech.waow.poll", 74 - "text": "what's the best language?", 75 - "options": ["rust", "zig", "go"], 76 - "createdAt": "2024-01-01T00:00:00.000Z" 77 - } 78 - ``` 79 - 80 - ### tech.waow.vote 81 - ```json 82 - { 83 - "$type": "tech.waow.vote", 84 - "subject": "at://did:plc:.../tech.waow.poll/...", 85 - "option": 0, 86 - "createdAt": "2024-01-01T00:00:00.000Z" 87 - } 88 - ``` 89 - 90 - ## key lessons learned 91 - 92 - ### vote updates, not delete+create 93 - when changing a vote, use `putRecord` to update the existing record rather than deleting and creating. this avoids race conditions where tap receives events out of order (create then delete) causing the vote to disappear. 94 - 95 - ### tap event ordering 96 - tap delivers events in the order they're received from the firehose, but the firehose itself can deliver events out of order. the backend must handle this gracefully: 97 - - `insertVote` uses upsert with timestamp comparison 98 - - only updates if the incoming vote is newer than existing 99 - 100 - ### one vote per user per poll 101 - enforced at multiple levels: 102 - - frontend: checks for existing vote before creating 103 - - backend: `UNIQUE(subject, voter)` constraint 104 - - backend: upsert logic in `insertVote` 105 - 106 - ## deployment 107 - 108 - ### fly.io apps 109 - - `pollz-backend` - zig backend with sqlite volume 110 - - `pollz-tap` - tap instance with sqlite volume 111 - 112 - ### cloudflare pages 113 - - frontend static files 114 - - oauth client metadata at `/oauth-client-metadata.json` 115 - 116 - ### environment variables 117 - 118 - backend: 119 - - `TAP_HOST` - tap hostname (default: pollz-tap.internal) 120 - - `TAP_PORT` - tap port (default: 2480) 121 - - `DATA_PATH` - sqlite db path (default: /data/pollz.db) 122 - 123 - tap: 124 - - `TAP_DATABASE_URL` - sqlite path 125 - - `TAP_COLLECTION_FILTERS` - collections to track 126 - - `TAP_SIGNAL_COLLECTION` - collection for auto-discovery
-122
docs/tap.md
··· 1 - # tap integration 2 - 3 - tap is bluesky's official atproto sync utility. pollz uses it to receive real-time events from the firehose. 4 - 5 - ## what tap provides 6 - 7 - - firehose connection with automatic reconnection 8 - - signature verification of repo structure and identity 9 - - automatic backfill when adding new repos 10 - - filtered output by collection 11 - - ordering guarantees - backfill completes before live events 12 - - cursor management - persists automatically, resumes on restart 13 - 14 - ## pollz tap configuration 15 - 16 - ```toml 17 - # tap/fly.toml 18 - [env] 19 - TAP_COLLECTION_FILTERS = "tech.waow.poll,tech.waow.vote" 20 - TAP_SIGNAL_COLLECTION = "tech.waow.poll" 21 - TAP_DATABASE_URL = "sqlite:///data/tap.db" 22 - TAP_DISABLE_ACKS = "true" 23 - ``` 24 - 25 - `TAP_SIGNAL_COLLECTION` makes tap automatically discover and track all repos that have ever created a poll. 26 - 27 - ## event format 28 - 29 - tap delivers events via websocket at `/channel`: 30 - 31 - ```json 32 - { 33 - "id": 12345, 34 - "type": "record", 35 - "record": { 36 - "live": true, 37 - "did": "did:plc:abc123", 38 - "collection": "tech.waow.poll", 39 - "rkey": "3kb3fge5lm32x", 40 - "action": "create", 41 - "record": { 42 - "text": "what's your favorite color?", 43 - "options": ["red", "blue", "green"], 44 - "$type": "tech.waow.poll", 45 - "createdAt": "2024-10-07T12:00:00.000Z" 46 - } 47 - } 48 - } 49 - ``` 50 - 51 - ### action types 52 - - `create` - new record created 53 - - `update` - existing record updated (same rkey) 54 - - `delete` - record deleted 55 - 56 - ## backend tap consumer 57 - 58 - the backend connects to tap via websocket and processes events: 59 - 60 - ```zig 61 - // tap.zig 62 - if (mem.eql(u8, action.string, "create") or mem.eql(u8, action.string, "update")) { 63 - // process poll or vote 64 - } else if (mem.eql(u8, action.string, "delete")) { 65 - // delete poll or vote 66 - } 67 - ``` 68 - 69 - ## handling out-of-order events 70 - 71 - tap delivers events in firehose order, but the firehose itself can deliver events out of order. example: 72 - 73 - 1. user deletes old vote, creates new vote 74 - 2. firehose delivers: create (new), delete (old) 75 - 3. if backend processes delete after create, the new vote disappears 76 - 77 - ### solution: use putRecord instead of delete+create 78 - 79 - when changing a vote, the frontend uses `putRecord` to update the existing record: 80 - 81 - ```typescript 82 - // api.ts 83 - if (existingRkey) { 84 - // update existing vote - single "update" event 85 - await rpc.post("com.atproto.repo.putRecord", { ... }); 86 - } else { 87 - // create new vote 88 - await rpc.post("com.atproto.repo.createRecord", { ... }); 89 - } 90 - ``` 91 - 92 - this results in a single "update" event instead of separate "delete" and "create" events, eliminating the race condition. 93 - 94 - ### backend upsert logic 95 - 96 - as additional protection, `insertVote` uses upsert with timestamp comparison: 97 - 98 - ```sql 99 - INSERT INTO votes (uri, subject, option, voter, created_at) 100 - VALUES (?, ?, ?, ?, ?) 101 - ON CONFLICT(subject, voter) DO UPDATE SET 102 - uri = excluded.uri, 103 - option = excluded.option, 104 - created_at = excluded.created_at 105 - WHERE excluded.created_at > votes.created_at OR votes.created_at IS NULL 106 - ``` 107 - 108 - this ensures that if out-of-order events do occur, older events don't overwrite newer ones. 109 - 110 - ## deployment 111 - 112 - tap runs as a separate fly.io app (`pollz-tap`) and communicates with the backend over fly's internal network: 113 - 114 - ``` 115 - pollz-tap.internal:2480 → pollz-backend 116 - ``` 117 - 118 - ## further reading 119 - 120 - - [tap README](https://github.com/bluesky-social/indigo/blob/main/cmd/tap/README.md) 121 - - [indigo repo](https://github.com/bluesky-social/indigo) 122 - - [bailey's tap guide](https://marvins-guide.leaflet.pub/3m7ttuppfzc23)
-126
docs/zig.md
··· 1 - # zig 0.15 notes 2 - 3 - reference for zig 0.15 patterns used in the backend. 4 - 5 - ## breaking changes from 0.14 6 - 7 - ### json.stringify → json.fmt 8 - ```zig 9 - // old: json.stringify(value, .{}, writer); 10 - // new: use json.fmt formatter 11 - try buffer.print(allocator, "{f}", .{json.fmt(value, .{})}); 12 - ``` 13 - 14 - ### std.ArrayList is unmanaged by default 15 - ```zig 16 - // old 0.14 style: 17 - var list = std.ArrayList(u8).init(allocator); 18 - try list.appendSlice("hello"); 19 - list.deinit(); 20 - 21 - // new 0.15 style: 22 - var list: std.ArrayList(u8) = .{}; 23 - try list.appendSlice(allocator, "hello"); 24 - list.deinit(allocator); 25 - ``` 26 - 27 - ### std.time.sleep removed 28 - ```zig 29 - // use posix.nanosleep instead 30 - std.posix.nanosleep(seconds, nanoseconds); 31 - ``` 32 - 33 - ### std.Uri.percentDecode → percentDecodeInPlace 34 - ```zig 35 - // copy to mutable buffer first, then decode in place 36 - const uri_buf = try alloc.dupe(u8, uri_encoded); 37 - const uri = std.Uri.percentDecodeInPlace(uri_buf); 38 - ``` 39 - 40 - ## http server patterns 41 - 42 - ### net.Stream → http.Server 43 - ```zig 44 - var read_buffer: [8192]u8 = undefined; 45 - var write_buffer: [8192]u8 = undefined; 46 - 47 - var reader = conn.stream.reader(&read_buffer); 48 - var writer = conn.stream.writer(&write_buffer); 49 - 50 - var server = http.Server.init(reader.interface(), &writer.interface); 51 - ``` 52 - 53 - ### responding to requests 54 - ```zig 55 - try request.respond(body, .{ 56 - .status = .ok, 57 - .extra_headers = &.{ 58 - .{ .name = "content-type", .value = "application/json" }, 59 - }, 60 - }); 61 - ``` 62 - 63 - ## websocket client (karlseguin/websocket.zig) 64 - 65 - ```zig 66 - const websocket = @import("websocket"); 67 - 68 - var client = try websocket.Client.init(allocator, .{ 69 - .host = "example.com", 70 - .port = 443, 71 - .tls = true, 72 - }); 73 - defer client.deinit(); 74 - 75 - // Host header must be provided manually 76 - client.handshake("/path", .{ .headers = "Host: example.com\r\n" }) catch |err| { 77 - // handle error 78 - }; 79 - 80 - // handler must have serverMessage(self, data) function 81 - var handler = MyHandler{}; 82 - try client.readLoop(&handler); 83 - ``` 84 - 85 - ## sqlite patterns (zqlite) 86 - 87 - ### prepared statements with bind 88 - ```zig 89 - var stmt = conn.prepare("SELECT * FROM votes WHERE uri = ?") catch return; 90 - defer stmt.deinit(); 91 - 92 - const row = stmt.bind(.{uri}).step() catch return; 93 - if (row) |r| { 94 - const subject = r[0].?.text; 95 - // ... 96 - } 97 - ``` 98 - 99 - ### upsert with ON CONFLICT 100 - ```zig 101 - conn.exec( 102 - \\INSERT INTO votes (uri, subject, option, voter, created_at) 103 - \\VALUES (?, ?, ?, ?, ?) 104 - \\ON CONFLICT(subject, voter) DO UPDATE SET 105 - \\ uri = excluded.uri, 106 - \\ option = excluded.option, 107 - \\ created_at = excluded.created_at 108 - \\WHERE excluded.created_at > votes.created_at OR votes.created_at IS NULL 109 - , .{ uri, subject, option, voter, created_at }) catch |err| { 110 - // handle error 111 - }; 112 - ``` 113 - 114 - ## build.zig.zon 115 - 116 - ```zig 117 - .{ 118 - .name = .pollz, 119 - .version = "0.0.0", 120 - .fingerprint = 0x..., // required in 0.15 121 - .dependencies = .{ 122 - .zqlite = .{ ... }, 123 - .websocket = .{ ... }, 124 - }, 125 - } 126 - ```
+23
frontend/.gitignore
··· 1 + node_modules 2 + 3 + # Output 4 + .output 5 + .vercel 6 + .netlify 7 + .wrangler 8 + /.svelte-kit 9 + /build 10 + 11 + # OS 12 + .DS_Store 13 + Thumbs.db 14 + 15 + # Env 16 + .env 17 + .env.* 18 + !.env.example 19 + !.env.test 20 + 21 + # Vite 22 + vite.config.js.timestamp-* 23 + vite.config.ts.timestamp-*
+24
frontend/package.json
··· 1 + { 2 + "name": "frontend", 3 + "private": true, 4 + "version": "0.0.1", 5 + "type": "module", 6 + "scripts": { 7 + "dev": "vite dev", 8 + "build": "vite build", 9 + "preview": "vite preview", 10 + "prepare": "svelte-kit sync || echo ''", 11 + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 12 + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" 13 + }, 14 + "devDependencies": { 15 + "@sveltejs/adapter-auto": "^7.0.0", 16 + "@sveltejs/adapter-static": "^3.0.10", 17 + "@sveltejs/kit": "^2.50.2", 18 + "@sveltejs/vite-plugin-svelte": "^6.2.4", 19 + "svelte": "^5.51.0", 20 + "svelte-check": "^4.4.2", 21 + "typescript": "^5.9.3", 22 + "vite": "^7.3.1" 23 + } 24 + }
+1018
frontend/pnpm-lock.yaml
··· 1 + lockfileVersion: '9.0' 2 + 3 + settings: 4 + autoInstallPeers: true 5 + excludeLinksFromLockfile: false 6 + 7 + importers: 8 + 9 + .: 10 + devDependencies: 11 + '@sveltejs/adapter-auto': 12 + specifier: ^7.0.0 13 + version: 7.0.1(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.11)(vite@7.3.1))(svelte@5.53.11)(typescript@5.9.3)(vite@7.3.1)) 14 + '@sveltejs/adapter-static': 15 + specifier: ^3.0.10 16 + version: 3.0.10(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.11)(vite@7.3.1))(svelte@5.53.11)(typescript@5.9.3)(vite@7.3.1)) 17 + '@sveltejs/kit': 18 + specifier: ^2.50.2 19 + version: 2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.11)(vite@7.3.1))(svelte@5.53.11)(typescript@5.9.3)(vite@7.3.1) 20 + '@sveltejs/vite-plugin-svelte': 21 + specifier: ^6.2.4 22 + version: 6.2.4(svelte@5.53.11)(vite@7.3.1) 23 + svelte: 24 + specifier: ^5.51.0 25 + version: 5.53.11 26 + svelte-check: 27 + specifier: ^4.4.2 28 + version: 4.4.5(picomatch@4.0.3)(svelte@5.53.11)(typescript@5.9.3) 29 + typescript: 30 + specifier: ^5.9.3 31 + version: 5.9.3 32 + vite: 33 + specifier: ^7.3.1 34 + version: 7.3.1 35 + 36 + packages: 37 + 38 + '@esbuild/aix-ppc64@0.27.4': 39 + resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} 40 + engines: {node: '>=18'} 41 + cpu: [ppc64] 42 + os: [aix] 43 + 44 + '@esbuild/android-arm64@0.27.4': 45 + resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} 46 + engines: {node: '>=18'} 47 + cpu: [arm64] 48 + os: [android] 49 + 50 + '@esbuild/android-arm@0.27.4': 51 + resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} 52 + engines: {node: '>=18'} 53 + cpu: [arm] 54 + os: [android] 55 + 56 + '@esbuild/android-x64@0.27.4': 57 + resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} 58 + engines: {node: '>=18'} 59 + cpu: [x64] 60 + os: [android] 61 + 62 + '@esbuild/darwin-arm64@0.27.4': 63 + resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} 64 + engines: {node: '>=18'} 65 + cpu: [arm64] 66 + os: [darwin] 67 + 68 + '@esbuild/darwin-x64@0.27.4': 69 + resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} 70 + engines: {node: '>=18'} 71 + cpu: [x64] 72 + os: [darwin] 73 + 74 + '@esbuild/freebsd-arm64@0.27.4': 75 + resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} 76 + engines: {node: '>=18'} 77 + cpu: [arm64] 78 + os: [freebsd] 79 + 80 + '@esbuild/freebsd-x64@0.27.4': 81 + resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} 82 + engines: {node: '>=18'} 83 + cpu: [x64] 84 + os: [freebsd] 85 + 86 + '@esbuild/linux-arm64@0.27.4': 87 + resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} 88 + engines: {node: '>=18'} 89 + cpu: [arm64] 90 + os: [linux] 91 + 92 + '@esbuild/linux-arm@0.27.4': 93 + resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} 94 + engines: {node: '>=18'} 95 + cpu: [arm] 96 + os: [linux] 97 + 98 + '@esbuild/linux-ia32@0.27.4': 99 + resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} 100 + engines: {node: '>=18'} 101 + cpu: [ia32] 102 + os: [linux] 103 + 104 + '@esbuild/linux-loong64@0.27.4': 105 + resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} 106 + engines: {node: '>=18'} 107 + cpu: [loong64] 108 + os: [linux] 109 + 110 + '@esbuild/linux-mips64el@0.27.4': 111 + resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} 112 + engines: {node: '>=18'} 113 + cpu: [mips64el] 114 + os: [linux] 115 + 116 + '@esbuild/linux-ppc64@0.27.4': 117 + resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} 118 + engines: {node: '>=18'} 119 + cpu: [ppc64] 120 + os: [linux] 121 + 122 + '@esbuild/linux-riscv64@0.27.4': 123 + resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} 124 + engines: {node: '>=18'} 125 + cpu: [riscv64] 126 + os: [linux] 127 + 128 + '@esbuild/linux-s390x@0.27.4': 129 + resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} 130 + engines: {node: '>=18'} 131 + cpu: [s390x] 132 + os: [linux] 133 + 134 + '@esbuild/linux-x64@0.27.4': 135 + resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} 136 + engines: {node: '>=18'} 137 + cpu: [x64] 138 + os: [linux] 139 + 140 + '@esbuild/netbsd-arm64@0.27.4': 141 + resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} 142 + engines: {node: '>=18'} 143 + cpu: [arm64] 144 + os: [netbsd] 145 + 146 + '@esbuild/netbsd-x64@0.27.4': 147 + resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} 148 + engines: {node: '>=18'} 149 + cpu: [x64] 150 + os: [netbsd] 151 + 152 + '@esbuild/openbsd-arm64@0.27.4': 153 + resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} 154 + engines: {node: '>=18'} 155 + cpu: [arm64] 156 + os: [openbsd] 157 + 158 + '@esbuild/openbsd-x64@0.27.4': 159 + resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} 160 + engines: {node: '>=18'} 161 + cpu: [x64] 162 + os: [openbsd] 163 + 164 + '@esbuild/openharmony-arm64@0.27.4': 165 + resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} 166 + engines: {node: '>=18'} 167 + cpu: [arm64] 168 + os: [openharmony] 169 + 170 + '@esbuild/sunos-x64@0.27.4': 171 + resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} 172 + engines: {node: '>=18'} 173 + cpu: [x64] 174 + os: [sunos] 175 + 176 + '@esbuild/win32-arm64@0.27.4': 177 + resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} 178 + engines: {node: '>=18'} 179 + cpu: [arm64] 180 + os: [win32] 181 + 182 + '@esbuild/win32-ia32@0.27.4': 183 + resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} 184 + engines: {node: '>=18'} 185 + cpu: [ia32] 186 + os: [win32] 187 + 188 + '@esbuild/win32-x64@0.27.4': 189 + resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} 190 + engines: {node: '>=18'} 191 + cpu: [x64] 192 + os: [win32] 193 + 194 + '@jridgewell/gen-mapping@0.3.13': 195 + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} 196 + 197 + '@jridgewell/remapping@2.3.5': 198 + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} 199 + 200 + '@jridgewell/resolve-uri@3.1.2': 201 + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} 202 + engines: {node: '>=6.0.0'} 203 + 204 + '@jridgewell/sourcemap-codec@1.5.5': 205 + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} 206 + 207 + '@jridgewell/trace-mapping@0.3.31': 208 + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} 209 + 210 + '@polka/url@1.0.0-next.29': 211 + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} 212 + 213 + '@rollup/rollup-android-arm-eabi@4.59.0': 214 + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} 215 + cpu: [arm] 216 + os: [android] 217 + 218 + '@rollup/rollup-android-arm64@4.59.0': 219 + resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} 220 + cpu: [arm64] 221 + os: [android] 222 + 223 + '@rollup/rollup-darwin-arm64@4.59.0': 224 + resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} 225 + cpu: [arm64] 226 + os: [darwin] 227 + 228 + '@rollup/rollup-darwin-x64@4.59.0': 229 + resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} 230 + cpu: [x64] 231 + os: [darwin] 232 + 233 + '@rollup/rollup-freebsd-arm64@4.59.0': 234 + resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} 235 + cpu: [arm64] 236 + os: [freebsd] 237 + 238 + '@rollup/rollup-freebsd-x64@4.59.0': 239 + resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} 240 + cpu: [x64] 241 + os: [freebsd] 242 + 243 + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': 244 + resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} 245 + cpu: [arm] 246 + os: [linux] 247 + 248 + '@rollup/rollup-linux-arm-musleabihf@4.59.0': 249 + resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} 250 + cpu: [arm] 251 + os: [linux] 252 + 253 + '@rollup/rollup-linux-arm64-gnu@4.59.0': 254 + resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} 255 + cpu: [arm64] 256 + os: [linux] 257 + 258 + '@rollup/rollup-linux-arm64-musl@4.59.0': 259 + resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} 260 + cpu: [arm64] 261 + os: [linux] 262 + 263 + '@rollup/rollup-linux-loong64-gnu@4.59.0': 264 + resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} 265 + cpu: [loong64] 266 + os: [linux] 267 + 268 + '@rollup/rollup-linux-loong64-musl@4.59.0': 269 + resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} 270 + cpu: [loong64] 271 + os: [linux] 272 + 273 + '@rollup/rollup-linux-ppc64-gnu@4.59.0': 274 + resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} 275 + cpu: [ppc64] 276 + os: [linux] 277 + 278 + '@rollup/rollup-linux-ppc64-musl@4.59.0': 279 + resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} 280 + cpu: [ppc64] 281 + os: [linux] 282 + 283 + '@rollup/rollup-linux-riscv64-gnu@4.59.0': 284 + resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} 285 + cpu: [riscv64] 286 + os: [linux] 287 + 288 + '@rollup/rollup-linux-riscv64-musl@4.59.0': 289 + resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} 290 + cpu: [riscv64] 291 + os: [linux] 292 + 293 + '@rollup/rollup-linux-s390x-gnu@4.59.0': 294 + resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} 295 + cpu: [s390x] 296 + os: [linux] 297 + 298 + '@rollup/rollup-linux-x64-gnu@4.59.0': 299 + resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} 300 + cpu: [x64] 301 + os: [linux] 302 + 303 + '@rollup/rollup-linux-x64-musl@4.59.0': 304 + resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} 305 + cpu: [x64] 306 + os: [linux] 307 + 308 + '@rollup/rollup-openbsd-x64@4.59.0': 309 + resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} 310 + cpu: [x64] 311 + os: [openbsd] 312 + 313 + '@rollup/rollup-openharmony-arm64@4.59.0': 314 + resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} 315 + cpu: [arm64] 316 + os: [openharmony] 317 + 318 + '@rollup/rollup-win32-arm64-msvc@4.59.0': 319 + resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} 320 + cpu: [arm64] 321 + os: [win32] 322 + 323 + '@rollup/rollup-win32-ia32-msvc@4.59.0': 324 + resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} 325 + cpu: [ia32] 326 + os: [win32] 327 + 328 + '@rollup/rollup-win32-x64-gnu@4.59.0': 329 + resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} 330 + cpu: [x64] 331 + os: [win32] 332 + 333 + '@rollup/rollup-win32-x64-msvc@4.59.0': 334 + resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} 335 + cpu: [x64] 336 + os: [win32] 337 + 338 + '@standard-schema/spec@1.1.0': 339 + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} 340 + 341 + '@sveltejs/acorn-typescript@1.0.9': 342 + resolution: {integrity: sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==} 343 + peerDependencies: 344 + acorn: ^8.9.0 345 + 346 + '@sveltejs/adapter-auto@7.0.1': 347 + resolution: {integrity: sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ==} 348 + peerDependencies: 349 + '@sveltejs/kit': ^2.0.0 350 + 351 + '@sveltejs/adapter-static@3.0.10': 352 + resolution: {integrity: sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==} 353 + peerDependencies: 354 + '@sveltejs/kit': ^2.0.0 355 + 356 + '@sveltejs/kit@2.55.0': 357 + resolution: {integrity: sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==} 358 + engines: {node: '>=18.13'} 359 + hasBin: true 360 + peerDependencies: 361 + '@opentelemetry/api': ^1.0.0 362 + '@sveltejs/vite-plugin-svelte': ^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0 363 + svelte: ^4.0.0 || ^5.0.0-next.0 364 + typescript: ^5.3.3 365 + vite: ^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0 366 + peerDependenciesMeta: 367 + '@opentelemetry/api': 368 + optional: true 369 + typescript: 370 + optional: true 371 + 372 + '@sveltejs/vite-plugin-svelte-inspector@5.0.2': 373 + resolution: {integrity: sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==} 374 + engines: {node: ^20.19 || ^22.12 || >=24} 375 + peerDependencies: 376 + '@sveltejs/vite-plugin-svelte': ^6.0.0-next.0 377 + svelte: ^5.0.0 378 + vite: ^6.3.0 || ^7.0.0 379 + 380 + '@sveltejs/vite-plugin-svelte@6.2.4': 381 + resolution: {integrity: sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==} 382 + engines: {node: ^20.19 || ^22.12 || >=24} 383 + peerDependencies: 384 + svelte: ^5.0.0 385 + vite: ^6.3.0 || ^7.0.0 386 + 387 + '@types/cookie@0.6.0': 388 + resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} 389 + 390 + '@types/estree@1.0.8': 391 + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} 392 + 393 + '@types/trusted-types@2.0.7': 394 + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} 395 + 396 + acorn@8.16.0: 397 + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} 398 + engines: {node: '>=0.4.0'} 399 + hasBin: true 400 + 401 + aria-query@5.3.1: 402 + resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} 403 + engines: {node: '>= 0.4'} 404 + 405 + axobject-query@4.1.0: 406 + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} 407 + engines: {node: '>= 0.4'} 408 + 409 + chokidar@4.0.3: 410 + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} 411 + engines: {node: '>= 14.16.0'} 412 + 413 + clsx@2.1.1: 414 + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} 415 + engines: {node: '>=6'} 416 + 417 + cookie@0.6.0: 418 + resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} 419 + engines: {node: '>= 0.6'} 420 + 421 + deepmerge@4.3.1: 422 + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} 423 + engines: {node: '>=0.10.0'} 424 + 425 + devalue@5.6.4: 426 + resolution: {integrity: sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==} 427 + 428 + esbuild@0.27.4: 429 + resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} 430 + engines: {node: '>=18'} 431 + hasBin: true 432 + 433 + esm-env@1.2.2: 434 + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} 435 + 436 + esrap@2.2.3: 437 + resolution: {integrity: sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ==} 438 + 439 + fdir@6.5.0: 440 + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} 441 + engines: {node: '>=12.0.0'} 442 + peerDependencies: 443 + picomatch: ^3 || ^4 444 + peerDependenciesMeta: 445 + picomatch: 446 + optional: true 447 + 448 + fsevents@2.3.3: 449 + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} 450 + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} 451 + os: [darwin] 452 + 453 + is-reference@3.0.3: 454 + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} 455 + 456 + kleur@4.1.5: 457 + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} 458 + engines: {node: '>=6'} 459 + 460 + locate-character@3.0.0: 461 + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} 462 + 463 + magic-string@0.30.21: 464 + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} 465 + 466 + mri@1.2.0: 467 + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} 468 + engines: {node: '>=4'} 469 + 470 + mrmime@2.0.1: 471 + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} 472 + engines: {node: '>=10'} 473 + 474 + nanoid@3.3.11: 475 + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} 476 + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} 477 + hasBin: true 478 + 479 + obug@2.1.1: 480 + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} 481 + 482 + picocolors@1.1.1: 483 + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} 484 + 485 + picomatch@4.0.3: 486 + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} 487 + engines: {node: '>=12'} 488 + 489 + postcss@8.5.8: 490 + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} 491 + engines: {node: ^10 || ^12 || >=14} 492 + 493 + readdirp@4.1.2: 494 + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} 495 + engines: {node: '>= 14.18.0'} 496 + 497 + rollup@4.59.0: 498 + resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} 499 + engines: {node: '>=18.0.0', npm: '>=8.0.0'} 500 + hasBin: true 501 + 502 + sade@1.8.1: 503 + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} 504 + engines: {node: '>=6'} 505 + 506 + set-cookie-parser@3.0.1: 507 + resolution: {integrity: sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==} 508 + 509 + sirv@3.0.2: 510 + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} 511 + engines: {node: '>=18'} 512 + 513 + source-map-js@1.2.1: 514 + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} 515 + engines: {node: '>=0.10.0'} 516 + 517 + svelte-check@4.4.5: 518 + resolution: {integrity: sha512-1bSwIRCvvmSHrlK52fOlZmVtUZgil43jNL/2H18pRpa+eQjzGt6e3zayxhp1S7GajPFKNM/2PMCG+DZFHlG9fw==} 519 + engines: {node: '>= 18.0.0'} 520 + hasBin: true 521 + peerDependencies: 522 + svelte: ^4.0.0 || ^5.0.0-next.0 523 + typescript: '>=5.0.0' 524 + 525 + svelte@5.53.11: 526 + resolution: {integrity: sha512-GYmqRjRhJYLQBonfdfGAt28gkfWEShrtXKGXcFGneXi502aBE+I1dJcs/YQriByvP6xqXRz/OdBGC6tfvUQHyQ==} 527 + engines: {node: '>=18'} 528 + 529 + tinyglobby@0.2.15: 530 + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} 531 + engines: {node: '>=12.0.0'} 532 + 533 + totalist@3.0.1: 534 + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} 535 + engines: {node: '>=6'} 536 + 537 + typescript@5.9.3: 538 + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} 539 + engines: {node: '>=14.17'} 540 + hasBin: true 541 + 542 + vite@7.3.1: 543 + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} 544 + engines: {node: ^20.19.0 || >=22.12.0} 545 + hasBin: true 546 + peerDependencies: 547 + '@types/node': ^20.19.0 || >=22.12.0 548 + jiti: '>=1.21.0' 549 + less: ^4.0.0 550 + lightningcss: ^1.21.0 551 + sass: ^1.70.0 552 + sass-embedded: ^1.70.0 553 + stylus: '>=0.54.8' 554 + sugarss: ^5.0.0 555 + terser: ^5.16.0 556 + tsx: ^4.8.1 557 + yaml: ^2.4.2 558 + peerDependenciesMeta: 559 + '@types/node': 560 + optional: true 561 + jiti: 562 + optional: true 563 + less: 564 + optional: true 565 + lightningcss: 566 + optional: true 567 + sass: 568 + optional: true 569 + sass-embedded: 570 + optional: true 571 + stylus: 572 + optional: true 573 + sugarss: 574 + optional: true 575 + terser: 576 + optional: true 577 + tsx: 578 + optional: true 579 + yaml: 580 + optional: true 581 + 582 + vitefu@1.1.2: 583 + resolution: {integrity: sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==} 584 + peerDependencies: 585 + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0 586 + peerDependenciesMeta: 587 + vite: 588 + optional: true 589 + 590 + zimmerframe@1.1.4: 591 + resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} 592 + 593 + snapshots: 594 + 595 + '@esbuild/aix-ppc64@0.27.4': 596 + optional: true 597 + 598 + '@esbuild/android-arm64@0.27.4': 599 + optional: true 600 + 601 + '@esbuild/android-arm@0.27.4': 602 + optional: true 603 + 604 + '@esbuild/android-x64@0.27.4': 605 + optional: true 606 + 607 + '@esbuild/darwin-arm64@0.27.4': 608 + optional: true 609 + 610 + '@esbuild/darwin-x64@0.27.4': 611 + optional: true 612 + 613 + '@esbuild/freebsd-arm64@0.27.4': 614 + optional: true 615 + 616 + '@esbuild/freebsd-x64@0.27.4': 617 + optional: true 618 + 619 + '@esbuild/linux-arm64@0.27.4': 620 + optional: true 621 + 622 + '@esbuild/linux-arm@0.27.4': 623 + optional: true 624 + 625 + '@esbuild/linux-ia32@0.27.4': 626 + optional: true 627 + 628 + '@esbuild/linux-loong64@0.27.4': 629 + optional: true 630 + 631 + '@esbuild/linux-mips64el@0.27.4': 632 + optional: true 633 + 634 + '@esbuild/linux-ppc64@0.27.4': 635 + optional: true 636 + 637 + '@esbuild/linux-riscv64@0.27.4': 638 + optional: true 639 + 640 + '@esbuild/linux-s390x@0.27.4': 641 + optional: true 642 + 643 + '@esbuild/linux-x64@0.27.4': 644 + optional: true 645 + 646 + '@esbuild/netbsd-arm64@0.27.4': 647 + optional: true 648 + 649 + '@esbuild/netbsd-x64@0.27.4': 650 + optional: true 651 + 652 + '@esbuild/openbsd-arm64@0.27.4': 653 + optional: true 654 + 655 + '@esbuild/openbsd-x64@0.27.4': 656 + optional: true 657 + 658 + '@esbuild/openharmony-arm64@0.27.4': 659 + optional: true 660 + 661 + '@esbuild/sunos-x64@0.27.4': 662 + optional: true 663 + 664 + '@esbuild/win32-arm64@0.27.4': 665 + optional: true 666 + 667 + '@esbuild/win32-ia32@0.27.4': 668 + optional: true 669 + 670 + '@esbuild/win32-x64@0.27.4': 671 + optional: true 672 + 673 + '@jridgewell/gen-mapping@0.3.13': 674 + dependencies: 675 + '@jridgewell/sourcemap-codec': 1.5.5 676 + '@jridgewell/trace-mapping': 0.3.31 677 + 678 + '@jridgewell/remapping@2.3.5': 679 + dependencies: 680 + '@jridgewell/gen-mapping': 0.3.13 681 + '@jridgewell/trace-mapping': 0.3.31 682 + 683 + '@jridgewell/resolve-uri@3.1.2': {} 684 + 685 + '@jridgewell/sourcemap-codec@1.5.5': {} 686 + 687 + '@jridgewell/trace-mapping@0.3.31': 688 + dependencies: 689 + '@jridgewell/resolve-uri': 3.1.2 690 + '@jridgewell/sourcemap-codec': 1.5.5 691 + 692 + '@polka/url@1.0.0-next.29': {} 693 + 694 + '@rollup/rollup-android-arm-eabi@4.59.0': 695 + optional: true 696 + 697 + '@rollup/rollup-android-arm64@4.59.0': 698 + optional: true 699 + 700 + '@rollup/rollup-darwin-arm64@4.59.0': 701 + optional: true 702 + 703 + '@rollup/rollup-darwin-x64@4.59.0': 704 + optional: true 705 + 706 + '@rollup/rollup-freebsd-arm64@4.59.0': 707 + optional: true 708 + 709 + '@rollup/rollup-freebsd-x64@4.59.0': 710 + optional: true 711 + 712 + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': 713 + optional: true 714 + 715 + '@rollup/rollup-linux-arm-musleabihf@4.59.0': 716 + optional: true 717 + 718 + '@rollup/rollup-linux-arm64-gnu@4.59.0': 719 + optional: true 720 + 721 + '@rollup/rollup-linux-arm64-musl@4.59.0': 722 + optional: true 723 + 724 + '@rollup/rollup-linux-loong64-gnu@4.59.0': 725 + optional: true 726 + 727 + '@rollup/rollup-linux-loong64-musl@4.59.0': 728 + optional: true 729 + 730 + '@rollup/rollup-linux-ppc64-gnu@4.59.0': 731 + optional: true 732 + 733 + '@rollup/rollup-linux-ppc64-musl@4.59.0': 734 + optional: true 735 + 736 + '@rollup/rollup-linux-riscv64-gnu@4.59.0': 737 + optional: true 738 + 739 + '@rollup/rollup-linux-riscv64-musl@4.59.0': 740 + optional: true 741 + 742 + '@rollup/rollup-linux-s390x-gnu@4.59.0': 743 + optional: true 744 + 745 + '@rollup/rollup-linux-x64-gnu@4.59.0': 746 + optional: true 747 + 748 + '@rollup/rollup-linux-x64-musl@4.59.0': 749 + optional: true 750 + 751 + '@rollup/rollup-openbsd-x64@4.59.0': 752 + optional: true 753 + 754 + '@rollup/rollup-openharmony-arm64@4.59.0': 755 + optional: true 756 + 757 + '@rollup/rollup-win32-arm64-msvc@4.59.0': 758 + optional: true 759 + 760 + '@rollup/rollup-win32-ia32-msvc@4.59.0': 761 + optional: true 762 + 763 + '@rollup/rollup-win32-x64-gnu@4.59.0': 764 + optional: true 765 + 766 + '@rollup/rollup-win32-x64-msvc@4.59.0': 767 + optional: true 768 + 769 + '@standard-schema/spec@1.1.0': {} 770 + 771 + '@sveltejs/acorn-typescript@1.0.9(acorn@8.16.0)': 772 + dependencies: 773 + acorn: 8.16.0 774 + 775 + '@sveltejs/adapter-auto@7.0.1(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.11)(vite@7.3.1))(svelte@5.53.11)(typescript@5.9.3)(vite@7.3.1))': 776 + dependencies: 777 + '@sveltejs/kit': 2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.11)(vite@7.3.1))(svelte@5.53.11)(typescript@5.9.3)(vite@7.3.1) 778 + 779 + '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.11)(vite@7.3.1))(svelte@5.53.11)(typescript@5.9.3)(vite@7.3.1))': 780 + dependencies: 781 + '@sveltejs/kit': 2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.11)(vite@7.3.1))(svelte@5.53.11)(typescript@5.9.3)(vite@7.3.1) 782 + 783 + '@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.11)(vite@7.3.1))(svelte@5.53.11)(typescript@5.9.3)(vite@7.3.1)': 784 + dependencies: 785 + '@standard-schema/spec': 1.1.0 786 + '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) 787 + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.11)(vite@7.3.1) 788 + '@types/cookie': 0.6.0 789 + acorn: 8.16.0 790 + cookie: 0.6.0 791 + devalue: 5.6.4 792 + esm-env: 1.2.2 793 + kleur: 4.1.5 794 + magic-string: 0.30.21 795 + mrmime: 2.0.1 796 + set-cookie-parser: 3.0.1 797 + sirv: 3.0.2 798 + svelte: 5.53.11 799 + vite: 7.3.1 800 + optionalDependencies: 801 + typescript: 5.9.3 802 + 803 + '@sveltejs/vite-plugin-svelte-inspector@5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.11)(vite@7.3.1))(svelte@5.53.11)(vite@7.3.1)': 804 + dependencies: 805 + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.11)(vite@7.3.1) 806 + obug: 2.1.1 807 + svelte: 5.53.11 808 + vite: 7.3.1 809 + 810 + '@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.11)(vite@7.3.1)': 811 + dependencies: 812 + '@sveltejs/vite-plugin-svelte-inspector': 5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.11)(vite@7.3.1))(svelte@5.53.11)(vite@7.3.1) 813 + deepmerge: 4.3.1 814 + magic-string: 0.30.21 815 + obug: 2.1.1 816 + svelte: 5.53.11 817 + vite: 7.3.1 818 + vitefu: 1.1.2(vite@7.3.1) 819 + 820 + '@types/cookie@0.6.0': {} 821 + 822 + '@types/estree@1.0.8': {} 823 + 824 + '@types/trusted-types@2.0.7': {} 825 + 826 + acorn@8.16.0: {} 827 + 828 + aria-query@5.3.1: {} 829 + 830 + axobject-query@4.1.0: {} 831 + 832 + chokidar@4.0.3: 833 + dependencies: 834 + readdirp: 4.1.2 835 + 836 + clsx@2.1.1: {} 837 + 838 + cookie@0.6.0: {} 839 + 840 + deepmerge@4.3.1: {} 841 + 842 + devalue@5.6.4: {} 843 + 844 + esbuild@0.27.4: 845 + optionalDependencies: 846 + '@esbuild/aix-ppc64': 0.27.4 847 + '@esbuild/android-arm': 0.27.4 848 + '@esbuild/android-arm64': 0.27.4 849 + '@esbuild/android-x64': 0.27.4 850 + '@esbuild/darwin-arm64': 0.27.4 851 + '@esbuild/darwin-x64': 0.27.4 852 + '@esbuild/freebsd-arm64': 0.27.4 853 + '@esbuild/freebsd-x64': 0.27.4 854 + '@esbuild/linux-arm': 0.27.4 855 + '@esbuild/linux-arm64': 0.27.4 856 + '@esbuild/linux-ia32': 0.27.4 857 + '@esbuild/linux-loong64': 0.27.4 858 + '@esbuild/linux-mips64el': 0.27.4 859 + '@esbuild/linux-ppc64': 0.27.4 860 + '@esbuild/linux-riscv64': 0.27.4 861 + '@esbuild/linux-s390x': 0.27.4 862 + '@esbuild/linux-x64': 0.27.4 863 + '@esbuild/netbsd-arm64': 0.27.4 864 + '@esbuild/netbsd-x64': 0.27.4 865 + '@esbuild/openbsd-arm64': 0.27.4 866 + '@esbuild/openbsd-x64': 0.27.4 867 + '@esbuild/openharmony-arm64': 0.27.4 868 + '@esbuild/sunos-x64': 0.27.4 869 + '@esbuild/win32-arm64': 0.27.4 870 + '@esbuild/win32-ia32': 0.27.4 871 + '@esbuild/win32-x64': 0.27.4 872 + 873 + esm-env@1.2.2: {} 874 + 875 + esrap@2.2.3: 876 + dependencies: 877 + '@jridgewell/sourcemap-codec': 1.5.5 878 + 879 + fdir@6.5.0(picomatch@4.0.3): 880 + optionalDependencies: 881 + picomatch: 4.0.3 882 + 883 + fsevents@2.3.3: 884 + optional: true 885 + 886 + is-reference@3.0.3: 887 + dependencies: 888 + '@types/estree': 1.0.8 889 + 890 + kleur@4.1.5: {} 891 + 892 + locate-character@3.0.0: {} 893 + 894 + magic-string@0.30.21: 895 + dependencies: 896 + '@jridgewell/sourcemap-codec': 1.5.5 897 + 898 + mri@1.2.0: {} 899 + 900 + mrmime@2.0.1: {} 901 + 902 + nanoid@3.3.11: {} 903 + 904 + obug@2.1.1: {} 905 + 906 + picocolors@1.1.1: {} 907 + 908 + picomatch@4.0.3: {} 909 + 910 + postcss@8.5.8: 911 + dependencies: 912 + nanoid: 3.3.11 913 + picocolors: 1.1.1 914 + source-map-js: 1.2.1 915 + 916 + readdirp@4.1.2: {} 917 + 918 + rollup@4.59.0: 919 + dependencies: 920 + '@types/estree': 1.0.8 921 + optionalDependencies: 922 + '@rollup/rollup-android-arm-eabi': 4.59.0 923 + '@rollup/rollup-android-arm64': 4.59.0 924 + '@rollup/rollup-darwin-arm64': 4.59.0 925 + '@rollup/rollup-darwin-x64': 4.59.0 926 + '@rollup/rollup-freebsd-arm64': 4.59.0 927 + '@rollup/rollup-freebsd-x64': 4.59.0 928 + '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 929 + '@rollup/rollup-linux-arm-musleabihf': 4.59.0 930 + '@rollup/rollup-linux-arm64-gnu': 4.59.0 931 + '@rollup/rollup-linux-arm64-musl': 4.59.0 932 + '@rollup/rollup-linux-loong64-gnu': 4.59.0 933 + '@rollup/rollup-linux-loong64-musl': 4.59.0 934 + '@rollup/rollup-linux-ppc64-gnu': 4.59.0 935 + '@rollup/rollup-linux-ppc64-musl': 4.59.0 936 + '@rollup/rollup-linux-riscv64-gnu': 4.59.0 937 + '@rollup/rollup-linux-riscv64-musl': 4.59.0 938 + '@rollup/rollup-linux-s390x-gnu': 4.59.0 939 + '@rollup/rollup-linux-x64-gnu': 4.59.0 940 + '@rollup/rollup-linux-x64-musl': 4.59.0 941 + '@rollup/rollup-openbsd-x64': 4.59.0 942 + '@rollup/rollup-openharmony-arm64': 4.59.0 943 + '@rollup/rollup-win32-arm64-msvc': 4.59.0 944 + '@rollup/rollup-win32-ia32-msvc': 4.59.0 945 + '@rollup/rollup-win32-x64-gnu': 4.59.0 946 + '@rollup/rollup-win32-x64-msvc': 4.59.0 947 + fsevents: 2.3.3 948 + 949 + sade@1.8.1: 950 + dependencies: 951 + mri: 1.2.0 952 + 953 + set-cookie-parser@3.0.1: {} 954 + 955 + sirv@3.0.2: 956 + dependencies: 957 + '@polka/url': 1.0.0-next.29 958 + mrmime: 2.0.1 959 + totalist: 3.0.1 960 + 961 + source-map-js@1.2.1: {} 962 + 963 + svelte-check@4.4.5(picomatch@4.0.3)(svelte@5.53.11)(typescript@5.9.3): 964 + dependencies: 965 + '@jridgewell/trace-mapping': 0.3.31 966 + chokidar: 4.0.3 967 + fdir: 6.5.0(picomatch@4.0.3) 968 + picocolors: 1.1.1 969 + sade: 1.8.1 970 + svelte: 5.53.11 971 + typescript: 5.9.3 972 + transitivePeerDependencies: 973 + - picomatch 974 + 975 + svelte@5.53.11: 976 + dependencies: 977 + '@jridgewell/remapping': 2.3.5 978 + '@jridgewell/sourcemap-codec': 1.5.5 979 + '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) 980 + '@types/estree': 1.0.8 981 + '@types/trusted-types': 2.0.7 982 + acorn: 8.16.0 983 + aria-query: 5.3.1 984 + axobject-query: 4.1.0 985 + clsx: 2.1.1 986 + devalue: 5.6.4 987 + esm-env: 1.2.2 988 + esrap: 2.2.3 989 + is-reference: 3.0.3 990 + locate-character: 3.0.0 991 + magic-string: 0.30.21 992 + zimmerframe: 1.1.4 993 + 994 + tinyglobby@0.2.15: 995 + dependencies: 996 + fdir: 6.5.0(picomatch@4.0.3) 997 + picomatch: 4.0.3 998 + 999 + totalist@3.0.1: {} 1000 + 1001 + typescript@5.9.3: {} 1002 + 1003 + vite@7.3.1: 1004 + dependencies: 1005 + esbuild: 0.27.4 1006 + fdir: 6.5.0(picomatch@4.0.3) 1007 + picomatch: 4.0.3 1008 + postcss: 8.5.8 1009 + rollup: 4.59.0 1010 + tinyglobby: 0.2.15 1011 + optionalDependencies: 1012 + fsevents: 2.3.3 1013 + 1014 + vitefu@1.1.2(vite@7.3.1): 1015 + optionalDependencies: 1016 + vite: 7.3.1 1017 + 1018 + zimmerframe@1.1.4: {}
+28
frontend/src/app.css
··· 1 + * { box-sizing: border-box; margin: 0; padding: 0; } 2 + 3 + body { 4 + font-family: monospace; 5 + max-width: 600px; 6 + margin: 0 auto; 7 + padding: 1rem; 8 + background: #0a0a0a; 9 + color: #ccc; 10 + font-size: 14px; 11 + line-height: 1.6; 12 + } 13 + 14 + a { color: #888; text-decoration: none; } 15 + a:hover { color: #fff; } 16 + 17 + input, button, textarea { 18 + font-family: monospace; 19 + font-size: 14px; 20 + padding: 0.5rem; 21 + border: 1px solid #333; 22 + background: #111; 23 + color: #ccc; 24 + } 25 + 26 + input:focus, textarea:focus { outline: 1px solid #444; } 27 + button { cursor: pointer; } 28 + button:hover { background: #222; }
+13
frontend/src/app.d.ts
··· 1 + // See https://svelte.dev/docs/kit/types#app.d.ts 2 + // for information about these interfaces 3 + declare global { 4 + namespace App { 5 + // interface Error {} 6 + // interface Locals {} 7 + // interface PageData {} 8 + // interface PageState {} 9 + // interface Platform {} 10 + } 11 + } 12 + 13 + export {};
+22
frontend/src/app.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" /> 6 + <link rel="icon" href="/favicon.svg" type="image/svg+xml" /> 7 + <title>pollz</title> 8 + <meta name="description" content="polls on atproto" /> 9 + <meta property="og:type" content="website" /> 10 + <meta property="og:title" content="pollz" /> 11 + <meta property="og:description" content="polls on atproto" /> 12 + <meta property="og:url" content="https://pollz.waow.tech" /> 13 + <meta property="og:site_name" content="pollz" /> 14 + <meta name="twitter:card" content="summary" /> 15 + <meta name="twitter:title" content="pollz" /> 16 + <meta name="twitter:description" content="polls on atproto" /> 17 + %sveltekit.head% 18 + </head> 19 + <body data-sveltekit-preload-data="hover"> 20 + <div style="display: contents">%sveltekit.body%</div> 21 + </body> 22 + </html>
+100
frontend/src/lib/api.ts
··· 1 + const API = import.meta.env.VITE_API_URL ?? 'https://api.pollz.waow.tech'; 2 + 3 + async function api(path: string, opts?: RequestInit) { 4 + const res = await fetch(`${API}${path}`, { 5 + credentials: 'include', 6 + ...opts 7 + }); 8 + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); 9 + const text = await res.text(); 10 + if (!text) return null; 11 + try { 12 + return JSON.parse(text); 13 + } catch { 14 + return null; 15 + } 16 + } 17 + 18 + export type Poll = { 19 + uri: string; 20 + repo: string; 21 + rkey: string; 22 + text: string; 23 + options: { text: string; count: number }[]; 24 + createdAt: string; 25 + author?: { did: string; handle: string; avatar: string | null }; 26 + }; 27 + 28 + export type PollDetail = { 29 + uri: string; 30 + repo: string; 31 + rkey: string; 32 + text: string; 33 + options: { text: string; count: number }[]; 34 + createdAt: string; 35 + }; 36 + 37 + export type Vote = { 38 + voter: string; 39 + option: number; 40 + uri: string; 41 + createdAt?: string; 42 + handle?: string; 43 + }; 44 + 45 + export type User = { 46 + did: string; 47 + handle: string; 48 + }; 49 + 50 + export async function fetchPolls(): Promise<Poll[]> { 51 + return api('/api/polls'); 52 + } 53 + 54 + export async function fetchPoll(uri: string): Promise<PollDetail | null> { 55 + try { 56 + return await api(`/api/polls/${encodeURIComponent(uri)}`); 57 + } catch { 58 + return null; 59 + } 60 + } 61 + 62 + export async function fetchVotes(pollUri: string): Promise<Vote[]> { 63 + return api(`/api/polls/${encodeURIComponent(pollUri)}/votes`); 64 + } 65 + 66 + export async function createPoll(text: string, options: string[]) { 67 + return api('/api/polls', { 68 + method: 'POST', 69 + headers: { 'Content-Type': 'application/json' }, 70 + body: JSON.stringify({ text, options }) 71 + }); 72 + } 73 + 74 + export async function deletePoll(pollUri: string) { 75 + return api(`/api/polls/${encodeURIComponent(pollUri)}`, { method: 'DELETE' }); 76 + } 77 + 78 + export async function vote(pollUri: string, option: number) { 79 + return api(`/api/polls/${encodeURIComponent(pollUri)}/vote`, { 80 + method: 'POST', 81 + headers: { 'Content-Type': 'application/json' }, 82 + body: JSON.stringify({ subject: pollUri, option }) 83 + }); 84 + } 85 + 86 + export async function getMe(): Promise<User | null> { 87 + try { 88 + return await api('/api/me'); 89 + } catch { 90 + return null; 91 + } 92 + } 93 + 94 + export async function logout() { 95 + await api('/api/logout', { method: 'POST' }); 96 + } 97 + 98 + export function loginUrl(handle: string): string { 99 + return `${API}/oauth/login?handle=${encodeURIComponent(handle)}`; 100 + }
+1
frontend/src/lib/assets/favicon.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y="80" font-size="85" font-family="serif">◉</text></svg>
+177
frontend/src/lib/components/VotersTooltip.svelte
··· 1 + <script module lang="ts"> 2 + import type { Vote } from '$lib/api'; 3 + 4 + const votesCache = new Map<string, Vote[]>(); 5 + 6 + export function invalidateVotesCache(pollUri: string) { 7 + votesCache.delete(pollUri); 8 + } 9 + </script> 10 + 11 + <script lang="ts"> 12 + import { fetchVotes } from '$lib/api'; 13 + import type { Snippet } from 'svelte'; 14 + 15 + let { 16 + pollUri, 17 + options, 18 + children 19 + }: { 20 + pollUri: string; 21 + options: { text: string; count: number }[]; 22 + children: Snippet; 23 + } = $props(); 24 + 25 + let visible = $state(false); 26 + let votes = $state<Vote[]>([]); 27 + let loading = $state(false); 28 + let timer: ReturnType<typeof setTimeout> | undefined = undefined; 29 + 30 + const grouped = $derived( 31 + votes.reduce<Record<number, Vote[]>>((acc, v) => { 32 + (acc[v.option] ??= []).push(v); 33 + return acc; 34 + }, {}) 35 + ); 36 + 37 + function formatVoter(v: Vote): string { 38 + if (v.handle) return `@${v.handle}`; 39 + const did = v.voter; 40 + if (did.length > 24) return did.slice(0, 20) + '...'; 41 + return did; 42 + } 43 + 44 + async function loadVotes() { 45 + if (votesCache.has(pollUri)) { 46 + votes = votesCache.get(pollUri)!; 47 + return; 48 + } 49 + loading = true; 50 + try { 51 + const result = await fetchVotes(pollUri); 52 + votesCache.set(pollUri, result); 53 + votes = result; 54 + } catch (e) { 55 + console.error('failed to fetch votes', e); 56 + votes = []; 57 + } finally { 58 + loading = false; 59 + } 60 + } 61 + 62 + async function show() { 63 + timer = setTimeout(async () => { 64 + visible = true; 65 + await loadVotes(); 66 + }, 150); 67 + } 68 + 69 + function hide() { 70 + clearTimeout(timer); 71 + timer = undefined; 72 + visible = false; 73 + document.removeEventListener('click', closeOnOutsideClick); 74 + } 75 + 76 + function toggle(e: MouseEvent) { 77 + e.stopPropagation(); 78 + if (visible) { 79 + hide(); 80 + return; 81 + } 82 + visible = true; 83 + loadVotes(); 84 + document.addEventListener('click', closeOnOutsideClick, { once: true }); 85 + } 86 + 87 + function closeOnOutsideClick() { 88 + hide(); 89 + } 90 + </script> 91 + 92 + <!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events --> 93 + <span 94 + class="tooltip-wrapper" 95 + onmouseenter={show} 96 + onmouseleave={hide} 97 + onfocusin={show} 98 + onfocusout={hide} 99 + onclick={toggle} 100 + > 101 + {@render children()} 102 + 103 + {#if visible} 104 + <div class="tooltip"> 105 + {#if loading} 106 + <span class="entry">loading...</span> 107 + {:else if votes.length === 0} 108 + <span class="entry">no votes yet</span> 109 + {:else} 110 + {#each options as option, i (i)} 111 + {@const optionVotes = grouped[i] ?? []} 112 + {#if optionVotes.length > 0} 113 + <div class="option-group"> 114 + <div class="option-label">{option.text}</div> 115 + {#each optionVotes as v (v.uri)} 116 + <div class="entry"> 117 + {#if v.handle} 118 + <a href="https://bsky.app/profile/{v.handle}" class="handle" target="_blank" rel="noopener">@{v.handle}</a> 119 + {:else} 120 + <span class="handle">{formatVoter(v)}</span> 121 + {/if} 122 + </div> 123 + {/each} 124 + </div> 125 + {/if} 126 + {/each} 127 + {/if} 128 + </div> 129 + {/if} 130 + </span> 131 + 132 + <style> 133 + .tooltip-wrapper { 134 + position: relative; 135 + display: inline-block; 136 + } 137 + 138 + .tooltip { 139 + position: absolute; 140 + top: 100%; 141 + left: 0; 142 + z-index: 10; 143 + background: #1a1a1a; 144 + border: 1px solid #333; 145 + padding: 0.5rem; 146 + font-family: monospace; 147 + font-size: 12px; 148 + max-height: 200px; 149 + overflow-y: auto; 150 + min-width: 180px; 151 + white-space: nowrap; 152 + } 153 + 154 + .option-group { 155 + margin-bottom: 0.25rem; 156 + } 157 + 158 + .option-label { 159 + color: #666; 160 + font-size: 11px; 161 + margin-bottom: 2px; 162 + } 163 + 164 + .entry { 165 + color: #999; 166 + line-height: 1.6; 167 + } 168 + 169 + .handle { 170 + color: #aaa; 171 + text-decoration: none; 172 + } 173 + 174 + a.handle:hover { 175 + color: #ddd; 176 + } 177 + </style>
+1
frontend/src/lib/index.ts
··· 1 + // place files you want to import through the `$lib` alias in this folder.
+21
frontend/src/lib/user.svelte.ts
··· 1 + import { getMe, type User } from './api'; 2 + 3 + let user = $state<User | null>(null); 4 + let loaded = $state(false); 5 + 6 + export function getUser() { 7 + return user; 8 + } 9 + 10 + export function isLoaded() { 11 + return loaded; 12 + } 13 + 14 + export function setUser(u: User | null) { 15 + user = u; 16 + } 17 + 18 + export async function loadUser() { 19 + user = await getMe(); 20 + loaded = true; 21 + }
+15
frontend/src/lib/utils.ts
··· 1 + export function ago(dateStr: string): string { 2 + const ms = Date.now() - new Date(dateStr).getTime(); 3 + const s = Math.floor(ms / 1000); 4 + if (s < 60) return 'just now'; 5 + const m = Math.floor(s / 60); 6 + if (m < 60) return `${m}m ago`; 7 + const h = Math.floor(m / 60); 8 + if (h < 24) return `${h}h ago`; 9 + const d = Math.floor(h / 24); 10 + return `${d}d ago`; 11 + } 12 + 13 + export function fullDate(dateStr: string): string { 14 + return new Date(dateStr).toLocaleString(); 15 + }
+95
frontend/src/routes/+layout.svelte
··· 1 + <script lang="ts"> 2 + import '../app.css'; 3 + import { loadUser, getUser, setUser, isLoaded } from '$lib/user.svelte'; 4 + import { logout as apiLogout, loginUrl } from '$lib/api'; 5 + import { onMount } from 'svelte'; 6 + 7 + let { children } = $props(); 8 + 9 + let handle = $state(''); 10 + 11 + onMount(() => { 12 + loadUser(); 13 + }); 14 + 15 + function doLogout() { 16 + apiLogout().then(() => setUser(null)); 17 + } 18 + 19 + function doLogin() { 20 + if (handle.trim()) { 21 + window.location.href = loginUrl(handle.trim()); 22 + } 23 + } 24 + </script> 25 + 26 + <header> 27 + <div class="left"> 28 + <a href="/">pollz</a> 29 + <a href="https://tangled.sh/@zzstoatzz.io/pollz" class="src">[src]</a> 30 + </div> 31 + <nav> 32 + {#if isLoaded()} 33 + {#if getUser()} 34 + <a href="/">all</a> 35 + <a href="/mine">mine</a> 36 + <a href="/new">new</a> 37 + <button onclick={doLogout}>logout</button> 38 + {:else} 39 + <input 40 + type="text" 41 + placeholder="handle" 42 + bind:value={handle} 43 + onkeydown={(e) => e.key === 'Enter' && doLogin()} 44 + /> 45 + <button onclick={doLogin}>login</button> 46 + {/if} 47 + {/if} 48 + </nav> 49 + </header> 50 + 51 + {@render children()} 52 + 53 + <style> 54 + header { 55 + display: flex; 56 + justify-content: space-between; 57 + align-items: center; 58 + padding-bottom: 0.75rem; 59 + margin-bottom: 1rem; 60 + border-bottom: 1px solid #222; 61 + } 62 + 63 + .left { 64 + display: flex; 65 + align-items: center; 66 + gap: 0.5rem; 67 + } 68 + 69 + .left a:first-child { 70 + font-weight: bold; 71 + color: #ccc; 72 + } 73 + 74 + .src { 75 + font-size: 0.75em; 76 + color: #555; 77 + } 78 + 79 + nav { 80 + display: flex; 81 + align-items: center; 82 + gap: 0.75rem; 83 + } 84 + 85 + nav input { 86 + width: 140px; 87 + padding: 0.25rem 0.4rem; 88 + font-size: 13px; 89 + } 90 + 91 + nav button { 92 + padding: 0.25rem 0.5rem; 93 + font-size: 13px; 94 + } 95 + </style>
+2
frontend/src/routes/+layout.ts
··· 1 + export const prerender = false; 2 + export const ssr = false;
+302
frontend/src/routes/+page.svelte
··· 1 + <script lang="ts"> 2 + import { fetchPolls, vote as apiVote, fetchPoll, deletePoll, type Poll } from '$lib/api'; 3 + import { ago, fullDate } from '$lib/utils'; 4 + import { getUser } from '$lib/user.svelte'; 5 + import { onMount } from 'svelte'; 6 + import VotersTooltip, { invalidateVotesCache } from '$lib/components/VotersTooltip.svelte'; 7 + 8 + let polls = $state<Poll[]>([]); 9 + let loading = $state(true); 10 + let votingUri = $state<string | null>(null); 11 + let statusMap: Record<string, string> = $state({}); 12 + 13 + onMount(async () => { 14 + try { 15 + const data = await fetchPolls(); 16 + polls = data.sort( 17 + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() 18 + ); 19 + } catch (e) { 20 + console.error('failed to fetch polls', e); 21 + } finally { 22 + loading = false; 23 + } 24 + }); 25 + 26 + function totalVotes(poll: Poll): number { 27 + return poll.options.reduce((sum, o) => sum + o.count, 0); 28 + } 29 + 30 + function pct(count: number, total: number): number { 31 + return total > 0 ? Math.round((count / total) * 100) : 0; 32 + } 33 + 34 + function setStatus(uri: string, msg: string, duration: number) { 35 + statusMap[uri] = msg; 36 + setTimeout(() => { 37 + if (statusMap[uri] === msg) statusMap[uri] = ''; 38 + }, duration); 39 + } 40 + 41 + function isOwner(poll: Poll): boolean { 42 + const user = getUser(); 43 + return !!user && poll.repo === user.did; 44 + } 45 + 46 + async function handleDelete(poll: Poll) { 47 + if (!confirm('delete this poll?')) return; 48 + setStatus(poll.uri, 'deleting...', 10000); 49 + try { 50 + await deletePoll(poll.uri); 51 + polls = polls.filter((p) => p.uri !== poll.uri); 52 + } catch (e) { 53 + console.error('delete failed', e); 54 + setStatus(poll.uri, 'failed to delete', 3000); 55 + } 56 + } 57 + 58 + async function handleVote(poll: Poll, optionIndex: number) { 59 + if (!getUser()) return; 60 + if (votingUri) return; 61 + 62 + votingUri = poll.uri; 63 + setStatus(poll.uri, 'voting...', 10000); 64 + try { 65 + await apiVote(poll.uri, optionIndex); 66 + 67 + const beforeCounts = poll.options.map((o) => o.count); 68 + let confirmed = false; 69 + for (let i = 0; i < 10; i++) { 70 + await new Promise((r) => setTimeout(r, 500)); 71 + const fresh = await fetchPoll(poll.uri); 72 + if (fresh && fresh.options.some((o, j) => o.count !== beforeCounts[j])) { 73 + const idx = polls.findIndex((p) => p.uri === poll.uri); 74 + if (idx !== -1) { 75 + polls[idx] = { ...polls[idx], options: fresh.options }; 76 + } 77 + confirmed = true; 78 + break; 79 + } 80 + } 81 + if (confirmed) { 82 + invalidateVotesCache(poll.uri); 83 + setStatus(poll.uri, 'voted', 2000); 84 + } else { 85 + setStatus(poll.uri, 'vote may still be processing', 3000); 86 + } 87 + } catch (e) { 88 + console.error('vote failed', e); 89 + setStatus(poll.uri, 'failed to vote', 3000); 90 + } finally { 91 + votingUri = null; 92 + } 93 + } 94 + </script> 95 + 96 + {#if getUser()} 97 + <a href="/new" class="new-poll">+ new poll</a> 98 + {/if} 99 + 100 + {#if loading} 101 + <p class="status">loading polls...</p> 102 + {:else if polls.length === 0} 103 + <p class="status">no polls yet</p> 104 + {:else} 105 + {#each polls as poll (poll.uri)} 106 + {@const total = totalVotes(poll)} 107 + <div class="poll"> 108 + <div class="poll-header"> 109 + {#if poll.author} 110 + <div class="poll-author"> 111 + {#if poll.author.avatar} 112 + <img 113 + src={poll.author.avatar} 114 + alt="" 115 + class="avatar" 116 + /> 117 + {:else} 118 + <div class="avatar avatar-placeholder"></div> 119 + {/if} 120 + <a href="https://bsky.app/profile/{poll.author.handle}" class="handle" target="_blank" rel="noopener">@{poll.author.handle}</a> 121 + </div> 122 + {/if} 123 + <a href="/poll/{poll.repo}/{poll.rkey}" class="poll-question">{poll.text}</a> 124 + </div> 125 + 126 + <div class="options"> 127 + {#each poll.options as option, i (i)} 128 + {@const p = pct(option.count, total)} 129 + <button 130 + class="option" 131 + disabled={votingUri === poll.uri || !getUser()} 132 + onclick={() => handleVote(poll, i)} 133 + > 134 + <div class="option-bar" style="width: {p}%"></div> 135 + <span class="option-text">{option.text}</span> 136 + <span class="option-count">{option.count} ({p}%)</span> 137 + </button> 138 + {/each} 139 + </div> 140 + 141 + <div class="poll-meta"> 142 + <span title={fullDate(poll.createdAt)}>{ago(poll.createdAt)}</span> &middot; 143 + <VotersTooltip pollUri={poll.uri} options={poll.options}> 144 + <span class="vote-count">{total} {total === 1 ? 'vote' : 'votes'}</span> 145 + </VotersTooltip> 146 + {#if isOwner(poll)} 147 + &middot; <button class="delete-btn" onclick={() => handleDelete(poll)}>delete</button> 148 + {/if} 149 + {#if statusMap[poll.uri]} 150 + <span class="inline-status"> &middot; {statusMap[poll.uri]}</span> 151 + {/if} 152 + </div> 153 + </div> 154 + {/each} 155 + {/if} 156 + 157 + <style> 158 + .new-poll { 159 + display: inline-block; 160 + margin-bottom: 1rem; 161 + color: #888; 162 + } 163 + 164 + .new-poll:hover { 165 + color: #ccc; 166 + } 167 + 168 + .status { 169 + color: #555; 170 + } 171 + 172 + .poll { 173 + border-bottom: 1px solid #222; 174 + padding: 1rem 0; 175 + } 176 + 177 + .poll-header { 178 + margin-bottom: 0.5rem; 179 + } 180 + 181 + .poll-author { 182 + display: flex; 183 + align-items: center; 184 + gap: 0.4rem; 185 + margin-bottom: 0.35rem; 186 + } 187 + 188 + .avatar { 189 + width: 20px; 190 + height: 20px; 191 + border-radius: 50%; 192 + object-fit: cover; 193 + } 194 + 195 + .avatar-placeholder { 196 + background: #333; 197 + } 198 + 199 + .handle { 200 + color: #555; 201 + font-size: 12px; 202 + text-decoration: none; 203 + } 204 + 205 + .handle:hover { 206 + color: #888; 207 + } 208 + 209 + .poll-question { 210 + color: #fff; 211 + display: block; 212 + font-size: 15px; 213 + } 214 + 215 + .poll-question:hover { 216 + color: #ccc; 217 + } 218 + 219 + .options { 220 + margin-top: 0.5rem; 221 + } 222 + 223 + .option { 224 + position: relative; 225 + display: flex; 226 + align-items: center; 227 + width: 100%; 228 + padding: 0.5rem 0.75rem; 229 + margin: 0.35rem 0; 230 + background: #111; 231 + border: 1px solid #222; 232 + cursor: pointer; 233 + color: #ccc; 234 + font-family: monospace; 235 + font-size: 13px; 236 + text-align: left; 237 + min-height: 44px; 238 + -webkit-tap-highlight-color: transparent; 239 + } 240 + 241 + .option:hover:not(:disabled) { 242 + border-color: #444; 243 + } 244 + 245 + .option:disabled { 246 + cursor: default; 247 + opacity: 0.7; 248 + } 249 + 250 + .option-bar { 251 + position: absolute; 252 + left: 0; 253 + top: 0; 254 + height: 100%; 255 + background: #1a3a1a; 256 + z-index: 0; 257 + transition: width 0.3s; 258 + } 259 + 260 + .option-text, 261 + .option-count { 262 + position: relative; 263 + z-index: 1; 264 + } 265 + 266 + .option-count { 267 + color: #888; 268 + font-size: 11px; 269 + margin-left: auto; 270 + padding-left: 0.5rem; 271 + white-space: nowrap; 272 + } 273 + 274 + .poll-meta { 275 + color: #555; 276 + font-size: 12px; 277 + margin-top: 0.5rem; 278 + } 279 + 280 + .vote-count { 281 + cursor: default; 282 + border-bottom: 1px dotted #444; 283 + } 284 + 285 + .delete-btn { 286 + background: none; 287 + border: none; 288 + color: #555; 289 + font-family: monospace; 290 + font-size: 12px; 291 + cursor: pointer; 292 + padding: 0; 293 + } 294 + 295 + .delete-btn:hover { 296 + color: #c44; 297 + } 298 + 299 + .inline-status { 300 + color: #888; 301 + } 302 + </style>
+85
frontend/src/routes/mine/+page.svelte
··· 1 + <script lang="ts"> 2 + import { fetchPolls, type Poll } from '$lib/api'; 3 + import { ago, fullDate } from '$lib/utils'; 4 + import { getUser } from '$lib/user.svelte'; 5 + import { onMount } from 'svelte'; 6 + 7 + let polls = $state<Poll[]>([]); 8 + let loading = $state(true); 9 + 10 + onMount(async () => { 11 + const user = getUser(); 12 + if (!user) { 13 + loading = false; 14 + return; 15 + } 16 + try { 17 + const all = await fetchPolls(); 18 + polls = all 19 + .filter((p) => p.repo === user.did) 20 + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); 21 + } finally { 22 + loading = false; 23 + } 24 + }); 25 + </script> 26 + 27 + {#if !getUser()} 28 + <p class="empty">login to see your polls</p> 29 + {:else if loading} 30 + <p class="empty">loading...</p> 31 + {:else if polls.length === 0} 32 + <p class="empty">you haven't created any polls yet</p> 33 + {:else} 34 + <ul> 35 + {#each polls as poll (poll.uri)} 36 + {@const total = poll.options.reduce((s, o) => s + o.count, 0)} 37 + <li> 38 + <a href="/poll/{poll.repo}/{poll.rkey}"> 39 + <span class="text">{poll.text}</span> 40 + <span class="meta"> 41 + {poll.options.length} options &middot; {total} {total === 1 ? 'vote' : 'votes'} &middot; <span title={fullDate(poll.createdAt)}>{ago(poll.createdAt)}</span> 42 + </span> 43 + </a> 44 + </li> 45 + {/each} 46 + </ul> 47 + {/if} 48 + 49 + <style> 50 + .empty { 51 + color: #555; 52 + margin-top: 2rem; 53 + text-align: center; 54 + } 55 + 56 + ul { 57 + list-style: none; 58 + } 59 + 60 + li { 61 + border: 1px solid #222; 62 + margin-bottom: 0.5rem; 63 + } 64 + 65 + li a { 66 + display: block; 67 + padding: 0.75rem; 68 + color: #ccc; 69 + text-decoration: none; 70 + } 71 + 72 + li a:hover { 73 + background: #151515; 74 + } 75 + 76 + .text { 77 + display: block; 78 + margin-bottom: 0.25rem; 79 + } 80 + 81 + .meta { 82 + font-size: 0.8em; 83 + color: #555; 84 + } 85 + </style>
+55
frontend/src/routes/new/+page.svelte
··· 1 + <script lang="ts"> 2 + import { createPoll } from '$lib/api'; 3 + import { getUser } from '$lib/user.svelte'; 4 + import { goto } from '$app/navigation'; 5 + 6 + let question = $state(''); 7 + let optionsText = $state(''); 8 + let status = $state(''); 9 + 10 + async function submit() { 11 + const text = question.trim(); 12 + const options = optionsText 13 + .split('\n') 14 + .map((o) => o.trim()) 15 + .filter(Boolean); 16 + 17 + if (!text || options.length < 2) { 18 + status = 'need a question and at least 2 options'; 19 + return; 20 + } 21 + 22 + status = 'creating...'; 23 + try { 24 + await createPoll(text, options); 25 + goto('/'); 26 + } catch (e) { 27 + status = e instanceof Error ? e.message : 'failed to create poll'; 28 + } 29 + } 30 + </script> 31 + 32 + {#if !getUser()} 33 + <p>login to create</p> 34 + {:else} 35 + <form class="create-form" onsubmit={(e) => { e.preventDefault(); submit(); }}> 36 + <input type="text" placeholder="question" bind:value={question} /> 37 + <textarea placeholder="options (one per line)" rows={4} bind:value={optionsText}></textarea> 38 + <button type="submit">create</button> 39 + {#if status} 40 + <p class="status">{status}</p> 41 + {/if} 42 + </form> 43 + {/if} 44 + 45 + <style> 46 + .create-form input, 47 + .create-form textarea { 48 + width: 100%; 49 + margin-bottom: 0.5rem; 50 + } 51 + 52 + .status { 53 + color: #666; 54 + } 55 + </style>
+270
frontend/src/routes/poll/[repo]/[rkey]/+page.svelte
··· 1 + <script lang="ts"> 2 + import { fetchPoll, vote as apiVote, deletePoll, type PollDetail } from '$lib/api'; 3 + import { ago, fullDate } from '$lib/utils'; 4 + import { getUser } from '$lib/user.svelte'; 5 + import { page } from '$app/state'; 6 + import { goto } from '$app/navigation'; 7 + import { onMount } from 'svelte'; 8 + import VotersTooltip, { invalidateVotesCache } from '$lib/components/VotersTooltip.svelte'; 9 + 10 + let poll = $state<PollDetail | null>(null); 11 + let loading = $state(true); 12 + let voting = $state(false); 13 + let status = $state(''); 14 + 15 + const repo = $derived(page.params.repo); 16 + const rkey = $derived(page.params.rkey); 17 + const uri = $derived(`at://${repo}/tech.waow.pollz.poll/${rkey}`); 18 + 19 + const totalVotes = $derived( 20 + poll ? poll.options.reduce((sum, o) => sum + o.count, 0) : 0 21 + ); 22 + 23 + const isOwner = $derived(() => { 24 + const user = getUser(); 25 + return !!user && repo === user.did; 26 + }); 27 + 28 + async function loadPoll() { 29 + poll = await fetchPoll(uri); 30 + } 31 + 32 + onMount(async () => { 33 + try { 34 + await loadPoll(); 35 + } catch (e) { 36 + console.error('failed to load poll', e); 37 + } finally { 38 + loading = false; 39 + } 40 + }); 41 + 42 + async function handleDelete() { 43 + if (!confirm('delete this poll?')) return; 44 + status = 'deleting...'; 45 + try { 46 + await deletePoll(uri); 47 + goto('/'); 48 + } catch (e) { 49 + status = e instanceof Error ? e.message : 'failed to delete'; 50 + setTimeout(() => (status = ''), 3000); 51 + } 52 + } 53 + 54 + async function handleVote(optionIndex: number) { 55 + if (!getUser()) { 56 + status = 'login to vote'; 57 + setTimeout(() => (status = ''), 2000); 58 + return; 59 + } 60 + if (!poll) return; 61 + voting = true; 62 + status = 'voting...'; 63 + try { 64 + await apiVote(uri, optionIndex); 65 + 66 + const beforeCounts = poll.options.map((o) => o.count); 67 + let confirmed = false; 68 + for (let i = 0; i < 10; i++) { 69 + await new Promise((r) => setTimeout(r, 500)); 70 + await loadPoll(); 71 + if (poll && poll.options.some((o, j) => o.count !== beforeCounts[j])) { 72 + confirmed = true; 73 + break; 74 + } 75 + } 76 + if (confirmed) { 77 + invalidateVotesCache(uri); 78 + status = 'voted'; 79 + setTimeout(() => (status = ''), 2000); 80 + } else { 81 + status = 'vote may still be processing'; 82 + setTimeout(() => (status = ''), 3000); 83 + } 84 + } catch (e) { 85 + status = e instanceof Error ? e.message : 'failed to vote'; 86 + setTimeout(() => (status = ''), 3000); 87 + } finally { 88 + voting = false; 89 + } 90 + } 91 + 92 + function copyLink() { 93 + navigator.clipboard.writeText(window.location.href); 94 + status = 'link copied'; 95 + setTimeout(() => (status = ''), 1500); 96 + } 97 + </script> 98 + 99 + <div class="poll-detail"> 100 + <a href="/" class="back">&larr; all polls</a> 101 + 102 + {#if loading} 103 + <p class="status-msg">loading...</p> 104 + {:else if !poll} 105 + <p class="status-msg">poll not found</p> 106 + {:else} 107 + <div class="poll-header"> 108 + <h2>{poll.text}</h2> 109 + <div class="header-actions"> 110 + {#if isOwner()} 111 + <button class="delete-btn" onclick={handleDelete}>delete</button> 112 + {/if} 113 + <button class="share-btn" onclick={copyLink}>copy link</button> 114 + </div> 115 + </div> 116 + 117 + <div class="options"> 118 + {#each poll.options as option, i (i)} 119 + {@const pct = totalVotes > 0 ? Math.round((option.count / totalVotes) * 100) : 0} 120 + <button 121 + class="option" 122 + disabled={voting} 123 + onclick={() => handleVote(i)} 124 + > 125 + <div class="option-bar" style="width: {pct}%"></div> 126 + <span class="option-text">{option.text}</span> 127 + <span class="option-count">{option.count} ({pct}%)</span> 128 + </button> 129 + {/each} 130 + </div> 131 + 132 + <div class="poll-meta"> 133 + <span title={fullDate(poll.createdAt)}>{ago(poll.createdAt)}</span> &middot; 134 + <VotersTooltip pollUri={poll.uri} options={poll.options}> 135 + <span class="vote-count">{totalVotes} vote{totalVotes === 1 ? '' : 's'}</span> 136 + </VotersTooltip> 137 + </div> 138 + {/if} 139 + 140 + {#if status} 141 + <p class="status-msg">{status}</p> 142 + {/if} 143 + </div> 144 + 145 + <style> 146 + .poll-detail { 147 + max-width: 600px; 148 + } 149 + 150 + .back { 151 + color: #555; 152 + font-size: 13px; 153 + display: inline-block; 154 + margin-bottom: 1rem; 155 + } 156 + 157 + .back:hover { 158 + color: #888; 159 + } 160 + 161 + h2 { 162 + margin: 0; 163 + font-size: 18px; 164 + font-weight: normal; 165 + } 166 + 167 + .status-msg { 168 + color: #888; 169 + font-size: 13px; 170 + margin-top: 0.5rem; 171 + } 172 + 173 + .poll-detail .options { 174 + margin-top: 0.5rem; 175 + } 176 + 177 + .poll-detail .option { 178 + position: relative; 179 + display: flex; 180 + align-items: center; 181 + width: 100%; 182 + padding: 0.75rem; 183 + margin: 0.5rem 0; 184 + background: #111; 185 + border: 1px solid #222; 186 + cursor: pointer; 187 + color: #ccc; 188 + font-family: monospace; 189 + font-size: 14px; 190 + text-align: left; 191 + min-height: 44px; 192 + -webkit-tap-highlight-color: transparent; 193 + } 194 + 195 + .poll-detail .option:hover { 196 + border-color: #444; 197 + } 198 + 199 + .poll-detail .option:disabled { 200 + cursor: wait; 201 + opacity: 0.7; 202 + } 203 + 204 + .option-bar { 205 + position: absolute; 206 + left: 0; 207 + top: 0; 208 + height: 100%; 209 + background: #1a3a1a; 210 + z-index: 0; 211 + transition: width 0.3s; 212 + } 213 + 214 + .option-text, 215 + .option-count { 216 + position: relative; 217 + z-index: 1; 218 + } 219 + 220 + .option-count { 221 + color: #888; 222 + font-size: 12px; 223 + margin-left: 1rem; 224 + } 225 + 226 + .poll-meta { 227 + color: #555; 228 + font-size: 12px; 229 + margin-top: 1rem; 230 + } 231 + 232 + .vote-count { 233 + cursor: default; 234 + border-bottom: 1px dotted #444; 235 + } 236 + 237 + .poll-header { 238 + display: flex; 239 + justify-content: space-between; 240 + align-items: flex-start; 241 + gap: 1rem; 242 + margin-bottom: 1rem; 243 + } 244 + 245 + .header-actions { 246 + display: flex; 247 + gap: 0.5rem; 248 + } 249 + 250 + .share-btn, 251 + .delete-btn { 252 + background: none; 253 + border: 1px solid #333; 254 + color: #888; 255 + padding: 0.4rem 0.8rem; 256 + font-family: monospace; 257 + font-size: 12px; 258 + cursor: pointer; 259 + } 260 + 261 + .share-btn:hover { 262 + border-color: #555; 263 + color: #ccc; 264 + } 265 + 266 + .delete-btn:hover { 267 + border-color: #733; 268 + color: #c44; 269 + } 270 + </style>
+1
frontend/static/_redirects
··· 1 + /* /index.html 200
+1
frontend/static/favicon.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y="80" font-size="85" font-family="serif">◉</text></svg>
+3
frontend/static/robots.txt
··· 1 + # allow crawling everything by default 2 + User-agent: * 3 + Disallow:
+16
frontend/svelte.config.js
··· 1 + import adapter from '@sveltejs/adapter-static'; 2 + 3 + /** @type {import('@sveltejs/kit').Config} */ 4 + const config = { 5 + kit: { 6 + adapter: adapter({ 7 + fallback: 'index.html' 8 + }) 9 + }, 10 + vitePlugin: { 11 + dynamicCompileOptions: ({ filename }) => 12 + filename.includes('node_modules') ? undefined : { runes: true } 13 + } 14 + }; 15 + 16 + export default config;
+20
frontend/tsconfig.json
··· 1 + { 2 + "extends": "./.svelte-kit/tsconfig.json", 3 + "compilerOptions": { 4 + "rewriteRelativeImportExtensions": true, 5 + "allowJs": true, 6 + "checkJs": true, 7 + "esModuleInterop": true, 8 + "forceConsistentCasingInFileNames": true, 9 + "resolveJsonModule": true, 10 + "skipLibCheck": true, 11 + "sourceMap": true, 12 + "strict": true, 13 + "moduleResolution": "bundler" 14 + } 15 + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias 16 + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files 17 + // 18 + // To make changes to top-level options such as include and exclude, we recommend extending 19 + // the generated config; see https://svelte.dev/docs/kit/configuration#typescript 20 + }
+6
frontend/vite.config.ts
··· 1 + import { sveltekit } from '@sveltejs/kit/vite'; 2 + import { defineConfig } from 'vite'; 3 + 4 + export default defineConfig({ 5 + plugins: [sveltekit()] 6 + });
-83
functions/poll/[repo]/[rkey].ts
··· 1 - // Cloudflare Pages Function for dynamic OG tags 2 - const BACKEND_URL = "https://pollz-backend.fly.dev"; 3 - 4 - interface Poll { 5 - uri: string; 6 - repo: string; 7 - rkey: string; 8 - text: string; 9 - options: Array<{ text: string; count: number }>; 10 - createdAt: string; 11 - } 12 - 13 - export const onRequest: PagesFunction = async (context) => { 14 - const { repo, rkey } = context.params as { repo: string; rkey: string }; 15 - const userAgent = context.request.headers.get("user-agent") || ""; 16 - 17 - // check if this is a bot/crawler requesting the page 18 - const isCrawler = /bot|crawler|spider|facebook|twitter|slack|discord|telegram|whatsapp|linkedin|preview/i.test(userAgent); 19 - 20 - if (!isCrawler) { 21 - // not a crawler, serve the SPA normally 22 - return context.next(); 23 - } 24 - 25 - // fetch poll data from backend 26 - const pollUri = `at://${repo}/tech.waow.poll/${rkey}`; 27 - try { 28 - const res = await fetch(`${BACKEND_URL}/api/polls/${encodeURIComponent(pollUri)}`); 29 - if (!res.ok) { 30 - return context.next(); 31 - } 32 - 33 - const poll: Poll = await res.json(); 34 - const total = poll.options.reduce((sum, o) => sum + o.count, 0); 35 - const optionsText = poll.options.map(o => `${o.text}: ${o.count}`).join(" | "); 36 - const description = `${total} vote${total === 1 ? "" : "s"} · ${optionsText}`; 37 - const url = `https://pollz.waow.tech/poll/${repo}/${rkey}`; 38 - 39 - // return HTML with proper OG tags for crawlers 40 - const html = `<!DOCTYPE html> 41 - <html lang="en"> 42 - <head> 43 - <meta charset="UTF-8"> 44 - <meta name="viewport" content="width=device-width, initial-scale=1.0"> 45 - <title>${escapeHtml(poll.text)} - pollz</title> 46 - <meta name="description" content="${escapeHtml(description)}"> 47 - 48 - <!-- Open Graph --> 49 - <meta property="og:type" content="website"> 50 - <meta property="og:title" content="${escapeHtml(poll.text)}"> 51 - <meta property="og:description" content="${escapeHtml(description)}"> 52 - <meta property="og:url" content="${url}"> 53 - <meta property="og:site_name" content="pollz"> 54 - 55 - <!-- Twitter --> 56 - <meta name="twitter:card" content="summary"> 57 - <meta name="twitter:title" content="${escapeHtml(poll.text)}"> 58 - <meta name="twitter:description" content="${escapeHtml(description)}"> 59 - 60 - <meta http-equiv="refresh" content="0;url=${url}"> 61 - </head> 62 - <body> 63 - <p>redirecting to <a href="${url}">${escapeHtml(poll.text)}</a>...</p> 64 - </body> 65 - </html>`; 66 - 67 - return new Response(html, { 68 - headers: { "content-type": "text/html;charset=UTF-8" }, 69 - }); 70 - } catch (e) { 71 - console.error("failed to fetch poll for OG tags:", e); 72 - return context.next(); 73 - } 74 - }; 75 - 76 - function escapeHtml(str: string): string { 77 - return str 78 - .replace(/&/g, "&amp;") 79 - .replace(/</g, "&lt;") 80 - .replace(/>/g, "&gt;") 81 - .replace(/"/g, "&quot;") 82 - .replace(/'/g, "&#39;"); 83 - }
-162
index.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>pollz</title> 7 - <meta name="description" content="polls on atproto"> 8 - 9 - <!-- Open Graph --> 10 - <meta property="og:type" content="website"> 11 - <meta property="og:title" content="pollz"> 12 - <meta property="og:description" content="polls on atproto"> 13 - <meta property="og:url" content="https://pollz.waow.tech"> 14 - <meta property="og:site_name" content="pollz"> 15 - 16 - <!-- Twitter --> 17 - <meta name="twitter:card" content="summary"> 18 - <meta name="twitter:title" content="pollz"> 19 - <meta name="twitter:description" content="polls on atproto"> 20 - <style> 21 - * { box-sizing: border-box; margin: 0; padding: 0; } 22 - body { 23 - font-family: monospace; 24 - max-width: 600px; 25 - margin: 0 auto; 26 - padding: 1rem; 27 - background: #0a0a0a; 28 - color: #ccc; 29 - font-size: 14px; 30 - line-height: 1.6; 31 - } 32 - a { color: #888; text-decoration: none; } 33 - a:hover { color: #fff; } 34 - header { 35 - display: flex; 36 - justify-content: space-between; 37 - align-items: center; 38 - padding: 1rem 0; 39 - border-bottom: 1px solid #222; 40 - margin-bottom: 1rem; 41 - } 42 - header h1 { font-size: 1rem; font-weight: normal; } 43 - input, button, textarea { 44 - font-family: monospace; 45 - font-size: 14px; 46 - padding: 0.5rem; 47 - border: 1px solid #333; 48 - background: #111; 49 - color: #ccc; 50 - } 51 - input:focus, textarea:focus { outline: 1px solid #444; } 52 - button { cursor: pointer; } 53 - button:hover { background: #222; } 54 - .poll { 55 - border-bottom: 1px solid #222; 56 - padding: 1rem 0; 57 - } 58 - .poll-question { color: #fff; margin-bottom: 0.5rem; } 59 - .poll-meta { color: #555; font-size: 12px; margin-bottom: 0.5rem; } 60 - .option { 61 - display: flex; 62 - justify-content: space-between; 63 - align-items: center; 64 - padding: 0.5rem 0; 65 - cursor: pointer; 66 - } 67 - .option:hover { color: #fff; } 68 - .option-text { flex: 1; } 69 - .option-count { color: #888; font-size: 12px; margin-left: 1rem; } 70 - .poll-detail .option { 71 - position: relative; 72 - padding: 0.75rem; 73 - margin: 0.5rem 0; 74 - background: #111; 75 - border: 1px solid #222; 76 - } 77 - .poll-detail .option-bar { 78 - position: absolute; 79 - left: 0; 80 - top: 0; 81 - height: 100%; 82 - background: #1a3a1a; 83 - z-index: 0; 84 - transition: width 0.3s; 85 - } 86 - .poll-detail .option-text, 87 - .poll-detail .option-count { 88 - position: relative; 89 - z-index: 1; 90 - } 91 - .poll-detail .poll-meta { 92 - margin-top: 1rem; 93 - } 94 - .create-form { margin-bottom: 2rem; } 95 - .create-form input, .create-form textarea { width: 100%; margin-bottom: 0.5rem; } 96 - .hidden { display: none; } 97 - #status { color: #666; padding: 0.5rem 0; } 98 - .vote-count { cursor: help; border-bottom: 1px dotted #555; } 99 - .vote-count:hover { color: #fff; } 100 - .voters-tooltip { 101 - background: #1a1a1a; 102 - border: 1px solid #333; 103 - padding: 0.5rem; 104 - font-size: 12px; 105 - z-index: 1000; 106 - max-width: 300px; 107 - box-shadow: 0 2px 8px rgba(0,0,0,0.5); 108 - } 109 - .voter { padding: 0.25rem 0; color: #aaa; word-break: break-all; } 110 - .voter-link { color: #6af; font-size: 11px; } 111 - .voter-link:hover { color: #8cf; text-decoration: underline; } 112 - .vote-time { color: #555; font-size: 10px; } 113 - .share-btn { 114 - background: none; 115 - border: 1px solid #333; 116 - color: #888; 117 - padding: 0.4rem 0.8rem; 118 - font-family: monospace; 119 - font-size: 12px; 120 - cursor: pointer; 121 - border-radius: 4px; 122 - transition: all 0.2s; 123 - } 124 - .share-btn:hover { border-color: #555; color: #ccc; } 125 - .share-btn.copied { border-color: #4a4; color: #6c6; } 126 - .poll-header { 127 - display: flex; 128 - justify-content: space-between; 129 - align-items: flex-start; 130 - gap: 1rem; 131 - margin-bottom: 1rem; 132 - } 133 - .poll-header h2 { margin: 0; flex: 1; } 134 - .toast { 135 - position: fixed; 136 - top: 1rem; 137 - left: 50%; 138 - transform: translateX(-50%); 139 - background: #1a1a1a; 140 - border: 1px solid #444; 141 - padding: 0.75rem 1.5rem; 142 - z-index: 1001; 143 - animation: toast-fade 3s ease-in-out forwards; 144 - } 145 - @keyframes toast-fade { 146 - 0% { opacity: 0; transform: translateX(-50%) translateY(-10px); } 147 - 10% { opacity: 1; transform: translateX(-50%) translateY(0); } 148 - 80% { opacity: 1; } 149 - 100% { opacity: 0; } 150 - } 151 - </style> 152 - </head> 153 - <body> 154 - <header> 155 - <h1><a href="/">pollz</a> <a href="https://tangled.sh/@zzstoatzz.io/pollz" target="_blank" style="font-size:11px;color:#555">[src]</a></h1> 156 - <nav id="nav"></nav> 157 - </header> 158 - <main id="app"></main> 159 - <p id="status"></p> 160 - <script type="module" src="/src/main.ts"></script> 161 - </body> 162 - </html>
+4 -9
justfile
··· 1 1 # pollz 2 2 mod backend 3 - mod tap 4 3 5 4 # show available commands 6 5 default: ··· 8 7 9 8 # build frontend 10 9 build: 11 - pnpm build 10 + cd frontend && pnpm build 12 11 13 12 # deploy frontend to cloudflare pages 14 13 deploy-frontend: 15 - pnpm build 16 - npx wrangler pages deploy dist --project-name=pollz-waow-tech --commit-dirty=true 14 + cd frontend && pnpm build 15 + npx wrangler pages deploy frontend/build --project-name=pollz-waow-tech --commit-dirty=true 17 16 18 17 # deploy backend to fly.io 19 18 deploy-backend: 20 19 just backend::deploy 21 20 22 - # deploy tap to fly.io 23 - deploy-tap: 24 - just tap::deploy 25 - 26 21 # deploy everything 27 - deploy: deploy-tap deploy-backend deploy-frontend 22 + deploy: deploy-backend deploy-frontend
+1 -1
lexicons/tech/waow/poll.json lexicons/tech/waow/pollz/poll.json
··· 1 1 { 2 2 "lexicon": 1, 3 - "id": "tech.waow.poll", 3 + "id": "tech.waow.pollz.poll", 4 4 "defs": { 5 5 "main": { 6 6 "type": "record",
+1 -1
lexicons/tech/waow/vote.json lexicons/tech/waow/pollz/vote.json
··· 1 1 { 2 2 "lexicon": 1, 3 - "id": "tech.waow.vote", 3 + "id": "tech.waow.pollz.vote", 4 4 "defs": { 5 5 "main": { 6 6 "type": "record",
-19
package.json
··· 1 - { 2 - "name": "pollz", 3 - "type": "module", 4 - "scripts": { 5 - "dev": "vite", 6 - "build": "vite build", 7 - "preview": "vite preview" 8 - }, 9 - "devDependencies": { 10 - "typescript": "^5.9.3", 11 - "vite": "^7.2.7" 12 - }, 13 - "dependencies": { 14 - "@atcute/client": "^4.1.1", 15 - "@atcute/identity": "^1.1.3", 16 - "@atcute/identity-resolver": "^1.2.0", 17 - "@atcute/oauth-browser-client": "^2.0.3" 18 - } 19 - }
-729
pnpm-lock.yaml
··· 1 - lockfileVersion: '9.0' 2 - 3 - settings: 4 - autoInstallPeers: true 5 - excludeLinksFromLockfile: false 6 - 7 - importers: 8 - 9 - .: 10 - dependencies: 11 - '@atcute/client': 12 - specifier: ^4.1.1 13 - version: 4.1.1 14 - '@atcute/identity': 15 - specifier: ^1.1.3 16 - version: 1.1.3 17 - '@atcute/identity-resolver': 18 - specifier: ^1.2.0 19 - version: 1.2.0(@atcute/identity@1.1.3) 20 - '@atcute/oauth-browser-client': 21 - specifier: ^2.0.3 22 - version: 2.0.3(@atcute/identity@1.1.3) 23 - devDependencies: 24 - typescript: 25 - specifier: ^5.9.3 26 - version: 5.9.3 27 - vite: 28 - specifier: ^7.2.7 29 - version: 7.3.0 30 - 31 - packages: 32 - 33 - '@atcute/client@4.1.1': 34 - resolution: {integrity: sha512-FROCbTTCeL5u4tO/n72jDEKyKqjdlXMB56Ehve3W/gnnLGCYWvN42sS7tvL1Mgu6sbO3yZwsXKDrmM2No4XpjA==} 35 - 36 - '@atcute/identity-resolver@1.2.0': 37 - resolution: {integrity: sha512-5UbSJfdV3JIkF8ksXz7g4nKBWasf2wROvzM66cfvTIWydWFO6/oS1KZd+zo9Eokje5Scf5+jsY9ZfgVARLepXg==} 38 - peerDependencies: 39 - '@atcute/identity': ^1.0.0 40 - 41 - '@atcute/identity@1.1.3': 42 - resolution: {integrity: sha512-oIqPoI8TwWeQxvcLmFEZLdN2XdWcaLVtlm8pNk0E72As9HNzzD9pwKPrLr3rmTLRIoULPPFmq9iFNsTeCIU9ng==} 43 - 44 - '@atcute/lexicons@1.2.5': 45 - resolution: {integrity: sha512-9yO9WdgxW8jZ7SbzUycH710z+JmsQ9W9n5S6i6eghYju32kkluFmgBeS47r8e8p2+Dv4DemS7o/3SUGsX9FR5Q==} 46 - 47 - '@atcute/multibase@1.1.6': 48 - resolution: {integrity: sha512-HBxuCgYLKPPxETV0Rot4VP9e24vKl8JdzGCZOVsDaOXJgbRZoRIF67Lp0H/OgnJeH/Xpva8Z5ReoTNJE5dn3kg==} 49 - 50 - '@atcute/oauth-browser-client@2.0.3': 51 - resolution: {integrity: sha512-rzUjwhjE4LRRKdQnCFQag/zXRZMEAB1hhBoLfnoQuHwWbmDUCL7fzwC3jRhDPp3om8XaYNDj8a/iqRip0wRqoQ==} 52 - 53 - '@atcute/uint8array@1.0.6': 54 - resolution: {integrity: sha512-ucfRBQc7BFT8n9eCyGOzDHEMKF/nZwhS2pPao4Xtab1ML3HdFYcX2DM1tadCzas85QTGxHe5urnUAAcNKGRi9A==} 55 - 56 - '@atcute/util-fetch@1.0.4': 57 - resolution: {integrity: sha512-sIU9Qk0dE8PLEXSfhy+gIJV+HpiiknMytCI2SqLlqd0vgZUtEKI/EQfP+23LHWvP+CLCzVDOa6cpH045OlmNBg==} 58 - 59 - '@badrap/valita@0.4.6': 60 - resolution: {integrity: sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg==} 61 - engines: {node: '>= 18'} 62 - 63 - '@esbuild/aix-ppc64@0.27.2': 64 - resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} 65 - engines: {node: '>=18'} 66 - cpu: [ppc64] 67 - os: [aix] 68 - 69 - '@esbuild/android-arm64@0.27.2': 70 - resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} 71 - engines: {node: '>=18'} 72 - cpu: [arm64] 73 - os: [android] 74 - 75 - '@esbuild/android-arm@0.27.2': 76 - resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} 77 - engines: {node: '>=18'} 78 - cpu: [arm] 79 - os: [android] 80 - 81 - '@esbuild/android-x64@0.27.2': 82 - resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} 83 - engines: {node: '>=18'} 84 - cpu: [x64] 85 - os: [android] 86 - 87 - '@esbuild/darwin-arm64@0.27.2': 88 - resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} 89 - engines: {node: '>=18'} 90 - cpu: [arm64] 91 - os: [darwin] 92 - 93 - '@esbuild/darwin-x64@0.27.2': 94 - resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} 95 - engines: {node: '>=18'} 96 - cpu: [x64] 97 - os: [darwin] 98 - 99 - '@esbuild/freebsd-arm64@0.27.2': 100 - resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} 101 - engines: {node: '>=18'} 102 - cpu: [arm64] 103 - os: [freebsd] 104 - 105 - '@esbuild/freebsd-x64@0.27.2': 106 - resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} 107 - engines: {node: '>=18'} 108 - cpu: [x64] 109 - os: [freebsd] 110 - 111 - '@esbuild/linux-arm64@0.27.2': 112 - resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} 113 - engines: {node: '>=18'} 114 - cpu: [arm64] 115 - os: [linux] 116 - 117 - '@esbuild/linux-arm@0.27.2': 118 - resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} 119 - engines: {node: '>=18'} 120 - cpu: [arm] 121 - os: [linux] 122 - 123 - '@esbuild/linux-ia32@0.27.2': 124 - resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} 125 - engines: {node: '>=18'} 126 - cpu: [ia32] 127 - os: [linux] 128 - 129 - '@esbuild/linux-loong64@0.27.2': 130 - resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} 131 - engines: {node: '>=18'} 132 - cpu: [loong64] 133 - os: [linux] 134 - 135 - '@esbuild/linux-mips64el@0.27.2': 136 - resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} 137 - engines: {node: '>=18'} 138 - cpu: [mips64el] 139 - os: [linux] 140 - 141 - '@esbuild/linux-ppc64@0.27.2': 142 - resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} 143 - engines: {node: '>=18'} 144 - cpu: [ppc64] 145 - os: [linux] 146 - 147 - '@esbuild/linux-riscv64@0.27.2': 148 - resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} 149 - engines: {node: '>=18'} 150 - cpu: [riscv64] 151 - os: [linux] 152 - 153 - '@esbuild/linux-s390x@0.27.2': 154 - resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} 155 - engines: {node: '>=18'} 156 - cpu: [s390x] 157 - os: [linux] 158 - 159 - '@esbuild/linux-x64@0.27.2': 160 - resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} 161 - engines: {node: '>=18'} 162 - cpu: [x64] 163 - os: [linux] 164 - 165 - '@esbuild/netbsd-arm64@0.27.2': 166 - resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} 167 - engines: {node: '>=18'} 168 - cpu: [arm64] 169 - os: [netbsd] 170 - 171 - '@esbuild/netbsd-x64@0.27.2': 172 - resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} 173 - engines: {node: '>=18'} 174 - cpu: [x64] 175 - os: [netbsd] 176 - 177 - '@esbuild/openbsd-arm64@0.27.2': 178 - resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} 179 - engines: {node: '>=18'} 180 - cpu: [arm64] 181 - os: [openbsd] 182 - 183 - '@esbuild/openbsd-x64@0.27.2': 184 - resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} 185 - engines: {node: '>=18'} 186 - cpu: [x64] 187 - os: [openbsd] 188 - 189 - '@esbuild/openharmony-arm64@0.27.2': 190 - resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} 191 - engines: {node: '>=18'} 192 - cpu: [arm64] 193 - os: [openharmony] 194 - 195 - '@esbuild/sunos-x64@0.27.2': 196 - resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} 197 - engines: {node: '>=18'} 198 - cpu: [x64] 199 - os: [sunos] 200 - 201 - '@esbuild/win32-arm64@0.27.2': 202 - resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} 203 - engines: {node: '>=18'} 204 - cpu: [arm64] 205 - os: [win32] 206 - 207 - '@esbuild/win32-ia32@0.27.2': 208 - resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} 209 - engines: {node: '>=18'} 210 - cpu: [ia32] 211 - os: [win32] 212 - 213 - '@esbuild/win32-x64@0.27.2': 214 - resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} 215 - engines: {node: '>=18'} 216 - cpu: [x64] 217 - os: [win32] 218 - 219 - '@rollup/rollup-android-arm-eabi@4.53.5': 220 - resolution: {integrity: sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==} 221 - cpu: [arm] 222 - os: [android] 223 - 224 - '@rollup/rollup-android-arm64@4.53.5': 225 - resolution: {integrity: sha512-wrSAViWvZHBMMlWk6EJhvg8/rjxzyEhEdgfMMjREHEq11EtJ6IP6yfcCH57YAEca2Oe3FNCE9DSTgU70EIGmVw==} 226 - cpu: [arm64] 227 - os: [android] 228 - 229 - '@rollup/rollup-darwin-arm64@4.53.5': 230 - resolution: {integrity: sha512-S87zZPBmRO6u1YXQLwpveZm4JfPpAa6oHBX7/ghSiGH3rz/KDgAu1rKdGutV+WUI6tKDMbaBJomhnT30Y2t4VQ==} 231 - cpu: [arm64] 232 - os: [darwin] 233 - 234 - '@rollup/rollup-darwin-x64@4.53.5': 235 - resolution: {integrity: sha512-YTbnsAaHo6VrAczISxgpTva8EkfQus0VPEVJCEaboHtZRIb6h6j0BNxRBOwnDciFTZLDPW5r+ZBmhL/+YpTZgA==} 236 - cpu: [x64] 237 - os: [darwin] 238 - 239 - '@rollup/rollup-freebsd-arm64@4.53.5': 240 - resolution: {integrity: sha512-1T8eY2J8rKJWzaznV7zedfdhD1BqVs1iqILhmHDq/bqCUZsrMt+j8VCTHhP0vdfbHK3e1IQ7VYx3jlKqwlf+vw==} 241 - cpu: [arm64] 242 - os: [freebsd] 243 - 244 - '@rollup/rollup-freebsd-x64@4.53.5': 245 - resolution: {integrity: sha512-sHTiuXyBJApxRn+VFMaw1U+Qsz4kcNlxQ742snICYPrY+DDL8/ZbaC4DVIB7vgZmp3jiDaKA0WpBdP0aqPJoBQ==} 246 - cpu: [x64] 247 - os: [freebsd] 248 - 249 - '@rollup/rollup-linux-arm-gnueabihf@4.53.5': 250 - resolution: {integrity: sha512-dV3T9MyAf0w8zPVLVBptVlzaXxka6xg1f16VAQmjg+4KMSTWDvhimI/Y6mp8oHwNrmnmVl9XxJ/w/mO4uIQONA==} 251 - cpu: [arm] 252 - os: [linux] 253 - 254 - '@rollup/rollup-linux-arm-musleabihf@4.53.5': 255 - resolution: {integrity: sha512-wIGYC1x/hyjP+KAu9+ewDI+fi5XSNiUi9Bvg6KGAh2TsNMA3tSEs+Sh6jJ/r4BV/bx/CyWu2ue9kDnIdRyafcQ==} 256 - cpu: [arm] 257 - os: [linux] 258 - 259 - '@rollup/rollup-linux-arm64-gnu@4.53.5': 260 - resolution: {integrity: sha512-Y+qVA0D9d0y2FRNiG9oM3Hut/DgODZbU9I8pLLPwAsU0tUKZ49cyV1tzmB/qRbSzGvY8lpgGkJuMyuhH7Ma+Vg==} 261 - cpu: [arm64] 262 - os: [linux] 263 - 264 - '@rollup/rollup-linux-arm64-musl@4.53.5': 265 - resolution: {integrity: sha512-juaC4bEgJsyFVfqhtGLz8mbopaWD+WeSOYr5E16y+1of6KQjc0BpwZLuxkClqY1i8sco+MdyoXPNiCkQou09+g==} 266 - cpu: [arm64] 267 - os: [linux] 268 - 269 - '@rollup/rollup-linux-loong64-gnu@4.53.5': 270 - resolution: {integrity: sha512-rIEC0hZ17A42iXtHX+EPJVL/CakHo+tT7W0pbzdAGuWOt2jxDFh7A/lRhsNHBcqL4T36+UiAgwO8pbmn3dE8wA==} 271 - cpu: [loong64] 272 - os: [linux] 273 - 274 - '@rollup/rollup-linux-ppc64-gnu@4.53.5': 275 - resolution: {integrity: sha512-T7l409NhUE552RcAOcmJHj3xyZ2h7vMWzcwQI0hvn5tqHh3oSoclf9WgTl+0QqffWFG8MEVZZP1/OBglKZx52Q==} 276 - cpu: [ppc64] 277 - os: [linux] 278 - 279 - '@rollup/rollup-linux-riscv64-gnu@4.53.5': 280 - resolution: {integrity: sha512-7OK5/GhxbnrMcxIFoYfhV/TkknarkYC1hqUw1wU2xUN3TVRLNT5FmBv4KkheSG2xZ6IEbRAhTooTV2+R5Tk0lQ==} 281 - cpu: [riscv64] 282 - os: [linux] 283 - 284 - '@rollup/rollup-linux-riscv64-musl@4.53.5': 285 - resolution: {integrity: sha512-GwuDBE/PsXaTa76lO5eLJTyr2k8QkPipAyOrs4V/KJufHCZBJ495VCGJol35grx9xryk4V+2zd3Ri+3v7NPh+w==} 286 - cpu: [riscv64] 287 - os: [linux] 288 - 289 - '@rollup/rollup-linux-s390x-gnu@4.53.5': 290 - resolution: {integrity: sha512-IAE1Ziyr1qNfnmiQLHBURAD+eh/zH1pIeJjeShleII7Vj8kyEm2PF77o+lf3WTHDpNJcu4IXJxNO0Zluro8bOw==} 291 - cpu: [s390x] 292 - os: [linux] 293 - 294 - '@rollup/rollup-linux-x64-gnu@4.53.5': 295 - resolution: {integrity: sha512-Pg6E+oP7GvZ4XwgRJBuSXZjcqpIW3yCBhK4BcsANvb47qMvAbCjR6E+1a/U2WXz1JJxp9/4Dno3/iSJLcm5auw==} 296 - cpu: [x64] 297 - os: [linux] 298 - 299 - '@rollup/rollup-linux-x64-musl@4.53.5': 300 - resolution: {integrity: sha512-txGtluxDKTxaMDzUduGP0wdfng24y1rygUMnmlUJ88fzCCULCLn7oE5kb2+tRB+MWq1QDZT6ObT5RrR8HFRKqg==} 301 - cpu: [x64] 302 - os: [linux] 303 - 304 - '@rollup/rollup-openharmony-arm64@4.53.5': 305 - resolution: {integrity: sha512-3DFiLPnTxiOQV993fMc+KO8zXHTcIjgaInrqlG8zDp1TlhYl6WgrOHuJkJQ6M8zHEcntSJsUp1XFZSY8C1DYbg==} 306 - cpu: [arm64] 307 - os: [openharmony] 308 - 309 - '@rollup/rollup-win32-arm64-msvc@4.53.5': 310 - resolution: {integrity: sha512-nggc/wPpNTgjGg75hu+Q/3i32R00Lq1B6N1DO7MCU340MRKL3WZJMjA9U4K4gzy3dkZPXm9E1Nc81FItBVGRlA==} 311 - cpu: [arm64] 312 - os: [win32] 313 - 314 - '@rollup/rollup-win32-ia32-msvc@4.53.5': 315 - resolution: {integrity: sha512-U/54pTbdQpPLBdEzCT6NBCFAfSZMvmjr0twhnD9f4EIvlm9wy3jjQ38yQj1AGznrNO65EWQMgm/QUjuIVrYF9w==} 316 - cpu: [ia32] 317 - os: [win32] 318 - 319 - '@rollup/rollup-win32-x64-gnu@4.53.5': 320 - resolution: {integrity: sha512-2NqKgZSuLH9SXBBV2dWNRCZmocgSOx8OJSdpRaEcRlIfX8YrKxUT6z0F1NpvDVhOsl190UFTRh2F2WDWWCYp3A==} 321 - cpu: [x64] 322 - os: [win32] 323 - 324 - '@rollup/rollup-win32-x64-msvc@4.53.5': 325 - resolution: {integrity: sha512-JRpZUhCfhZ4keB5v0fe02gQJy05GqboPOaxvjugW04RLSYYoB/9t2lx2u/tMs/Na/1NXfY8QYjgRljRpN+MjTQ==} 326 - cpu: [x64] 327 - os: [win32] 328 - 329 - '@standard-schema/spec@1.1.0': 330 - resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} 331 - 332 - '@types/estree@1.0.8': 333 - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} 334 - 335 - esbuild@0.27.2: 336 - resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} 337 - engines: {node: '>=18'} 338 - hasBin: true 339 - 340 - esm-env@1.2.2: 341 - resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} 342 - 343 - fdir@6.5.0: 344 - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} 345 - engines: {node: '>=12.0.0'} 346 - peerDependencies: 347 - picomatch: ^3 || ^4 348 - peerDependenciesMeta: 349 - picomatch: 350 - optional: true 351 - 352 - fsevents@2.3.3: 353 - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} 354 - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} 355 - os: [darwin] 356 - 357 - nanoid@3.3.11: 358 - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} 359 - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} 360 - hasBin: true 361 - 362 - nanoid@5.1.6: 363 - resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==} 364 - engines: {node: ^18 || >=20} 365 - hasBin: true 366 - 367 - picocolors@1.1.1: 368 - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} 369 - 370 - picomatch@4.0.3: 371 - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} 372 - engines: {node: '>=12'} 373 - 374 - postcss@8.5.6: 375 - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} 376 - engines: {node: ^10 || ^12 || >=14} 377 - 378 - rollup@4.53.5: 379 - resolution: {integrity: sha512-iTNAbFSlRpcHeeWu73ywU/8KuU/LZmNCSxp6fjQkJBD3ivUb8tpDrXhIxEzA05HlYMEwmtaUnb3RP+YNv162OQ==} 380 - engines: {node: '>=18.0.0', npm: '>=8.0.0'} 381 - hasBin: true 382 - 383 - source-map-js@1.2.1: 384 - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} 385 - engines: {node: '>=0.10.0'} 386 - 387 - tinyglobby@0.2.15: 388 - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} 389 - engines: {node: '>=12.0.0'} 390 - 391 - typescript@5.9.3: 392 - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} 393 - engines: {node: '>=14.17'} 394 - hasBin: true 395 - 396 - vite@7.3.0: 397 - resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==} 398 - engines: {node: ^20.19.0 || >=22.12.0} 399 - hasBin: true 400 - peerDependencies: 401 - '@types/node': ^20.19.0 || >=22.12.0 402 - jiti: '>=1.21.0' 403 - less: ^4.0.0 404 - lightningcss: ^1.21.0 405 - sass: ^1.70.0 406 - sass-embedded: ^1.70.0 407 - stylus: '>=0.54.8' 408 - sugarss: ^5.0.0 409 - terser: ^5.16.0 410 - tsx: ^4.8.1 411 - yaml: ^2.4.2 412 - peerDependenciesMeta: 413 - '@types/node': 414 - optional: true 415 - jiti: 416 - optional: true 417 - less: 418 - optional: true 419 - lightningcss: 420 - optional: true 421 - sass: 422 - optional: true 423 - sass-embedded: 424 - optional: true 425 - stylus: 426 - optional: true 427 - sugarss: 428 - optional: true 429 - terser: 430 - optional: true 431 - tsx: 432 - optional: true 433 - yaml: 434 - optional: true 435 - 436 - snapshots: 437 - 438 - '@atcute/client@4.1.1': 439 - dependencies: 440 - '@atcute/identity': 1.1.3 441 - '@atcute/lexicons': 1.2.5 442 - 443 - '@atcute/identity-resolver@1.2.0(@atcute/identity@1.1.3)': 444 - dependencies: 445 - '@atcute/identity': 1.1.3 446 - '@atcute/lexicons': 1.2.5 447 - '@atcute/util-fetch': 1.0.4 448 - '@badrap/valita': 0.4.6 449 - 450 - '@atcute/identity@1.1.3': 451 - dependencies: 452 - '@atcute/lexicons': 1.2.5 453 - '@badrap/valita': 0.4.6 454 - 455 - '@atcute/lexicons@1.2.5': 456 - dependencies: 457 - '@standard-schema/spec': 1.1.0 458 - esm-env: 1.2.2 459 - 460 - '@atcute/multibase@1.1.6': 461 - dependencies: 462 - '@atcute/uint8array': 1.0.6 463 - 464 - '@atcute/oauth-browser-client@2.0.3(@atcute/identity@1.1.3)': 465 - dependencies: 466 - '@atcute/client': 4.1.1 467 - '@atcute/identity-resolver': 1.2.0(@atcute/identity@1.1.3) 468 - '@atcute/lexicons': 1.2.5 469 - '@atcute/multibase': 1.1.6 470 - '@atcute/uint8array': 1.0.6 471 - nanoid: 5.1.6 472 - transitivePeerDependencies: 473 - - '@atcute/identity' 474 - 475 - '@atcute/uint8array@1.0.6': {} 476 - 477 - '@atcute/util-fetch@1.0.4': 478 - dependencies: 479 - '@badrap/valita': 0.4.6 480 - 481 - '@badrap/valita@0.4.6': {} 482 - 483 - '@esbuild/aix-ppc64@0.27.2': 484 - optional: true 485 - 486 - '@esbuild/android-arm64@0.27.2': 487 - optional: true 488 - 489 - '@esbuild/android-arm@0.27.2': 490 - optional: true 491 - 492 - '@esbuild/android-x64@0.27.2': 493 - optional: true 494 - 495 - '@esbuild/darwin-arm64@0.27.2': 496 - optional: true 497 - 498 - '@esbuild/darwin-x64@0.27.2': 499 - optional: true 500 - 501 - '@esbuild/freebsd-arm64@0.27.2': 502 - optional: true 503 - 504 - '@esbuild/freebsd-x64@0.27.2': 505 - optional: true 506 - 507 - '@esbuild/linux-arm64@0.27.2': 508 - optional: true 509 - 510 - '@esbuild/linux-arm@0.27.2': 511 - optional: true 512 - 513 - '@esbuild/linux-ia32@0.27.2': 514 - optional: true 515 - 516 - '@esbuild/linux-loong64@0.27.2': 517 - optional: true 518 - 519 - '@esbuild/linux-mips64el@0.27.2': 520 - optional: true 521 - 522 - '@esbuild/linux-ppc64@0.27.2': 523 - optional: true 524 - 525 - '@esbuild/linux-riscv64@0.27.2': 526 - optional: true 527 - 528 - '@esbuild/linux-s390x@0.27.2': 529 - optional: true 530 - 531 - '@esbuild/linux-x64@0.27.2': 532 - optional: true 533 - 534 - '@esbuild/netbsd-arm64@0.27.2': 535 - optional: true 536 - 537 - '@esbuild/netbsd-x64@0.27.2': 538 - optional: true 539 - 540 - '@esbuild/openbsd-arm64@0.27.2': 541 - optional: true 542 - 543 - '@esbuild/openbsd-x64@0.27.2': 544 - optional: true 545 - 546 - '@esbuild/openharmony-arm64@0.27.2': 547 - optional: true 548 - 549 - '@esbuild/sunos-x64@0.27.2': 550 - optional: true 551 - 552 - '@esbuild/win32-arm64@0.27.2': 553 - optional: true 554 - 555 - '@esbuild/win32-ia32@0.27.2': 556 - optional: true 557 - 558 - '@esbuild/win32-x64@0.27.2': 559 - optional: true 560 - 561 - '@rollup/rollup-android-arm-eabi@4.53.5': 562 - optional: true 563 - 564 - '@rollup/rollup-android-arm64@4.53.5': 565 - optional: true 566 - 567 - '@rollup/rollup-darwin-arm64@4.53.5': 568 - optional: true 569 - 570 - '@rollup/rollup-darwin-x64@4.53.5': 571 - optional: true 572 - 573 - '@rollup/rollup-freebsd-arm64@4.53.5': 574 - optional: true 575 - 576 - '@rollup/rollup-freebsd-x64@4.53.5': 577 - optional: true 578 - 579 - '@rollup/rollup-linux-arm-gnueabihf@4.53.5': 580 - optional: true 581 - 582 - '@rollup/rollup-linux-arm-musleabihf@4.53.5': 583 - optional: true 584 - 585 - '@rollup/rollup-linux-arm64-gnu@4.53.5': 586 - optional: true 587 - 588 - '@rollup/rollup-linux-arm64-musl@4.53.5': 589 - optional: true 590 - 591 - '@rollup/rollup-linux-loong64-gnu@4.53.5': 592 - optional: true 593 - 594 - '@rollup/rollup-linux-ppc64-gnu@4.53.5': 595 - optional: true 596 - 597 - '@rollup/rollup-linux-riscv64-gnu@4.53.5': 598 - optional: true 599 - 600 - '@rollup/rollup-linux-riscv64-musl@4.53.5': 601 - optional: true 602 - 603 - '@rollup/rollup-linux-s390x-gnu@4.53.5': 604 - optional: true 605 - 606 - '@rollup/rollup-linux-x64-gnu@4.53.5': 607 - optional: true 608 - 609 - '@rollup/rollup-linux-x64-musl@4.53.5': 610 - optional: true 611 - 612 - '@rollup/rollup-openharmony-arm64@4.53.5': 613 - optional: true 614 - 615 - '@rollup/rollup-win32-arm64-msvc@4.53.5': 616 - optional: true 617 - 618 - '@rollup/rollup-win32-ia32-msvc@4.53.5': 619 - optional: true 620 - 621 - '@rollup/rollup-win32-x64-gnu@4.53.5': 622 - optional: true 623 - 624 - '@rollup/rollup-win32-x64-msvc@4.53.5': 625 - optional: true 626 - 627 - '@standard-schema/spec@1.1.0': {} 628 - 629 - '@types/estree@1.0.8': {} 630 - 631 - esbuild@0.27.2: 632 - optionalDependencies: 633 - '@esbuild/aix-ppc64': 0.27.2 634 - '@esbuild/android-arm': 0.27.2 635 - '@esbuild/android-arm64': 0.27.2 636 - '@esbuild/android-x64': 0.27.2 637 - '@esbuild/darwin-arm64': 0.27.2 638 - '@esbuild/darwin-x64': 0.27.2 639 - '@esbuild/freebsd-arm64': 0.27.2 640 - '@esbuild/freebsd-x64': 0.27.2 641 - '@esbuild/linux-arm': 0.27.2 642 - '@esbuild/linux-arm64': 0.27.2 643 - '@esbuild/linux-ia32': 0.27.2 644 - '@esbuild/linux-loong64': 0.27.2 645 - '@esbuild/linux-mips64el': 0.27.2 646 - '@esbuild/linux-ppc64': 0.27.2 647 - '@esbuild/linux-riscv64': 0.27.2 648 - '@esbuild/linux-s390x': 0.27.2 649 - '@esbuild/linux-x64': 0.27.2 650 - '@esbuild/netbsd-arm64': 0.27.2 651 - '@esbuild/netbsd-x64': 0.27.2 652 - '@esbuild/openbsd-arm64': 0.27.2 653 - '@esbuild/openbsd-x64': 0.27.2 654 - '@esbuild/openharmony-arm64': 0.27.2 655 - '@esbuild/sunos-x64': 0.27.2 656 - '@esbuild/win32-arm64': 0.27.2 657 - '@esbuild/win32-ia32': 0.27.2 658 - '@esbuild/win32-x64': 0.27.2 659 - 660 - esm-env@1.2.2: {} 661 - 662 - fdir@6.5.0(picomatch@4.0.3): 663 - optionalDependencies: 664 - picomatch: 4.0.3 665 - 666 - fsevents@2.3.3: 667 - optional: true 668 - 669 - nanoid@3.3.11: {} 670 - 671 - nanoid@5.1.6: {} 672 - 673 - picocolors@1.1.1: {} 674 - 675 - picomatch@4.0.3: {} 676 - 677 - postcss@8.5.6: 678 - dependencies: 679 - nanoid: 3.3.11 680 - picocolors: 1.1.1 681 - source-map-js: 1.2.1 682 - 683 - rollup@4.53.5: 684 - dependencies: 685 - '@types/estree': 1.0.8 686 - optionalDependencies: 687 - '@rollup/rollup-android-arm-eabi': 4.53.5 688 - '@rollup/rollup-android-arm64': 4.53.5 689 - '@rollup/rollup-darwin-arm64': 4.53.5 690 - '@rollup/rollup-darwin-x64': 4.53.5 691 - '@rollup/rollup-freebsd-arm64': 4.53.5 692 - '@rollup/rollup-freebsd-x64': 4.53.5 693 - '@rollup/rollup-linux-arm-gnueabihf': 4.53.5 694 - '@rollup/rollup-linux-arm-musleabihf': 4.53.5 695 - '@rollup/rollup-linux-arm64-gnu': 4.53.5 696 - '@rollup/rollup-linux-arm64-musl': 4.53.5 697 - '@rollup/rollup-linux-loong64-gnu': 4.53.5 698 - '@rollup/rollup-linux-ppc64-gnu': 4.53.5 699 - '@rollup/rollup-linux-riscv64-gnu': 4.53.5 700 - '@rollup/rollup-linux-riscv64-musl': 4.53.5 701 - '@rollup/rollup-linux-s390x-gnu': 4.53.5 702 - '@rollup/rollup-linux-x64-gnu': 4.53.5 703 - '@rollup/rollup-linux-x64-musl': 4.53.5 704 - '@rollup/rollup-openharmony-arm64': 4.53.5 705 - '@rollup/rollup-win32-arm64-msvc': 4.53.5 706 - '@rollup/rollup-win32-ia32-msvc': 4.53.5 707 - '@rollup/rollup-win32-x64-gnu': 4.53.5 708 - '@rollup/rollup-win32-x64-msvc': 4.53.5 709 - fsevents: 2.3.3 710 - 711 - source-map-js@1.2.1: {} 712 - 713 - tinyglobby@0.2.15: 714 - dependencies: 715 - fdir: 6.5.0(picomatch@4.0.3) 716 - picomatch: 4.0.3 717 - 718 - typescript@5.9.3: {} 719 - 720 - vite@7.3.0: 721 - dependencies: 722 - esbuild: 0.27.2 723 - fdir: 6.5.0(picomatch@4.0.3) 724 - picomatch: 4.0.3 725 - postcss: 8.5.6 726 - rollup: 4.53.5 727 - tinyglobby: 0.2.15 728 - optionalDependencies: 729 - fsevents: 2.3.3
-1
public/_redirects
··· 1 - /* /index.html 200
-12
public/oauth-client-metadata.json
··· 1 - { 2 - "client_id": "https://pollz.waow.tech/oauth-client-metadata.json", 3 - "client_name": "pollz", 4 - "client_uri": "https://pollz.waow.tech", 5 - "redirect_uris": ["https://pollz.waow.tech/"], 6 - "grant_types": ["authorization_code", "refresh_token"], 7 - "response_types": ["code"], 8 - "scope": "atproto repo:tech.waow.poll repo:tech.waow.vote", 9 - "token_endpoint_auth_method": "none", 10 - "application_type": "web", 11 - "dpop_bound_access_tokens": true 12 - }
+33 -10
readme.md
··· 1 1 # pollz 2 2 3 - polls on atproto ([lexicon](https://ufos.microcosm.blue/collection/?nsid=tech.waow.pollz1)) 3 + polls on [AT Protocol](https://atproto.com). create polls, vote, see results in real time. 4 + 5 + **[pollz.waow.tech](https://pollz.waow.tech)** 6 + 7 + ## lexicons 8 + 9 + - [`tech.waow.pollz.poll`](lexicons/tech/waow/pollz/poll.json) — a poll with question + options 10 + - [`tech.waow.pollz.vote`](lexicons/tech/waow/pollz/vote.json) — a vote on a poll 11 + 12 + ## stack 4 13 5 14 ``` 6 - firehose → tap → backend (zig + sqlite) → frontend 7 - 8 - user's PDS (oauth) 15 + jetstream → backend (zig + sqlite + oauth) → frontend (sveltekit) 16 + 17 + user's PDS (oauth) 18 + ``` 19 + 20 + - **backend**: [zig](https://ziglang.org) 0.15, [zqlite](https://github.com/karlseguin/zqlite.zig), [zat](https://tangled.sh/zzstoatzz.io/zat) (AT Protocol primitives) 21 + - **frontend**: [sveltekit](https://svelte.dev) + static adapter 22 + - **infra**: [fly.io](https://fly.io) (backend), [cloudflare pages](https://pages.cloudflare.com) (frontend) 23 + 24 + ## develop 25 + 26 + ```sh 27 + # backend 28 + cd backend && zig build -Doptimize=Debug && ./zig-out/bin/pollz 29 + 30 + # frontend 31 + cd frontend && pnpm dev 9 32 ``` 10 33 11 - ## stack 34 + ## deploy 12 35 13 - - [tap](https://github.com/bluesky-social/atproto/tree/main/packages/tap) - firehose sync 14 - - [zig](https://ziglang.org) + [zqlite](https://github.com/karlseguin/zqlite.zig) - backend 15 - - [atcute](https://github.com/mary-ext/atcute) - atproto client 16 - - [fly.io](https://fly.io) - backend hosting 17 - - [cloudflare pages](https://pages.cloudflare.com) - frontend hosting 36 + ```sh 37 + just deploy # both 38 + just deploy-backend # fly.io 39 + just deploy-frontend # cloudflare pages 40 + ```
-283
src/lib/api.ts
··· 1 - import { Client, simpleFetchHandler } from "@atcute/client"; 2 - import { 3 - CompositeDidDocumentResolver, 4 - CompositeHandleResolver, 5 - DohJsonHandleResolver, 6 - PlcDidDocumentResolver, 7 - AtprotoWebDidDocumentResolver, 8 - WellKnownHandleResolver, 9 - } from "@atcute/identity-resolver"; 10 - import { 11 - configureOAuth, 12 - createAuthorizationUrl, 13 - defaultIdentityResolver, 14 - finalizeAuthorization, 15 - getSession, 16 - OAuthUserAgent, 17 - deleteStoredSession, 18 - } from "@atcute/oauth-browser-client"; 19 - 20 - export const POLL = "tech.waow.poll"; 21 - export const VOTE = "tech.waow.vote"; 22 - 23 - export const didDocumentResolver = new CompositeDidDocumentResolver({ 24 - methods: { 25 - plc: new PlcDidDocumentResolver(), 26 - web: new AtprotoWebDidDocumentResolver(), 27 - }, 28 - }); 29 - 30 - export const handleResolver = new CompositeHandleResolver({ 31 - strategy: "dns-first", 32 - methods: { 33 - dns: new DohJsonHandleResolver({ dohUrl: "https://dns.google/resolve?" }), 34 - http: new WellKnownHandleResolver(), 35 - }, 36 - }); 37 - 38 - const BASE_URL = import.meta.env.VITE_BASE_URL || "https://pollz.waow.tech"; 39 - export const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || "https://pollz-backend.fly.dev"; 40 - 41 - configureOAuth({ 42 - metadata: { 43 - client_id: `${BASE_URL}/oauth-client-metadata.json`, 44 - redirect_uri: `${BASE_URL}/`, 45 - }, 46 - identityResolver: defaultIdentityResolver({ 47 - handleResolver, 48 - didDocumentResolver, 49 - }), 50 - }); 51 - 52 - // state 53 - export let agent: OAuthUserAgent | null = null; 54 - export let currentDid: string | null = null; 55 - 56 - export const setAgent = (a: OAuthUserAgent | null) => { agent = a; }; 57 - export const setCurrentDid = (did: string | null) => { currentDid = did; }; 58 - 59 - export type Poll = { 60 - uri: string; 61 - repo: string; 62 - rkey: string; 63 - text: string; 64 - options: string[]; 65 - createdAt: string; 66 - voteCount?: number; 67 - }; 68 - 69 - export const polls = new Map<string, Poll>(); 70 - 71 - // oauth 72 - export const login = async (handle: string): Promise<void> => { 73 - const url = await createAuthorizationUrl({ 74 - scope: `atproto repo:${POLL} repo:${VOTE}`, 75 - target: { type: "account", identifier: handle }, 76 - }); 77 - location.assign(url); 78 - }; 79 - 80 - export const logout = async (): Promise<void> => { 81 - if (currentDid) { 82 - await deleteStoredSession(currentDid as `did:${string}:${string}`); 83 - localStorage.removeItem("lastDid"); 84 - } 85 - agent = null; 86 - currentDid = null; 87 - }; 88 - 89 - export const handleCallback = async (): Promise<boolean> => { 90 - const params = new URLSearchParams(location.hash.slice(1)); 91 - if (!params.has("state")) return false; 92 - 93 - history.replaceState(null, "", "/"); 94 - const { session } = await finalizeAuthorization(params); 95 - agent = new OAuthUserAgent(session); 96 - currentDid = session.info.sub; 97 - localStorage.setItem("lastDid", currentDid); 98 - return true; 99 - }; 100 - 101 - export const restoreSession = async (): Promise<void> => { 102 - const lastDid = localStorage.getItem("lastDid"); 103 - if (!lastDid) return; 104 - 105 - try { 106 - const session = await getSession(lastDid as `did:${string}:${string}`); 107 - agent = new OAuthUserAgent(session); 108 - currentDid = session.info.sub; 109 - } catch { 110 - localStorage.removeItem("lastDid"); 111 - } 112 - }; 113 - 114 - // backend api 115 - export const fetchPolls = async (): Promise<void> => { 116 - const res = await fetch(`${BACKEND_URL}/api/polls`); 117 - if (!res.ok) throw new Error("failed to fetch polls"); 118 - 119 - const backendPolls = await res.json() as Array<{ 120 - uri: string; 121 - repo: string; 122 - rkey: string; 123 - text: string; 124 - options: string[]; 125 - createdAt: string; 126 - voteCount: number; 127 - }>; 128 - 129 - for (const p of backendPolls) { 130 - const existing = polls.get(p.uri); 131 - if (existing) { 132 - existing.voteCount = p.voteCount; 133 - } else { 134 - polls.set(p.uri, { 135 - uri: p.uri, 136 - repo: p.repo, 137 - rkey: p.rkey, 138 - text: p.text, 139 - options: p.options, 140 - createdAt: p.createdAt, 141 - voteCount: p.voteCount, 142 - }); 143 - } 144 - } 145 - }; 146 - 147 - export const fetchPoll = async (uri: string) => { 148 - const res = await fetch(`${BACKEND_URL}/api/polls/${encodeURIComponent(uri)}`); 149 - if (!res.ok) return null; 150 - return res.json() as Promise<{ 151 - uri: string; 152 - repo: string; 153 - rkey: string; 154 - text: string; 155 - options: Array<{ text: string; count: number }>; 156 - createdAt: string; 157 - }>; 158 - }; 159 - 160 - export const fetchVoters = async (pollUri: string) => { 161 - const res = await fetch(`${BACKEND_URL}/api/polls/${encodeURIComponent(pollUri)}/votes`); 162 - if (!res.ok) return []; 163 - return res.json() as Promise<Array<{ voter: string; option: number; uri: string; createdAt?: string }>>; 164 - }; 165 - 166 - // create poll 167 - export const createPoll = async (text: string, options: string[]): Promise<string | null> => { 168 - if (!agent || !currentDid) return null; 169 - 170 - const rpc = new Client({ handler: agent }); 171 - const res = await rpc.post("com.atproto.repo.createRecord", { 172 - input: { 173 - repo: currentDid, 174 - collection: POLL, 175 - record: { $type: POLL, text, options, createdAt: new Date().toISOString() }, 176 - }, 177 - }); 178 - 179 - if (!res.ok) throw new Error(res.data.error || "failed to create poll"); 180 - 181 - const rkey = res.data.uri.split("/").pop()!; 182 - polls.set(res.data.uri, { 183 - uri: res.data.uri, 184 - repo: currentDid, 185 - rkey, 186 - text, 187 - options, 188 - createdAt: new Date().toISOString(), 189 - }); 190 - 191 - return res.data.uri; 192 - }; 193 - 194 - // vote - creates or updates vote record on user's PDS 195 - export const vote = async (pollUri: string, option: number): Promise<void> => { 196 - if (!agent || !currentDid) throw new Error("not logged in"); 197 - 198 - const rpc = new Client({ handler: agent }); 199 - 200 - // check if we already have a vote on this poll 201 - const existing = await rpc.get("com.atproto.repo.listRecords", { 202 - params: { repo: currentDid, collection: VOTE, limit: 100 }, 203 - }); 204 - 205 - let existingRkey: string | null = null; 206 - if (existing.ok) { 207 - for (const record of existing.data.records) { 208 - const val = record.value as { subject?: string }; 209 - if (val.subject === pollUri) { 210 - existingRkey = record.uri.split("/").pop()!; 211 - break; 212 - } 213 - } 214 - } 215 - 216 - if (existingRkey) { 217 - // update existing vote 218 - const res = await rpc.post("com.atproto.repo.putRecord", { 219 - input: { 220 - repo: currentDid, 221 - collection: VOTE, 222 - rkey: existingRkey, 223 - record: { $type: VOTE, subject: pollUri, option, createdAt: new Date().toISOString() }, 224 - }, 225 - }); 226 - if (!res.ok) throw new Error(res.data.error || res.data.message || "vote update failed"); 227 - } else { 228 - // create new vote 229 - const res = await rpc.post("com.atproto.repo.createRecord", { 230 - input: { 231 - repo: currentDid, 232 - collection: VOTE, 233 - record: { $type: VOTE, subject: pollUri, option, createdAt: new Date().toISOString() }, 234 - }, 235 - }); 236 - if (!res.ok) throw new Error(res.data.error || res.data.message || "vote failed"); 237 - } 238 - }; 239 - 240 - // resolve handle from DID 241 - const handleCache = new Map<string, string>(); 242 - 243 - export const resolveHandle = async (did: string): Promise<string> => { 244 - if (handleCache.has(did)) return handleCache.get(did)!; 245 - try { 246 - const res = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${did}`); 247 - if (res.ok) { 248 - const data = await res.json(); 249 - if (data.handle) { 250 - handleCache.set(did, data.handle); 251 - return data.handle; 252 - } 253 - } 254 - } catch {} 255 - return did; 256 - }; 257 - 258 - // fetch poll directly from PDS (fallback) 259 - export const fetchPollFromPDS = async (repo: string, rkey: string) => { 260 - const didDoc = await didDocumentResolver.resolve(repo as `did:${string}:${string}`); 261 - const pds = didDoc?.service?.find((s: { id: string }) => s.id === "#atproto_pds") as { serviceEndpoint?: string } | undefined; 262 - const pdsUrl = pds?.serviceEndpoint || "https://bsky.social"; 263 - 264 - const pdsClient = new Client({ 265 - handler: simpleFetchHandler({ service: pdsUrl }), 266 - }); 267 - 268 - const res = await pdsClient.get("com.atproto.repo.getRecord", { 269 - params: { repo, collection: POLL, rkey }, 270 - }); 271 - 272 - if (!res.ok) return null; 273 - 274 - const rec = res.data.value as { text: string; options: string[]; createdAt: string }; 275 - return { 276 - uri: res.data.uri, 277 - repo, 278 - rkey, 279 - text: rec.text, 280 - options: rec.options, 281 - createdAt: rec.createdAt, 282 - }; 283 - };
-16
src/lib/utils.ts
··· 1 - // html escaping 2 - export const esc = (s: string) => 3 - s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); 4 - 5 - // relative time 6 - export const ago = (date: string) => { 7 - const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000); 8 - if (seconds < 60) return "just now"; 9 - const minutes = Math.floor(seconds / 60); 10 - if (minutes < 60) return `${minutes}m ago`; 11 - const hours = Math.floor(minutes / 60); 12 - if (hours < 24) return `${hours}h ago`; 13 - const days = Math.floor(hours / 24); 14 - if (days < 30) return `${days}d ago`; 15 - return new Date(date).toLocaleDateString(); 16 - };
-446
src/main.ts
··· 1 - import { 2 - POLL, 3 - agent, 4 - currentDid, 5 - setAgent, 6 - setCurrentDid, 7 - polls, 8 - login, 9 - logout, 10 - handleCallback, 11 - restoreSession, 12 - fetchPolls, 13 - fetchPoll, 14 - fetchVoters, 15 - createPoll, 16 - vote, 17 - resolveHandle, 18 - fetchPollFromPDS, 19 - type Poll, 20 - } from "./lib/api"; 21 - import { esc, ago } from "./lib/utils"; 22 - 23 - const app = document.getElementById("app")!; 24 - const nav = document.getElementById("nav")!; 25 - const status = document.getElementById("status")!; 26 - 27 - const setStatus = (msg: string) => (status.textContent = msg); 28 - 29 - const showToast = (msg: string) => { 30 - const existing = document.querySelector(".toast"); 31 - if (existing) existing.remove(); 32 - 33 - const toast = document.createElement("div"); 34 - toast.className = "toast"; 35 - toast.textContent = msg; 36 - document.body.appendChild(toast); 37 - 38 - setTimeout(() => toast.remove(), 3000); 39 - }; 40 - 41 - // track if a vote is in progress to prevent double-clicks 42 - let votingInProgress = false; 43 - 44 - // render 45 - const render = () => { 46 - renderNav(); 47 - 48 - const path = location.pathname; 49 - const match = path.match(/^\/poll\/([^/]+)\/([^/]+)$/); 50 - 51 - if (match) { 52 - renderPollPage(match[1], match[2]); 53 - } else if (path === "/new") { 54 - renderCreate(); 55 - } else if (path === "/mine") { 56 - renderHome(true); 57 - } else { 58 - renderHome(false); 59 - } 60 - }; 61 - 62 - const renderNav = () => { 63 - if (agent) { 64 - nav.innerHTML = `<a href="/">all</a> · <a href="/mine">mine</a> · <a href="/new">new</a> · <a href="#" id="logout">logout</a>`; 65 - document.getElementById("logout")!.onclick = async (e) => { 66 - e.preventDefault(); 67 - await logout(); 68 - setAgent(null); 69 - setCurrentDid(null); 70 - render(); 71 - }; 72 - } else { 73 - nav.innerHTML = `<input id="handle" placeholder="handle" style="width:120px"/> <button id="login">login</button>`; 74 - document.getElementById("login")!.onclick = async () => { 75 - const handle = (document.getElementById("handle") as HTMLInputElement).value.trim(); 76 - if (!handle) return; 77 - setStatus("redirecting..."); 78 - try { 79 - await login(handle); 80 - } catch (e) { 81 - setStatus(`error: ${e}`); 82 - } 83 - }; 84 - } 85 - }; 86 - 87 - const renderHome = async (mineOnly: boolean = false) => { 88 - app.innerHTML = "<p>loading polls...</p>"; 89 - 90 - try { 91 - await fetchPolls(); 92 - 93 - let filteredPolls = Array.from(polls.values()); 94 - if (mineOnly && currentDid) { 95 - filteredPolls = filteredPolls.filter((p) => p.repo === currentDid); 96 - } 97 - filteredPolls.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); 98 - 99 - const newLink = agent ? `<p><a href="/new">+ new poll</a></p>` : `<p>login to create polls</p>`; 100 - const heading = mineOnly ? `<p><strong>my polls</strong></p>` : ""; 101 - 102 - if (filteredPolls.length === 0) { 103 - const msg = mineOnly ? "you haven't created any polls yet" : "no polls yet"; 104 - app.innerHTML = newLink + heading + `<p>${msg}</p>`; 105 - } else { 106 - app.innerHTML = newLink + heading + filteredPolls.map(renderPollCard).join(""); 107 - attachVoteHandlers(); 108 - } 109 - } catch (e) { 110 - console.error("renderHome error:", e); 111 - app.innerHTML = "<p>failed to load polls</p>"; 112 - } 113 - }; 114 - 115 - const renderPollCard = (p: Poll) => { 116 - const total = p.voteCount ?? 0; 117 - const disabled = votingInProgress ? " disabled" : ""; 118 - 119 - const opts = p.options 120 - .map((opt, i) => ` 121 - <div class="option${disabled}" data-vote="${i}" data-poll="${p.uri}"> 122 - <span class="option-text">${esc(opt)}</span> 123 - </div> 124 - `) 125 - .join(""); 126 - 127 - return ` 128 - <div class="poll"> 129 - <a href="/poll/${p.repo}/${p.rkey}" class="poll-question">${esc(p.text)}</a> 130 - <div class="poll-meta">${ago(p.createdAt)} · <span class="vote-count" data-poll-uri="${p.uri}">${total} vote${total === 1 ? "" : "s"}</span></div> 131 - ${opts} 132 - </div> 133 - `; 134 - }; 135 - 136 - // voters tooltip 137 - type VoteInfo = { voter: string; option: number; uri: string; createdAt?: string; handle?: string }; 138 - const votersCache = new Map<string, VoteInfo[]>(); 139 - const pollOptionsCache = new Map<string, string[]>(); // for tooltip option names 140 - let activeTooltip: HTMLElement | null = null; 141 - let tooltipTimeout: ReturnType<typeof setTimeout> | null = null; 142 - 143 - const showVotersTooltip = async (e: Event) => { 144 - const el = e.target as HTMLElement; 145 - const pollUri = el.dataset.pollUri; 146 - if (!pollUri) return; 147 - 148 - if (tooltipTimeout) { 149 - clearTimeout(tooltipTimeout); 150 - tooltipTimeout = null; 151 - } 152 - 153 - if (!votersCache.has(pollUri)) { 154 - try { 155 - const voters = await fetchVoters(pollUri); 156 - votersCache.set(pollUri, voters); 157 - } catch (err) { 158 - console.error("failed to fetch voters:", err); 159 - return; 160 - } 161 - } 162 - 163 - const voters = votersCache.get(pollUri); 164 - if (!voters || voters.length === 0) return; 165 - 166 - await Promise.all(voters.map(async (v) => { 167 - if (!v.handle) { 168 - v.handle = await resolveHandle(v.voter); 169 - } 170 - })); 171 - 172 - const poll = polls.get(pollUri); 173 - const options = poll?.options || pollOptionsCache.get(pollUri) || []; 174 - 175 - if (activeTooltip) activeTooltip.remove(); 176 - 177 - const tooltip = document.createElement("div"); 178 - tooltip.className = "voters-tooltip"; 179 - tooltip.innerHTML = voters 180 - .map((v) => { 181 - const optText = options[v.option] || `option ${v.option}`; 182 - const profileUrl = `https://bsky.app/profile/${v.voter}`; 183 - const displayName = v.handle || v.voter; 184 - const timeStr = v.createdAt ? ago(v.createdAt) : ""; 185 - return `<div class="voter"><a href="${profileUrl}" target="_blank" class="voter-link">@${esc(displayName)}</a> → ${esc(optText)}${timeStr ? ` <span class="vote-time">${timeStr}</span>` : ""}</div>`; 186 - }) 187 - .join(""); 188 - 189 - tooltip.addEventListener("mouseenter", () => { 190 - if (tooltipTimeout) { 191 - clearTimeout(tooltipTimeout); 192 - tooltipTimeout = null; 193 - } 194 - }); 195 - tooltip.addEventListener("mouseleave", hideVotersTooltip); 196 - 197 - const rect = el.getBoundingClientRect(); 198 - tooltip.style.position = "fixed"; 199 - tooltip.style.left = `${rect.left}px`; 200 - tooltip.style.top = `${rect.bottom + 4}px`; 201 - 202 - document.body.appendChild(tooltip); 203 - activeTooltip = tooltip; 204 - }; 205 - 206 - const hideVotersTooltip = () => { 207 - tooltipTimeout = setTimeout(() => { 208 - if (activeTooltip) { 209 - activeTooltip.remove(); 210 - activeTooltip = null; 211 - } 212 - }, 150); 213 - }; 214 - 215 - const attachVoteHandlers = () => { 216 - app.querySelectorAll("[data-vote]").forEach((el) => { 217 - el.addEventListener("click", async (e) => { 218 - e.preventDefault(); 219 - const t = e.currentTarget as HTMLElement; 220 - await handleVote(t.dataset.poll!, parseInt(t.dataset.vote!, 10)); 221 - }); 222 - }); 223 - 224 - app.querySelectorAll(".vote-count").forEach((el) => { 225 - el.addEventListener("mouseenter", showVotersTooltip); 226 - el.addEventListener("mouseleave", hideVotersTooltip); 227 - }); 228 - }; 229 - 230 - const handleVote = async (pollUri: string, option: number) => { 231 - if (!agent || !currentDid) { 232 - showToast("login to vote"); 233 - return; 234 - } 235 - 236 - if (votingInProgress) { 237 - return; 238 - } 239 - 240 - votingInProgress = true; 241 - setStatus("voting..."); 242 - 243 - // disable all vote options visually 244 - app.querySelectorAll("[data-vote]").forEach((el) => { 245 - el.classList.add("disabled"); 246 - }); 247 - 248 - try { 249 - await vote(pollUri, option); 250 - setStatus("confirming..."); 251 - 252 - // poll backend until vote is confirmed (tap needs time to process) 253 - const maxWait = 10000; 254 - const pollInterval = 500; 255 - const start = Date.now(); 256 - 257 - while (Date.now() - start < maxWait) { 258 - const voters = await fetchVoters(pollUri); 259 - const myVote = voters.find(v => v.voter === currentDid); 260 - if (myVote && myVote.option === option) { 261 - break; 262 - } 263 - await new Promise(r => setTimeout(r, pollInterval)); 264 - } 265 - 266 - // clear voters cache so tooltip shows fresh data 267 - votersCache.delete(pollUri); 268 - 269 - setStatus(""); 270 - render(); 271 - } catch (e) { 272 - console.error("vote error:", e); 273 - setStatus(`error: ${e}`); 274 - setTimeout(() => { 275 - setStatus(""); 276 - render(); 277 - }, 2000); 278 - } finally { 279 - votingInProgress = false; 280 - } 281 - }; 282 - 283 - const attachShareHandler = () => { 284 - const btn = app.querySelector(".share-btn") as HTMLButtonElement; 285 - if (!btn) return; 286 - 287 - btn.addEventListener("click", async () => { 288 - const url = btn.dataset.url!; 289 - try { 290 - await navigator.clipboard.writeText(url); 291 - btn.textContent = "copied!"; 292 - btn.classList.add("copied"); 293 - setTimeout(() => { 294 - btn.textContent = "copy link"; 295 - btn.classList.remove("copied"); 296 - }, 2000); 297 - } catch { 298 - const input = document.createElement("input"); 299 - input.value = url; 300 - document.body.appendChild(input); 301 - input.select(); 302 - document.execCommand("copy"); 303 - document.body.removeChild(input); 304 - btn.textContent = "copied!"; 305 - btn.classList.add("copied"); 306 - setTimeout(() => { 307 - btn.textContent = "copy link"; 308 - btn.classList.remove("copied"); 309 - }, 2000); 310 - } 311 - }); 312 - }; 313 - 314 - const renderPollPage = async (repo: string, rkey: string) => { 315 - const uri = `at://${repo}/${POLL}/${rkey}`; 316 - app.innerHTML = "<p>loading...</p>"; 317 - 318 - try { 319 - const data = await fetchPoll(uri); 320 - 321 - if (data) { 322 - // cache options for tooltip 323 - pollOptionsCache.set(uri, data.options.map(o => o.text)); 324 - const total = data.options.reduce((sum, o) => sum + o.count, 0); 325 - const disabled = votingInProgress ? " disabled" : ""; 326 - 327 - const opts = data.options 328 - .map((opt, i) => { 329 - const pct = total > 0 ? Math.round((opt.count / total) * 100) : 0; 330 - return ` 331 - <div class="option${disabled}" data-vote="${i}" data-poll="${uri}"> 332 - <div class="option-bar" style="width: ${pct}%"></div> 333 - <span class="option-text">${esc(opt.text)}</span> 334 - <span class="option-count">${opt.count} (${pct}%)</span> 335 - </div>`; 336 - }) 337 - .join(""); 338 - 339 - const shareUrl = `${window.location.origin}/poll/${repo}/${rkey}`; 340 - app.innerHTML = ` 341 - <p><a href="/">&larr; back</a></p> 342 - <div class="poll-detail"> 343 - <div class="poll-header"> 344 - <h2 class="poll-question">${esc(data.text)}</h2> 345 - <button class="share-btn" data-url="${shareUrl}">copy link</button> 346 - </div> 347 - ${opts} 348 - <div class="poll-meta">${ago(data.createdAt)} · <span class="vote-count" data-poll-uri="${uri}">${total} vote${total === 1 ? "" : "s"}</span></div> 349 - </div>`; 350 - attachVoteHandlers(); 351 - attachShareHandler(); 352 - return; 353 - } 354 - 355 - // fallback to direct PDS fetch if backend doesn't have it 356 - const pdsData = await fetchPollFromPDS(repo, rkey); 357 - if (!pdsData) { 358 - app.innerHTML = "<p>not found</p>"; 359 - return; 360 - } 361 - 362 - const poll: Poll = { ...pdsData }; 363 - polls.set(uri, poll); 364 - 365 - app.innerHTML = `<p><a href="/">&larr; back</a></p>${renderPollCard(poll)}`; 366 - attachVoteHandlers(); 367 - } catch (e) { 368 - console.error("renderPoll error:", e); 369 - app.innerHTML = "<p>error loading poll</p>"; 370 - } 371 - }; 372 - 373 - const renderCreate = () => { 374 - if (!agent) { 375 - app.innerHTML = "<p>login to create</p>"; 376 - return; 377 - } 378 - app.innerHTML = ` 379 - <div class="create-form"> 380 - <input type="text" id="question" placeholder="question" /> 381 - <textarea id="options" rows="4" placeholder="options (one per line)"></textarea> 382 - <button id="create">create</button> 383 - </div> 384 - `; 385 - document.getElementById("create")!.onclick = handleCreate; 386 - }; 387 - 388 - const handleCreate = async () => { 389 - if (!agent || !currentDid) return; 390 - 391 - const text = (document.getElementById("question") as HTMLInputElement).value.trim(); 392 - const options = (document.getElementById("options") as HTMLTextAreaElement).value 393 - .split("\n") 394 - .map((s) => s.trim()) 395 - .filter(Boolean); 396 - 397 - if (!text || options.length < 2) { 398 - setStatus("need question + 2 options"); 399 - return; 400 - } 401 - 402 - setStatus("creating..."); 403 - try { 404 - await createPoll(text, options); 405 - setStatus(""); 406 - history.pushState(null, "", "/"); 407 - render(); 408 - } catch (e) { 409 - setStatus(`error: ${e}`); 410 - } 411 - }; 412 - 413 - // oauth callback handler 414 - const handleOAuthCallback = async () => { 415 - const params = new URLSearchParams(location.hash.slice(1)); 416 - if (!params.has("state")) return false; 417 - 418 - setStatus("logging in..."); 419 - 420 - try { 421 - const success = await handleCallback(); 422 - setStatus(""); 423 - return success; 424 - } catch (e) { 425 - setStatus(`login failed: ${e}`); 426 - return false; 427 - } 428 - }; 429 - 430 - // routing 431 - window.addEventListener("popstate", render); 432 - document.addEventListener("click", (e) => { 433 - const a = (e.target as HTMLElement).closest("a"); 434 - if (a?.href.startsWith(location.origin) && !a.href.includes("#")) { 435 - e.preventDefault(); 436 - history.pushState(null, "", a.href); 437 - render(); 438 - } 439 - }); 440 - 441 - // init 442 - (async () => { 443 - await handleOAuthCallback(); 444 - await restoreSession(); 445 - render(); 446 - })();
-30
tap/fly.toml
··· 1 - app = 'pollz-tap' 2 - primary_region = 'iad' 3 - 4 - [build] 5 - image = 'ghcr.io/bluesky-social/indigo/tap:latest' 6 - 7 - [env] 8 - TAP_DATABASE_URL = 'sqlite:///data/tap.db' 9 - TAP_BIND = ':2480' 10 - TAP_SIGNAL_COLLECTION = 'tech.waow.poll' 11 - TAP_COLLECTION_FILTERS = 'tech.waow.poll,tech.waow.vote' 12 - TAP_DISABLE_ACKS = 'true' 13 - TAP_LOG_LEVEL = 'info' 14 - TAP_CURSOR_SAVE_INTERVAL = '5s' 15 - 16 - [http_service] 17 - internal_port = 2480 18 - force_https = false 19 - auto_stop_machines = 'off' 20 - auto_start_machines = true 21 - min_machines_running = 1 22 - 23 - [[vm]] 24 - memory = '512mb' 25 - cpu_kind = 'shared' 26 - cpus = 1 27 - 28 - [mounts] 29 - source = 'tap_data' 30 - destination = '/data'
-17
tap/justfile
··· 1 - # tap instance for pollz 2 - 3 - # deploy tap to fly.io 4 - deploy: 5 - fly deploy --app pollz-tap 6 - 7 - # check tap status 8 - status: 9 - fly status --app pollz-tap 10 - 11 - # view tap logs 12 - logs: 13 - fly logs --app pollz-tap 14 - 15 - # ssh into tap instance 16 - ssh: 17 - fly ssh console --app pollz-tap
-12
tsconfig.json
··· 1 - { 2 - "compilerOptions": { 3 - "target": "ESNext", 4 - "module": "ESNext", 5 - "moduleResolution": "bundler", 6 - "strict": true, 7 - "skipLibCheck": true, 8 - "esModuleInterop": true, 9 - "resolveJsonModule": true 10 - }, 11 - "include": ["src"] 12 - }
-36
vite.config.ts
··· 1 - import { defineConfig } from "vite"; 2 - import metadata from "./public/oauth-client-metadata.json"; 3 - 4 - const SERVER_PORT = 5173; 5 - 6 - export default defineConfig({ 7 - plugins: [ 8 - { 9 - name: "oauth", 10 - config(_conf, { command }) { 11 - if (command === "build") { 12 - process.env.VITE_OAUTH_CLIENT_ID = metadata.client_id; 13 - process.env.VITE_OAUTH_REDIRECT_URL = metadata.redirect_uris[0]; 14 - } else { 15 - // local dev: use http://localhost client ID trick 16 - const redirectUri = `http://127.0.0.1:${SERVER_PORT}/`; 17 - const clientId = 18 - `http://localhost` + 19 - `?redirect_uri=${encodeURIComponent(redirectUri)}` + 20 - `&scope=${encodeURIComponent(metadata.scope)}`; 21 - 22 - process.env.VITE_OAUTH_CLIENT_ID = clientId; 23 - process.env.VITE_OAUTH_REDIRECT_URL = redirectUri; 24 - } 25 - }, 26 - }, 27 - ], 28 - server: { 29 - host: "127.0.0.1", 30 - port: SERVER_PORT, 31 - strictPort: true, 32 - }, 33 - build: { 34 - target: "esnext", 35 - }, 36 - });