Exosphere is a set of small, modular, self-hostable community tools built on the AT Protocol. app.exosphere.site
7
fork

Configure Feed

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

refactor: replace sphere slug with did handle instead

Hugo 703edde2 6a0a3570

+1545 -404
+4 -3
drizzle/0000_left_hammerhead.sql
··· 41 41 --> statement-breakpoint 42 42 CREATE TABLE `spheres` ( 43 43 `id` text PRIMARY KEY NOT NULL, 44 - `slug` text NOT NULL, 44 + `handle` text NOT NULL, 45 45 `name` text NOT NULL, 46 46 `description` text, 47 47 `visibility` text DEFAULT 'public' NOT NULL, ··· 52 52 `updated_at` text DEFAULT (datetime('now')) NOT NULL 53 53 ); 54 54 --> statement-breakpoint 55 - CREATE UNIQUE INDEX `spheres_slug_unique` ON `spheres` (`slug`);--> statement-breakpoint 55 + CREATE UNIQUE INDEX `spheres_handle_unique` ON `spheres` (`handle`);--> statement-breakpoint 56 56 CREATE TABLE `feature_request_comment_votes` ( 57 57 `comment_id` text NOT NULL, 58 58 `author_did` text NOT NULL, ··· 113 113 `hidden_at` text, 114 114 `moderated_by` text, 115 115 `created_at` text DEFAULT (datetime('now')) NOT NULL, 116 - `updated_at` text DEFAULT (datetime('now')) NOT NULL 116 + `updated_at` text DEFAULT (datetime('now')) NOT NULL, 117 + FOREIGN KEY (`sphere_id`) REFERENCES `spheres`(`id`) ON UPDATE no action ON DELETE no action 117 118 ); 118 119 --> statement-breakpoint 119 120 CREATE UNIQUE INDEX `idx_feature_requests_sphere_number` ON `feature_requests` (`sphere_id`,`number`);--> statement-breakpoint
+28
drizzle/0001_blue_trauma.sql
··· 1 + PRAGMA foreign_keys=OFF;--> statement-breakpoint 2 + CREATE TABLE `__new_feature_requests` ( 3 + `id` text PRIMARY KEY NOT NULL, 4 + `sphere_id` text NOT NULL, 5 + `number` integer NOT NULL, 6 + `author_did` text NOT NULL, 7 + `title` text NOT NULL, 8 + `description` text NOT NULL, 9 + `category` text DEFAULT 'general' NOT NULL, 10 + `status` text DEFAULT 'requested' NOT NULL, 11 + `duplicate_of_id` text, 12 + `pds_uri` text, 13 + `hidden_at` text, 14 + `moderated_by` text, 15 + `created_at` text DEFAULT (datetime('now')) NOT NULL, 16 + `updated_at` text DEFAULT (datetime('now')) NOT NULL, 17 + FOREIGN KEY (`sphere_id`) REFERENCES `spheres`(`id`) ON UPDATE no action ON DELETE no action 18 + ); 19 + --> statement-breakpoint 20 + INSERT INTO `__new_feature_requests`("id", "sphere_id", "number", "author_did", "title", "description", "category", "status", "duplicate_of_id", "pds_uri", "hidden_at", "moderated_by", "created_at", "updated_at") SELECT "id", "sphere_id", "number", "author_did", "title", "description", "category", "status", "duplicate_of_id", "pds_uri", "hidden_at", "moderated_by", "created_at", "updated_at" FROM `feature_requests`;--> statement-breakpoint 21 + DROP TABLE `feature_requests`;--> statement-breakpoint 22 + ALTER TABLE `__new_feature_requests` RENAME TO `feature_requests`;--> statement-breakpoint 23 + PRAGMA foreign_keys=ON;--> statement-breakpoint 24 + CREATE UNIQUE INDEX `idx_feature_requests_sphere_number` ON `feature_requests` (`sphere_id`,`number`);--> statement-breakpoint 25 + CREATE INDEX `idx_feature_requests_sphere` ON `feature_requests` (`sphere_id`);--> statement-breakpoint 26 + CREATE INDEX `idx_feature_requests_status` ON `feature_requests` (`status`);--> statement-breakpoint 27 + CREATE INDEX `idx_feature_requests_created` ON `feature_requests` (`created_at`);--> statement-breakpoint 28 + CREATE INDEX `idx_feature_requests_category` ON `feature_requests` (`category`);
+5 -5
drizzle/meta/0000_snapshot.json
··· 272 272 "notNull": true, 273 273 "autoincrement": false 274 274 }, 275 - "slug": { 276 - "name": "slug", 275 + "handle": { 276 + "name": "handle", 277 277 "type": "text", 278 278 "primaryKey": false, 279 279 "notNull": true, ··· 341 341 } 342 342 }, 343 343 "indexes": { 344 - "spheres_slug_unique": { 345 - "name": "spheres_slug_unique", 344 + "spheres_handle_unique": { 345 + "name": "spheres_handle_unique", 346 346 "columns": [ 347 - "slug" 347 + "handle" 348 348 ], 349 349 "isUnique": true 350 350 }
+925
drizzle/meta/0001_snapshot.json
··· 1 + { 2 + "version": "6", 3 + "dialect": "sqlite", 4 + "id": "cea369de-a802-4177-8538-5b15823a3bfc", 5 + "prevId": "4c5f0d67-4da8-40d5-bcc8-33c2b60ac8a6", 6 + "tables": { 7 + "oauth_sessions": { 8 + "name": "oauth_sessions", 9 + "columns": { 10 + "key": { 11 + "name": "key", 12 + "type": "text", 13 + "primaryKey": true, 14 + "notNull": true, 15 + "autoincrement": false 16 + }, 17 + "session": { 18 + "name": "session", 19 + "type": "text", 20 + "primaryKey": false, 21 + "notNull": true, 22 + "autoincrement": false 23 + }, 24 + "created_at": { 25 + "name": "created_at", 26 + "type": "text", 27 + "primaryKey": false, 28 + "notNull": true, 29 + "autoincrement": false, 30 + "default": "(datetime('now'))" 31 + }, 32 + "updated_at": { 33 + "name": "updated_at", 34 + "type": "text", 35 + "primaryKey": false, 36 + "notNull": true, 37 + "autoincrement": false, 38 + "default": "(datetime('now'))" 39 + } 40 + }, 41 + "indexes": {}, 42 + "foreignKeys": {}, 43 + "compositePrimaryKeys": {}, 44 + "uniqueConstraints": {}, 45 + "checkConstraints": {} 46 + }, 47 + "oauth_states": { 48 + "name": "oauth_states", 49 + "columns": { 50 + "key": { 51 + "name": "key", 52 + "type": "text", 53 + "primaryKey": true, 54 + "notNull": true, 55 + "autoincrement": false 56 + }, 57 + "state": { 58 + "name": "state", 59 + "type": "text", 60 + "primaryKey": false, 61 + "notNull": true, 62 + "autoincrement": false 63 + }, 64 + "created_at": { 65 + "name": "created_at", 66 + "type": "text", 67 + "primaryKey": false, 68 + "notNull": true, 69 + "autoincrement": false, 70 + "default": "(datetime('now'))" 71 + } 72 + }, 73 + "indexes": {}, 74 + "foreignKeys": {}, 75 + "compositePrimaryKeys": {}, 76 + "uniqueConstraints": {}, 77 + "checkConstraints": {} 78 + }, 79 + "indexer_cursor": { 80 + "name": "indexer_cursor", 81 + "columns": { 82 + "id": { 83 + "name": "id", 84 + "type": "text", 85 + "primaryKey": true, 86 + "notNull": true, 87 + "autoincrement": false, 88 + "default": "'jetstream'" 89 + }, 90 + "cursor": { 91 + "name": "cursor", 92 + "type": "integer", 93 + "primaryKey": false, 94 + "notNull": true, 95 + "autoincrement": false 96 + }, 97 + "updated_at": { 98 + "name": "updated_at", 99 + "type": "text", 100 + "primaryKey": false, 101 + "notNull": true, 102 + "autoincrement": false, 103 + "default": "(datetime('now'))" 104 + } 105 + }, 106 + "indexes": {}, 107 + "foreignKeys": {}, 108 + "compositePrimaryKeys": {}, 109 + "uniqueConstraints": {}, 110 + "checkConstraints": {} 111 + }, 112 + "sphere_members": { 113 + "name": "sphere_members", 114 + "columns": { 115 + "sphere_id": { 116 + "name": "sphere_id", 117 + "type": "text", 118 + "primaryKey": false, 119 + "notNull": true, 120 + "autoincrement": false 121 + }, 122 + "did": { 123 + "name": "did", 124 + "type": "text", 125 + "primaryKey": false, 126 + "notNull": true, 127 + "autoincrement": false 128 + }, 129 + "role": { 130 + "name": "role", 131 + "type": "text", 132 + "primaryKey": false, 133 + "notNull": true, 134 + "autoincrement": false, 135 + "default": "'member'" 136 + }, 137 + "status": { 138 + "name": "status", 139 + "type": "text", 140 + "primaryKey": false, 141 + "notNull": true, 142 + "autoincrement": false, 143 + "default": "'invited'" 144 + }, 145 + "invited_by": { 146 + "name": "invited_by", 147 + "type": "text", 148 + "primaryKey": false, 149 + "notNull": false, 150 + "autoincrement": false 151 + }, 152 + "pds_uri": { 153 + "name": "pds_uri", 154 + "type": "text", 155 + "primaryKey": false, 156 + "notNull": false, 157 + "autoincrement": false 158 + }, 159 + "approval_pds_uri": { 160 + "name": "approval_pds_uri", 161 + "type": "text", 162 + "primaryKey": false, 163 + "notNull": false, 164 + "autoincrement": false 165 + }, 166 + "created_at": { 167 + "name": "created_at", 168 + "type": "text", 169 + "primaryKey": false, 170 + "notNull": true, 171 + "autoincrement": false, 172 + "default": "(datetime('now'))" 173 + } 174 + }, 175 + "indexes": { 176 + "idx_sphere_members_did": { 177 + "name": "idx_sphere_members_did", 178 + "columns": [ 179 + "did" 180 + ], 181 + "isUnique": false 182 + } 183 + }, 184 + "foreignKeys": { 185 + "sphere_members_sphere_id_spheres_id_fk": { 186 + "name": "sphere_members_sphere_id_spheres_id_fk", 187 + "tableFrom": "sphere_members", 188 + "tableTo": "spheres", 189 + "columnsFrom": [ 190 + "sphere_id" 191 + ], 192 + "columnsTo": [ 193 + "id" 194 + ], 195 + "onDelete": "no action", 196 + "onUpdate": "no action" 197 + } 198 + }, 199 + "compositePrimaryKeys": { 200 + "sphere_members_sphere_id_did_pk": { 201 + "columns": [ 202 + "sphere_id", 203 + "did" 204 + ], 205 + "name": "sphere_members_sphere_id_did_pk" 206 + } 207 + }, 208 + "uniqueConstraints": {}, 209 + "checkConstraints": {} 210 + }, 211 + "sphere_modules": { 212 + "name": "sphere_modules", 213 + "columns": { 214 + "sphere_id": { 215 + "name": "sphere_id", 216 + "type": "text", 217 + "primaryKey": false, 218 + "notNull": true, 219 + "autoincrement": false 220 + }, 221 + "module_name": { 222 + "name": "module_name", 223 + "type": "text", 224 + "primaryKey": false, 225 + "notNull": true, 226 + "autoincrement": false 227 + }, 228 + "enabled_at": { 229 + "name": "enabled_at", 230 + "type": "text", 231 + "primaryKey": false, 232 + "notNull": true, 233 + "autoincrement": false, 234 + "default": "(datetime('now'))" 235 + } 236 + }, 237 + "indexes": {}, 238 + "foreignKeys": { 239 + "sphere_modules_sphere_id_spheres_id_fk": { 240 + "name": "sphere_modules_sphere_id_spheres_id_fk", 241 + "tableFrom": "sphere_modules", 242 + "tableTo": "spheres", 243 + "columnsFrom": [ 244 + "sphere_id" 245 + ], 246 + "columnsTo": [ 247 + "id" 248 + ], 249 + "onDelete": "no action", 250 + "onUpdate": "no action" 251 + } 252 + }, 253 + "compositePrimaryKeys": { 254 + "sphere_modules_sphere_id_module_name_pk": { 255 + "columns": [ 256 + "sphere_id", 257 + "module_name" 258 + ], 259 + "name": "sphere_modules_sphere_id_module_name_pk" 260 + } 261 + }, 262 + "uniqueConstraints": {}, 263 + "checkConstraints": {} 264 + }, 265 + "spheres": { 266 + "name": "spheres", 267 + "columns": { 268 + "id": { 269 + "name": "id", 270 + "type": "text", 271 + "primaryKey": true, 272 + "notNull": true, 273 + "autoincrement": false 274 + }, 275 + "handle": { 276 + "name": "handle", 277 + "type": "text", 278 + "primaryKey": false, 279 + "notNull": true, 280 + "autoincrement": false 281 + }, 282 + "name": { 283 + "name": "name", 284 + "type": "text", 285 + "primaryKey": false, 286 + "notNull": true, 287 + "autoincrement": false 288 + }, 289 + "description": { 290 + "name": "description", 291 + "type": "text", 292 + "primaryKey": false, 293 + "notNull": false, 294 + "autoincrement": false 295 + }, 296 + "visibility": { 297 + "name": "visibility", 298 + "type": "text", 299 + "primaryKey": false, 300 + "notNull": true, 301 + "autoincrement": false, 302 + "default": "'public'" 303 + }, 304 + "write_access": { 305 + "name": "write_access", 306 + "type": "text", 307 + "primaryKey": false, 308 + "notNull": true, 309 + "autoincrement": false, 310 + "default": "'open'" 311 + }, 312 + "owner_did": { 313 + "name": "owner_did", 314 + "type": "text", 315 + "primaryKey": false, 316 + "notNull": true, 317 + "autoincrement": false 318 + }, 319 + "pds_uri": { 320 + "name": "pds_uri", 321 + "type": "text", 322 + "primaryKey": false, 323 + "notNull": false, 324 + "autoincrement": false 325 + }, 326 + "created_at": { 327 + "name": "created_at", 328 + "type": "text", 329 + "primaryKey": false, 330 + "notNull": true, 331 + "autoincrement": false, 332 + "default": "(datetime('now'))" 333 + }, 334 + "updated_at": { 335 + "name": "updated_at", 336 + "type": "text", 337 + "primaryKey": false, 338 + "notNull": true, 339 + "autoincrement": false, 340 + "default": "(datetime('now'))" 341 + } 342 + }, 343 + "indexes": { 344 + "spheres_handle_unique": { 345 + "name": "spheres_handle_unique", 346 + "columns": [ 347 + "handle" 348 + ], 349 + "isUnique": true 350 + } 351 + }, 352 + "foreignKeys": {}, 353 + "compositePrimaryKeys": {}, 354 + "uniqueConstraints": {}, 355 + "checkConstraints": {} 356 + }, 357 + "feature_request_comment_votes": { 358 + "name": "feature_request_comment_votes", 359 + "columns": { 360 + "comment_id": { 361 + "name": "comment_id", 362 + "type": "text", 363 + "primaryKey": false, 364 + "notNull": true, 365 + "autoincrement": false 366 + }, 367 + "author_did": { 368 + "name": "author_did", 369 + "type": "text", 370 + "primaryKey": false, 371 + "notNull": true, 372 + "autoincrement": false 373 + }, 374 + "pds_uri": { 375 + "name": "pds_uri", 376 + "type": "text", 377 + "primaryKey": false, 378 + "notNull": false, 379 + "autoincrement": false 380 + }, 381 + "created_at": { 382 + "name": "created_at", 383 + "type": "text", 384 + "primaryKey": false, 385 + "notNull": true, 386 + "autoincrement": false, 387 + "default": "(datetime('now'))" 388 + } 389 + }, 390 + "indexes": { 391 + "idx_feature_request_comment_votes_comment": { 392 + "name": "idx_feature_request_comment_votes_comment", 393 + "columns": [ 394 + "comment_id" 395 + ], 396 + "isUnique": false 397 + } 398 + }, 399 + "foreignKeys": { 400 + "feature_request_comment_votes_comment_id_feature_request_comments_id_fk": { 401 + "name": "feature_request_comment_votes_comment_id_feature_request_comments_id_fk", 402 + "tableFrom": "feature_request_comment_votes", 403 + "tableTo": "feature_request_comments", 404 + "columnsFrom": [ 405 + "comment_id" 406 + ], 407 + "columnsTo": [ 408 + "id" 409 + ], 410 + "onDelete": "no action", 411 + "onUpdate": "no action" 412 + } 413 + }, 414 + "compositePrimaryKeys": { 415 + "feature_request_comment_votes_comment_id_author_did_pk": { 416 + "columns": [ 417 + "comment_id", 418 + "author_did" 419 + ], 420 + "name": "feature_request_comment_votes_comment_id_author_did_pk" 421 + } 422 + }, 423 + "uniqueConstraints": {}, 424 + "checkConstraints": {} 425 + }, 426 + "feature_request_comments": { 427 + "name": "feature_request_comments", 428 + "columns": { 429 + "id": { 430 + "name": "id", 431 + "type": "text", 432 + "primaryKey": true, 433 + "notNull": true, 434 + "autoincrement": false 435 + }, 436 + "request_id": { 437 + "name": "request_id", 438 + "type": "text", 439 + "primaryKey": false, 440 + "notNull": true, 441 + "autoincrement": false 442 + }, 443 + "author_did": { 444 + "name": "author_did", 445 + "type": "text", 446 + "primaryKey": false, 447 + "notNull": true, 448 + "autoincrement": false 449 + }, 450 + "content": { 451 + "name": "content", 452 + "type": "text", 453 + "primaryKey": false, 454 + "notNull": true, 455 + "autoincrement": false 456 + }, 457 + "pds_uri": { 458 + "name": "pds_uri", 459 + "type": "text", 460 + "primaryKey": false, 461 + "notNull": false, 462 + "autoincrement": false 463 + }, 464 + "created_at": { 465 + "name": "created_at", 466 + "type": "text", 467 + "primaryKey": false, 468 + "notNull": true, 469 + "autoincrement": false, 470 + "default": "(datetime('now'))" 471 + }, 472 + "updated_at": { 473 + "name": "updated_at", 474 + "type": "text", 475 + "primaryKey": false, 476 + "notNull": true, 477 + "autoincrement": false, 478 + "default": "(datetime('now'))" 479 + }, 480 + "hidden_at": { 481 + "name": "hidden_at", 482 + "type": "text", 483 + "primaryKey": false, 484 + "notNull": false, 485 + "autoincrement": false 486 + }, 487 + "moderated_by": { 488 + "name": "moderated_by", 489 + "type": "text", 490 + "primaryKey": false, 491 + "notNull": false, 492 + "autoincrement": false 493 + } 494 + }, 495 + "indexes": { 496 + "idx_feature_request_comments_request": { 497 + "name": "idx_feature_request_comments_request", 498 + "columns": [ 499 + "request_id" 500 + ], 501 + "isUnique": false 502 + }, 503 + "idx_feature_request_comments_author_request": { 504 + "name": "idx_feature_request_comments_author_request", 505 + "columns": [ 506 + "author_did", 507 + "request_id" 508 + ], 509 + "isUnique": false 510 + } 511 + }, 512 + "foreignKeys": { 513 + "feature_request_comments_request_id_feature_requests_id_fk": { 514 + "name": "feature_request_comments_request_id_feature_requests_id_fk", 515 + "tableFrom": "feature_request_comments", 516 + "tableTo": "feature_requests", 517 + "columnsFrom": [ 518 + "request_id" 519 + ], 520 + "columnsTo": [ 521 + "id" 522 + ], 523 + "onDelete": "no action", 524 + "onUpdate": "no action" 525 + } 526 + }, 527 + "compositePrimaryKeys": {}, 528 + "uniqueConstraints": {}, 529 + "checkConstraints": {} 530 + }, 531 + "feature_request_statuses": { 532 + "name": "feature_request_statuses", 533 + "columns": { 534 + "id": { 535 + "name": "id", 536 + "type": "text", 537 + "primaryKey": true, 538 + "notNull": true, 539 + "autoincrement": false 540 + }, 541 + "request_id": { 542 + "name": "request_id", 543 + "type": "text", 544 + "primaryKey": false, 545 + "notNull": true, 546 + "autoincrement": false 547 + }, 548 + "author_did": { 549 + "name": "author_did", 550 + "type": "text", 551 + "primaryKey": false, 552 + "notNull": true, 553 + "autoincrement": false 554 + }, 555 + "status": { 556 + "name": "status", 557 + "type": "text", 558 + "primaryKey": false, 559 + "notNull": true, 560 + "autoincrement": false 561 + }, 562 + "pds_uri": { 563 + "name": "pds_uri", 564 + "type": "text", 565 + "primaryKey": false, 566 + "notNull": false, 567 + "autoincrement": false 568 + }, 569 + "created_at": { 570 + "name": "created_at", 571 + "type": "text", 572 + "primaryKey": false, 573 + "notNull": true, 574 + "autoincrement": false, 575 + "default": "(datetime('now'))" 576 + } 577 + }, 578 + "indexes": { 579 + "idx_feature_request_statuses_request": { 580 + "name": "idx_feature_request_statuses_request", 581 + "columns": [ 582 + "request_id" 583 + ], 584 + "isUnique": false 585 + } 586 + }, 587 + "foreignKeys": { 588 + "feature_request_statuses_request_id_feature_requests_id_fk": { 589 + "name": "feature_request_statuses_request_id_feature_requests_id_fk", 590 + "tableFrom": "feature_request_statuses", 591 + "tableTo": "feature_requests", 592 + "columnsFrom": [ 593 + "request_id" 594 + ], 595 + "columnsTo": [ 596 + "id" 597 + ], 598 + "onDelete": "no action", 599 + "onUpdate": "no action" 600 + } 601 + }, 602 + "compositePrimaryKeys": {}, 603 + "uniqueConstraints": {}, 604 + "checkConstraints": {} 605 + }, 606 + "feature_request_votes": { 607 + "name": "feature_request_votes", 608 + "columns": { 609 + "request_id": { 610 + "name": "request_id", 611 + "type": "text", 612 + "primaryKey": false, 613 + "notNull": true, 614 + "autoincrement": false 615 + }, 616 + "author_did": { 617 + "name": "author_did", 618 + "type": "text", 619 + "primaryKey": false, 620 + "notNull": true, 621 + "autoincrement": false 622 + }, 623 + "pds_uri": { 624 + "name": "pds_uri", 625 + "type": "text", 626 + "primaryKey": false, 627 + "notNull": false, 628 + "autoincrement": false 629 + }, 630 + "created_at": { 631 + "name": "created_at", 632 + "type": "text", 633 + "primaryKey": false, 634 + "notNull": true, 635 + "autoincrement": false, 636 + "default": "(datetime('now'))" 637 + } 638 + }, 639 + "indexes": { 640 + "idx_feature_request_votes_request": { 641 + "name": "idx_feature_request_votes_request", 642 + "columns": [ 643 + "request_id" 644 + ], 645 + "isUnique": false 646 + } 647 + }, 648 + "foreignKeys": { 649 + "feature_request_votes_request_id_feature_requests_id_fk": { 650 + "name": "feature_request_votes_request_id_feature_requests_id_fk", 651 + "tableFrom": "feature_request_votes", 652 + "tableTo": "feature_requests", 653 + "columnsFrom": [ 654 + "request_id" 655 + ], 656 + "columnsTo": [ 657 + "id" 658 + ], 659 + "onDelete": "no action", 660 + "onUpdate": "no action" 661 + } 662 + }, 663 + "compositePrimaryKeys": { 664 + "feature_request_votes_request_id_author_did_pk": { 665 + "columns": [ 666 + "request_id", 667 + "author_did" 668 + ], 669 + "name": "feature_request_votes_request_id_author_did_pk" 670 + } 671 + }, 672 + "uniqueConstraints": {}, 673 + "checkConstraints": {} 674 + }, 675 + "feature_requests": { 676 + "name": "feature_requests", 677 + "columns": { 678 + "id": { 679 + "name": "id", 680 + "type": "text", 681 + "primaryKey": true, 682 + "notNull": true, 683 + "autoincrement": false 684 + }, 685 + "sphere_id": { 686 + "name": "sphere_id", 687 + "type": "text", 688 + "primaryKey": false, 689 + "notNull": true, 690 + "autoincrement": false 691 + }, 692 + "number": { 693 + "name": "number", 694 + "type": "integer", 695 + "primaryKey": false, 696 + "notNull": true, 697 + "autoincrement": false 698 + }, 699 + "author_did": { 700 + "name": "author_did", 701 + "type": "text", 702 + "primaryKey": false, 703 + "notNull": true, 704 + "autoincrement": false 705 + }, 706 + "title": { 707 + "name": "title", 708 + "type": "text", 709 + "primaryKey": false, 710 + "notNull": true, 711 + "autoincrement": false 712 + }, 713 + "description": { 714 + "name": "description", 715 + "type": "text", 716 + "primaryKey": false, 717 + "notNull": true, 718 + "autoincrement": false 719 + }, 720 + "category": { 721 + "name": "category", 722 + "type": "text", 723 + "primaryKey": false, 724 + "notNull": true, 725 + "autoincrement": false, 726 + "default": "'general'" 727 + }, 728 + "status": { 729 + "name": "status", 730 + "type": "text", 731 + "primaryKey": false, 732 + "notNull": true, 733 + "autoincrement": false, 734 + "default": "'requested'" 735 + }, 736 + "duplicate_of_id": { 737 + "name": "duplicate_of_id", 738 + "type": "text", 739 + "primaryKey": false, 740 + "notNull": false, 741 + "autoincrement": false 742 + }, 743 + "pds_uri": { 744 + "name": "pds_uri", 745 + "type": "text", 746 + "primaryKey": false, 747 + "notNull": false, 748 + "autoincrement": false 749 + }, 750 + "hidden_at": { 751 + "name": "hidden_at", 752 + "type": "text", 753 + "primaryKey": false, 754 + "notNull": false, 755 + "autoincrement": false 756 + }, 757 + "moderated_by": { 758 + "name": "moderated_by", 759 + "type": "text", 760 + "primaryKey": false, 761 + "notNull": false, 762 + "autoincrement": false 763 + }, 764 + "created_at": { 765 + "name": "created_at", 766 + "type": "text", 767 + "primaryKey": false, 768 + "notNull": true, 769 + "autoincrement": false, 770 + "default": "(datetime('now'))" 771 + }, 772 + "updated_at": { 773 + "name": "updated_at", 774 + "type": "text", 775 + "primaryKey": false, 776 + "notNull": true, 777 + "autoincrement": false, 778 + "default": "(datetime('now'))" 779 + } 780 + }, 781 + "indexes": { 782 + "idx_feature_requests_sphere_number": { 783 + "name": "idx_feature_requests_sphere_number", 784 + "columns": [ 785 + "sphere_id", 786 + "number" 787 + ], 788 + "isUnique": true 789 + }, 790 + "idx_feature_requests_sphere": { 791 + "name": "idx_feature_requests_sphere", 792 + "columns": [ 793 + "sphere_id" 794 + ], 795 + "isUnique": false 796 + }, 797 + "idx_feature_requests_status": { 798 + "name": "idx_feature_requests_status", 799 + "columns": [ 800 + "status" 801 + ], 802 + "isUnique": false 803 + }, 804 + "idx_feature_requests_created": { 805 + "name": "idx_feature_requests_created", 806 + "columns": [ 807 + "created_at" 808 + ], 809 + "isUnique": false 810 + }, 811 + "idx_feature_requests_category": { 812 + "name": "idx_feature_requests_category", 813 + "columns": [ 814 + "category" 815 + ], 816 + "isUnique": false 817 + } 818 + }, 819 + "foreignKeys": { 820 + "feature_requests_sphere_id_spheres_id_fk": { 821 + "name": "feature_requests_sphere_id_spheres_id_fk", 822 + "tableFrom": "feature_requests", 823 + "tableTo": "spheres", 824 + "columnsFrom": [ 825 + "sphere_id" 826 + ], 827 + "columnsTo": [ 828 + "id" 829 + ], 830 + "onDelete": "no action", 831 + "onUpdate": "no action" 832 + } 833 + }, 834 + "compositePrimaryKeys": {}, 835 + "uniqueConstraints": {}, 836 + "checkConstraints": {} 837 + }, 838 + "feed_posts": { 839 + "name": "feed_posts", 840 + "columns": { 841 + "id": { 842 + "name": "id", 843 + "type": "text", 844 + "primaryKey": true, 845 + "notNull": true, 846 + "autoincrement": false 847 + }, 848 + "author_did": { 849 + "name": "author_did", 850 + "type": "text", 851 + "primaryKey": false, 852 + "notNull": true, 853 + "autoincrement": false 854 + }, 855 + "content": { 856 + "name": "content", 857 + "type": "text", 858 + "primaryKey": false, 859 + "notNull": true, 860 + "autoincrement": false 861 + }, 862 + "parent_id": { 863 + "name": "parent_id", 864 + "type": "text", 865 + "primaryKey": false, 866 + "notNull": false, 867 + "autoincrement": false 868 + }, 869 + "pds_uri": { 870 + "name": "pds_uri", 871 + "type": "text", 872 + "primaryKey": false, 873 + "notNull": false, 874 + "autoincrement": false 875 + }, 876 + "created_at": { 877 + "name": "created_at", 878 + "type": "text", 879 + "primaryKey": false, 880 + "notNull": true, 881 + "autoincrement": false, 882 + "default": "(datetime('now'))" 883 + }, 884 + "updated_at": { 885 + "name": "updated_at", 886 + "type": "text", 887 + "primaryKey": false, 888 + "notNull": true, 889 + "autoincrement": false, 890 + "default": "(datetime('now'))" 891 + } 892 + }, 893 + "indexes": { 894 + "idx_feed_posts_parent": { 895 + "name": "idx_feed_posts_parent", 896 + "columns": [ 897 + "parent_id" 898 + ], 899 + "isUnique": false 900 + }, 901 + "idx_feed_posts_created": { 902 + "name": "idx_feed_posts_created", 903 + "columns": [ 904 + "created_at" 905 + ], 906 + "isUnique": false 907 + } 908 + }, 909 + "foreignKeys": {}, 910 + "compositePrimaryKeys": {}, 911 + "uniqueConstraints": {}, 912 + "checkConstraints": {} 913 + } 914 + }, 915 + "views": {}, 916 + "enums": {}, 917 + "_meta": { 918 + "schemas": {}, 919 + "tables": {}, 920 + "columns": {} 921 + }, 922 + "internal": { 923 + "indexes": {} 924 + } 925 + }
+7
drizzle/meta/_journal.json
··· 8 8 "when": 1774343285849, 9 9 "tag": "0000_left_hammerhead", 10 10 "breakpoints": true 11 + }, 12 + { 13 + "idx": 1, 14 + "version": "6", 15 + "when": 1774359886018, 16 + "tag": "0001_blue_trauma", 17 + "breakpoints": true 11 18 } 12 19 ] 13 20 }
+5 -2
packages/app/e2e/global-setup.ts
··· 4 4 const ROOT = resolve(import.meta.dirname, "../../.."); 5 5 6 6 export default function globalSetup() { 7 - // Build the frontend for production SSR 8 - execSync("bun run build", { stdio: "inherit", cwd: ROOT }); 7 + // Build once with MULTI_SPHERE="" — works for both self-hosted and multi-sphere e2e tests because: 8 + // - SSR calls setMultiSphere(data.multiSphere) before rendering 9 + // - Client calls setMultiSphere(ssrData.multiSphere) before hydration 10 + // The build-time __MULTI_SPHERE__ is only a fallback for dev without SSR. 11 + execSync("bun run build", { stdio: "inherit", cwd: ROOT, env: { ...process.env, MULTI_SPHERE: "" } }); 9 12 10 13 // Seed the test database 11 14 const seedScript = resolve(import.meta.dirname, "seed.ts");
+30 -10
packages/app/e2e/playwright.config.ts
··· 1 1 import { defineConfig, devices } from "@playwright/test"; 2 2 3 - const PORT = 3002; 3 + const SELF_HOSTED_PORT = 3002; 4 + const MULTI_SPHERE_PORT = 3003; 4 5 5 6 export default defineConfig({ 6 7 testDir: "./tests", ··· 11 12 reporter: "html", 12 13 globalSetup: "./global-setup.ts", 13 14 use: { 14 - baseURL: `http://localhost:${PORT}`, 15 15 trace: "on-first-retry", 16 16 }, 17 17 projects: [ 18 18 { 19 - name: "chromium", 20 - use: { ...devices["Desktop Chrome"] }, 19 + name: "self-hosted", 20 + testDir: "./tests/self-hosted", 21 + use: { 22 + ...devices["Desktop Chrome"], 23 + baseURL: `http://localhost:${SELF_HOSTED_PORT}`, 24 + }, 25 + }, 26 + { 27 + name: "multi-sphere", 28 + testDir: "./tests/multi-sphere", 29 + use: { 30 + ...devices["Desktop Chrome"], 31 + baseURL: `http://localhost:${MULTI_SPHERE_PORT}`, 32 + }, 21 33 }, 22 34 ], 23 - webServer: { 24 - command: `NODE_ENV=production DATABASE_PATH=/tmp/exosphere-e2e/db.sqlite DISABLE_INDEXER=1 PORT=${PORT} bun run packages/app/src/server.ts`, 25 - port: PORT, 26 - reuseExistingServer: !process.env.CI, 27 - cwd: "../../../", 28 - }, 35 + webServer: [ 36 + { 37 + command: `NODE_ENV=production DATABASE_PATH=/tmp/exosphere-e2e/self-hosted.sqlite DISABLE_INDEXER=1 MULTI_SPHERE= PORT=${SELF_HOSTED_PORT} bun run packages/app/src/server.ts`, 38 + port: SELF_HOSTED_PORT, 39 + reuseExistingServer: !process.env.CI, 40 + cwd: "../../../", 41 + }, 42 + { 43 + command: `NODE_ENV=production DATABASE_PATH=/tmp/exosphere-e2e/multi-sphere.sqlite DISABLE_INDEXER=1 MULTI_SPHERE=1 PORT=${MULTI_SPHERE_PORT} bun run packages/app/src/server.ts`, 44 + port: MULTI_SPHERE_PORT, 45 + reuseExistingServer: !process.env.CI, 46 + cwd: "../../../", 47 + }, 48 + ], 29 49 });
+149 -115
packages/app/e2e/seed.ts
··· 1 1 /** 2 2 * E2E test seed script — runs under Bun to access bun:sqlite. 3 3 * 4 - * Creates a temporary SQLite database with migrations applied and 5 - * seeds it with a sphere, feature requests, and comments. 4 + * Creates two SQLite databases: 5 + * - self-hosted.sqlite — single sphere (self-hosted mode) 6 + * - multi-sphere.sqlite — two spheres (hosted / multi-sphere mode) 6 7 */ 7 8 8 9 import { Database } from "bun:sqlite"; ··· 11 12 import { resolve } from "node:path"; 12 13 13 14 const E2E_DB_DIR = "/tmp/exosphere-e2e"; 14 - const E2E_DB_PATH = `${E2E_DB_DIR}/db.sqlite`; 15 15 const MIGRATIONS_DIR = resolve(import.meta.dirname, "../../../drizzle"); 16 16 17 - // Clean previous test DB 17 + // Clean previous test DBs 18 18 rmSync(E2E_DB_DIR, { recursive: true, force: true }); 19 19 mkdirSync(E2E_DB_DIR, { recursive: true }); 20 20 21 - // Create database 22 - const sqlite = new Database(E2E_DB_PATH, { create: true }); 23 - sqlite.run("PRAGMA journal_mode = WAL;"); 24 - sqlite.run("PRAGMA foreign_keys = ON;"); 21 + /** Create a fresh database with migrations applied. */ 22 + function createDatabase(dbPath: string): Database { 23 + const db = new Database(dbPath, { create: true }); 24 + db.run("PRAGMA journal_mode = WAL;"); 25 + db.run("PRAGMA foreign_keys = ON;"); 25 26 26 - // Apply migrations — read the SQL files and split on drizzle's statement breakpoints 27 - const journal = JSON.parse(readFileSync(resolve(MIGRATIONS_DIR, "meta/_journal.json"), "utf-8")); 28 - for (const entry of journal.entries) { 29 - const sql = readFileSync(resolve(MIGRATIONS_DIR, `${entry.tag}.sql`), "utf-8"); 30 - const statements = sql.split("--> statement-breakpoint"); 31 - for (const stmt of statements) { 32 - const trimmed = stmt.trim(); 33 - if (trimmed) sqlite.run(trimmed); 27 + const journal = JSON.parse(readFileSync(resolve(MIGRATIONS_DIR, "meta/_journal.json"), "utf-8")); 28 + for (const entry of journal.entries) { 29 + const sql = readFileSync(resolve(MIGRATIONS_DIR, `${entry.tag}.sql`), "utf-8"); 30 + const statements = sql.split("--> statement-breakpoint"); 31 + for (const stmt of statements) { 32 + const trimmed = stmt.trim(); 33 + if (trimmed) db.run(trimmed); 34 + } 34 35 } 36 + return db; 35 37 } 36 38 37 - // ---- Seed data ---- 39 + // ---- Shared test DIDs ---- 38 40 39 41 const OWNER_DID = "did:plc:e2e-test-owner"; 40 42 const MEMBER_DID = "did:plc:e2e-test-member"; 41 - const SPHERE_ID = "e2e-sphere-001"; 42 43 43 - // Sphere 44 - sqlite.run( 45 - `INSERT INTO spheres (id, slug, name, description, visibility, write_access, owner_did) 46 - VALUES (?, ?, ?, ?, 'public', 'open', ?)`, 47 - [SPHERE_ID, "test-sphere", "Test Sphere", "A sphere for E2E testing", OWNER_DID], 48 - ); 44 + // ============================================================ 45 + // Self-hosted database — single sphere 46 + // ============================================================ 47 + { 48 + const db = createDatabase(`${E2E_DB_DIR}/self-hosted.sqlite`); 49 49 50 - // Owner membership 51 - sqlite.run( 52 - `INSERT INTO sphere_members (sphere_id, did, role, status) 53 - VALUES (?, ?, 'owner', 'active')`, 54 - [SPHERE_ID, OWNER_DID], 55 - ); 50 + const SPHERE_ID = "e2e-sphere-001"; 56 51 57 - // Enable feature-requests module 58 - sqlite.run( 59 - `INSERT INTO sphere_modules (sphere_id, module_name) 60 - VALUES (?, 'feature-requests')`, 61 - [SPHERE_ID], 62 - ); 52 + db.run( 53 + `INSERT INTO spheres (id, handle, name, description, visibility, write_access, owner_did) 54 + VALUES (?, ?, ?, ?, 'public', 'open', ?)`, 55 + [SPHERE_ID, "test.bsky.social", "Test Sphere", "A sphere for E2E testing", OWNER_DID], 56 + ); 63 57 64 - // Feature requests 65 - const featureRequests = [ 66 - { 67 - id: "fr-001", 68 - number: 1, 69 - title: "Add dark mode support", 70 - description: "It would be great to have a dark mode option for better readability at night.", 71 - category: "enhancement", 72 - status: "approved", 73 - authorDid: MEMBER_DID, 74 - }, 75 - { 76 - id: "fr-002", 77 - number: 2, 78 - title: "Export data as CSV", 79 - description: "Allow users to export their data in CSV format for analysis.", 80 - category: "general", 81 - status: "requested", 82 - authorDid: OWNER_DID, 83 - }, 84 - { 85 - id: "fr-003", 86 - number: 3, 87 - title: "Mobile app", 88 - description: "A native mobile application would improve the user experience significantly.", 89 - category: "general", 90 - status: "requested", 91 - authorDid: MEMBER_DID, 92 - }, 93 - { 94 - id: "fr-004", 95 - number: 4, 96 - title: "Keyboard shortcuts", 97 - description: "Add keyboard shortcuts for common actions like voting and navigation.", 98 - category: "enhancement", 99 - status: "done", 100 - authorDid: OWNER_DID, 101 - }, 102 - { 103 - id: "fr-005", 104 - number: 5, 105 - title: "Windows phone support", 106 - description: "Please add support for Windows Phone platform.", 107 - category: "general", 108 - status: "not-planned", 109 - authorDid: MEMBER_DID, 110 - }, 111 - ]; 58 + db.run( 59 + `INSERT INTO sphere_members (sphere_id, did, role, status) 60 + VALUES (?, ?, 'owner', 'active')`, 61 + [SPHERE_ID, OWNER_DID], 62 + ); 112 63 113 - for (const fr of featureRequests) { 114 - sqlite.run( 115 - `INSERT INTO feature_requests (id, sphere_id, number, title, description, category, status, author_did) 116 - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, 117 - [fr.id, SPHERE_ID, fr.number, fr.title, fr.description, fr.category, fr.status, fr.authorDid], 64 + db.run( 65 + `INSERT INTO sphere_modules (sphere_id, module_name) 66 + VALUES (?, 'feature-requests')`, 67 + [SPHERE_ID], 118 68 ); 69 + 70 + const featureRequests = [ 71 + { id: "fr-001", number: 1, title: "Add dark mode support", description: "It would be great to have a dark mode option for better readability at night.", category: "enhancement", status: "approved", authorDid: MEMBER_DID }, 72 + { id: "fr-002", number: 2, title: "Export data as CSV", description: "Allow users to export their data in CSV format for analysis.", category: "general", status: "requested", authorDid: OWNER_DID }, 73 + { id: "fr-003", number: 3, title: "Mobile app", description: "A native mobile application would improve the user experience significantly.", category: "general", status: "requested", authorDid: MEMBER_DID }, 74 + { id: "fr-004", number: 4, title: "Keyboard shortcuts", description: "Add keyboard shortcuts for common actions like voting and navigation.", category: "enhancement", status: "done", authorDid: OWNER_DID }, 75 + { id: "fr-005", number: 5, title: "Windows phone support", description: "Please add support for Windows Phone platform.", category: "general", status: "not-planned", authorDid: MEMBER_DID }, 76 + ]; 77 + 78 + for (const fr of featureRequests) { 79 + db.run( 80 + `INSERT INTO feature_requests (id, sphere_id, number, title, description, category, status, author_did) 81 + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, 82 + [fr.id, SPHERE_ID, fr.number, fr.title, fr.description, fr.category, fr.status, fr.authorDid], 83 + ); 84 + } 85 + 86 + db.run(`INSERT INTO feature_request_votes (request_id, author_did) VALUES (?, ?)`, ["fr-001", OWNER_DID]); 87 + db.run(`INSERT INTO feature_request_votes (request_id, author_did) VALUES (?, ?)`, ["fr-001", MEMBER_DID]); 88 + db.run(`INSERT INTO feature_request_votes (request_id, author_did) VALUES (?, ?)`, ["fr-002", MEMBER_DID]); 89 + 90 + db.run( 91 + `INSERT INTO feature_request_comments (id, request_id, author_did, content) VALUES (?, ?, ?, ?)`, 92 + ["comment-001", "fr-001", OWNER_DID, "Great idea! We should prioritize this."], 93 + ); 94 + db.run( 95 + `INSERT INTO feature_request_comments (id, request_id, author_did, content) VALUES (?, ?, ?, ?)`, 96 + ["comment-002", "fr-001", MEMBER_DID, "I agree, dark mode would be very useful."], 97 + ); 98 + 99 + db.close(); 100 + console.log("[e2e-seed] Self-hosted database seeded"); 119 101 } 120 102 121 - // Votes for fr-001 (2 votes) and fr-002 (1 vote) 122 - sqlite.run(`INSERT INTO feature_request_votes (request_id, author_did) VALUES (?, ?)`, [ 123 - "fr-001", 124 - OWNER_DID, 125 - ]); 126 - sqlite.run(`INSERT INTO feature_request_votes (request_id, author_did) VALUES (?, ?)`, [ 127 - "fr-001", 128 - MEMBER_DID, 129 - ]); 130 - sqlite.run(`INSERT INTO feature_request_votes (request_id, author_did) VALUES (?, ?)`, [ 131 - "fr-002", 132 - MEMBER_DID, 133 - ]); 103 + // ============================================================ 104 + // Multi-sphere database — two spheres with separate data 105 + // ============================================================ 106 + { 107 + const db = createDatabase(`${E2E_DB_DIR}/multi-sphere.sqlite`); 134 108 135 - // Comments on fr-001 136 - sqlite.run( 137 - `INSERT INTO feature_request_comments (id, request_id, author_did, content) 138 - VALUES (?, ?, ?, ?)`, 139 - ["comment-001", "fr-001", OWNER_DID, "Great idea! We should prioritize this."], 140 - ); 141 - sqlite.run( 142 - `INSERT INTO feature_request_comments (id, request_id, author_did, content) 143 - VALUES (?, ?, ?, ?)`, 144 - ["comment-002", "fr-001", MEMBER_DID, "I agree, dark mode would be very useful."], 145 - ); 109 + // ---- Sphere A: alpha.test ---- 146 110 147 - sqlite.close(); 111 + db.run( 112 + `INSERT INTO spheres (id, handle, name, description, visibility, write_access, owner_did) 113 + VALUES (?, ?, ?, ?, 'public', 'open', ?)`, 114 + ["sphere-alpha", "alpha.test", "Alpha Sphere", "First test sphere", OWNER_DID], 115 + ); 116 + db.run( 117 + `INSERT INTO sphere_members (sphere_id, did, role, status) VALUES (?, ?, 'owner', 'active')`, 118 + ["sphere-alpha", OWNER_DID], 119 + ); 120 + db.run( 121 + `INSERT INTO sphere_modules (sphere_id, module_name) VALUES (?, 'feature-requests')`, 122 + ["sphere-alpha"], 123 + ); 148 124 149 - console.log(`[e2e-seed] Database seeded at ${E2E_DB_PATH}`); 125 + // Alpha feature requests (#1-#4 — per-sphere numbering, covers all status tabs) 126 + const alphaRequests = [ 127 + { id: "alpha-fr-001", number: 1, title: "Alpha dark mode", description: "Dark mode for Alpha sphere.", category: "enhancement", status: "approved", authorDid: OWNER_DID }, 128 + { id: "alpha-fr-002", number: 2, title: "Alpha CSV export", description: "CSV export for Alpha sphere.", category: "general", status: "requested", authorDid: MEMBER_DID }, 129 + { id: "alpha-fr-003", number: 3, title: "Alpha done feature", description: "A completed feature.", category: "general", status: "done", authorDid: OWNER_DID }, 130 + { id: "alpha-fr-004", number: 4, title: "Alpha rejected idea", description: "A rejected feature.", category: "general", status: "not-planned", authorDid: MEMBER_DID }, 131 + ]; 132 + 133 + for (const fr of alphaRequests) { 134 + db.run( 135 + `INSERT INTO feature_requests (id, sphere_id, number, title, description, category, status, author_did) 136 + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, 137 + [fr.id, "sphere-alpha", fr.number, fr.title, fr.description, fr.category, fr.status, fr.authorDid], 138 + ); 139 + } 140 + 141 + db.run(`INSERT INTO feature_request_votes (request_id, author_did) VALUES (?, ?)`, ["alpha-fr-001", OWNER_DID]); 142 + db.run(`INSERT INTO feature_request_votes (request_id, author_did) VALUES (?, ?)`, ["alpha-fr-001", MEMBER_DID]); 143 + 144 + db.run( 145 + `INSERT INTO feature_request_comments (id, request_id, author_did, content) VALUES (?, ?, ?, ?)`, 146 + ["alpha-comment-001", "alpha-fr-001", OWNER_DID, "Alpha sphere comment on dark mode."], 147 + ); 148 + 149 + // ---- Sphere B: beta.test ---- 150 + 151 + db.run( 152 + `INSERT INTO spheres (id, handle, name, description, visibility, write_access, owner_did) 153 + VALUES (?, ?, ?, ?, 'public', 'open', ?)`, 154 + ["sphere-beta", "beta.test", "Beta Sphere", "Second test sphere", MEMBER_DID], 155 + ); 156 + db.run( 157 + `INSERT INTO sphere_members (sphere_id, did, role, status) VALUES (?, ?, 'owner', 'active')`, 158 + ["sphere-beta", MEMBER_DID], 159 + ); 160 + db.run( 161 + `INSERT INTO sphere_modules (sphere_id, module_name) VALUES (?, 'feature-requests')`, 162 + ["sphere-beta"], 163 + ); 164 + 165 + // Beta feature requests (#1-#2 — independent numbering from Alpha) 166 + const betaRequests = [ 167 + { id: "beta-fr-001", number: 1, title: "Beta mobile app", description: "Mobile app for Beta sphere.", category: "general", status: "requested", authorDid: MEMBER_DID }, 168 + { id: "beta-fr-002", number: 2, title: "Beta API access", description: "Public API for Beta sphere.", category: "enhancement", status: "approved", authorDid: OWNER_DID }, 169 + ]; 170 + 171 + for (const fr of betaRequests) { 172 + db.run( 173 + `INSERT INTO feature_requests (id, sphere_id, number, title, description, category, status, author_did) 174 + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, 175 + [fr.id, "sphere-beta", fr.number, fr.title, fr.description, fr.category, fr.status, fr.authorDid], 176 + ); 177 + } 178 + 179 + db.run(`INSERT INTO feature_request_votes (request_id, author_did) VALUES (?, ?)`, ["beta-fr-001", MEMBER_DID]); 180 + 181 + db.close(); 182 + console.log("[e2e-seed] Multi-sphere database seeded"); 183 + }
packages/app/e2e/tests/feature-requests.spec.ts packages/app/e2e/tests/self-hosted/feature-requests.spec.ts
+133
packages/app/e2e/tests/multi-sphere/feature-requests.spec.ts
··· 1 + import { test, expect } from "@playwright/test"; 2 + 3 + // In multi-sphere mode, all sphere pages are under /s/:handle 4 + const ALPHA = "/s/alpha.test"; 5 + const BETA = "/s/beta.test"; 6 + 7 + test.describe("Multi-sphere: Alpha sphere feature requests", () => { 8 + test("renders active requests for Alpha sphere", async ({ page }) => { 9 + await page.goto(`${ALPHA}/infuse`); 10 + 11 + await expect(page.getByRole("heading", { name: "Infuse" })).toBeVisible(); 12 + 13 + // Alpha's active feature requests (approved + requested) 14 + await expect(page.getByRole("link", { name: /Alpha dark mode/ })).toBeVisible(); 15 + await expect(page.getByRole("link", { name: /Alpha CSV export/ })).toBeVisible(); 16 + 17 + // Alpha's completed feature request should NOT appear on active tab 18 + await expect(page.getByRole("link", { name: /Alpha done feature/ })).not.toBeVisible(); 19 + 20 + // Beta's feature requests should NOT appear 21 + await expect(page.getByRole("link", { name: /Beta mobile app/ })).not.toBeVisible(); 22 + await expect(page.getByRole("link", { name: /Beta API access/ })).not.toBeVisible(); 23 + }); 24 + 25 + test("done tab shows completed requests for Alpha", async ({ page }) => { 26 + await page.goto(`${ALPHA}/infuse/done`); 27 + 28 + await expect(page.getByRole("link", { name: /Alpha done feature/ })).toBeVisible(); 29 + 30 + // Active requests should not appear 31 + await expect(page.getByRole("link", { name: /Alpha dark mode/ })).not.toBeVisible(); 32 + }); 33 + 34 + test("not-planned tab shows rejected requests for Alpha", async ({ page }) => { 35 + await page.goto(`${ALPHA}/infuse/not-planned`); 36 + 37 + await expect(page.getByRole("link", { name: /Alpha rejected idea/ })).toBeVisible(); 38 + 39 + // Active requests should not appear 40 + await expect(page.getByRole("link", { name: /Alpha dark mode/ })).not.toBeVisible(); 41 + }); 42 + 43 + test("renders feature request detail with comments", async ({ page }) => { 44 + await page.goto(`${ALPHA}/infuse/1`); 45 + 46 + await expect(page.getByRole("heading", { name: /Alpha dark mode/ })).toBeVisible(); 47 + await expect(page.getByText("Alpha sphere comment on dark mode.")).toBeVisible(); 48 + }); 49 + 50 + test("renders vote counts", async ({ page }) => { 51 + await page.goto(`${ALPHA}/infuse`); 52 + 53 + await expect(page.getByRole("link", { name: /Alpha dark mode/ })).toBeVisible(); 54 + 55 + const voteElements = page.getByText(/△\d/); 56 + await expect(voteElements.first()).toBeVisible(); 57 + }); 58 + }); 59 + 60 + test.describe("Multi-sphere: Beta sphere feature requests", () => { 61 + test("renders active requests for Beta sphere", async ({ page }) => { 62 + await page.goto(`${BETA}/infuse`); 63 + 64 + await expect(page.getByRole("heading", { name: "Infuse" })).toBeVisible(); 65 + 66 + // Beta's active feature requests 67 + await expect(page.getByRole("link", { name: /Beta mobile app/ })).toBeVisible(); 68 + await expect(page.getByRole("link", { name: /Beta API access/ })).toBeVisible(); 69 + 70 + // Alpha's feature requests should NOT appear 71 + await expect(page.getByRole("link", { name: /Alpha dark mode/ })).not.toBeVisible(); 72 + await expect(page.getByRole("link", { name: /Alpha CSV export/ })).not.toBeVisible(); 73 + }); 74 + 75 + test("renders feature request detail for Beta", async ({ page }) => { 76 + await page.goto(`${BETA}/infuse/1`); 77 + 78 + // Beta's FR #1 (independent numbering) 79 + await expect(page.getByRole("heading", { name: /Beta mobile app/ })).toBeVisible(); 80 + }); 81 + }); 82 + 83 + test.describe("Multi-sphere: sphere isolation", () => { 84 + test("same FR number resolves to different content per sphere", async ({ page }) => { 85 + // FR #1 in Alpha = "Alpha dark mode" 86 + await page.goto(`${ALPHA}/infuse/1`); 87 + await expect(page.getByRole("heading", { name: /Alpha dark mode/ })).toBeVisible(); 88 + 89 + // FR #1 in Beta = "Beta mobile app" 90 + await page.goto(`${BETA}/infuse/1`); 91 + await expect(page.getByRole("heading", { name: /Beta mobile app/ })).toBeVisible(); 92 + }); 93 + 94 + test("header shows sphere name", async ({ page }) => { 95 + await page.goto(`${ALPHA}/infuse`); 96 + await expect(page.getByRole("link", { name: "Alpha Sphere" })).toBeVisible(); 97 + 98 + await page.goto(`${BETA}/infuse`); 99 + await expect(page.getByRole("link", { name: "Beta Sphere" })).toBeVisible(); 100 + }); 101 + }); 102 + 103 + test.describe("Multi-sphere: navigation", () => { 104 + test("navigates from list to detail within a sphere", async ({ page }) => { 105 + await page.goto(`${ALPHA}/infuse`); 106 + 107 + await page.getByRole("link", { name: /Alpha dark mode/ }).click(); 108 + 109 + await expect(page).toHaveURL(/\/s\/alpha\.test\/infuse\/1/); 110 + await expect(page.getByText("Alpha sphere comment on dark mode.")).toBeVisible(); 111 + }); 112 + 113 + test("navigates between tabs within a sphere", async ({ page }) => { 114 + await page.goto(`${ALPHA}/infuse`); 115 + 116 + await page.getByRole("link", { name: "Done" }).click(); 117 + await expect(page).toHaveURL(/\/s\/alpha\.test\/infuse\/done/); 118 + await expect(page.getByRole("link", { name: /Alpha done feature/ })).toBeVisible(); 119 + 120 + await page.getByRole("link", { name: "Requests" }).click(); 121 + await expect(page).toHaveURL(/\/s\/alpha\.test\/infuse/); 122 + await expect(page.getByRole("link", { name: /Alpha dark mode/ })).toBeVisible(); 123 + }); 124 + 125 + test("root page shows sign-in without sphere context", async ({ page }) => { 126 + await page.goto("/"); 127 + 128 + // In multi-sphere mode, unauthenticated root shows sign-in — no sphere loaded 129 + await expect(page.getByRole("link", { name: "Sign in" })).toBeVisible(); 130 + // Header should show generic "Exosphere" — not a specific sphere name 131 + await expect(page.getByRole("link", { name: "Exosphere", exact: true })).toBeVisible(); 132 + }); 133 + });
+6 -7
packages/app/src/api/spheres.ts
··· 9 9 available: string[]; 10 10 } 11 11 12 - export function getSphereModules(slug: string) { 13 - return apiFetch<ModulesData>(`/api/spheres/${encodeURIComponent(slug)}/modules`); 12 + export function getSphereModules(handle: string) { 13 + return apiFetch<ModulesData>(`/api/spheres/${encodeURIComponent(handle)}/modules`); 14 14 } 15 15 16 16 export function createSphere(body: { 17 17 name: string; 18 - slug?: string; 19 18 description?: string; 20 19 visibility: "public" | "private"; 21 20 writeAccess: "open" | "members"; ··· 27 26 }); 28 27 } 29 28 30 - export function enableModule(slug: string, moduleName: string) { 29 + export function enableModule(handle: string, moduleName: string) { 31 30 return apiFetch<{ modules: SphereModuleInfo[] }>( 32 - `/api/spheres/${encodeURIComponent(slug)}/modules`, 31 + `/api/spheres/${encodeURIComponent(handle)}/modules`, 33 32 { 34 33 method: "POST", 35 34 headers: { "Content-Type": "application/json" }, ··· 38 37 ); 39 38 } 40 39 41 - export function disableModule(slug: string, moduleName: string) { 40 + export function disableModule(handle: string, moduleName: string) { 42 41 return apiFetch<{ modules: SphereModuleInfo[] }>( 43 - `/api/spheres/${encodeURIComponent(slug)}/modules/${encodeURIComponent(moduleName)}`, 42 + `/api/spheres/${encodeURIComponent(handle)}/modules/${encodeURIComponent(moduleName)}`, 44 43 { method: "DELETE" }, 45 44 ); 46 45 }
+10 -11
packages/app/src/app.tsx
··· 3 3 import { auth, logout } from "@exosphere/client/auth"; 4 4 import { useLocation, useRoute } from "@exosphere/client/router"; 5 5 import { spherePath } from "@exosphere/client/router"; 6 - import { sphereState, sphereSlug, loadSphere } from "@exosphere/client/sphere"; 6 + import { sphereState, sphereHandle, loadSphere } from "@exosphere/client/sphere"; 7 7 import { isMultiSphere } from "@exosphere/client/config"; 8 8 import * as ui from "@exosphere/client/ui.css"; 9 9 import { ThemeToggle } from "@exosphere/client/components/theme-toggle"; ··· 67 67 return auth.value.authenticated ? <Dashboard /> : <SignInPage />; 68 68 } 69 69 70 - /** Watches the :sphereSlug route param and reloads sphere data when it changes. */ 70 + /** Watches the :sphereHandle route param and reloads sphere data when it changes. */ 71 71 function SphereLoader() { 72 72 const { params } = useRoute(); 73 - const currentSlug = sphereSlug.value; 74 73 useEffect(() => { 75 - const urlSlug = params.sphereSlug; 76 - if (urlSlug && urlSlug !== currentSlug) { 77 - loadSphere(urlSlug); 74 + const urlHandle = params.sphereHandle; 75 + if (urlHandle && urlHandle !== sphereHandle.peek()) { 76 + loadSphere(urlHandle); 78 77 } 79 - }, [params.sphereSlug]); 78 + }, [params.sphereHandle]); 80 79 return null; 81 80 } 82 81 83 - /** Wraps a component with SphereLoader to reload sphere data when :sphereSlug changes. */ 82 + /** Wraps a component with SphereLoader to reload sphere data when :sphereHandle changes. */ 84 83 function withSphereLoader(Component: ComponentType) { 85 84 return function SphereRoute() { 86 85 return ( ··· 92 91 }; 93 92 } 94 93 95 - /** Build routes with /s/:sphereSlug prefix for multi-sphere mode. */ 94 + /** Build routes with /s/:sphereHandle prefix for multi-sphere mode. */ 96 95 function buildRoutes(moduleRoutes: ModuleRoute[]): ModuleRoute[] { 97 96 return moduleRoutes.map((r) => ({ 98 97 ...r, 99 - path: `/s/:sphereSlug${r.path}`, 98 + path: `/s/:sphereHandle${r.path}`, 100 99 component: withSphereLoader(r.component), 101 100 })); 102 101 } ··· 111 110 <Route path="/sign-in" component={SignInPage} /> 112 111 <Route path="/spheres/new" component={CreateSphereGuarded} /> 113 112 {routes.map((r) => <Route key={r.path} path={r.path} component={r.component} />)} 114 - <Route path="/s/:sphereSlug" component={withSphereLoader(SpherePage)} /> 113 + <Route path="/s/:sphereHandle" component={withSphereLoader(SpherePage)} /> 115 114 <Route path="/" component={MultiSphereDefaultPage} /> 116 115 <Route default component={MultiSphereDefaultPage} /> 117 116 </Router>
+3 -3
packages/app/src/client.tsx
··· 5 5 loadSphere, 6 6 refreshSphere, 7 7 sphereState, 8 - getSphereSlugFromUrl, 8 + getSphereHandleFromUrl, 9 9 } from "@exosphere/client/sphere"; 10 10 import { ssrPageData } from "@exosphere/client/ssr-data"; 11 11 import { setMultiSphere } from "@exosphere/client/config"; ··· 43 43 } else { 44 44 // Fallback: no SSR data (dev without SSR), behave as before 45 45 checkSession(); 46 - const slug = getSphereSlugFromUrl(); 47 - loadSphere(slug ?? undefined); 46 + const handle = getSphereHandleFromUrl(); 47 + loadSphere(handle ?? undefined); 48 48 } 49 49 50 50 hydrate(<App />, document.getElementById("app")!);
+9 -19
packages/app/src/pages/create-sphere.tsx
··· 2 2 import { useLocation } from "@exosphere/client/router"; 3 3 import { loadSphere } from "@exosphere/client/sphere"; 4 4 import { isMultiSphere } from "@exosphere/client/config"; 5 + import { auth } from "@exosphere/client/auth"; 5 6 import * as ui from "@exosphere/client/ui.css"; 6 7 import { createSphere } from "../api/spheres.ts"; 7 8 8 9 export function CreateSphere() { 9 10 const name = useSignal(""); 10 - const slug = useSignal(""); 11 11 const description = useSignal(""); 12 12 const visibility = useSignal("public"); 13 13 const writeAccess = useSignal("open"); ··· 30 30 visibility: visibility.value, 31 31 writeAccess: writeAccess.value, 32 32 }; 33 - if (slug.value.trim()) body.slug = slug.value.trim(); 34 33 if (description.value.trim()) body.description = description.value.trim(); 35 34 36 35 try { 37 36 const res = await createSphere(body as Parameters<typeof createSphere>[0]); 38 37 if (isMultiSphere) { 39 38 // In multi-sphere mode, navigate to the new sphere directly 40 - const newSlug = res.sphere.slug; 41 - await loadSphere(newSlug); 42 - route(`/s/${newSlug}`); 39 + const newHandle = res.sphere.handle; 40 + await loadSphere(newHandle); 41 + route(`/s/${newHandle}`); 43 42 } else { 44 43 await loadSphere(); 45 44 route("/"); ··· 67 66 value={name.value} 68 67 onInput={(e) => (name.value = (e.target as HTMLInputElement).value)} 69 68 /> 70 - </div> 71 - 72 - <div> 73 - <label class={ui.label} htmlFor="slug"> 74 - Slug <span class={ui.labelHint}>(optional — auto-generated from name)</span> 75 - </label> 76 - <input 77 - id="slug" 78 - class={ui.input} 79 - type="text" 80 - placeholder="my-community" 81 - value={slug.value} 82 - onInput={(e) => (slug.value = (e.target as HTMLInputElement).value)} 83 - /> 69 + <p class={ui.muted} style="margin-block-start: 0.5rem"> 70 + Your sphere will be accessible at{" "} 71 + {isMultiSphere ? `${location.origin}/s/${auth.value.handle}` : location.origin}.<br /> 72 + You should create it with an organization handle, not a personal one. 73 + </p> 84 74 </div> 85 75 86 76 <div>
+2 -2
packages/app/src/pages/dashboard.tsx
··· 4 4 5 5 interface SphereListItem { 6 6 id: string; 7 - slug: string; 7 + handle: string; 8 8 name: string; 9 9 description: string | null; 10 10 visibility: string; ··· 40 40 ) : ( 41 41 <div class={ui.stackSm}> 42 42 {data.spheres.map((sphere) => ( 43 - <a key={sphere.id} href={`/s/${sphere.slug}`} class={ui.cardLink}> 43 + <a key={sphere.id} href={`/s/${sphere.handle}`} class={ui.cardLink}> 44 44 <div class={ui.row}> 45 45 <strong>{sphere.name}</strong> 46 46 <span class={ui.badge}>{sphere.visibility}</span>
+14 -13
packages/app/src/pages/sphere.tsx
··· 1 1 import { auth } from "@exosphere/client/auth"; 2 - import { sphereState, sphereSlug, refreshSphere } from "@exosphere/client/sphere"; 2 + import { sphereState, sphereHandle, refreshSphere } from "@exosphere/client/sphere"; 3 3 import { useQuery } from "@exosphere/client/hooks"; 4 4 import { spherePath } from "@exosphere/client/router"; 5 5 import * as ui from "@exosphere/client/ui.css"; ··· 11 11 12 12 /** Map internal module names to user-facing labels (used for display and URL paths). */ 13 13 const moduleLabels: Record<string, { label: string; path: string; description: string }> = { 14 - feeds: { 15 - label: "Feeds", 16 - path: "feeds", 17 - description: "Discuss topics and share updates with the community.", 18 - }, 14 + // feeds: { 15 + // label: "Feeds", 16 + // path: "feeds", 17 + // description: "Discuss topics and share updates with the community.", 18 + // }, 19 19 "feature-requests": { 20 20 label: "Infuse", 21 21 path: "infuse", ··· 25 25 26 26 export function SpherePage() { 27 27 const { data } = sphereState.value; 28 - const slug = sphereSlug.value!; 29 - const modules = useQuery(() => getSphereModules(slug), [slug]); 28 + const handle = sphereHandle.value; 29 + 30 + if (!data || !handle) return null; 31 + 32 + const modules = useQuery(() => getSphereModules(handle), [handle]); 30 33 31 34 const isAdminOrOwner = () => { 32 - if (!auth.value.authenticated || !data) return false; 35 + if (!auth.value.authenticated) return false; 33 36 const role = data.role; 34 37 if (role === "owner" || role === "admin") return true; 35 38 return data.sphere.ownerDid === auth.value.did; 36 39 }; 37 40 38 41 const enableModule = async (moduleName: string) => { 39 - await apiEnableModule(slug, moduleName); 42 + await apiEnableModule(handle, moduleName); 40 43 modules.refetch(); 41 44 refreshSphere(); 42 45 }; 43 46 44 47 const disableModule = async (moduleName: string) => { 45 - await apiDisableModule(slug, moduleName); 48 + await apiDisableModule(handle, moduleName); 46 49 modules.refetch(); 47 50 refreshSphere(); 48 51 }; 49 - 50 - if (!data) return null; 51 52 52 53 const { sphere: s, modules: enabledModules, memberCount } = data; 53 54 const enabledNames = enabledModules.map((m) => m.name);
+11 -11
packages/app/src/server.ts
··· 17 17 // Register modules — sphere context is injected by middleware in both modes 18 18 if (isMultiSphere) { 19 19 for (const mod of modules) { 20 - app.use(`/api/s/:sphereSlug/${mod.name}/*`, sphereContext); 21 - app.route(`/api/s/:sphereSlug/${mod.name}`, mod.api); 20 + app.use(`/api/s/:sphereHandle/${mod.name}/*`, sphereContext); 21 + app.route(`/api/s/:sphereHandle/${mod.name}`, mod.api); 22 22 } 23 23 } else { 24 24 for (const mod of modules) { ··· 119 119 } 120 120 121 121 // Load sphere data 122 - // In multi-sphere mode, extract slug from URL path /s/:slug/... 122 + // In multi-sphere mode, extract handle from URL path /s/:handle/... 123 123 // In self-hosted mode, load the single sphere 124 124 let sphere; 125 - let sphereSlugFromUrl: string | undefined; 125 + let sphereHandleFromUrl: string | undefined; 126 126 try { 127 127 if (isMultiSphere) { 128 - const slugMatch = c.req.path.match(/^\/s\/([^/]+)/); 129 - if (slugMatch) { 130 - sphereSlugFromUrl = slugMatch[1]; 131 - sphere = getCurrentSphere(authData.did, sphereSlugFromUrl); 128 + const handleMatch = c.req.path.match(/^\/s\/([^/]+)/); 129 + if (handleMatch) { 130 + sphereHandleFromUrl = handleMatch[1]; 131 + sphere = getCurrentSphere(authData.did, sphereHandleFromUrl); 132 132 } 133 - // No slug in URL = dashboard/sign-in page — no sphere to load 133 + // No handle in URL = dashboard/sign-in page — no sphere to load 134 134 } else { 135 135 sphere = getCurrentSphere(authData.did); 136 136 } ··· 141 141 // Prefetch page data by calling our own API routes internally 142 142 const pageData: Record<string, unknown> = {}; 143 143 if (sphere) { 144 - const prefetches = ssrPrefetch(c.req.path, sphereSlugFromUrl); 144 + const prefetches = ssrPrefetch(c.req.path, sphereHandleFromUrl); 145 145 for (const prefetch of prefetches) { 146 146 try { 147 147 const res = await app.request(prefetch.apiUrl); ··· 152 152 } 153 153 154 154 // For individual feature requests, also prefetch comments 155 - const apiBase = sphereSlugFromUrl ? `/api/s/${sphereSlugFromUrl}` : "/api"; 155 + const apiBase = sphereHandleFromUrl ? `/api/s/${sphereHandleFromUrl}` : "/api"; 156 156 const frData = pageData["feature-request"] as 157 157 | { featureRequest?: { id: string } } 158 158 | undefined;
+8 -5
packages/app/src/ssr-prefetch.ts
··· 1 1 /** Map a page path to prefetch descriptors for SSR data loading. 2 - * When `sphereSlug` is provided, API URLs use the multi-sphere prefix. */ 3 - export function ssrPrefetch(path: string, sphereSlug?: string): { key: string; apiUrl: string }[] { 4 - const apiBase = sphereSlug ? `/api/s/${sphereSlug}` : "/api"; 2 + * When `sphereHandle` is provided, API URLs use the multi-sphere prefix. */ 3 + export function ssrPrefetch( 4 + path: string, 5 + sphereHandle?: string, 6 + ): { key: string; apiUrl: string }[] { 7 + const apiBase = sphereHandle ? `/api/s/${sphereHandle}` : "/api"; 5 8 6 - // In multi-sphere mode, strip the /s/:slug prefix to match module paths 7 - const modulePath = sphereSlug ? path.replace(/^\/s\/[^/]+/, "") : path; 9 + // In multi-sphere mode, strip the /s/:handle prefix to match module paths 10 + const modulePath = sphereHandle ? path.replace(/^\/s\/[^/]+/, "") : path; 8 11 9 12 if (modulePath === "/infuse") 10 13 return [
+10 -10
packages/app/src/vite-ssr-plugin.ts
··· 80 80 authenticated: false, 81 81 }; 82 82 let sphereData: unknown = null; 83 - let sphereSlugFromUrl: string | undefined; 83 + let sphereHandleFromUrl: string | undefined; 84 84 try { 85 - // In multi-sphere mode, extract slug from URL path /s/:slug/... 85 + // In multi-sphere mode, extract handle from URL path /s/:handle/... 86 86 if (isMultiSphere) { 87 - const slugMatch = url.match(/^\/s\/([^/]+)/); 88 - if (slugMatch) sphereSlugFromUrl = slugMatch[1]; 87 + const handleMatch = url.match(/^\/s\/([^/]+)/); 88 + if (handleMatch) sphereHandleFromUrl = handleMatch[1]; 89 89 } 90 - // In multi-sphere mode without a slug, this is the dashboard — no sphere to load 91 - const sphereUrl = sphereSlugFromUrl 92 - ? `${API_SERVER}/api/spheres/${sphereSlugFromUrl}` 90 + // In multi-sphere mode without a handle, this is the dashboard — no sphere to load 91 + const sphereUrl = sphereHandleFromUrl 92 + ? `${API_SERVER}/api/spheres/${sphereHandleFromUrl}` 93 93 : isMultiSphere 94 94 ? null 95 95 : `${API_SERVER}/api/spheres/current`; ··· 116 116 // Prefetch page data from the API server 117 117 const pageData: Record<string, unknown> = {}; 118 118 if (sphereData) { 119 - const prefetches = ssrPrefetch(url, sphereSlugFromUrl); 119 + const prefetches = ssrPrefetch(url, sphereHandleFromUrl); 120 120 for (const prefetch of prefetches) { 121 121 try { 122 122 const res = await fetch(`${API_SERVER}${prefetch.apiUrl}`, { headers: { cookie } }); ··· 127 127 } 128 128 129 129 // For individual feature requests, also prefetch comments 130 - const apiBase = sphereSlugFromUrl 131 - ? `${API_SERVER}/api/s/${sphereSlugFromUrl}` 130 + const apiBase = sphereHandleFromUrl 131 + ? `${API_SERVER}/api/s/${sphereHandleFromUrl}` 132 132 : `${API_SERVER}/api`; 133 133 const frData = pageData["feature-request"] as 134 134 | { featureRequest?: { id: string } }
+3 -2
packages/app/vite.config.ts
··· 23 23 } 24 24 25 25 export default defineConfig(async ({ mode }) => { 26 - // Load env from monorepo root (where .env lives) with empty prefix to include all vars 26 + // Load env from monorepo root (where .env lives) with empty prefix to include all vars. 27 + // process.env.MULTI_SPHERE takes precedence (allows e2e builds to override the .env file). 27 28 const env = loadEnv(mode, ROOT, ""); 28 - const isMultiSphere = env.MULTI_SPHERE === "1"; 29 + const isMultiSphere = (process.env.MULTI_SPHERE ?? env.MULTI_SPHERE) === "1"; 29 30 30 31 const pdsIssuer = await getPdsIssuer(); 31 32
+1
packages/client/package.json
··· 4 4 "type": "module", 5 5 "exports": { 6 6 "./api": "./src/api.ts", 7 + "./module-api": "./src/module-api.ts", 7 8 "./auth": "./src/auth.ts", 8 9 "./router": "./src/router.tsx", 9 10 "./sphere": "./src/sphere.ts",
-16
packages/client/src/api.ts
··· 1 - import { isMultiSphere } from "./config.ts"; 2 - import { sphereSlug } from "./sphere.ts"; 3 - 4 1 export class ApiError extends Error { 5 2 constructor( 6 3 public status: number, ··· 21 18 } 22 19 return data as T; 23 20 } 24 - 25 - /** 26 - * Fetch a module API endpoint with automatic sphere-scoping. 27 - * `path` should start with the module name, e.g. "/feature-requests/123". 28 - * 29 - * - Multi-sphere mode: calls `/api/s/{sphereSlug}/{path}` 30 - * - Self-hosted mode: calls `/api/{path}` 31 - */ 32 - export function moduleFetch<T>(path: string, options?: RequestInit): Promise<T> { 33 - const slug = sphereSlug.value; 34 - const base = isMultiSphere && slug ? `/api/s/${slug}` : "/api"; 35 - return apiFetch<T>(`${base}${path}`, options); 36 - }
+6 -1
packages/client/src/config.ts
··· 1 1 /** Whether the app runs in multi-sphere (hosted) mode. 2 2 * Initialized at build time via Vite's `define` (works even without SSR). 3 - * Can be overridden per-request during SSR via `setMultiSphere()`. */ 3 + * Can be overridden per-request during SSR via `setMultiSphere()`. 4 + * 5 + * NOTE: This is module-level mutable state. In SSR, `setMultiSphere` is called 6 + * before each synchronous `prerender()`. This is safe because Bun is single-threaded 7 + * and the render is synchronous. If SSR ever becomes concurrent, this must be 8 + * replaced with a request-scoped context. */ 4 9 declare const __MULTI_SPHERE__: boolean; 5 10 export let isMultiSphere: boolean = 6 11 typeof __MULTI_SPHERE__ !== "undefined" ? __MULTI_SPHERE__ : false;
+16
packages/client/src/module-api.ts
··· 1 + import { isMultiSphere } from "./config.ts"; 2 + import { sphereHandle } from "./sphere.ts"; 3 + import { apiFetch } from "./api.ts"; 4 + 5 + /** 6 + * Fetch a module API endpoint with automatic sphere-scoping. 7 + * `path` should start with the module name, e.g. "/feature-requests/123". 8 + * 9 + * - Multi-sphere mode: calls `/api/s/{sphereHandle}/{path}` 10 + * - Self-hosted mode: calls `/api/{path}` 11 + */ 12 + export function moduleFetch<T>(path: string, options?: RequestInit): Promise<T> { 13 + const handle = sphereHandle.value; 14 + const base = isMultiSphere && handle ? `/api/s/${handle}` : "/api"; 15 + return apiFetch<T>(`${base}${path}`, options); 16 + }
+6 -6
packages/client/src/router.tsx
··· 4 4 export { default as lazy } from "preact-iso/lazy"; 5 5 6 6 import { isMultiSphere } from "./config.ts"; 7 - import { sphereSlug } from "./sphere.ts"; 7 + import { sphereHandle } from "./sphere.ts"; 8 8 9 9 /** Build a path with the sphere prefix in multi-sphere mode. 10 - * e.g. spherePath("/infuse") → "/s/my-team/infuse" (hosted) or "/infuse" (self-hosted) */ 10 + * e.g. spherePath("/infuse") → "/s/leaflet.pub/infuse" (hosted) or "/infuse" (self-hosted) */ 11 11 export function spherePath(path: string): string { 12 12 if (!isMultiSphere) return path; 13 - const slug = sphereSlug.value; 14 - if (!slug) return path; 15 - // Avoid trailing slash for root path: spherePath("/") → "/s/slug" not "/s/slug/" 16 - return path === "/" ? `/s/${slug}` : `/s/${slug}${path}`; 13 + const handle = sphereHandle.value; 14 + if (!handle) return path; 15 + // Avoid trailing slash for root path: spherePath("/") → "/s/handle" not "/s/handle/" 16 + return path === "/" ? `/s/${handle}` : `/s/${handle}${path}`; 17 17 }
+12 -12
packages/client/src/sphere.ts
··· 19 19 error: null, 20 20 }); 21 21 22 - export const sphereSlug = computed(() => sphereState.value.data?.sphere.slug ?? null); 22 + export const sphereHandle = computed(() => sphereState.value.data?.sphere.handle ?? null); 23 23 24 24 /** 25 - * Load a sphere. In multi-sphere mode, pass the slug from the URL. 26 - * In self-hosted mode (no slug), loads `/api/spheres/current`. 25 + * Load a sphere. In multi-sphere mode, pass the handle from the URL. 26 + * In self-hosted mode (no handle), loads `/api/spheres/current`. 27 27 */ 28 - export async function loadSphere(slug?: string) { 29 - // In multi-sphere mode with no slug, there's no sphere to load (dashboard page) 30 - if (isMultiSphere && !slug) { 28 + export async function loadSphere(handle?: string) { 29 + // In multi-sphere mode with no handle, there's no sphere to load (dashboard page) 30 + if (isMultiSphere && !handle) { 31 31 sphereState.value = { pending: false, loading: false, data: null, error: null }; 32 32 return; 33 33 } ··· 36 36 sphereState.value = { ...sphereState.value, loading: true }; 37 37 }, LOADING_DELAY); 38 38 try { 39 - const url = slug ? `/api/spheres/${slug}` : "/api/spheres/current"; 39 + const url = handle ? `/api/spheres/${handle}` : "/api/spheres/current"; 40 40 const data = await apiFetch<SphereData>(url); 41 41 clearTimeout(timer); 42 42 sphereState.value = { pending: false, loading: false, data, error: null }; ··· 53 53 54 54 /** Silent refresh — keeps existing data visible while fetching. */ 55 55 export async function refreshSphere() { 56 - const slug = sphereSlug.value; 56 + const handle = sphereHandle.value; 57 57 // In multi-sphere mode with no current sphere, nothing to refresh 58 - if (isMultiSphere && !slug) return; 58 + if (isMultiSphere && !handle) return; 59 59 try { 60 - const url = slug ? `/api/spheres/${slug}` : "/api/spheres/current"; 60 + const url = handle ? `/api/spheres/${handle}` : "/api/spheres/current"; 61 61 const data = await apiFetch<SphereData>(url); 62 62 sphereState.value = { pending: false, loading: false, data, error: null }; 63 63 } catch (err) { ··· 68 68 } 69 69 } 70 70 71 - /** Extract sphere slug from the current URL path in multi-sphere mode. */ 72 - export function getSphereSlugFromUrl(): string | null { 71 + /** Extract sphere handle from the current URL path in multi-sphere mode. */ 72 + export function getSphereHandleFromUrl(): string | null { 73 73 const match = location.pathname.match(/^\/s\/([^/]+)/); 74 74 return match?.[1] ?? null; 75 75 }
+6 -2
packages/core/src/__tests__/helpers/test-db.ts
··· 20 20 /** Insert a sphere with sensible defaults. Returns the inserted values. */ 21 21 export function seedSphere( 22 22 db: BetterSQLite3Database, 23 - overrides: Partial<typeof spheres.$inferInsert> & { id: string; slug: string; ownerDid: string }, 23 + overrides: Partial<typeof spheres.$inferInsert> & { 24 + id: string; 25 + handle: string; 26 + ownerDid: string; 27 + }, 24 28 ) { 25 29 const values = { 26 - name: overrides.slug, 30 + name: overrides.handle, 27 31 visibility: "public" as const, 28 32 writeAccess: "open" as const, 29 33 ...overrides,
+19 -19
packages/core/src/__tests__/sphere-operations.test.ts
··· 24 24 const OWNER_DID = "did:plc:owner1"; 25 25 const MEMBER_DID = "did:plc:member1"; 26 26 const SPHERE_ID = "sphere-1"; 27 - const SPHERE_SLUG = "test-sphere"; 27 + const SPHERE_HANDLE = "test.bsky.social"; 28 28 29 29 beforeEach(() => { 30 30 db = createTestDb(); ··· 34 34 35 35 describe("getActiveMemberRole", () => { 36 36 it("returns role for an active member", () => { 37 - seedSphere(db, { id: SPHERE_ID, slug: SPHERE_SLUG, ownerDid: OWNER_DID }); 37 + seedSphere(db, { id: SPHERE_ID, handle: SPHERE_HANDLE, ownerDid: OWNER_DID }); 38 38 expect(getActiveMemberRole(SPHERE_ID, OWNER_DID)).toBe("owner"); 39 39 }); 40 40 41 41 it("returns null for an invited (non-active) member", () => { 42 - seedSphere(db, { id: SPHERE_ID, slug: SPHERE_SLUG, ownerDid: OWNER_DID }); 42 + seedSphere(db, { id: SPHERE_ID, handle: SPHERE_HANDLE, ownerDid: OWNER_DID }); 43 43 db.insert(sphereMembers) 44 44 .values({ sphereId: SPHERE_ID, did: MEMBER_DID, role: "member", status: "invited" }) 45 45 .run(); ··· 47 47 }); 48 48 49 49 it("returns null for a non-member", () => { 50 - seedSphere(db, { id: SPHERE_ID, slug: SPHERE_SLUG, ownerDid: OWNER_DID }); 50 + seedSphere(db, { id: SPHERE_ID, handle: SPHERE_HANDLE, ownerDid: OWNER_DID }); 51 51 expect(getActiveMemberRole(SPHERE_ID, "did:plc:unknown")).toBeNull(); 52 52 }); 53 53 }); ··· 57 57 describe("findSphereByAtUri", () => { 58 58 it("finds a sphere by pdsUri", () => { 59 59 const pdsUri = "at://did:plc:owner1/com.exosphere.sphere/sphere-1"; 60 - seedSphere(db, { id: SPHERE_ID, slug: SPHERE_SLUG, ownerDid: OWNER_DID, pdsUri }); 60 + seedSphere(db, { id: SPHERE_ID, handle: SPHERE_HANDLE, ownerDid: OWNER_DID, pdsUri }); 61 61 const result = findSphereByAtUri(pdsUri); 62 62 expect(result).toEqual({ id: SPHERE_ID }); 63 63 }); 64 64 65 65 it("falls back to ownerDid lookup when pdsUri doesn't match", () => { 66 - seedSphere(db, { id: SPHERE_ID, slug: SPHERE_SLUG, ownerDid: OWNER_DID }); 66 + seedSphere(db, { id: SPHERE_ID, handle: SPHERE_HANDLE, ownerDid: OWNER_DID }); 67 67 const result = findSphereByAtUri(`at://${OWNER_DID}/com.exosphere.sphere/any-rkey`); 68 68 expect(result).toEqual({ id: SPHERE_ID }); 69 69 }); ··· 84 84 upsertSphereFromRecord({ 85 85 did: OWNER_DID, 86 86 rkey: SPHERE_ID, 87 - record: { slug: SPHERE_SLUG, name: "My Sphere", visibility: "public", writeAccess: "open" }, 87 + record: { handle: SPHERE_HANDLE, name: "My Sphere", visibility: "public", writeAccess: "open" }, 88 88 pdsUri: "at://did:plc:owner1/com.exosphere.sphere/sphere-1", 89 89 }); 90 90 91 91 const sphere = db.select().from(spheres).where(eq(spheres.id, SPHERE_ID)).get(); 92 92 expect(sphere).toBeDefined(); 93 - expect(sphere!.slug).toBe(SPHERE_SLUG); 93 + expect(sphere!.handle).toBe(SPHERE_HANDLE); 94 94 expect(sphere!.name).toBe("My Sphere"); 95 95 expect(sphere!.ownerDid).toBe(OWNER_DID); 96 96 ··· 105 105 }); 106 106 107 107 it("updates an existing sphere owned by the same DID", () => { 108 - seedSphere(db, { id: SPHERE_ID, slug: SPHERE_SLUG, ownerDid: OWNER_DID }); 108 + seedSphere(db, { id: SPHERE_ID, handle: SPHERE_HANDLE, ownerDid: OWNER_DID }); 109 109 110 110 upsertSphereFromRecord({ 111 111 did: OWNER_DID, 112 112 rkey: SPHERE_ID, 113 - record: { slug: SPHERE_SLUG, name: "Updated Name", description: "New desc" }, 113 + record: { handle: SPHERE_HANDLE, name: "Updated Name", description: "New desc" }, 114 114 pdsUri: "at://did:plc:owner1/com.exosphere.sphere/sphere-1", 115 115 }); 116 116 ··· 120 120 }); 121 121 122 122 it("ignores update when DID doesn't match owner", () => { 123 - seedSphere(db, { id: SPHERE_ID, slug: SPHERE_SLUG, ownerDid: OWNER_DID }); 123 + seedSphere(db, { id: SPHERE_ID, handle: SPHERE_HANDLE, ownerDid: OWNER_DID }); 124 124 125 125 upsertSphereFromRecord({ 126 126 did: "did:plc:attacker", 127 127 rkey: "other-rkey", 128 - record: { slug: SPHERE_SLUG, name: "Hijacked" }, 128 + record: { handle: SPHERE_HANDLE, name: "Hijacked" }, 129 129 pdsUri: "at://did:plc:attacker/com.exosphere.sphere/other-rkey", 130 130 }); 131 131 132 132 const sphere = db.select().from(spheres).where(eq(spheres.id, SPHERE_ID)).get(); 133 - expect(sphere!.name).toBe(SPHERE_SLUG); // unchanged (name defaults to slug in seedSphere) 133 + expect(sphere!.name).toBe(SPHERE_HANDLE); // unchanged (name defaults to handle in seedSphere) 134 134 }); 135 135 136 - it("ignores record with no slug", () => { 136 + it("ignores record with no handle", () => { 137 137 upsertSphereFromRecord({ 138 138 did: OWNER_DID, 139 139 rkey: SPHERE_ID, ··· 150 150 151 151 describe("upsertMemberInvite", () => { 152 152 it("inserts a new invited member", () => { 153 - seedSphere(db, { id: SPHERE_ID, slug: SPHERE_SLUG, ownerDid: OWNER_DID }); 153 + seedSphere(db, { id: SPHERE_ID, handle: SPHERE_HANDLE, ownerDid: OWNER_DID }); 154 154 155 155 upsertMemberInvite({ 156 156 sphereId: SPHERE_ID, ··· 172 172 }); 173 173 174 174 it("re-invites an existing member (updates role and status)", () => { 175 - seedSphere(db, { id: SPHERE_ID, slug: SPHERE_SLUG, ownerDid: OWNER_DID }); 175 + seedSphere(db, { id: SPHERE_ID, handle: SPHERE_HANDLE, ownerDid: OWNER_DID }); 176 176 db.insert(sphereMembers) 177 177 .values({ sphereId: SPHERE_ID, did: MEMBER_DID, role: "member", status: "active" }) 178 178 .run(); ··· 199 199 200 200 describe("activateMember", () => { 201 201 it("activates an invited member", () => { 202 - seedSphere(db, { id: SPHERE_ID, slug: SPHERE_SLUG, ownerDid: OWNER_DID }); 202 + seedSphere(db, { id: SPHERE_ID, handle: SPHERE_HANDLE, ownerDid: OWNER_DID }); 203 203 db.insert(sphereMembers) 204 204 .values({ 205 205 sphereId: SPHERE_ID, ··· 227 227 228 228 describe("deleteSphereMemberApproval", () => { 229 229 it("revokes a member by approvalPdsUri", () => { 230 - seedSphere(db, { id: SPHERE_ID, slug: SPHERE_SLUG, ownerDid: OWNER_DID }); 230 + seedSphere(db, { id: SPHERE_ID, handle: SPHERE_HANDLE, ownerDid: OWNER_DID }); 231 231 const approvalUri = "at://did:plc:owner1/com.exosphere.sphereMemberApproval/rkey1"; 232 232 db.insert(sphereMembers) 233 233 .values({ ··· 255 255 256 256 describe("deleteSphereMember", () => { 257 257 it("clears pdsUri for the member", () => { 258 - seedSphere(db, { id: SPHERE_ID, slug: SPHERE_SLUG, ownerDid: OWNER_DID }); 258 + seedSphere(db, { id: SPHERE_ID, handle: SPHERE_HANDLE, ownerDid: OWNER_DID }); 259 259 const memberPdsUri = "at://did:plc:member1/com.exosphere.sphereMember/rkey1"; 260 260 db.insert(sphereMembers) 261 261 .values({
-30
packages/core/src/__tests__/sphere-schemas.test.ts
··· 12 12 const result = createSphereSchema.parse({ name: "My Sphere" }); 13 13 expect(result.visibility).toBe("public"); 14 14 expect(result.writeAccess).toBe("open"); 15 - expect(result.slug).toBeUndefined(); 16 15 }); 17 16 18 17 it("accepts explicit visibility and writeAccess", () => { ··· 23 22 }); 24 23 expect(result.visibility).toBe("private"); 25 24 expect(result.writeAccess).toBe("members"); 26 - }); 27 - 28 - it("accepts a valid slug", () => { 29 - const result = createSphereSchema.parse({ name: "Test", slug: "my-sphere" }); 30 - expect(result.slug).toBe("my-sphere"); 31 - }); 32 - 33 - it("accepts slug with numbers", () => { 34 - expect(createSphereSchema.parse({ name: "T", slug: "sphere-42" }).slug).toBe("sphere-42"); 35 - }); 36 - 37 - it("rejects slug shorter than 3 chars", () => { 38 - expect(() => createSphereSchema.parse({ name: "T", slug: "ab" })).toThrow(); 39 - }); 40 - 41 - it("rejects slug longer than 50 chars", () => { 42 - expect(() => createSphereSchema.parse({ name: "T", slug: "a".repeat(51) })).toThrow(); 43 - }); 44 - 45 - it("rejects slug with uppercase letters", () => { 46 - expect(() => createSphereSchema.parse({ name: "T", slug: "Bad" })).toThrow(); 47 - }); 48 - 49 - it("rejects slug starting with a hyphen", () => { 50 - expect(() => createSphereSchema.parse({ name: "T", slug: "-bad" })).toThrow(); 51 - }); 52 - 53 - it("rejects slug ending with a hyphen", () => { 54 - expect(() => createSphereSchema.parse({ name: "T", slug: "bad-" })).toThrow(); 55 25 }); 56 26 57 27 it("rejects empty name", () => {
+1 -1
packages/core/src/db/schema/spheres.ts
··· 4 4 5 5 export const spheres = sqliteTable("spheres", { 6 6 id: text("id").primaryKey(), 7 - slug: text("slug").unique().notNull(), 7 + handle: text("handle").unique().notNull(), 8 8 name: text("name").notNull(), 9 9 description: text("description"), 10 10 visibility: text("visibility", { enum: ["public", "private"] })
+3 -3
packages/core/src/lexicons/site.exosphere.featureRequest.json
··· 8 8 "key": "tid", 9 9 "record": { 10 10 "type": "object", 11 - "required": ["title", "sphereSlug", "createdAt"], 11 + "required": ["title", "sphereHandle", "createdAt"], 12 12 "properties": { 13 13 "title": { 14 14 "type": "string", ··· 22 22 "type": "string", 23 23 "knownValues": ["general", "enhancement", "bug", "integration", "ui-ux"] 24 24 }, 25 - "sphereSlug": { 25 + "sphereHandle": { 26 26 "type": "string", 27 - "description": "Slug of the Sphere this request belongs to." 27 + "description": "Handle of the Sphere this request belongs to." 28 28 }, 29 29 "createdAt": { 30 30 "type": "string",
+3 -3
packages/core/src/lexicons/site.exosphere.featureRequestStatus.json
··· 8 8 "key": "tid", 9 9 "record": { 10 10 "type": "object", 11 - "required": ["subject", "status", "sphereSlug", "createdAt"], 11 + "required": ["subject", "status", "sphereHandle", "createdAt"], 12 12 "properties": { 13 13 "subject": { 14 14 "type": "string", ··· 19 19 "type": "string", 20 20 "knownValues": ["requested", "not-planned", "approved", "in-progress", "done"] 21 21 }, 22 - "sphereSlug": { 22 + "sphereHandle": { 23 23 "type": "string", 24 - "description": "Slug of the Sphere (used by indexers to scope the status change)." 24 + "description": "Handle of the Sphere (used by indexers to scope the status change)." 25 25 }, 26 26 "createdAt": { 27 27 "type": "string",
+4 -4
packages/core/src/lexicons/site.exosphere.sphere.json
··· 8 8 "key": "self", 9 9 "record": { 10 10 "type": "object", 11 - "required": ["slug", "name", "visibility", "writeAccess", "createdAt"], 11 + "required": ["handle", "name", "visibility", "writeAccess", "createdAt"], 12 12 "properties": { 13 - "slug": { 13 + "handle": { 14 14 "type": "string", 15 - "description": "URL-friendly unique identifier for the Sphere.", 16 - "maxLength": 64 15 + "description": "Bluesky handle of the Sphere owner, used as the Sphere's unique identifier.", 16 + "maxLength": 253 17 17 }, 18 18 "name": { 19 19 "type": "string",
+2 -10
packages/core/src/sphere/api/helpers.ts
··· 2 2 import { getDb } from "../../db/index.ts"; 3 3 import { spheres, sphereModules } from "../../db/schema/index.ts"; 4 4 5 - export function slugify(text: string): string { 6 - return text 7 - .toLowerCase() 8 - .replace(/[^a-z0-9]+/g, "-") 9 - .replace(/^-+|-+$/g, "") 10 - .slice(0, 50); 11 - } 12 - 13 5 export function formatModules(rows: (typeof sphereModules.$inferSelect)[]) { 14 6 return rows.map((m) => ({ name: m.moduleName, enabledAt: m.enabledAt })); 15 7 } 16 8 17 - export function findSphere(slug: string) { 18 - return getDb().select().from(spheres).where(eq(spheres.slug, slug)).get() ?? null; 9 + export function findSphere(handle: string) { 10 + return getDb().select().from(spheres).where(eq(spheres.handle, handle)).get() ?? null; 19 11 } 20 12 21 13 export function getEnabledModules(sphereId: string) {
+10 -10
packages/core/src/sphere/api/members.ts
··· 21 21 const app = new Hono<AuthEnv>(); 22 22 23 23 // List members of a sphere 24 - app.get("/:slug/members", requireAuth, (c) => { 25 - const sphere = findSphere(c.req.param("slug")); 24 + app.get("/:handle/members", requireAuth, (c) => { 25 + const sphere = findSphere(c.req.param("handle")); 26 26 if (!sphere) { 27 27 return c.json({ error: "Sphere not found" }, 404); 28 28 } ··· 51 51 52 52 // Invite a member (admin/owner only) 53 53 // Publishes a sphereMemberApproval record on the inviter's PDS 54 - app.post("/:slug/members", requireAuth, async (c) => { 55 - const sphere = findSphere(c.req.param("slug")); 54 + app.post("/:handle/members", requireAuth, async (c) => { 55 + const sphere = findSphere(c.req.param("handle")); 56 56 if (!sphere) { 57 57 return c.json({ error: "Sphere not found" }, 404); 58 58 } ··· 108 108 109 109 // Accept an invitation (the invited user calls this) 110 110 // Publishes a sphereMember record on the member's PDS 111 - app.post("/:slug/members/accept", requireAuth, async (c) => { 112 - const sphere = findSphere(c.req.param("slug")); 111 + app.post("/:handle/members/accept", requireAuth, async (c) => { 112 + const sphere = findSphere(c.req.param("handle")); 113 113 if (!sphere) { 114 114 return c.json({ error: "Sphere not found" }, 404); 115 115 } ··· 149 149 150 150 // Revoke a member (admin/owner only) 151 151 // Deletes the approval record from the revoker's PDS 152 - app.delete("/:slug/members/:did", requireAuth, async (c) => { 153 - const sphere = findSphere(c.req.param("slug")); 152 + app.delete("/:handle/members/:did", requireAuth, async (c) => { 153 + const sphere = findSphere(c.req.param("handle")); 154 154 if (!sphere) { 155 155 return c.json({ error: "Sphere not found" }, 404); 156 156 } ··· 201 201 }); 202 202 203 203 // Update a member's role (admin/owner only) 204 - app.put("/:slug/members/:did", requireAuth, async (c) => { 205 - const sphere = findSphere(c.req.param("slug")); 204 + app.put("/:handle/members/:did", requireAuth, async (c) => { 205 + const sphere = findSphere(c.req.param("handle")); 206 206 if (!sphere) { 207 207 return c.json({ error: "Sphere not found" }, 404); 208 208 }
+6 -6
packages/core/src/sphere/api/modules.ts
··· 12 12 const app = new Hono<AuthEnv>(); 13 13 14 14 // List enabled modules for a sphere 15 - app.get("/:slug/modules", (c) => { 16 - const sphere = findSphere(c.req.param("slug")); 15 + app.get("/:handle/modules", (c) => { 16 + const sphere = findSphere(c.req.param("handle")); 17 17 if (!sphere) { 18 18 return c.json({ error: "Sphere not found" }, 404); 19 19 } ··· 25 25 }); 26 26 27 27 // Enable module 28 - app.post("/:slug/modules", requireAuth, async (c) => { 29 - const sphere = findSphere(c.req.param("slug")); 28 + app.post("/:handle/modules", requireAuth, async (c) => { 29 + const sphere = findSphere(c.req.param("handle")); 30 30 if (!sphere) { 31 31 return c.json({ error: "Sphere not found" }, 404); 32 32 } ··· 64 64 }); 65 65 66 66 // Disable module 67 - app.delete("/:slug/modules/:moduleName", requireAuth, (c) => { 68 - const sphere = findSphere(c.req.param("slug")); 67 + app.delete("/:handle/modules/:moduleName", requireAuth, (c) => { 68 + const sphere = findSphere(c.req.param("handle")); 69 69 if (!sphere) { 70 70 return c.json({ error: "Sphere not found" }, 404); 71 71 }
+32 -21
packages/core/src/sphere/api/spheres.ts
··· 7 7 import { putPdsRecord, generateRkey } from "../../pds.ts"; 8 8 import { createSphereSchema, updateSphereSchema } from "../schemas.ts"; 9 9 import { getActiveMemberRole, isAdminOrOwner } from "../operations.ts"; 10 - import { findSphere, getEnabledModules, formatModules, slugify } from "./helpers.ts"; 10 + import { findSphere, getEnabledModules, formatModules } from "./helpers.ts"; 11 11 12 12 const SPHERE_COLLECTION = "site.exosphere.sphere"; 13 13 14 14 /** Load the current sphere with its modules, member count, and caller's role. 15 - * If `slug` is provided, loads that specific sphere; otherwise loads the first sphere. */ 16 - export function getCurrentSphere(did: string | null, slug?: string) { 17 - const sphere = slug 18 - ? getDb().select().from(spheres).where(eq(spheres.slug, slug)).get() 15 + * If `handle` is provided, loads that specific sphere; otherwise loads the first sphere. */ 16 + export function getCurrentSphere(did: string | null, handle?: string) { 17 + const sphere = handle 18 + ? getDb().select().from(spheres).where(eq(spheres.handle, handle)).get() 19 19 : getDb().select().from(spheres).orderBy(spheres.createdAt).limit(1).get(); 20 20 if (!sphere) return null; 21 21 const modules = getEnabledModules(sphere.id); ··· 39 39 } 40 40 41 41 const { name, description, visibility, writeAccess } = parsed.data; 42 - const slug = parsed.data.slug || slugify(name); 43 42 const did = c.var.did; 44 43 const id = generateRkey(); 45 44 46 - if (slug.length < 3) { 47 - return c.json({ error: "Generated slug is too short. Provide a slug explicitly." }, 400); 45 + // Resolve the creator's Bluesky handle from their AT Protocol session 46 + const session = c.var.session; 47 + const descRes = await session.fetchHandler( 48 + `/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(session.did)}`, 49 + ); 50 + if (!descRes.ok) { 51 + return c.json({ error: "Could not resolve handle" }, 500); 52 + } 53 + const { handle } = (await descRes.json()) as { handle: string }; 54 + if (!handle || handle.length > 253) { 55 + return c.json({ error: "Invalid handle" }, 400); 48 56 } 49 57 50 58 const db = getDb(); 51 59 52 - const existing = db.select({ id: spheres.id }).from(spheres).where(eq(spheres.slug, slug)).get(); 60 + const existing = db 61 + .select({ id: spheres.id }) 62 + .from(spheres) 63 + .where(eq(spheres.handle, handle)) 64 + .get(); 53 65 if (existing) { 54 - return c.json({ error: "Slug already taken" }, 409); 66 + return c.json({ error: "A sphere already exists for this handle" }, 409); 55 67 } 56 68 57 69 // Write Sphere declaration to owner's PDS 58 - const session = c.var.session; 59 70 const now = new Date().toISOString(); 60 71 const pdsUri = await putPdsRecord(session, SPHERE_COLLECTION, "self", { 61 - slug, 72 + handle, 62 73 name, 63 74 description: description ?? undefined, 64 75 visibility, ··· 70 81 tx.insert(spheres) 71 82 .values({ 72 83 id, 73 - slug, 84 + handle, 74 85 name, 75 86 description: description ?? null, 76 87 visibility, ··· 90 101 .run(); 91 102 }); 92 103 93 - const sphere = findSphere(slug)!; 104 + const sphere = findSphere(handle)!; 94 105 return c.json({ sphere }, 201); 95 106 }); 96 107 ··· 151 162 }); 152 163 }); 153 164 154 - // Get sphere by slug 155 - app.get("/:slug", optionalAuth, (c) => { 156 - const sphere = findSphere(c.req.param("slug")); 165 + // Get sphere by handle 166 + app.get("/:handle", optionalAuth, (c) => { 167 + const sphere = findSphere(c.req.param("handle")); 157 168 if (!sphere) { 158 169 return c.json({ error: "Sphere not found" }, 404); 159 170 } ··· 177 188 }); 178 189 179 190 // Update sphere settings 180 - app.put("/:slug", requireAuth, async (c) => { 181 - const sphere = findSphere(c.req.param("slug")); 191 + app.put("/:handle", requireAuth, async (c) => { 192 + const sphere = findSphere(c.req.param("handle")); 182 193 if (!sphere) { 183 194 return c.json({ error: "Sphere not found" }, 404); 184 195 } ··· 210 221 const session = c.var.session; 211 222 const modules = getEnabledModules(sphere.id).map((m) => m.moduleName); 212 223 const pdsUri = await putPdsRecord(session, SPHERE_COLLECTION, "self", { 213 - slug: sphere.slug, 224 + handle: sphere.handle, 214 225 name: updates.name ?? sphere.name, 215 226 description: updates.description ?? sphere.description ?? undefined, 216 227 visibility: updates.visibility ?? sphere.visibility, ··· 225 236 226 237 getDb().update(spheres).set(set).where(eq(spheres.id, sphere.id)).run(); 227 238 228 - const updated = findSphere(c.req.param("slug"))!; 239 + const updated = findSphere(c.req.param("handle"))!; 229 240 return c.json({ sphere: updated }); 230 241 }); 231 242
+6 -6
packages/core/src/sphere/middleware.ts
··· 8 8 /** 9 9 * Middleware that resolves the current sphere and injects it into the Hono context. 10 10 * 11 - * - Multi-sphere mode: reads `:sphereSlug` from URL params. 11 + * - Multi-sphere mode: reads `:sphereHandle` from URL params. 12 12 * - Self-hosted mode: loads the first (and only) sphere. 13 13 * 14 - * Sets `c.var.sphereId`, `c.var.sphereSlug`, `c.var.sphereVisibility`, 14 + * Sets `c.var.sphereId`, `c.var.sphereHandle`, `c.var.sphereVisibility`, 15 15 * `c.var.sphereWriteAccess`, `c.var.sphereOwnerDid`, `c.var.spherePdsUri`. 16 16 */ 17 17 export const sphereContext = createMiddleware<SphereEnv>(async (c, next) => { ··· 19 19 let sphere; 20 20 21 21 if (isMultiSphere) { 22 - const slug = c.req.param("sphereSlug"); 23 - if (!slug) { 22 + const handle = c.req.param("sphereHandle"); 23 + if (!handle) { 24 24 return c.json({ error: "Sphere not found" }, 404); 25 25 } 26 - sphere = db.select().from(spheres).where(eq(spheres.slug, slug)).get(); 26 + sphere = db.select().from(spheres).where(eq(spheres.handle, handle)).get(); 27 27 } else { 28 28 sphere = db.select().from(spheres).orderBy(spheres.createdAt).limit(1).get(); 29 29 } ··· 33 33 } 34 34 35 35 c.set("sphereId", sphere.id); 36 - c.set("sphereSlug", sphere.slug); 36 + c.set("sphereHandle", sphere.handle); 37 37 c.set("sphereVisibility", sphere.visibility as "public" | "private"); 38 38 c.set("sphereWriteAccess", sphere.writeAccess as "open" | "members"); 39 39 c.set("sphereOwnerDid", sphere.ownerDid);
+5 -5
packages/core/src/sphere/operations.ts
··· 58 58 } 59 59 60 60 export function upsertSphereFromRecord({ did, rkey, record, pdsUri }: UpsertSphereParams): void { 61 - const slug = record.slug as string; 62 - if (!slug) return; 61 + const handle = record.handle as string; 62 + if (!handle) return; 63 63 64 64 const db = getDb(); 65 65 const existing = db 66 66 .select({ id: spheres.id, ownerDid: spheres.ownerDid }) 67 67 .from(spheres) 68 - .where(eq(spheres.slug, slug)) 68 + .where(eq(spheres.handle, handle)) 69 69 .get(); 70 70 71 71 if (existing) { ··· 83 83 db.insert(spheres) 84 84 .values({ 85 85 id: rkey, 86 - slug, 87 - name: (record.name as string) ?? slug, 86 + handle, 87 + name: (record.name as string) ?? handle, 88 88 description: (record.description as string) ?? null, 89 89 visibility: (record.visibility as "public" | "private") ?? "public", 90 90 writeAccess: (record.writeAccess as "open" | "members") ?? "open",
-8
packages/core/src/sphere/schemas.ts
··· 1 1 import { z } from "zod"; 2 2 3 - const slugPattern = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/; 4 - 5 3 export const createSphereSchema = z.object({ 6 4 name: z.string().min(1).max(100), 7 - slug: z 8 - .string() 9 - .min(3) 10 - .max(50) 11 - .regex(slugPattern, "Must be lowercase alphanumeric with hyphens") 12 - .optional(), 13 5 description: z.string().max(500).optional(), 14 6 visibility: z.enum(["public", "private"]).default("public"), 15 7 writeAccess: z.enum(["open", "members"]).default("open"),
+2 -2
packages/core/src/types/index.ts
··· 6 6 export type SphereEnv = { 7 7 Variables: { 8 8 sphereId: string; 9 - sphereSlug: string; 9 + sphereHandle: string; 10 10 sphereVisibility: "public" | "private"; 11 11 sphereWriteAccess: "open" | "members"; 12 12 sphereOwnerDid: string; ··· 51 51 enabledAt: string; 52 52 } 53 53 54 - /** Shape returned by GET /api/spheres/:slug. */ 54 + /** Shape returned by GET /api/spheres/:handle. */ 55 55 export interface SphereData { 56 56 sphere: import("../db/schema/spheres.ts").Sphere; 57 57 modules: SphereModuleInfo[];
+2 -1
packages/feature-requests/src/__tests__/db-operations.test.ts
··· 3 3 import { eq, and } from "drizzle-orm"; 4 4 5 5 // Use the shared test-db helper from core (resolves via workspace) 6 - import { createTestDb } from "../../../core/src/__tests__/helpers/test-db.ts"; 6 + import { createTestDb, seedSphere } from "../../../core/src/__tests__/helpers/test-db.ts"; 7 7 8 8 let db: BetterSQLite3Database; 9 9 ··· 57 57 58 58 beforeEach(() => { 59 59 db = createTestDb(); 60 + seedSphere(db, { id: SPHERE_ID, handle: "test.bsky.social", ownerDid: AUTHOR_DID }); 60 61 }); 61 62 62 63 // ---- insertFeatureRequest ----
+7
packages/feature-requests/src/api/comments.ts
··· 504 504 const id = c.req.param("id"); 505 505 const db = getDb(); 506 506 const did = c.var.did; 507 + const sphereId = c.var.sphereId; 508 + 509 + // Verify the comment's FR belongs to this sphere 510 + const row = findCommentInSphere(id, sphereId); 511 + if (!row) { 512 + return c.json({ error: "Comment not found" }, 404); 513 + } 507 514 508 515 const vote = db 509 516 .select({ pdsUri: featureRequestCommentVotes.pdsUri })
+2 -2
packages/feature-requests/src/api/requests.ts
··· 150 150 151 151 const { title, description, category } = result.data; 152 152 const sphereId = c.var.sphereId; 153 - const sphereSlug = c.var.sphereSlug; 153 + const sphereHandle = c.var.sphereHandle; 154 154 const sphereVisibility = c.var.sphereVisibility; 155 155 const id = generateRkey(); 156 156 const did = c.var.did; ··· 173 173 title, 174 174 description, 175 175 category, 176 - sphereSlug, 176 + sphereHandle, 177 177 createdAt: now, 178 178 }, 179 179 }),
+4 -4
packages/feature-requests/src/api/statuses.ts
··· 30 30 const db = getDb(); 31 31 const did = c.var.did; 32 32 const sphereId = c.var.sphereId; 33 - const sphereSlug = c.var.sphereSlug; 33 + const sphereHandle = c.var.sphereHandle; 34 34 const sphereVisibility = c.var.sphereVisibility; 35 35 36 36 // Check FR exists, belongs to this sphere, is "requested", and not hidden ··· 101 101 $type: STATUS_COLLECTION, 102 102 subject: fr.pdsUri, 103 103 status: "duplicate", 104 - sphereSlug, 104 + sphereHandle, 105 105 createdAt: now, 106 106 }, 107 107 }), ··· 165 165 const db = getDb(); 166 166 const did = c.var.did; 167 167 const sphereId = c.var.sphereId; 168 - const sphereSlug = c.var.sphereSlug; 168 + const sphereHandle = c.var.sphereHandle; 169 169 const sphereVisibility = c.var.sphereVisibility; 170 170 171 171 const existing = db ··· 203 203 $type: STATUS_COLLECTION, 204 204 subject: existing.pdsUri, 205 205 status, 206 - sphereSlug, 206 + sphereHandle, 207 207 createdAt: now, 208 208 }, 209 209 }),
+11
packages/feature-requests/src/api/votes.ts
··· 113 113 const id = c.req.param("id"); 114 114 const db = getDb(); 115 115 const did = c.var.did; 116 + const sphereId = c.var.sphereId; 117 + 118 + // Verify the vote's FR belongs to this sphere 119 + const fr = db 120 + .select({ id: featureRequests.id }) 121 + .from(featureRequests) 122 + .where(and(eq(featureRequests.id, id), eq(featureRequests.sphereId, sphereId))) 123 + .get(); 124 + if (!fr) { 125 + return c.json({ error: "Feature request not found" }, 404); 126 + } 116 127 117 128 const vote = db 118 129 .select({ pdsUri: featureRequestVotes.pdsUri })
+4 -1
packages/feature-requests/src/db/schema.ts
··· 8 8 } from "drizzle-orm/sqlite-core"; 9 9 import { sql } from "drizzle-orm"; 10 10 import type { InferSelectModel } from "drizzle-orm"; 11 + import { spheres } from "@exosphere/core/db/schema"; 11 12 import { categories, statuses } from "../schemas/feature-request.ts"; 12 13 13 14 export const featureRequests = sqliteTable( 14 15 "feature_requests", 15 16 { 16 17 id: text("id").primaryKey(), 17 - sphereId: text("sphere_id").notNull(), 18 + sphereId: text("sphere_id") 19 + .notNull() 20 + .references(() => spheres.id), 18 21 number: integer("number").notNull(), 19 22 authorDid: text("author_did").notNull(), 20 23 title: text("title").notNull(),
+5 -5
packages/feature-requests/src/indexer.ts
··· 30 30 const STATUS_COLLECTION = "site.exosphere.featureRequestStatus"; 31 31 32 32 function findSphereForAccess( 33 - sphereSlug: string, 33 + sphereHandle: string, 34 34 did: string, 35 35 ): { allowed: boolean; sphereId: string | null } { 36 36 const db = getDb(); 37 37 const sphere = db 38 38 .select({ id: spheres.id, writeAccess: spheres.writeAccess }) 39 39 .from(spheres) 40 - .where(eq(spheres.slug, sphereSlug)) 40 + .where(eq(spheres.handle, sphereHandle)) 41 41 .get(); 42 42 if (!sphere) return { allowed: false, sphereId: null }; 43 43 if (sphere.writeAccess === "open") return { allowed: true, sphereId: sphere.id }; ··· 62 62 const pdsUri = buildAtUri(did, collection, rkey); 63 63 64 64 if (collection === COLLECTION) { 65 - const sphereSlug = record.sphereSlug as string; 66 - if (!sphereSlug) return; // Reject records without a sphere 65 + const sphereHandle = record.sphereHandle as string; 66 + if (!sphereHandle) return; // Reject records without a sphere 67 67 68 - const access = findSphereForAccess(sphereSlug, did); 68 + const access = findSphereForAccess(sphereHandle, did); 69 69 if (!access.allowed || !access.sphereId) return; 70 70 const sphereId = access.sphereId; 71 71
+1 -1
packages/feature-requests/src/ui/api/comment-votes.ts
··· 1 - import { moduleFetch } from "@exosphere/client/api"; 1 + import { moduleFetch } from "@exosphere/client/module-api"; 2 2 3 3 export function getMyCommentVotes() { 4 4 return moduleFetch<{ votes: string[] }>("/feature-requests/comments/votes");
+1 -1
packages/feature-requests/src/ui/api/comments.ts
··· 1 - import { moduleFetch } from "@exosphere/client/api"; 1 + import { moduleFetch } from "@exosphere/client/module-api"; 2 2 import type { FeatureRequestComment, FeatureRequestCommentListItem } from "../../types.ts"; 3 3 4 4 export type { FeatureRequestComment, FeatureRequestCommentListItem };
+1 -1
packages/feature-requests/src/ui/api/feature-requests.ts
··· 1 - import { moduleFetch } from "@exosphere/client/api"; 1 + import { moduleFetch } from "@exosphere/client/module-api"; 2 2 import type { FeatureRequest, FeatureRequestListItem } from "../../types.ts"; 3 3 4 4 export type { FeatureRequest, FeatureRequestListItem };
+1 -1
packages/feature-requests/src/ui/api/search.ts
··· 1 - import { moduleFetch } from "@exosphere/client/api"; 1 + import { moduleFetch } from "@exosphere/client/module-api"; 2 2 3 3 export function searchFeatureRequests(q: string, excludeId: string) { 4 4 return moduleFetch<{
+1 -1
packages/feature-requests/src/ui/api/status.ts
··· 1 - import { moduleFetch } from "@exosphere/client/api"; 1 + import { moduleFetch } from "@exosphere/client/module-api"; 2 2 import type { FeatureRequestStatus } from "../../types.ts"; 3 3 4 4 export type { FeatureRequestStatus };
+1 -1
packages/feature-requests/src/ui/api/votes.ts
··· 1 - import { moduleFetch } from "@exosphere/client/api"; 1 + import { moduleFetch } from "@exosphere/client/module-api"; 2 2 3 3 export function voteFeatureRequest(id: string) { 4 4 return moduleFetch<{ voteCount: number }>(`/feature-requests/${encodeURIComponent(id)}/vote`, {
+1 -1
packages/indexer/src/modules.ts
··· 3 3 import { feedsModule } from "@exosphere/feeds"; 4 4 import { featureRequestsModule } from "@exosphere/feature-requests"; 5 5 6 - export const modules: ExosphereModule[] = [feedsModule, featureRequestsModule]; 6 + export const modules: ExosphereModule[] = [featureRequestsModule]; 7 7 export { coreIndexer };
+1 -1
packages/mcp/src/__tests__/routes.test.ts
··· 11 11 // Mock sphere endpoint 12 12 api.get("/api/spheres/current", (c) => 13 13 c.json({ 14 - sphere: { name: "Test Sphere", slug: "test", visibility: "public" }, 14 + sphere: { name: "Test Sphere", handle: "test.bsky.social", visibility: "public" }, 15 15 modules: ["feature-requests"], 16 16 memberCount: 5, 17 17 role: null,