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.

chore: allow multi-spheres instances

Hugo 6a0a3570 9bcaeb24

+974 -480
+4
.env.example
··· 8 8 # and URL rewriting from the PDS hostname to localhost. 9 9 PDS_URL=http://localhost:3000 10 10 11 + # Enable multi-sphere (hosted) mode: path-based routing under /s/:slug 12 + # Leave unset or set to 0 for single-sphere self-hosted mode. 13 + # MULTI_SPHERE=1 14 + 11 15 # ES256 private key in JWK format for OAuth client authentication 12 16 # Only needed in production (local dev uses loopback auth with no key). 13 17 # Generate one with: bun run packages/core/scripts/generate-key.ts
+10 -9
bun.lock
··· 9 9 "@types/better-sqlite3": "^7.6.13", 10 10 "@types/bun": "^1.3.11", 11 11 "better-sqlite3": "^12.8.0", 12 + "bun-types": "^1.3.11", 12 13 "drizzle-kit": "^0.31.10", 13 14 "oxfmt": "^0.41.0", 14 - "vitest": "^4.1.0", 15 + "vitest": "^4.1.1", 15 16 }, 16 17 }, 17 18 "packages/app": { ··· 427 428 428 429 "@vanilla-extract/vite-plugin": ["@vanilla-extract/vite-plugin@5.2.1", "", { "dependencies": { "@vanilla-extract/compiler": "^0.6.0", "@vanilla-extract/integration": "^8.0.9" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-1dmCgmTmls/c4G+t453vZIzZ+82ftr+JC2J48C1drVkiwtZ7DscYSIko9Ci0CyDptBLWz5EO9fWnqzfHnns8tg=="], 429 430 430 - "@vitest/expect": ["@vitest/expect@4.1.0", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.0", "@vitest/utils": "4.1.0", "chai": "^6.2.2", "tinyrainbow": "^3.0.3" } }, "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA=="], 431 + "@vitest/expect": ["@vitest/expect@4.1.1", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.1", "@vitest/utils": "4.1.1", "chai": "^6.2.2", "tinyrainbow": "^3.0.3" } }, "sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A=="], 431 432 432 - "@vitest/mocker": ["@vitest/mocker@4.1.0", "", { "dependencies": { "@vitest/spy": "4.1.0", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw=="], 433 + "@vitest/mocker": ["@vitest/mocker@4.1.1", "", { "dependencies": { "@vitest/spy": "4.1.1", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A=="], 433 434 434 - "@vitest/pretty-format": ["@vitest/pretty-format@4.1.0", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A=="], 435 + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.1", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ=="], 435 436 436 - "@vitest/runner": ["@vitest/runner@4.1.0", "", { "dependencies": { "@vitest/utils": "4.1.0", "pathe": "^2.0.3" } }, "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ=="], 437 + "@vitest/runner": ["@vitest/runner@4.1.1", "", { "dependencies": { "@vitest/utils": "4.1.1", "pathe": "^2.0.3" } }, "sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A=="], 437 438 438 - "@vitest/snapshot": ["@vitest/snapshot@4.1.0", "", { "dependencies": { "@vitest/pretty-format": "4.1.0", "@vitest/utils": "4.1.0", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg=="], 439 + "@vitest/snapshot": ["@vitest/snapshot@4.1.1", "", { "dependencies": { "@vitest/pretty-format": "4.1.1", "@vitest/utils": "4.1.1", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg=="], 439 440 440 - "@vitest/spy": ["@vitest/spy@4.1.0", "", {}, "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw=="], 441 + "@vitest/spy": ["@vitest/spy@4.1.1", "", {}, "sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA=="], 441 442 442 - "@vitest/utils": ["@vitest/utils@4.1.0", "", { "dependencies": { "@vitest/pretty-format": "4.1.0", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.0.3" } }, "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw=="], 443 + "@vitest/utils": ["@vitest/utils@4.1.1", "", { "dependencies": { "@vitest/pretty-format": "4.1.1", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.0.3" } }, "sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ=="], 443 444 444 445 "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], 445 446 ··· 753 754 754 755 "vite-prerender-plugin": ["vite-prerender-plugin@0.5.13", "", { "dependencies": { "kolorist": "^1.8.0", "magic-string": "0.x >= 0.26.0", "node-html-parser": "^6.1.12", "simple-code-frame": "^1.3.0", "source-map": "^0.7.4", "stack-trace": "^1.0.0-pre2" }, "peerDependencies": { "vite": "5.x || 6.x || 7.x || 8.x" } }, "sha512-IKSpYkzDBsKAxa05naRbj7GvNVMSdww/Z/E89oO3xndz+gWnOBOKOAbEXv7qDhktY/j3vHgJmoV1pPzqU2tx9g=="], 755 756 756 - "vitest": ["vitest@4.1.0", "", { "dependencies": { "@vitest/expect": "4.1.0", "@vitest/mocker": "4.1.0", "@vitest/pretty-format": "4.1.0", "@vitest/runner": "4.1.0", "@vitest/snapshot": "4.1.0", "@vitest/spy": "4.1.0", "@vitest/utils": "4.1.0", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.0", "@vitest/browser-preview": "4.1.0", "@vitest/browser-webdriverio": "4.1.0", "@vitest/ui": "4.1.0", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw=="], 757 + "vitest": ["vitest@4.1.1", "", { "dependencies": { "@vitest/expect": "4.1.1", "@vitest/mocker": "4.1.1", "@vitest/pretty-format": "4.1.1", "@vitest/runner": "4.1.1", "@vitest/snapshot": "4.1.1", "@vitest/spy": "4.1.1", "@vitest/utils": "4.1.1", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.1", "@vitest/browser-preview": "4.1.1", "@vitest/browser-webdriverio": "4.1.1", "@vitest/ui": "4.1.1", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA=="], 757 758 758 759 "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], 759 760
+3 -1
drizzle/0000_famous_wallop.sql drizzle/0000_left_hammerhead.sql
··· 101 101 CREATE INDEX `idx_feature_request_votes_request` ON `feature_request_votes` (`request_id`);--> statement-breakpoint 102 102 CREATE TABLE `feature_requests` ( 103 103 `id` text PRIMARY KEY NOT NULL, 104 + `sphere_id` text NOT NULL, 104 105 `number` integer NOT NULL, 105 106 `author_did` text NOT NULL, 106 107 `title` text NOT NULL, ··· 115 116 `updated_at` text DEFAULT (datetime('now')) NOT NULL 116 117 ); 117 118 --> statement-breakpoint 118 - CREATE UNIQUE INDEX `feature_requests_number_unique` ON `feature_requests` (`number`);--> statement-breakpoint 119 + CREATE UNIQUE INDEX `idx_feature_requests_sphere_number` ON `feature_requests` (`sphere_id`,`number`);--> statement-breakpoint 120 + CREATE INDEX `idx_feature_requests_sphere` ON `feature_requests` (`sphere_id`);--> statement-breakpoint 119 121 CREATE INDEX `idx_feature_requests_status` ON `feature_requests` (`status`);--> statement-breakpoint 120 122 CREATE INDEX `idx_feature_requests_created` ON `feature_requests` (`created_at`);--> statement-breakpoint 121 123 CREATE INDEX `idx_feature_requests_category` ON `feature_requests` (`category`);--> statement-breakpoint
+111 -33
drizzle/meta/0000_snapshot.json
··· 1 1 { 2 2 "version": "6", 3 3 "dialect": "sqlite", 4 - "id": "0a0e0853-16f1-4551-939a-ba2839138eed", 4 + "id": "4c5f0d67-4da8-40d5-bcc8-33c2b60ac8a6", 5 5 "prevId": "00000000-0000-0000-0000-000000000000", 6 6 "tables": { 7 7 "oauth_sessions": { ··· 175 175 "indexes": { 176 176 "idx_sphere_members_did": { 177 177 "name": "idx_sphere_members_did", 178 - "columns": ["did"], 178 + "columns": [ 179 + "did" 180 + ], 179 181 "isUnique": false 180 182 } 181 183 }, ··· 184 186 "name": "sphere_members_sphere_id_spheres_id_fk", 185 187 "tableFrom": "sphere_members", 186 188 "tableTo": "spheres", 187 - "columnsFrom": ["sphere_id"], 188 - "columnsTo": ["id"], 189 + "columnsFrom": [ 190 + "sphere_id" 191 + ], 192 + "columnsTo": [ 193 + "id" 194 + ], 189 195 "onDelete": "no action", 190 196 "onUpdate": "no action" 191 197 } 192 198 }, 193 199 "compositePrimaryKeys": { 194 200 "sphere_members_sphere_id_did_pk": { 195 - "columns": ["sphere_id", "did"], 201 + "columns": [ 202 + "sphere_id", 203 + "did" 204 + ], 196 205 "name": "sphere_members_sphere_id_did_pk" 197 206 } 198 207 }, ··· 231 240 "name": "sphere_modules_sphere_id_spheres_id_fk", 232 241 "tableFrom": "sphere_modules", 233 242 "tableTo": "spheres", 234 - "columnsFrom": ["sphere_id"], 235 - "columnsTo": ["id"], 243 + "columnsFrom": [ 244 + "sphere_id" 245 + ], 246 + "columnsTo": [ 247 + "id" 248 + ], 236 249 "onDelete": "no action", 237 250 "onUpdate": "no action" 238 251 } 239 252 }, 240 253 "compositePrimaryKeys": { 241 254 "sphere_modules_sphere_id_module_name_pk": { 242 - "columns": ["sphere_id", "module_name"], 255 + "columns": [ 256 + "sphere_id", 257 + "module_name" 258 + ], 243 259 "name": "sphere_modules_sphere_id_module_name_pk" 244 260 } 245 261 }, ··· 327 343 "indexes": { 328 344 "spheres_slug_unique": { 329 345 "name": "spheres_slug_unique", 330 - "columns": ["slug"], 346 + "columns": [ 347 + "slug" 348 + ], 331 349 "isUnique": true 332 350 } 333 351 }, ··· 372 390 "indexes": { 373 391 "idx_feature_request_comment_votes_comment": { 374 392 "name": "idx_feature_request_comment_votes_comment", 375 - "columns": ["comment_id"], 393 + "columns": [ 394 + "comment_id" 395 + ], 376 396 "isUnique": false 377 397 } 378 398 }, ··· 381 401 "name": "feature_request_comment_votes_comment_id_feature_request_comments_id_fk", 382 402 "tableFrom": "feature_request_comment_votes", 383 403 "tableTo": "feature_request_comments", 384 - "columnsFrom": ["comment_id"], 385 - "columnsTo": ["id"], 404 + "columnsFrom": [ 405 + "comment_id" 406 + ], 407 + "columnsTo": [ 408 + "id" 409 + ], 386 410 "onDelete": "no action", 387 411 "onUpdate": "no action" 388 412 } 389 413 }, 390 414 "compositePrimaryKeys": { 391 415 "feature_request_comment_votes_comment_id_author_did_pk": { 392 - "columns": ["comment_id", "author_did"], 416 + "columns": [ 417 + "comment_id", 418 + "author_did" 419 + ], 393 420 "name": "feature_request_comment_votes_comment_id_author_did_pk" 394 421 } 395 422 }, ··· 468 495 "indexes": { 469 496 "idx_feature_request_comments_request": { 470 497 "name": "idx_feature_request_comments_request", 471 - "columns": ["request_id"], 498 + "columns": [ 499 + "request_id" 500 + ], 472 501 "isUnique": false 473 502 }, 474 503 "idx_feature_request_comments_author_request": { 475 504 "name": "idx_feature_request_comments_author_request", 476 - "columns": ["author_did", "request_id"], 505 + "columns": [ 506 + "author_did", 507 + "request_id" 508 + ], 477 509 "isUnique": false 478 510 } 479 511 }, ··· 482 514 "name": "feature_request_comments_request_id_feature_requests_id_fk", 483 515 "tableFrom": "feature_request_comments", 484 516 "tableTo": "feature_requests", 485 - "columnsFrom": ["request_id"], 486 - "columnsTo": ["id"], 517 + "columnsFrom": [ 518 + "request_id" 519 + ], 520 + "columnsTo": [ 521 + "id" 522 + ], 487 523 "onDelete": "no action", 488 524 "onUpdate": "no action" 489 525 } ··· 542 578 "indexes": { 543 579 "idx_feature_request_statuses_request": { 544 580 "name": "idx_feature_request_statuses_request", 545 - "columns": ["request_id"], 581 + "columns": [ 582 + "request_id" 583 + ], 546 584 "isUnique": false 547 585 } 548 586 }, ··· 551 589 "name": "feature_request_statuses_request_id_feature_requests_id_fk", 552 590 "tableFrom": "feature_request_statuses", 553 591 "tableTo": "feature_requests", 554 - "columnsFrom": ["request_id"], 555 - "columnsTo": ["id"], 592 + "columnsFrom": [ 593 + "request_id" 594 + ], 595 + "columnsTo": [ 596 + "id" 597 + ], 556 598 "onDelete": "no action", 557 599 "onUpdate": "no action" 558 600 } ··· 597 639 "indexes": { 598 640 "idx_feature_request_votes_request": { 599 641 "name": "idx_feature_request_votes_request", 600 - "columns": ["request_id"], 642 + "columns": [ 643 + "request_id" 644 + ], 601 645 "isUnique": false 602 646 } 603 647 }, ··· 606 650 "name": "feature_request_votes_request_id_feature_requests_id_fk", 607 651 "tableFrom": "feature_request_votes", 608 652 "tableTo": "feature_requests", 609 - "columnsFrom": ["request_id"], 610 - "columnsTo": ["id"], 653 + "columnsFrom": [ 654 + "request_id" 655 + ], 656 + "columnsTo": [ 657 + "id" 658 + ], 611 659 "onDelete": "no action", 612 660 "onUpdate": "no action" 613 661 } 614 662 }, 615 663 "compositePrimaryKeys": { 616 664 "feature_request_votes_request_id_author_did_pk": { 617 - "columns": ["request_id", "author_did"], 665 + "columns": [ 666 + "request_id", 667 + "author_did" 668 + ], 618 669 "name": "feature_request_votes_request_id_author_did_pk" 619 670 } 620 671 }, ··· 628 679 "name": "id", 629 680 "type": "text", 630 681 "primaryKey": true, 682 + "notNull": true, 683 + "autoincrement": false 684 + }, 685 + "sphere_id": { 686 + "name": "sphere_id", 687 + "type": "text", 688 + "primaryKey": false, 631 689 "notNull": true, 632 690 "autoincrement": false 633 691 }, ··· 721 779 } 722 780 }, 723 781 "indexes": { 724 - "feature_requests_number_unique": { 725 - "name": "feature_requests_number_unique", 726 - "columns": ["number"], 782 + "idx_feature_requests_sphere_number": { 783 + "name": "idx_feature_requests_sphere_number", 784 + "columns": [ 785 + "sphere_id", 786 + "number" 787 + ], 727 788 "isUnique": true 728 789 }, 790 + "idx_feature_requests_sphere": { 791 + "name": "idx_feature_requests_sphere", 792 + "columns": [ 793 + "sphere_id" 794 + ], 795 + "isUnique": false 796 + }, 729 797 "idx_feature_requests_status": { 730 798 "name": "idx_feature_requests_status", 731 - "columns": ["status"], 799 + "columns": [ 800 + "status" 801 + ], 732 802 "isUnique": false 733 803 }, 734 804 "idx_feature_requests_created": { 735 805 "name": "idx_feature_requests_created", 736 - "columns": ["created_at"], 806 + "columns": [ 807 + "created_at" 808 + ], 737 809 "isUnique": false 738 810 }, 739 811 "idx_feature_requests_category": { 740 812 "name": "idx_feature_requests_category", 741 - "columns": ["category"], 813 + "columns": [ 814 + "category" 815 + ], 742 816 "isUnique": false 743 817 } 744 818 }, ··· 805 879 "indexes": { 806 880 "idx_feed_posts_parent": { 807 881 "name": "idx_feed_posts_parent", 808 - "columns": ["parent_id"], 882 + "columns": [ 883 + "parent_id" 884 + ], 809 885 "isUnique": false 810 886 }, 811 887 "idx_feed_posts_created": { 812 888 "name": "idx_feed_posts_created", 813 - "columns": ["created_at"], 889 + "columns": [ 890 + "created_at" 891 + ], 814 892 "isUnique": false 815 893 } 816 894 }, ··· 830 908 "internal": { 831 909 "indexes": {} 832 910 } 833 - } 911 + }
+3 -3
drizzle/meta/_journal.json
··· 5 5 { 6 6 "idx": 0, 7 7 "version": "6", 8 - "when": 1774005081703, 9 - "tag": "0000_famous_wallop", 8 + "when": 1774343285849, 9 + "tag": "0000_left_hammerhead", 10 10 "breakpoints": true 11 11 } 12 12 ] 13 - } 13 + }
+2 -1
package.json
··· 30 30 "@types/better-sqlite3": "^7.6.13", 31 31 "@types/bun": "^1.3.11", 32 32 "better-sqlite3": "^12.8.0", 33 + "bun-types": "^1.3.11", 33 34 "drizzle-kit": "^0.31.10", 34 35 "oxfmt": "^0.41.0", 35 - "vitest": "^4.1.0" 36 + "vitest": "^4.1.1" 36 37 } 37 38 }
+3 -3
packages/app/e2e/seed.ts
··· 112 112 113 113 for (const fr of featureRequests) { 114 114 sqlite.run( 115 - `INSERT INTO feature_requests (id, number, title, description, category, status, author_did) 116 - VALUES (?, ?, ?, ?, ?, ?, ?)`, 117 - [fr.id, fr.number, fr.title, fr.description, fr.category, fr.status, fr.authorDid], 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], 118 118 ); 119 119 } 120 120
+76 -6
packages/app/src/app.tsx
··· 1 - import { useEffect } from "preact/hooks"; 1 + import type { ComponentType } from "preact"; 2 + import { useEffect, useMemo } from "preact/hooks"; 2 3 import { auth, logout } from "@exosphere/client/auth"; 3 - import { useLocation } from "@exosphere/client/router"; 4 - import { sphereState } from "@exosphere/client/sphere"; 4 + import { useLocation, useRoute } from "@exosphere/client/router"; 5 + import { spherePath } from "@exosphere/client/router"; 6 + import { sphereState, sphereSlug, loadSphere } from "@exosphere/client/sphere"; 7 + import { isMultiSphere } from "@exosphere/client/config"; 5 8 import * as ui from "@exosphere/client/ui.css"; 6 9 import { ThemeToggle } from "@exosphere/client/components/theme-toggle"; 7 10 import { Sun, Moon, Monitor } from "lucide-preact"; 8 11 import { SignIn } from "./pages/sign-in.tsx"; 9 12 import { CreateSphere } from "./pages/create-sphere.tsx"; 10 13 import { SpherePage } from "./pages/sphere.tsx"; 14 + import { Dashboard } from "./pages/dashboard.tsx"; 11 15 import type { ModuleRoute } from "@exosphere/client/types"; 12 16 import { feedsModule } from "@exosphere/feeds/client"; 13 17 import { featureRequestsModule } from "@exosphere/feature-requests/client"; ··· 58 62 ); 59 63 } 60 64 65 + /** Reactive default page for multi-sphere mode — reads auth state on each render. */ 66 + function MultiSphereDefaultPage() { 67 + return auth.value.authenticated ? <Dashboard /> : <SignInPage />; 68 + } 69 + 70 + /** Watches the :sphereSlug route param and reloads sphere data when it changes. */ 71 + function SphereLoader() { 72 + const { params } = useRoute(); 73 + const currentSlug = sphereSlug.value; 74 + useEffect(() => { 75 + const urlSlug = params.sphereSlug; 76 + if (urlSlug && urlSlug !== currentSlug) { 77 + loadSphere(urlSlug); 78 + } 79 + }, [params.sphereSlug]); 80 + return null; 81 + } 82 + 83 + /** Wraps a component with SphereLoader to reload sphere data when :sphereSlug changes. */ 84 + function withSphereLoader(Component: ComponentType) { 85 + return function SphereRoute() { 86 + return ( 87 + <> 88 + <SphereLoader /> 89 + <Component /> 90 + </> 91 + ); 92 + }; 93 + } 94 + 95 + /** Build routes with /s/:sphereSlug prefix for multi-sphere mode. */ 96 + function buildRoutes(moduleRoutes: ModuleRoute[]): ModuleRoute[] { 97 + return moduleRoutes.map((r) => ({ 98 + ...r, 99 + path: `/s/:sphereSlug${r.path}`, 100 + component: withSphereLoader(r.component), 101 + })); 102 + } 103 + 61 104 function MainContent({ moduleRoutes }: { moduleRoutes: ModuleRoute[] }) { 62 105 const { pending, data } = sphereState.value; 63 106 107 + if (isMultiSphere) { 108 + const routes = useMemo(() => buildRoutes(moduleRoutes), [moduleRoutes]); 109 + return ( 110 + <Router> 111 + <Route path="/sign-in" component={SignInPage} /> 112 + <Route path="/spheres/new" component={CreateSphereGuarded} /> 113 + {routes.map((r) => <Route key={r.path} path={r.path} component={r.component} />)} 114 + <Route path="/s/:sphereSlug" component={withSphereLoader(SpherePage)} /> 115 + <Route path="/" component={MultiSphereDefaultPage} /> 116 + <Route default component={MultiSphereDefaultPage} /> 117 + </Router> 118 + ); 119 + } 120 + 121 + // Self-hosted mode — original behavior 64 122 // No sphere exists — limited routing 65 123 if (!pending && !data) { 66 124 return ( ··· 99 157 <div class={ui.footerInner}> 100 158 <span>&copy; {new Date().getFullYear()} Exosphere</span> 101 159 <span class={ui.footerSep}>&middot;</span> 102 - <a class={ui.footerLink} href="https://bsky.app/profile/hugo.exosphere.site" target="_blank" rel="noopener noreferrer"> 160 + <a 161 + class={ui.footerLink} 162 + href="https://bsky.app/profile/hugo.exosphere.site" 163 + target="_blank" 164 + rel="noopener noreferrer" 165 + > 103 166 @hugo.exosphere.site 104 167 </a> 105 168 <span class={ui.footerSep}>&middot;</span> 106 - <a class={ui.footerLink} href="https://tangled.org/exosphere.site/app" target="_blank" rel="noopener noreferrer"> 169 + <a 170 + class={ui.footerLink} 171 + href="https://tangled.org/exosphere.site/app" 172 + target="_blank" 173 + rel="noopener noreferrer" 174 + > 107 175 Source 108 176 </a> 109 177 </div> ··· 116 184 const { loading, authenticated, did, handle } = auth.value; 117 185 const sphere = sphereState.value.data?.sphere; 118 186 187 + const homeHref = isMultiSphere && sphere ? spherePath("/") : "/"; 188 + 119 189 return ( 120 190 <header class={ui.header}> 121 191 <div class={ui.headerInner}> 122 - <a href="/" class={ui.headerTitle}> 192 + <a href={homeHref} class={ui.headerTitle}> 123 193 {sphere?.name ?? "Exosphere"} 124 194 </a> 125 195 <nav class={ui.headerNav}>
+12 -2
packages/app/src/client.tsx
··· 1 1 import { hydrate } from "preact-iso"; 2 2 import { App } from "./app.tsx"; 3 3 import { checkSession, auth } from "@exosphere/client/auth"; 4 - import { loadSphere, refreshSphere, sphereState } from "@exosphere/client/sphere"; 4 + import { 5 + loadSphere, 6 + refreshSphere, 7 + sphereState, 8 + getSphereSlugFromUrl, 9 + } from "@exosphere/client/sphere"; 5 10 import { ssrPageData } from "@exosphere/client/ssr-data"; 11 + import { setMultiSphere } from "@exosphere/client/config"; 6 12 import { initTheme } from "@exosphere/client/theme-state"; 7 13 import { lightTheme, darkTheme } from "./app.css.ts"; 8 14 ··· 12 18 // Read server-injected data to match SSR output during hydration 13 19 const ssrData: import("./entry-server.tsx").SSRData | undefined = (window as any).__SSR_DATA__; 14 20 if (ssrData) { 21 + // Initialize multi-sphere mode from server data 22 + setMultiSphere(ssrData.multiSphere); 23 + 15 24 auth.value = { 16 25 loading: false, 17 26 authenticated: ssrData.auth.authenticated, ··· 34 43 } else { 35 44 // Fallback: no SSR data (dev without SSR), behave as before 36 45 checkSession(); 37 - loadSphere(); 46 + const slug = getSphereSlugFromUrl(); 47 + loadSphere(slug ?? undefined); 38 48 } 39 49 40 50 hydrate(<App />, document.getElementById("app")!);
+5
packages/app/src/entry-server.tsx
··· 2 2 import { App } from "./app.tsx"; 3 3 import { auth } from "@exosphere/client/auth"; 4 4 import { sphereState } from "@exosphere/client/sphere"; 5 + import { setMultiSphere } from "@exosphere/client/config"; 5 6 import { ssrPageData } from "@exosphere/client/ssr-data"; 6 7 import { lightTheme, darkTheme } from "./app.css.ts"; 7 8 import type { SphereData } from "@exosphere/core/types"; ··· 14 15 auth: { authenticated: boolean; did: string | null; handle: string | null }; 15 16 sphere: SphereData | null; 16 17 sphereError: string | null; 18 + multiSphere: boolean; 17 19 pageData: Record<string, unknown> | null; 18 20 } 19 21 20 22 export async function render(url: string, data: SSRData) { 21 23 // Stub location for preact-iso router 22 24 locationStub(url); 25 + 26 + // Set multi-sphere mode before rendering (always set explicitly to avoid stale state) 27 + setMultiSphere(data.multiSphere); 23 28 24 29 // Set signals before rendering 25 30 auth.value = {
+17 -5
packages/app/src/pages/create-sphere.tsx
··· 1 1 import { useSignal } from "@preact/signals"; 2 2 import { useLocation } from "@exosphere/client/router"; 3 3 import { loadSphere } from "@exosphere/client/sphere"; 4 + import { isMultiSphere } from "@exosphere/client/config"; 4 5 import * as ui from "@exosphere/client/ui.css"; 5 6 import { createSphere } from "../api/spheres.ts"; 6 7 ··· 33 34 if (description.value.trim()) body.description = description.value.trim(); 34 35 35 36 try { 36 - await createSphere(body as Parameters<typeof createSphere>[0]); 37 - await loadSphere(); 38 - route("/"); 37 + const res = await createSphere(body as Parameters<typeof createSphere>[0]); 38 + if (isMultiSphere) { 39 + // In multi-sphere mode, navigate to the new sphere directly 40 + const newSlug = res.sphere.slug; 41 + await loadSphere(newSlug); 42 + route(`/s/${newSlug}`); 43 + } else { 44 + await loadSphere(); 45 + route("/"); 46 + } 39 47 } catch (e) { 40 48 error.value = e instanceof Error ? e.message : "Network error. Please try again."; 41 49 submitting.value = false; ··· 90 98 91 99 <div> 92 100 <label class={ui.label} htmlFor="visibility"> 93 - Visibility 101 + Visibility (only Public is available for now) 94 102 </label> 95 103 <select 96 104 id="visibility" 97 105 class={ui.select} 98 106 value={visibility.value} 99 107 onChange={(e) => (visibility.value = (e.target as HTMLSelectElement).value)} 108 + // can only be Public for now 109 + disabled 100 110 > 101 111 <option value="public">Public — anyone can view</option> 102 112 <option value="private">Private — members only</option> ··· 105 115 106 116 <div> 107 117 <label class={ui.label} htmlFor="writeAccess"> 108 - Write access 118 + Write access (only Open is available for now) 109 119 </label> 110 120 <select 111 121 id="writeAccess" 112 122 class={ui.select} 113 123 value={writeAccess.value} 114 124 onChange={(e) => (writeAccess.value = (e.target as HTMLSelectElement).value)} 125 + // can only be Open for now 126 + disabled 115 127 > 116 128 <option value="open">Open — any authenticated user</option> 117 129 <option value="members">Members only</option>
+56
packages/app/src/pages/dashboard.tsx
··· 1 + import { useQuery } from "@exosphere/client/hooks"; 2 + import { apiFetch } from "@exosphere/client/api"; 3 + import * as ui from "@exosphere/client/ui.css"; 4 + 5 + interface SphereListItem { 6 + id: string; 7 + slug: string; 8 + name: string; 9 + description: string | null; 10 + visibility: string; 11 + } 12 + 13 + function getMySpheres() { 14 + return apiFetch<{ spheres: SphereListItem[] }>("/api/spheres?member=true"); 15 + } 16 + 17 + export function Dashboard() { 18 + const { data, pending, loading } = useQuery(() => getMySpheres(), []); 19 + 20 + return ( 21 + <div class={ui.container}> 22 + <div class={ui.section}> 23 + <div class={ui.row}> 24 + <h1 class={ui.pageTitle}>My Spheres</h1> 25 + <a href="/spheres/new" class={ui.button}> 26 + Create sphere 27 + </a> 28 + </div> 29 + 30 + {pending ? ( 31 + loading ? ( 32 + <p class={ui.muted}>Loading...</p> 33 + ) : null 34 + ) : !data || data.spheres.length === 0 ? ( 35 + <div class={ui.card}> 36 + <p class={ui.description}> 37 + You're not a member of any sphere yet. Create one to get started. 38 + </p> 39 + </div> 40 + ) : ( 41 + <div class={ui.stackSm}> 42 + {data.spheres.map((sphere) => ( 43 + <a key={sphere.id} href={`/s/${sphere.slug}`} class={ui.cardLink}> 44 + <div class={ui.row}> 45 + <strong>{sphere.name}</strong> 46 + <span class={ui.badge}>{sphere.visibility}</span> 47 + </div> 48 + {sphere.description && <p class={ui.muted}>{sphere.description}</p>} 49 + </a> 50 + ))} 51 + </div> 52 + )} 53 + </div> 54 + </div> 55 + ); 56 + }
+2 -1
packages/app/src/pages/sphere.tsx
··· 1 1 import { auth } from "@exosphere/client/auth"; 2 2 import { sphereState, sphereSlug, refreshSphere } from "@exosphere/client/sphere"; 3 3 import { useQuery } from "@exosphere/client/hooks"; 4 + import { spherePath } from "@exosphere/client/router"; 4 5 import * as ui from "@exosphere/client/ui.css"; 5 6 import { 6 7 getSphereModules, ··· 84 85 <div class={ui.card} key={mod.name}> 85 86 <div class={ui.row}> 86 87 <div> 87 - <a href={`/${moduleLabels[mod.name]?.path ?? mod.name}`}> 88 + <a href={spherePath(`/${moduleLabels[mod.name]?.path ?? mod.name}`)}> 88 89 <strong>{moduleLabels[mod.name]?.label ?? mod.name}</strong> 89 90 </a> 90 91 <span class={`${ui.muted} ${ui.inlineTag}`}>enabled</span>
+31 -8
packages/app/src/server.ts
··· 2 2 import { serveStatic } from "hono/bun"; 3 3 import { getCookie } from "hono/cookie"; 4 4 import { getOAuthClient, oauthRoutes } from "@exosphere/core/auth"; 5 - import { createSphereRoutes, getCurrentSphere } from "@exosphere/core/sphere"; 5 + import { createSphereRoutes, getCurrentSphere, sphereContext } from "@exosphere/core/sphere"; 6 + import { isMultiSphere } from "@exosphere/core/config"; 6 7 import { startJetstream, stopCursorFlushing } from "@exosphere/indexer"; 7 8 import { modules, coreIndexer } from "@exosphere/indexer/modules"; 8 9 import { createMcpRoutes } from "@exosphere/mcp"; ··· 13 14 // Mount OAuth routes 14 15 app.route("/api/oauth", oauthRoutes); 15 16 16 - // Register modules 17 - for (const mod of modules) { 18 - app.route(`/api/${mod.name}`, mod.api); 17 + // Register modules — sphere context is injected by middleware in both modes 18 + if (isMultiSphere) { 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); 22 + } 23 + } else { 24 + for (const mod of modules) { 25 + app.use(`/api/${mod.name}/*`, sphereContext); 26 + app.route(`/api/${mod.name}`, mod.api); 27 + } 19 28 } 20 29 21 30 // Mount Sphere routes (needs the list of available modules for validation) ··· 110 119 } 111 120 112 121 // Load sphere data 122 + // In multi-sphere mode, extract slug from URL path /s/:slug/... 123 + // In self-hosted mode, load the single sphere 113 124 let sphere; 125 + let sphereSlugFromUrl: string | undefined; 114 126 try { 115 - sphere = getCurrentSphere(authData.did); 127 + if (isMultiSphere) { 128 + const slugMatch = c.req.path.match(/^\/s\/([^/]+)/); 129 + if (slugMatch) { 130 + sphereSlugFromUrl = slugMatch[1]; 131 + sphere = getCurrentSphere(authData.did, sphereSlugFromUrl); 132 + } 133 + // No slug in URL = dashboard/sign-in page — no sphere to load 134 + } else { 135 + sphere = getCurrentSphere(authData.did); 136 + } 116 137 } catch { 117 138 sphere = null; 118 139 } ··· 120 141 // Prefetch page data by calling our own API routes internally 121 142 const pageData: Record<string, unknown> = {}; 122 143 if (sphere) { 123 - const prefetches = ssrPrefetch(c.req.path); 144 + const prefetches = ssrPrefetch(c.req.path, sphereSlugFromUrl); 124 145 for (const prefetch of prefetches) { 125 146 try { 126 147 const res = await app.request(prefetch.apiUrl); ··· 131 152 } 132 153 133 154 // For individual feature requests, also prefetch comments 155 + const apiBase = sphereSlugFromUrl ? `/api/s/${sphereSlugFromUrl}` : "/api"; 134 156 const frData = pageData["feature-request"] as 135 157 | { featureRequest?: { id: string } } 136 158 | undefined; 137 159 if (frData?.featureRequest?.id) { 138 160 try { 139 161 const res = await app.request( 140 - `/api/feature-requests/${frData.featureRequest.id}/comments`, 162 + `${apiBase}/feature-requests/${frData.featureRequest.id}/comments`, 141 163 ); 142 164 if (res.ok) pageData["feature-request-comments"] = await res.json(); 143 165 } catch { ··· 149 171 const ssrData = { 150 172 auth: authData, 151 173 sphere, 152 - sphereError: sphere ? null : "No sphere configured", 174 + sphereError: sphere ? null : isMultiSphere ? null : "No sphere configured", 175 + multiSphere: isMultiSphere, 153 176 pageData, 154 177 }; 155 178
+20 -10
packages/app/src/ssr-prefetch.ts
··· 1 - /** Map a page path to prefetch descriptors for SSR data loading. */ 2 - export function ssrPrefetch(path: string): { key: string; apiUrl: string }[] { 3 - if (path === "/infuse") 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"; 5 + 6 + // In multi-sphere mode, strip the /s/:slug prefix to match module paths 7 + const modulePath = sphereSlug ? path.replace(/^\/s\/[^/]+/, "") : path; 8 + 9 + if (modulePath === "/infuse") 4 10 return [ 5 11 { 6 12 key: "feature-requests", 7 - apiUrl: "/api/feature-requests?status=requested,approved,in-progress", 13 + apiUrl: `${apiBase}/feature-requests?status=requested,approved,in-progress`, 8 14 }, 9 15 ]; 10 - if (path === "/infuse/done") 11 - return [{ key: "feature-requests-done", apiUrl: "/api/feature-requests?status=done" }]; 12 - if (path === "/infuse/not-planned") 16 + if (modulePath === "/infuse/done") 17 + return [{ key: "feature-requests-done", apiUrl: `${apiBase}/feature-requests?status=done` }]; 18 + if (modulePath === "/infuse/not-planned") 13 19 return [ 14 - { key: "feature-requests-not-planned", apiUrl: "/api/feature-requests?status=not-planned" }, 20 + { 21 + key: "feature-requests-not-planned", 22 + apiUrl: `${apiBase}/feature-requests?status=not-planned`, 23 + }, 15 24 ]; 16 - const frMatch = path.match(/^\/infuse\/(\d+)$/); 17 - if (frMatch) return [{ key: "feature-request", apiUrl: `/api/feature-requests/${frMatch[1]}` }]; 25 + const frMatch = modulePath.match(/^\/infuse\/(\d+)$/); 26 + if (frMatch) 27 + return [{ key: "feature-request", apiUrl: `${apiBase}/feature-requests/${frMatch[1]}` }]; 18 28 return []; 19 29 }
+32 -10
packages/app/src/vite-ssr-plugin.ts
··· 47 47 return styles.map((css) => `<style data-vite-dev-css>${css}</style>`).join("\n"); 48 48 } 49 49 50 - export function ssrDevPlugin(): Plugin { 50 + export function ssrDevPlugin({ isMultiSphere = false } = {}): Plugin { 51 51 return { 52 52 name: "exosphere-ssr", 53 53 configureServer(server) { ··· 76 76 const cookie = req.headers.cookie ?? ""; 77 77 78 78 // Fetch auth and sphere data from the Hono API server 79 - let authData: { authenticated: boolean; did?: string } = { authenticated: false }; 79 + let authData: { authenticated: boolean; did?: string; handle?: string } = { 80 + authenticated: false, 81 + }; 80 82 let sphereData: unknown = null; 83 + let sphereSlugFromUrl: string | undefined; 81 84 try { 82 - const [authRes, sphereRes] = await Promise.all([ 83 - fetch(`${API_SERVER}/api/oauth/session`, { headers: { cookie } }), 84 - fetch(`${API_SERVER}/api/spheres/current`, { headers: { cookie } }), 85 - ]); 85 + // In multi-sphere mode, extract slug from URL path /s/:slug/... 86 + if (isMultiSphere) { 87 + const slugMatch = url.match(/^\/s\/([^/]+)/); 88 + if (slugMatch) sphereSlugFromUrl = slugMatch[1]; 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}` 93 + : isMultiSphere 94 + ? null 95 + : `${API_SERVER}/api/spheres/current`; 96 + 97 + const authRes = await fetch(`${API_SERVER}/api/oauth/session`, { 98 + headers: { cookie }, 99 + }); 86 100 authData = await authRes.json(); 87 - sphereData = sphereRes.ok ? await sphereRes.json() : null; 101 + if (sphereUrl) { 102 + const sphereRes = await fetch(sphereUrl, { headers: { cookie } }); 103 + sphereData = sphereRes.ok ? await sphereRes.json() : null; 104 + } 88 105 } catch { 89 106 // API server not running — render with empty data 90 107 } ··· 99 116 // Prefetch page data from the API server 100 117 const pageData: Record<string, unknown> = {}; 101 118 if (sphereData) { 102 - const prefetches = ssrPrefetch(url); 119 + const prefetches = ssrPrefetch(url, sphereSlugFromUrl); 103 120 for (const prefetch of prefetches) { 104 121 try { 105 122 const res = await fetch(`${API_SERVER}${prefetch.apiUrl}`, { headers: { cookie } }); ··· 110 127 } 111 128 112 129 // For individual feature requests, also prefetch comments 130 + const apiBase = sphereSlugFromUrl 131 + ? `${API_SERVER}/api/s/${sphereSlugFromUrl}` 132 + : `${API_SERVER}/api`; 113 133 const frData = pageData["feature-request"] as 114 134 | { featureRequest?: { id: string } } 115 135 | undefined; 116 136 if (frData?.featureRequest?.id) { 117 137 try { 118 138 const res = await fetch( 119 - `${API_SERVER}/api/feature-requests/${frData.featureRequest.id}/comments`, 139 + `${apiBase}/feature-requests/${frData.featureRequest.id}/comments`, 120 140 { headers: { cookie } }, 121 141 ); 122 142 if (res.ok) pageData["feature-request-comments"] = await res.json(); ··· 130 150 auth: { 131 151 authenticated: authData.authenticated, 132 152 did: authData.did ?? null, 153 + handle: authData.handle ?? null, 133 154 }, 134 155 sphere: sphereData, 135 - sphereError: sphereData ? null : "No sphere configured", 156 + sphereError: sphereData ? null : isMultiSphere ? null : "No sphere configured", 157 + multiSphere: isMultiSphere, 136 158 pageData, 137 159 }; 138 160
+1 -1
packages/app/test-results/.last-run.json
··· 1 1 { 2 2 "status": "passed", 3 3 "failedTests": [] 4 - } 4 + }
+15 -3
packages/app/vite.config.ts
··· 1 - import { defineConfig } from "vite"; 1 + import path from "node:path"; 2 + import { defineConfig, loadEnv } from "vite"; 2 3 import preact from "@preact/preset-vite"; 3 4 import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin"; 4 5 import { ssrDevPlugin } from "./src/vite-ssr-plugin.ts"; 6 + 7 + // Monorepo root — where the .env file lives 8 + const ROOT = path.resolve(import.meta.dirname, "../.."); 5 9 6 10 const PDS_TARGET = "http://localhost:3000"; 7 11 ··· 18 22 } 19 23 } 20 24 21 - export default defineConfig(async () => { 25 + export default defineConfig(async ({ mode }) => { 26 + // Load env from monorepo root (where .env lives) with empty prefix to include all vars 27 + const env = loadEnv(mode, ROOT, ""); 28 + const isMultiSphere = env.MULTI_SPHERE === "1"; 29 + 22 30 const pdsIssuer = await getPdsIssuer(); 23 31 24 32 const pdsProxy: import("vite").ProxyOptions | undefined = pdsIssuer ··· 115 123 116 124 return { 117 125 build: { manifest: true }, 118 - plugins: [preact(), vanillaExtractPlugin(), ssrDevPlugin()], 126 + envDir: ROOT, 127 + define: { 128 + __MULTI_SPHERE__: JSON.stringify(isMultiSphere), 129 + }, 130 + plugins: [preact(), vanillaExtractPlugin(), ssrDevPlugin({ isMultiSphere })], 119 131 ssr: { 120 132 noExternal: ["@exosphere/client", "@exosphere/feeds", "@exosphere/feature-requests"], 121 133 },
+1
packages/client/package.json
··· 12 12 "./theme.css": "./src/theme.css.ts", 13 13 "./ui.css": "./src/ui.css.ts", 14 14 "./types": "./src/types.ts", 15 + "./config": "./src/config.ts", 15 16 "./format": "./src/format.ts", 16 17 "./theme-state": "./src/theme-state.ts", 17 18 "./components/collapsible-section": "./src/components/collapsible-section.tsx",
+16
packages/client/src/api.ts
··· 1 + import { isMultiSphere } from "./config.ts"; 2 + import { sphereSlug } from "./sphere.ts"; 3 + 1 4 export class ApiError extends Error { 2 5 constructor( 3 6 public status: number, ··· 18 21 } 19 22 return data as T; 20 23 } 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 + }
+11
packages/client/src/config.ts
··· 1 + /** Whether the app runs in multi-sphere (hosted) mode. 2 + * Initialized at build time via Vite's `define` (works even without SSR). 3 + * Can be overridden per-request during SSR via `setMultiSphere()`. */ 4 + declare const __MULTI_SPHERE__: boolean; 5 + export let isMultiSphere: boolean = 6 + typeof __MULTI_SPHERE__ !== "undefined" ? __MULTI_SPHERE__ : false; 7 + 8 + /** Called once at bootstrap with the value from __SSR_DATA__ or server config. */ 9 + export function setMultiSphere(value: boolean) { 10 + isMultiSphere = value; 11 + }
+13
packages/client/src/router.tsx
··· 2 2 // Re-exported from preact-iso/lazy (not preact/compat) because it installs 3 3 // the options.__e patch that preact-iso's Router needs to handle suspense. 4 4 export { default as lazy } from "preact-iso/lazy"; 5 + 6 + import { isMultiSphere } from "./config.ts"; 7 + import { sphereSlug } from "./sphere.ts"; 8 + 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) */ 11 + export function spherePath(path: string): string { 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}`; 17 + }
+24 -3
packages/client/src/sphere.ts
··· 1 1 import { signal, computed } from "@preact/signals"; 2 2 import type { SphereData } from "@exosphere/core/types"; 3 3 import { apiFetch } from "./api.ts"; 4 + import { isMultiSphere } from "./config.ts"; 4 5 5 6 const LOADING_DELAY = 400; 6 7 ··· 20 21 21 22 export const sphereSlug = computed(() => sphereState.value.data?.sphere.slug ?? null); 22 23 23 - export async function loadSphere() { 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`. 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) { 31 + sphereState.value = { pending: false, loading: false, data: null, error: null }; 32 + return; 33 + } 24 34 sphereState.value = { pending: true, loading: false, data: null, error: null }; 25 35 const timer = setTimeout(() => { 26 36 sphereState.value = { ...sphereState.value, loading: true }; 27 37 }, LOADING_DELAY); 28 38 try { 29 - const data = await apiFetch<SphereData>("/api/spheres/current"); 39 + const url = slug ? `/api/spheres/${slug}` : "/api/spheres/current"; 40 + const data = await apiFetch<SphereData>(url); 30 41 clearTimeout(timer); 31 42 sphereState.value = { pending: false, loading: false, data, error: null }; 32 43 } catch (err) { ··· 42 53 43 54 /** Silent refresh — keeps existing data visible while fetching. */ 44 55 export async function refreshSphere() { 56 + const slug = sphereSlug.value; 57 + // In multi-sphere mode with no current sphere, nothing to refresh 58 + if (isMultiSphere && !slug) return; 45 59 try { 46 - const data = await apiFetch<SphereData>("/api/spheres/current"); 60 + const url = slug ? `/api/spheres/${slug}` : "/api/spheres/current"; 61 + const data = await apiFetch<SphereData>(url); 47 62 sphereState.value = { pending: false, loading: false, data, error: null }; 48 63 } catch (err) { 49 64 sphereState.value = { ··· 52 67 }; 53 68 } 54 69 } 70 + 71 + /** Extract sphere slug from the current URL path in multi-sphere mode. */ 72 + export function getSphereSlugFromUrl(): string | null { 73 + const match = location.pathname.match(/^\/s\/([^/]+)/); 74 + return match?.[1] ?? null; 75 + }
+2 -1
packages/core/package.json
··· 12 12 "./pds": "./src/pds.ts", 13 13 "./sphere": "./src/sphere/index.ts", 14 14 "./identity": "./src/identity/index.ts", 15 - "./types": "./src/types/index.ts" 15 + "./types": "./src/types/index.ts", 16 + "./config": "./src/config.ts" 16 17 }, 17 18 "dependencies": { 18 19 "@atproto/common-web": "^0.4.18",
+1
packages/core/src/config.ts
··· 1 + export const isMultiSphere = process.env.MULTI_SPHERE === "1";
+34 -6
packages/core/src/sphere/api/spheres.ts
··· 11 11 12 12 const SPHERE_COLLECTION = "site.exosphere.sphere"; 13 13 14 - /** Load the current sphere with its modules, member count, and caller's role. */ 15 - export function getCurrentSphere(did: string | null) { 16 - const sphere = getDb().select().from(spheres).orderBy(spheres.createdAt).limit(1).get(); 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() 19 + : getDb().select().from(spheres).orderBy(spheres.createdAt).limit(1).get(); 17 20 if (!sphere) return null; 18 21 const modules = getEnabledModules(sphere.id); 19 22 const memberCount = getDb() ··· 91 94 return c.json({ sphere }, 201); 92 95 }); 93 96 94 - // List spheres 95 - app.get("/", (c) => { 96 - const rows = getDb().select().from(spheres).orderBy(spheres.createdAt).all(); 97 + // List spheres. ?member=true filters to spheres the caller is a member of. 98 + app.get("/", optionalAuth, (c) => { 99 + const db = getDb(); 100 + const memberOnly = c.req.query("member") === "true"; 101 + const did = c.var.did; 102 + 103 + if (memberOnly && !did) { 104 + return c.json({ spheres: [] }); 105 + } 106 + 107 + if (memberOnly && did) { 108 + const rows = db 109 + .select({ sphere: spheres }) 110 + .from(spheres) 111 + .innerJoin( 112 + sphereMembers, 113 + and( 114 + eq(sphereMembers.sphereId, spheres.id), 115 + eq(sphereMembers.did, did), 116 + eq(sphereMembers.status, "active"), 117 + ), 118 + ) 119 + .orderBy(spheres.createdAt) 120 + .all(); 121 + return c.json({ spheres: rows.map((r) => r.sphere) }); 122 + } 123 + 124 + const rows = db.select().from(spheres).orderBy(spheres.createdAt).all(); 97 125 return c.json({ spheres: rows }); 98 126 }); 99 127
+1
packages/core/src/sphere/index.ts
··· 7 7 findSphereByAtUri, 8 8 } from "./operations.ts"; 9 9 export type { ModerationHandler } from "./operations.ts"; 10 + export { sphereContext } from "./middleware.ts";
+43
packages/core/src/sphere/middleware.ts
··· 1 + import { createMiddleware } from "hono/factory"; 2 + import { eq } from "../db/drizzle.ts"; 3 + import { getDb } from "../db/index.ts"; 4 + import { spheres } from "../db/schema/index.ts"; 5 + import { isMultiSphere } from "../config.ts"; 6 + import type { SphereEnv } from "../types/index.ts"; 7 + 8 + /** 9 + * Middleware that resolves the current sphere and injects it into the Hono context. 10 + * 11 + * - Multi-sphere mode: reads `:sphereSlug` from URL params. 12 + * - Self-hosted mode: loads the first (and only) sphere. 13 + * 14 + * Sets `c.var.sphereId`, `c.var.sphereSlug`, `c.var.sphereVisibility`, 15 + * `c.var.sphereWriteAccess`, `c.var.sphereOwnerDid`, `c.var.spherePdsUri`. 16 + */ 17 + export const sphereContext = createMiddleware<SphereEnv>(async (c, next) => { 18 + const db = getDb(); 19 + let sphere; 20 + 21 + if (isMultiSphere) { 22 + const slug = c.req.param("sphereSlug"); 23 + if (!slug) { 24 + return c.json({ error: "Sphere not found" }, 404); 25 + } 26 + sphere = db.select().from(spheres).where(eq(spheres.slug, slug)).get(); 27 + } else { 28 + sphere = db.select().from(spheres).orderBy(spheres.createdAt).limit(1).get(); 29 + } 30 + 31 + if (!sphere) { 32 + return c.json({ error: "Sphere not found" }, 404); 33 + } 34 + 35 + c.set("sphereId", sphere.id); 36 + c.set("sphereSlug", sphere.slug); 37 + c.set("sphereVisibility", sphere.visibility as "public" | "private"); 38 + c.set("sphereWriteAccess", sphere.writeAccess as "open" | "members"); 39 + c.set("sphereOwnerDid", sphere.ownerDid); 40 + c.set("spherePdsUri", sphere.pdsUri); 41 + 42 + await next(); 43 + });
+12
packages/core/src/types/index.ts
··· 2 2 import type { BlankSchema } from "hono/types"; 3 3 import type { SphereMember } from "../db/schema/spheres.ts"; 4 4 5 + /** Hono env variables set by the sphereContext middleware. */ 6 + export type SphereEnv = { 7 + Variables: { 8 + sphereId: string; 9 + sphereSlug: string; 10 + sphereVisibility: "public" | "private"; 11 + sphereWriteAccess: "open" | "members"; 12 + sphereOwnerDid: string; 13 + spherePdsUri: string | null; 14 + }; 15 + }; 16 + 5 17 export interface JetstreamCommitEvent { 6 18 did: string; 7 19 time_us: number;
+6
packages/feature-requests/src/__tests__/db-operations.test.ts
··· 39 39 40 40 const AUTHOR_DID = "did:plc:author1"; 41 41 const MOD_DID = "did:plc:mod1"; 42 + const SPHERE_ID = "test-sphere-001"; 42 43 43 44 function seedFR(overrides: Partial<typeof featureRequests.$inferInsert> & { id: string }) { 44 45 const values = { 46 + sphereId: SPHERE_ID, 45 47 number: 1, 46 48 authorDid: AUTHOR_DID, 47 49 title: "Test FR", ··· 63 65 it("inserts a feature request with auto-incrementing number", () => { 64 66 const fr1 = insertFeatureRequest({ 65 67 id: "fr-1", 68 + sphereId: SPHERE_ID, 66 69 authorDid: AUTHOR_DID, 67 70 title: "First", 68 71 description: "Desc", ··· 74 77 75 78 const fr2 = insertFeatureRequest({ 76 79 id: "fr-2", 80 + sphereId: SPHERE_ID, 77 81 authorDid: AUTHOR_DID, 78 82 title: "Second", 79 83 description: "Desc", ··· 86 90 it("handles conflict (duplicate id) gracefully", () => { 87 91 insertFeatureRequest({ 88 92 id: "fr-1", 93 + sphereId: SPHERE_ID, 89 94 authorDid: AUTHOR_DID, 90 95 title: "Original", 91 96 description: "Desc", ··· 96 101 // Same id — onConflictDoNothing, returns existing row 97 102 const dup = insertFeatureRequest({ 98 103 id: "fr-1", 104 + sphereId: SPHERE_ID, 99 105 authorDid: AUTHOR_DID, 100 106 title: "Duplicate", 101 107 description: "Desc",
+9 -16
packages/feature-requests/src/__tests__/schemas.test.ts
··· 11 11 const valid = { 12 12 title: "Add dark mode", 13 13 description: "It would be great to have a dark mode option.", 14 - sphereSlug: "my-sphere", 15 14 }; 16 15 17 16 it("accepts valid input with defaults", () => { ··· 45 44 createFeatureRequestSchema.parse({ ...valid, description: "x".repeat(10001) }), 46 45 ).toThrow(); 47 46 }); 48 - 49 - it("rejects missing sphereSlug", () => { 50 - const { sphereSlug: _, ...noSlug } = valid; 51 - expect(() => createFeatureRequestSchema.parse(noSlug)).toThrow(); 52 - }); 53 47 }); 54 48 55 49 describe("updateStatusSchema", () => { 56 50 it("accepts a settable status", () => { 57 - const result = updateStatusSchema.parse({ status: "approved", sphereSlug: "s" }); 51 + const result = updateStatusSchema.parse({ status: "approved" }); 58 52 expect(result.status).toBe("approved"); 59 53 }); 60 54 61 55 it("rejects 'duplicate' (not a settable status)", () => { 62 - expect(() => updateStatusSchema.parse({ status: "duplicate", sphereSlug: "s" })).toThrow(); 56 + expect(() => updateStatusSchema.parse({ status: "duplicate" })).toThrow(); 63 57 }); 64 58 65 59 it("rejects unknown status", () => { 66 - expect(() => updateStatusSchema.parse({ status: "unknown", sphereSlug: "s" })).toThrow(); 60 + expect(() => updateStatusSchema.parse({ status: "unknown" })).toThrow(); 67 61 }); 68 62 }); 69 63 ··· 71 65 it("accepts valid input", () => { 72 66 const result = markAsDuplicateSchema.parse({ 73 67 duplicateOfId: "abc123", 74 - sphereSlug: "s", 75 68 }); 76 69 expect(result.duplicateOfId).toBe("abc123"); 77 70 }); 78 71 79 72 it("rejects empty duplicateOfId", () => { 80 - expect(() => markAsDuplicateSchema.parse({ duplicateOfId: "", sphereSlug: "s" })).toThrow(); 73 + expect(() => markAsDuplicateSchema.parse({ duplicateOfId: "" })).toThrow(); 81 74 }); 82 75 }); 83 76 84 77 describe("createCommentSchema", () => { 85 78 it("accepts valid input", () => { 86 - const result = createCommentSchema.parse({ content: "Nice idea!", sphereSlug: "s" }); 79 + const result = createCommentSchema.parse({ content: "Nice idea!" }); 87 80 expect(result.content).toBe("Nice idea!"); 88 81 }); 89 82 90 83 it("rejects empty content", () => { 91 - expect(() => createCommentSchema.parse({ content: "", sphereSlug: "s" })).toThrow(); 84 + expect(() => createCommentSchema.parse({ content: "" })).toThrow(); 92 85 }); 93 86 94 87 it("rejects content over 5 000 chars", () => { 95 88 expect(() => 96 - createCommentSchema.parse({ content: "x".repeat(5001), sphereSlug: "s" }), 89 + createCommentSchema.parse({ content: "x".repeat(5001) }), 97 90 ).toThrow(); 98 91 }); 99 92 }); 100 93 101 94 describe("updateCommentSchema", () => { 102 95 it("accepts valid input", () => { 103 - const result = updateCommentSchema.parse({ content: "Updated!", sphereSlug: "s" }); 96 + const result = updateCommentSchema.parse({ content: "Updated!" }); 104 97 expect(result.content).toBe("Updated!"); 105 98 }); 106 99 107 100 it("rejects empty content", () => { 108 - expect(() => updateCommentSchema.parse({ content: "", sphereSlug: "s" })).toThrow(); 101 + expect(() => updateCommentSchema.parse({ content: "" })).toThrow(); 109 102 }); 110 103 });
+99 -113
packages/feature-requests/src/api/comments.ts
··· 6 6 import { getActiveMemberRole, isAdminOrOwner } from "@exosphere/core/sphere"; 7 7 import { putPdsRecord, deletePdsRecord, generateRkey } from "@exosphere/core/pds"; 8 8 import { resolveDidHandles } from "@exosphere/core/identity"; 9 - import { spheres } from "@exosphere/core/db/schema"; 9 + import type { SphereEnv } from "@exosphere/core/types"; 10 10 import { 11 11 featureRequests, 12 12 featureRequestComments, ··· 28 28 const COMMENT_VOTE_COLLECTION = "site.exosphere.featureRequestCommentVote"; 29 29 const MODERATION_COLLECTION = "site.exosphere.moderation"; 30 30 31 - const app = new Hono<AuthEnv>(); 31 + const app = new Hono<AuthEnv & SphereEnv>(); 32 + 33 + /** Verify a comment belongs to the current sphere by joining through its parent FR. */ 34 + function findCommentInSphere(commentId: string, sphereId: string) { 35 + return getDb() 36 + .select() 37 + .from(featureRequestComments) 38 + .innerJoin(featureRequests, eq(featureRequests.id, featureRequestComments.requestId)) 39 + .where( 40 + and(eq(featureRequestComments.id, commentId), eq(featureRequests.sphereId, sphereId)), 41 + ) 42 + .get(); 43 + } 32 44 33 45 // ---- Comments ---- 34 46 35 47 // List comments for a feature request 36 48 app.get("/:id/comments", async (c) => { 37 49 const requestId = c.req.param("id"); 50 + const sphereId = c.var.sphereId; 38 51 const sortParam = c.req.query("sort"); 39 52 const orderParam = c.req.query("order"); 40 53 const sortBy = sortParam === "votes" ? "votes" : "date"; 41 54 const orderDir = orderParam === "asc" ? asc : desc; 42 55 const db = getDb(); 43 56 44 - // When the request is closed (done/not-planned), hide comments posted after the closing date 57 + // Verify the FR belongs to this sphere 45 58 const request = db 46 59 .select({ status: featureRequests.status }) 47 60 .from(featureRequests) 48 - .where(eq(featureRequests.id, requestId)) 61 + .where(and(eq(featureRequests.id, requestId), eq(featureRequests.sphereId, sphereId))) 49 62 .get(); 50 63 64 + if (!request) { 65 + return c.json({ comments: [] }); 66 + } 67 + 68 + // When the request is closed (done/not-planned), hide comments posted after the closing date 51 69 let cutoffDate: string | null = null; 52 70 if ( 53 - request && 54 - (request.status === "done" || 55 - request.status === "not-planned" || 56 - request.status === "duplicate") 71 + request.status === "done" || 72 + request.status === "not-planned" || 73 + request.status === "duplicate" 57 74 ) { 58 75 const closedStatus = db 59 76 .select({ createdAt: featureRequestStatuses.createdAt }) ··· 121 138 return c.json({ error: z.flattenError(result.error) }, 400); 122 139 } 123 140 124 - const { content, sphereSlug } = result.data; 141 + const { content } = result.data; 142 + const sphereId = c.var.sphereId; 143 + const sphereVisibility = c.var.sphereVisibility; 125 144 const db = getDb(); 126 145 const did = c.var.did; 127 146 128 - // Check feature request exists and is visible 147 + // Check feature request exists, belongs to this sphere, and is visible 129 148 const existing = db 130 149 .select({ id: featureRequests.id, pdsUri: featureRequests.pdsUri }) 131 150 .from(featureRequests) 132 - .where(and(eq(featureRequests.id, requestId), sql`${featureRequests.hiddenAt} is null`)) 151 + .where( 152 + and( 153 + eq(featureRequests.id, requestId), 154 + eq(featureRequests.sphereId, sphereId), 155 + sql`${featureRequests.hiddenAt} is null`, 156 + ), 157 + ) 133 158 .get(); 134 159 if (!existing) { 135 160 return c.json({ error: "Feature request not found" }, 404); ··· 155 180 let pdsUri: string | null = null; 156 181 157 182 // Write to PDS for public spheres 158 - const sphere = db 159 - .select({ visibility: spheres.visibility }) 160 - .from(spheres) 161 - .where(eq(spheres.slug, sphereSlug)) 162 - .get(); 163 - 164 - if (sphere?.visibility === "public") { 183 + if (sphereVisibility === "public") { 165 184 const session = c.var.session; 166 185 const now = new Date().toISOString(); 167 186 const res = await session.fetchHandler("/xrpc/com.atproto.repo.putRecord", { ··· 212 231 return c.json({ error: z.flattenError(result.error) }, 400); 213 232 } 214 233 215 - const { content, sphereSlug } = result.data; 234 + const { content } = result.data; 235 + const sphereId = c.var.sphereId; 216 236 const db = getDb(); 217 237 const did = c.var.did; 218 238 219 - const comment = db 220 - .select() 221 - .from(featureRequestComments) 222 - .where(eq(featureRequestComments.id, id)) 223 - .get(); 224 - if (!comment) { 239 + const row = findCommentInSphere(id, sphereId); 240 + if (!row) { 225 241 return c.json({ error: "Comment not found" }, 404); 226 242 } 243 + const comment = row.feature_request_comments; 227 244 if (comment.authorDid !== did) { 228 245 return c.json({ error: "Forbidden" }, 403); 229 246 } ··· 282 299 const id = c.req.param("id"); 283 300 const db = getDb(); 284 301 const did = c.var.did; 302 + const sphereId = c.var.sphereId; 303 + const sphereOwnerDid = c.var.sphereOwnerDid; 304 + const spherePdsUri = c.var.spherePdsUri; 285 305 286 - const comment = db 287 - .select() 288 - .from(featureRequestComments) 289 - .where(eq(featureRequestComments.id, id)) 290 - .get(); 291 - if (!comment) { 306 + const row = findCommentInSphere(id, sphereId); 307 + if (!row) { 292 308 return c.json({ error: "Comment not found" }, 404); 293 309 } 310 + const comment = row.feature_request_comments; 294 311 295 312 const isAuthor = comment.authorDid === did; 296 313 297 314 if (!isAuthor) { 298 315 // Only admin/owner can moderate other users' comments 299 - const sphereSlug = c.req.query("sphereSlug"); 300 - if (!sphereSlug) { 301 - return c.json({ error: "Forbidden" }, 403); 302 - } 303 - const sphere = db 304 - .select({ id: spheres.id, ownerDid: spheres.ownerDid, pdsUri: spheres.pdsUri }) 305 - .from(spheres) 306 - .where(eq(spheres.slug, sphereSlug)) 307 - .get(); 308 - if (!sphere) { 309 - return c.json({ error: "Forbidden" }, 403); 310 - } 311 - const role = getActiveMemberRole(sphere.id, did); 316 + const role = getActiveMemberRole(sphereId, did); 312 317 if (!isAdminOrOwner(role)) { 313 318 return c.json({ error: "Forbidden" }, 403); 314 319 } ··· 320 325 321 326 // Admin moderation — publish moderation record on admin's PDS, hide locally 322 327 if (comment.pdsUri) { 323 - const sphereUri = sphere.pdsUri ?? `at://${sphere.ownerDid}/site.exosphere.sphere/self`; 328 + const sphereUri = spherePdsUri ?? `at://${sphereOwnerDid}/site.exosphere.sphere/self`; 324 329 const session = c.var.session; 325 330 const pdsUri = await putPdsRecord(session, MODERATION_COLLECTION, id, { 326 331 sphere: sphereUri, ··· 371 376 // Admin/owner-only: unhide a moderated comment 372 377 app.post("/comments/:id/unhide", requireAuth, async (c) => { 373 378 const id = c.req.param("id"); 374 - const sphereSlug = c.req.query("sphereSlug"); 375 - if (!sphereSlug) { 376 - return c.json({ error: "sphereSlug query parameter is required" }, 400); 377 - } 378 - 379 - const db = getDb(); 379 + const sphereId = c.var.sphereId; 380 380 const did = c.var.did; 381 381 382 - const sphere = db 383 - .select({ id: spheres.id }) 384 - .from(spheres) 385 - .where(eq(spheres.slug, sphereSlug)) 386 - .get(); 387 - if (!sphere) { 388 - return c.json({ error: "Sphere not found" }, 404); 389 - } 390 - 391 - const role = getActiveMemberRole(sphere.id, did); 382 + const role = getActiveMemberRole(sphereId, did); 392 383 if (!isAdminOrOwner(role)) { 393 384 return c.json({ error: "Forbidden" }, 403); 394 385 } 395 386 396 - const comment = db 397 - .select() 398 - .from(featureRequestComments) 399 - .where(eq(featureRequestComments.id, id)) 400 - .get(); 401 - if (!comment) { 387 + const row = findCommentInSphere(id, sphereId); 388 + if (!row) { 402 389 return c.json({ error: "Comment not found" }, 404); 403 390 } 391 + const comment = row.feature_request_comments; 404 392 if (!comment.hiddenAt) { 405 393 return c.json({ ok: true }); 406 394 } ··· 418 406 419 407 // ---- Comment Votes ---- 420 408 421 - // Get current user's comment votes (list of comment IDs) 409 + // Get current user's comment votes for this sphere (list of comment IDs) 422 410 app.get("/comments/votes", requireAuth, (c) => { 423 411 const db = getDb(); 412 + const sphereId = c.var.sphereId; 424 413 const rows = db 425 414 .select({ commentId: featureRequestCommentVotes.commentId }) 426 415 .from(featureRequestCommentVotes) 427 - .where(eq(featureRequestCommentVotes.authorDid, c.var.did)) 416 + .innerJoin( 417 + featureRequestComments, 418 + eq(featureRequestComments.id, featureRequestCommentVotes.commentId), 419 + ) 420 + .innerJoin(featureRequests, eq(featureRequests.id, featureRequestComments.requestId)) 421 + .where( 422 + and( 423 + eq(featureRequestCommentVotes.authorDid, c.var.did), 424 + eq(featureRequests.sphereId, sphereId), 425 + ), 426 + ) 428 427 .all(); 429 428 return c.json({ votes: rows.map((r) => r.commentId) }); 430 429 }); ··· 434 433 const id = c.req.param("id"); 435 434 const db = getDb(); 436 435 const did = c.var.did; 436 + const sphereId = c.var.sphereId; 437 + const sphereVisibility = c.var.sphereVisibility; 437 438 438 - const comment = db 439 - .select({ 440 - id: featureRequestComments.id, 441 - pdsUri: featureRequestComments.pdsUri, 442 - }) 443 - .from(featureRequestComments) 444 - .where(eq(featureRequestComments.id, id)) 445 - .get(); 446 - if (!comment) { 439 + const row = findCommentInSphere(id, sphereId); 440 + if (!row) { 447 441 return c.json({ error: "Comment not found" }, 404); 448 442 } 443 + const comment = row.feature_request_comments; 449 444 450 445 const alreadyVoted = db 451 446 .select({ commentId: featureRequestCommentVotes.commentId }) ··· 461 456 return c.json({ error: "Already voted" }, 409); 462 457 } 463 458 464 - const sphereSlug = c.req.query("sphereSlug"); 465 459 let votePdsUri: string | null = null; 466 460 467 - if (sphereSlug) { 468 - const sphere = db 469 - .select({ visibility: spheres.visibility }) 470 - .from(spheres) 471 - .where(eq(spheres.slug, sphereSlug)) 472 - .get(); 473 - 474 - if (sphere?.visibility === "public" && comment.pdsUri) { 475 - const session = c.var.session; 476 - const now = new Date().toISOString(); 477 - const res = await session.fetchHandler("/xrpc/com.atproto.repo.putRecord", { 478 - method: "POST", 479 - headers: { "Content-Type": "application/json" }, 480 - body: JSON.stringify({ 481 - repo: session.did, 482 - collection: COMMENT_VOTE_COLLECTION, 483 - rkey: id, 484 - record: { 485 - $type: COMMENT_VOTE_COLLECTION, 486 - subject: comment.pdsUri, 487 - createdAt: now, 488 - }, 489 - }), 490 - }); 461 + if (sphereVisibility === "public" && comment.pdsUri) { 462 + const session = c.var.session; 463 + const now = new Date().toISOString(); 464 + const res = await session.fetchHandler("/xrpc/com.atproto.repo.putRecord", { 465 + method: "POST", 466 + headers: { "Content-Type": "application/json" }, 467 + body: JSON.stringify({ 468 + repo: session.did, 469 + collection: COMMENT_VOTE_COLLECTION, 470 + rkey: id, 471 + record: { 472 + $type: COMMENT_VOTE_COLLECTION, 473 + subject: comment.pdsUri, 474 + createdAt: now, 475 + }, 476 + }), 477 + }); 491 478 492 - if (res.ok) { 493 - const data = (await res.json()) as { uri: string }; 494 - votePdsUri = data.uri; 495 - } else { 496 - console.error( 497 - "[feature-requests] PDS comment vote write failed:", 498 - res.status, 499 - await res.text().catch(() => ""), 500 - ); 501 - } 479 + if (res.ok) { 480 + const data = (await res.json()) as { uri: string }; 481 + votePdsUri = data.uri; 482 + } else { 483 + console.error( 484 + "[feature-requests] PDS comment vote write failed:", 485 + res.status, 486 + await res.text().catch(() => ""), 487 + ); 502 488 } 503 489 } 504 490
+45 -54
packages/feature-requests/src/api/requests.ts
··· 6 6 import { getActiveMemberRole, isAdminOrOwner } from "@exosphere/core/sphere"; 7 7 import { putPdsRecord, deletePdsRecord, generateRkey } from "@exosphere/core/pds"; 8 8 import { resolveDidHandles } from "@exosphere/core/identity"; 9 - import { spheres } from "@exosphere/core/db/schema"; 9 + import type { SphereEnv } from "@exosphere/core/types"; 10 10 import { featureRequests, featureRequestVotes, featureRequestComments } from "../db/schema.ts"; 11 11 import { createFeatureRequestSchema, statuses } from "../schemas/feature-request.ts"; 12 12 import type { Status } from "../schemas/feature-request.ts"; ··· 20 20 const COLLECTION = "site.exosphere.featureRequest"; 21 21 const MODERATION_COLLECTION = "site.exosphere.moderation"; 22 22 23 - const app = new Hono<AuthEnv>(); 23 + const app = new Hono<AuthEnv & SphereEnv>(); 24 24 25 25 app.get("/:number{[0-9]+}", async (c) => { 26 26 const number = parseInt(c.req.param("number"), 10); 27 + const sphereId = c.var.sphereId; 27 28 const db = getDb(); 28 29 const row = db 29 30 .select({ ··· 46 47 }) 47 48 .from(featureRequests) 48 49 .leftJoin(featureRequestVotes, eq(featureRequestVotes.requestId, featureRequests.id)) 49 - .where(and(eq(featureRequests.number, number), sql`${featureRequests.hiddenAt} is null`)) 50 + .where( 51 + and( 52 + eq(featureRequests.number, number), 53 + eq(featureRequests.sphereId, sphereId), 54 + sql`${featureRequests.hiddenAt} is null`, 55 + ), 56 + ) 50 57 .groupBy(featureRequests.id) 51 58 .get(); 52 59 ··· 85 92 86 93 app.get("/", (c) => { 87 94 const db = getDb(); 95 + const sphereId = c.var.sphereId; 88 96 const statusParam = c.req.query("status"); 89 97 const validStatuses = new Set<string>(statuses); 90 98 const statusFilter = statusParam ··· 96 104 const sortBy = sortParam === "votes" ? "votes" : "date"; 97 105 const orderDir = orderParam === "asc" ? asc : desc; 98 106 99 - const conditions = [sql`${featureRequests.hiddenAt} is null`]; 107 + const conditions = [ 108 + eq(featureRequests.sphereId, sphereId), 109 + sql`${featureRequests.hiddenAt} is null`, 110 + ]; 100 111 if (statusFilter && statusFilter.length > 0) { 101 112 conditions.push(inArray(featureRequests.status, statusFilter)); 102 113 } ··· 137 148 return c.json({ error: z.flattenError(result.error) }, 400); 138 149 } 139 150 140 - const { title, description, category, sphereSlug } = result.data; 141 - const db = getDb(); 151 + const { title, description, category } = result.data; 152 + const sphereId = c.var.sphereId; 153 + const sphereSlug = c.var.sphereSlug; 154 + const sphereVisibility = c.var.sphereVisibility; 142 155 const id = generateRkey(); 143 156 const did = c.var.did; 144 157 145 - // Check sphere visibility 146 - const sphere = db 147 - .select({ visibility: spheres.visibility }) 148 - .from(spheres) 149 - .where(eq(spheres.slug, sphereSlug)) 150 - .get(); 151 - 152 - if (!sphere) { 153 - return c.json({ error: "Sphere not found" }, 404); 154 - } 155 - 156 158 let pdsUri: string | null = null; 157 159 158 160 // Write to PDS for public spheres 159 - if (sphere.visibility === "public") { 161 + if (sphereVisibility === "public") { 160 162 const session = c.var.session; 161 163 const now = new Date().toISOString(); 162 164 const res = await session.fetchHandler("/xrpc/com.atproto.repo.putRecord", { ··· 189 191 } 190 192 } 191 193 192 - const row = insertFeatureRequest({ id, authorDid: did, title, description, category, pdsUri }); 194 + const row = insertFeatureRequest({ 195 + id, 196 + sphereId, 197 + authorDid: did, 198 + title, 199 + description, 200 + category, 201 + pdsUri, 202 + }); 193 203 194 204 return c.json({ featureRequest: row }, 201); 195 205 }); ··· 199 209 const id = c.req.param("id"); 200 210 const db = getDb(); 201 211 const did = c.var.did; 212 + const sphereId = c.var.sphereId; 202 213 203 214 const existing = db 204 215 .select({ ··· 207 218 pdsUri: featureRequests.pdsUri, 208 219 }) 209 220 .from(featureRequests) 210 - .where(eq(featureRequests.id, id)) 221 + .where(and(eq(featureRequests.id, id), eq(featureRequests.sphereId, sphereId))) 211 222 .get(); 212 223 if (!existing) { 213 224 return c.json({ error: "Feature request not found" }, 404); ··· 247 258 // Admin/owner-only: hide a feature request 248 259 app.post("/:id/hide", requireAuth, async (c) => { 249 260 const id = c.req.param("id"); 250 - const sphereSlug = c.req.query("sphereSlug"); 251 - if (!sphereSlug) { 252 - return c.json({ error: "sphereSlug query parameter is required" }, 400); 253 - } 254 - 255 261 const db = getDb(); 256 262 const did = c.var.did; 263 + const sphereId = c.var.sphereId; 264 + const sphereOwnerDid = c.var.sphereOwnerDid; 265 + const spherePdsUri = c.var.spherePdsUri; 257 266 258 - const sphere = db 259 - .select({ id: spheres.id, ownerDid: spheres.ownerDid, pdsUri: spheres.pdsUri }) 260 - .from(spheres) 261 - .where(eq(spheres.slug, sphereSlug)) 262 - .get(); 263 - if (!sphere) { 264 - return c.json({ error: "Sphere not found" }, 404); 265 - } 266 - 267 - const role = getActiveMemberRole(sphere.id, did); 267 + const role = getActiveMemberRole(sphereId, did); 268 268 if (!isAdminOrOwner(role)) { 269 269 return c.json({ error: "Forbidden" }, 403); 270 270 } ··· 276 276 hiddenAt: featureRequests.hiddenAt, 277 277 }) 278 278 .from(featureRequests) 279 - .where(eq(featureRequests.id, id)) 279 + .where(and(eq(featureRequests.id, id), eq(featureRequests.sphereId, sphereId))) 280 280 .get(); 281 281 if (!existing) { 282 282 return c.json({ error: "Feature request not found" }, 404); ··· 289 289 290 290 // Publish moderation record on admin's PDS 291 291 if (existing.pdsUri) { 292 - const sphereUri = sphere.pdsUri ?? `at://${sphere.ownerDid}/site.exosphere.sphere/self`; 292 + const sphereUri = spherePdsUri ?? `at://${sphereOwnerDid}/site.exosphere.sphere/self`; 293 293 const session = c.var.session; 294 294 const pdsUri = await putPdsRecord(session, MODERATION_COLLECTION, id, { 295 295 sphere: sphereUri, ··· 313 313 // Admin/owner-only: unhide a feature request 314 314 app.post("/:id/unhide", requireAuth, async (c) => { 315 315 const id = c.req.param("id"); 316 - const sphereSlug = c.req.query("sphereSlug"); 317 - if (!sphereSlug) { 318 - return c.json({ error: "sphereSlug query parameter is required" }, 400); 319 - } 320 - 321 316 const db = getDb(); 322 317 const did = c.var.did; 323 - 324 - const sphere = db 325 - .select({ id: spheres.id }) 326 - .from(spheres) 327 - .where(eq(spheres.slug, sphereSlug)) 328 - .get(); 329 - if (!sphere) { 330 - return c.json({ error: "Sphere not found" }, 404); 331 - } 318 + const sphereId = c.var.sphereId; 332 319 333 - const role = getActiveMemberRole(sphere.id, did); 320 + const role = getActiveMemberRole(sphereId, did); 334 321 if (!isAdminOrOwner(role)) { 335 322 return c.json({ error: "Forbidden" }, 403); 336 323 } ··· 342 329 moderatedBy: featureRequests.moderatedBy, 343 330 }) 344 331 .from(featureRequests) 345 - .where(eq(featureRequests.id, id)) 332 + .where(and(eq(featureRequests.id, id), eq(featureRequests.sphereId, sphereId))) 346 333 .get(); 347 334 if (!existing) { 348 335 return c.json({ error: "Feature request not found" }, 404); ··· 368 355 app.get("/search", (c) => { 369 356 const q = c.req.query("q")?.trim(); 370 357 const excludeId = c.req.query("excludeId"); 358 + const sphereId = c.var.sphereId; 371 359 if (!q) { 372 360 return c.json({ results: [] }); 373 361 } 374 362 375 363 const db = getDb(); 376 - const conditions = [sql`${featureRequests.hiddenAt} is null`]; 364 + const conditions = [ 365 + eq(featureRequests.sphereId, sphereId), 366 + sql`${featureRequests.hiddenAt} is null`, 367 + ]; 377 368 if (excludeId) { 378 369 conditions.push(sql`${featureRequests.id} != ${excludeId}`); 379 370 }
+2 -1
packages/feature-requests/src/api/routes.ts
··· 1 1 import { Hono } from "hono"; 2 2 import type { AuthEnv } from "@exosphere/core/auth"; 3 + import type { SphereEnv } from "@exosphere/core/types"; 3 4 import { requestsApi } from "./requests.ts"; 4 5 import { statusesApi } from "./statuses.ts"; 5 6 import { votesApi } from "./votes.ts"; 6 7 import { commentsApi } from "./comments.ts"; 7 8 8 - const app = new Hono<AuthEnv>(); 9 + const app = new Hono<AuthEnv & SphereEnv>(); 9 10 10 11 app.route("/", requestsApi); 11 12 app.route("/", statusesApi);
+30 -30
packages/feature-requests/src/api/statuses.ts
··· 6 6 import { getActiveMemberRole, isAdminOrOwner } from "@exosphere/core/sphere"; 7 7 import { generateRkey } from "@exosphere/core/pds"; 8 8 import { resolveDidHandles } from "@exosphere/core/identity"; 9 - import { spheres } from "@exosphere/core/db/schema"; 9 + import type { SphereEnv } from "@exosphere/core/types"; 10 10 import { featureRequests, featureRequestStatuses } from "../db/schema.ts"; 11 11 import { updateStatusSchema, markAsDuplicateSchema } from "../schemas/feature-request.ts"; 12 12 import { insertStatusAndUpdateFR } from "../db/operations.ts"; 13 13 14 14 const STATUS_COLLECTION = "site.exosphere.featureRequestStatus"; 15 15 16 - const app = new Hono<AuthEnv>(); 16 + const app = new Hono<AuthEnv & SphereEnv>(); 17 17 18 18 // ---- Duplicates ---- 19 19 ··· 26 26 return c.json({ error: z.flattenError(result.error) }, 400); 27 27 } 28 28 29 - const { duplicateOfId, sphereSlug } = result.data; 29 + const { duplicateOfId } = result.data; 30 30 const db = getDb(); 31 31 const did = c.var.did; 32 + const sphereId = c.var.sphereId; 33 + const sphereSlug = c.var.sphereSlug; 34 + const sphereVisibility = c.var.sphereVisibility; 32 35 33 - // Check FR exists, is "requested", and not hidden 36 + // Check FR exists, belongs to this sphere, is "requested", and not hidden 34 37 const fr = db 35 38 .select({ 36 39 id: featureRequests.id, ··· 39 42 pdsUri: featureRequests.pdsUri, 40 43 }) 41 44 .from(featureRequests) 42 - .where(and(eq(featureRequests.id, id), sql`${featureRequests.hiddenAt} is null`)) 45 + .where( 46 + and( 47 + eq(featureRequests.id, id), 48 + eq(featureRequests.sphereId, sphereId), 49 + sql`${featureRequests.hiddenAt} is null`, 50 + ), 51 + ) 43 52 .get(); 44 53 45 54 if (!fr) { ··· 50 59 } 51 60 52 61 // Permission: admin/owner OR author 53 - const sphere = db 54 - .select({ id: spheres.id, visibility: spheres.visibility }) 55 - .from(spheres) 56 - .where(eq(spheres.slug, sphereSlug)) 57 - .get(); 58 - if (!sphere) { 59 - return c.json({ error: "Sphere not found" }, 404); 60 - } 61 - 62 - const role = getActiveMemberRole(sphere.id, did); 62 + const role = getActiveMemberRole(sphereId, did); 63 63 const canMark = isAdminOrOwner(role) || fr.authorDid === did; 64 64 if (!canMark) { 65 65 return c.json({ error: "Forbidden" }, 403); ··· 73 73 const target = db 74 74 .select({ id: featureRequests.id }) 75 75 .from(featureRequests) 76 - .where(and(eq(featureRequests.id, duplicateOfId), sql`${featureRequests.hiddenAt} is null`)) 76 + .where( 77 + and( 78 + eq(featureRequests.id, duplicateOfId), 79 + eq(featureRequests.sphereId, sphereId), 80 + sql`${featureRequests.hiddenAt} is null`, 81 + ), 82 + ) 77 83 .get(); 78 84 if (!target) { 79 85 return c.json({ error: "Target feature request not found" }, 404); ··· 81 87 82 88 // Write to PDS for public spheres 83 89 let pdsUri: string | null = null; 84 - if (sphere.visibility === "public" && fr.pdsUri) { 90 + if (sphereVisibility === "public" && fr.pdsUri) { 85 91 const session = c.var.session; 86 92 const now = new Date().toISOString(); 87 93 const res = await session.fetchHandler("/xrpc/com.atproto.repo.putRecord", { ··· 155 161 return c.json({ error: z.flattenError(result.error) }, 400); 156 162 } 157 163 158 - const { status, sphereSlug } = result.data; 164 + const { status } = result.data; 159 165 const db = getDb(); 160 166 const did = c.var.did; 167 + const sphereId = c.var.sphereId; 168 + const sphereSlug = c.var.sphereSlug; 169 + const sphereVisibility = c.var.sphereVisibility; 161 170 162 171 const existing = db 163 172 .select({ ··· 166 175 pdsUri: featureRequests.pdsUri, 167 176 }) 168 177 .from(featureRequests) 169 - .where(eq(featureRequests.id, id)) 178 + .where(and(eq(featureRequests.id, id), eq(featureRequests.sphereId, sphereId))) 170 179 .get(); 171 180 if (!existing) { 172 181 return c.json({ error: "Feature request not found" }, 404); 173 182 } 174 183 175 - const sphere = db 176 - .select({ id: spheres.id, visibility: spheres.visibility }) 177 - .from(spheres) 178 - .where(eq(spheres.slug, sphereSlug)) 179 - .get(); 180 - if (!sphere) { 181 - return c.json({ error: "Sphere not found" }, 404); 182 - } 183 - 184 - const role = getActiveMemberRole(sphere.id, did); 184 + const role = getActiveMemberRole(sphereId, did); 185 185 if (!isAdminOrOwner(role)) { 186 186 return c.json({ error: "Forbidden" }, 403); 187 187 } ··· 189 189 let pdsUri: string | null = null; 190 190 191 191 // Write to PDS for public spheres 192 - if (sphere.visibility === "public" && existing.pdsUri) { 192 + if (sphereVisibility === "public" && existing.pdsUri) { 193 193 const session = c.var.session; 194 194 const now = new Date().toISOString(); 195 195 const res = await session.fetchHandler("/xrpc/com.atproto.repo.putRecord", {
+46 -40
packages/feature-requests/src/api/votes.ts
··· 2 2 import { getDb } from "@exosphere/core/db"; 3 3 import { eq, and, count, sql } from "@exosphere/core/db/drizzle"; 4 4 import { requireAuth, type AuthEnv } from "@exosphere/core/auth"; 5 - import { spheres } from "@exosphere/core/db/schema"; 5 + import type { SphereEnv } from "@exosphere/core/types"; 6 6 import { featureRequests, featureRequestVotes } from "../db/schema.ts"; 7 7 import { insertVote, deleteVoteByAuthor } from "../db/operations.ts"; 8 8 9 9 const VOTE_COLLECTION = "site.exosphere.featureRequestVote"; 10 10 11 - const app = new Hono<AuthEnv>(); 11 + const app = new Hono<AuthEnv & SphereEnv>(); 12 12 13 - // Get current user's votes (list of request IDs) 13 + // Get current user's votes for this sphere (list of request IDs) 14 14 app.get("/votes", requireAuth, (c) => { 15 15 const db = getDb(); 16 + const sphereId = c.var.sphereId; 16 17 const rows = db 17 18 .select({ requestId: featureRequestVotes.requestId }) 18 19 .from(featureRequestVotes) 19 - .where(eq(featureRequestVotes.authorDid, c.var.did)) 20 + .innerJoin(featureRequests, eq(featureRequests.id, featureRequestVotes.requestId)) 21 + .where( 22 + and( 23 + eq(featureRequestVotes.authorDid, c.var.did), 24 + eq(featureRequests.sphereId, sphereId), 25 + ), 26 + ) 20 27 .all(); 21 28 return c.json({ votes: rows.map((r) => r.requestId) }); 22 29 }); ··· 26 33 const id = c.req.param("id"); 27 34 const db = getDb(); 28 35 const did = c.var.did; 36 + const sphereId = c.var.sphereId; 37 + const sphereVisibility = c.var.sphereVisibility; 29 38 30 39 const existing = db 31 40 .select({ ··· 33 42 pdsUri: featureRequests.pdsUri, 34 43 }) 35 44 .from(featureRequests) 36 - .where(and(eq(featureRequests.id, id), sql`${featureRequests.hiddenAt} is null`)) 45 + .where( 46 + and( 47 + eq(featureRequests.id, id), 48 + eq(featureRequests.sphereId, sphereId), 49 + sql`${featureRequests.hiddenAt} is null`, 50 + ), 51 + ) 37 52 .get(); 38 53 if (!existing) { 39 54 return c.json({ error: "Feature request not found" }, 404); ··· 49 64 return c.json({ error: "Already voted" }, 409); 50 65 } 51 66 52 - const sphereSlug = c.req.query("sphereSlug"); 53 67 let votePdsUri: string | null = null; 54 68 55 69 // Write to PDS for public spheres 56 - if (sphereSlug) { 57 - const sphere = db 58 - .select({ visibility: spheres.visibility }) 59 - .from(spheres) 60 - .where(eq(spheres.slug, sphereSlug)) 61 - .get(); 62 - 63 - if (sphere?.visibility === "public" && existing.pdsUri) { 64 - const session = c.var.session; 65 - const now = new Date().toISOString(); 66 - const res = await session.fetchHandler("/xrpc/com.atproto.repo.putRecord", { 67 - method: "POST", 68 - headers: { "Content-Type": "application/json" }, 69 - body: JSON.stringify({ 70 - repo: session.did, 71 - collection: VOTE_COLLECTION, 72 - rkey: id, 73 - record: { 74 - $type: VOTE_COLLECTION, 75 - subject: existing.pdsUri, 76 - createdAt: now, 77 - }, 78 - }), 79 - }); 70 + if (sphereVisibility === "public" && existing.pdsUri) { 71 + const session = c.var.session; 72 + const now = new Date().toISOString(); 73 + const res = await session.fetchHandler("/xrpc/com.atproto.repo.putRecord", { 74 + method: "POST", 75 + headers: { "Content-Type": "application/json" }, 76 + body: JSON.stringify({ 77 + repo: session.did, 78 + collection: VOTE_COLLECTION, 79 + rkey: id, 80 + record: { 81 + $type: VOTE_COLLECTION, 82 + subject: existing.pdsUri, 83 + createdAt: now, 84 + }, 85 + }), 86 + }); 80 87 81 - if (res.ok) { 82 - const data = (await res.json()) as { uri: string }; 83 - votePdsUri = data.uri; 84 - } else { 85 - console.error( 86 - "[feature-requests] PDS vote write failed:", 87 - res.status, 88 - await res.text().catch(() => ""), 89 - ); 90 - } 88 + if (res.ok) { 89 + const data = (await res.json()) as { uri: string }; 90 + votePdsUri = data.uri; 91 + } else { 92 + console.error( 93 + "[feature-requests] PDS vote write failed:", 94 + res.status, 95 + await res.text().catch(() => ""), 96 + ); 91 97 } 92 98 } 93 99
+3
packages/feature-requests/src/db/operations.ts
··· 14 14 15 15 export function insertFeatureRequest(params: { 16 16 id: string; 17 + sphereId: string; 17 18 authorDid: string; 18 19 title: string; 19 20 description: string; ··· 25 26 const lastNumber = tx 26 27 .select({ maxNumber: max(featureRequests.number) }) 27 28 .from(featureRequests) 29 + .where(eq(featureRequests.sphereId, params.sphereId)) 28 30 .get(); 29 31 const number = (lastNumber?.maxNumber ?? 0) + 1; 30 32 31 33 tx.insert(featureRequests) 32 34 .values({ 33 35 id: params.id, 36 + sphereId: params.sphereId, 34 37 number, 35 38 authorDid: params.authorDid, 36 39 title: params.title,
+12 -2
packages/feature-requests/src/db/schema.ts
··· 1 - import { sqliteTable, text, integer, primaryKey, index } from "drizzle-orm/sqlite-core"; 1 + import { 2 + sqliteTable, 3 + text, 4 + integer, 5 + primaryKey, 6 + index, 7 + uniqueIndex, 8 + } from "drizzle-orm/sqlite-core"; 2 9 import { sql } from "drizzle-orm"; 3 10 import type { InferSelectModel } from "drizzle-orm"; 4 11 import { categories, statuses } from "../schemas/feature-request.ts"; ··· 7 14 "feature_requests", 8 15 { 9 16 id: text("id").primaryKey(), 10 - number: integer("number").notNull().unique(), 17 + sphereId: text("sphere_id").notNull(), 18 + number: integer("number").notNull(), 11 19 authorDid: text("author_did").notNull(), 12 20 title: text("title").notNull(), 13 21 description: text("description").notNull(), ··· 29 37 .default(sql`(datetime('now'))`), 30 38 }, 31 39 (table) => [ 40 + uniqueIndex("idx_feature_requests_sphere_number").on(table.sphereId, table.number), 41 + index("idx_feature_requests_sphere").on(table.sphereId), 32 42 index("idx_feature_requests_status").on(table.status), 33 43 index("idx_feature_requests_created").on(table.createdAt), 34 44 index("idx_feature_requests_category").on(table.category),
+13 -5
packages/feature-requests/src/indexer.ts
··· 29 29 const COMMENT_VOTE_COLLECTION = "site.exosphere.featureRequestCommentVote"; 30 30 const STATUS_COLLECTION = "site.exosphere.featureRequestStatus"; 31 31 32 - function findSphereForAccess(sphereSlug: string, did: string): boolean { 32 + function findSphereForAccess( 33 + sphereSlug: string, 34 + did: string, 35 + ): { allowed: boolean; sphereId: string | null } { 33 36 const db = getDb(); 34 37 const sphere = db 35 38 .select({ id: spheres.id, writeAccess: spheres.writeAccess }) 36 39 .from(spheres) 37 40 .where(eq(spheres.slug, sphereSlug)) 38 41 .get(); 39 - if (!sphere) return false; 40 - if (sphere.writeAccess === "open") return true; 42 + if (!sphere) return { allowed: false, sphereId: null }; 43 + if (sphere.writeAccess === "open") return { allowed: true, sphereId: sphere.id }; 41 44 const role = getActiveMemberRole(sphere.id, did); 42 - return role !== null; 45 + return { allowed: role !== null, sphereId: sphere.id }; 43 46 } 44 47 45 48 export const featureRequestsIndexer: ModuleIndexer = { ··· 60 63 61 64 if (collection === COLLECTION) { 62 65 const sphereSlug = record.sphereSlug as string; 63 - if (sphereSlug && !findSphereForAccess(sphereSlug, did)) return; 66 + if (!sphereSlug) return; // Reject records without a sphere 67 + 68 + const access = findSphereForAccess(sphereSlug, did); 69 + if (!access.allowed || !access.sphereId) return; 70 + const sphereId = access.sphereId; 64 71 65 72 const rawCategory = record.category as string; 66 73 const category: Category = (categories as readonly string[]).includes(rawCategory) ··· 69 76 70 77 insertFeatureRequest({ 71 78 id: rkey, 79 + sphereId, 72 80 authorDid: did, 73 81 title: (record.title as string) ?? "", 74 82 description: (record.description as string) ?? "",
-2
packages/feature-requests/src/schemas/comment.ts
··· 2 2 3 3 export const createCommentSchema = z.object({ 4 4 content: z.string().min(1).max(5000), 5 - sphereSlug: z.string().min(1), 6 5 }); 7 6 8 7 export const updateCommentSchema = z.object({ 9 8 content: z.string().min(1).max(5000), 10 - sphereSlug: z.string().min(1), 11 9 }); 12 10 13 11 export type CreateComment = z.infer<typeof createCommentSchema>;
-3
packages/feature-requests/src/schemas/feature-request.ts
··· 45 45 46 46 export const updateStatusSchema = z.object({ 47 47 status: z.enum(settableStatuses), 48 - sphereSlug: z.string().min(1), 49 48 }); 50 49 51 50 export const markAsDuplicateSchema = z.object({ 52 51 duplicateOfId: z.string().min(1), 53 - sphereSlug: z.string().min(1), 54 52 }); 55 53 56 54 export const createFeatureRequestSchema = z.object({ 57 55 title: z.string().min(1).max(200), 58 56 description: z.string().min(1).max(10000), 59 57 category: z.enum(categories).default("general"), 60 - sphereSlug: z.string().min(1), 61 58 }); 62 59 63 60 export type CreateFeatureRequest = z.infer<typeof createFeatureRequestSchema>;
+7 -7
packages/feature-requests/src/ui/api/comment-votes.ts
··· 1 - import { apiFetch } from "@exosphere/client/api"; 1 + import { moduleFetch } from "@exosphere/client/api"; 2 2 3 3 export function getMyCommentVotes() { 4 - return apiFetch<{ votes: string[] }>("/api/feature-requests/comments/votes"); 4 + return moduleFetch<{ votes: string[] }>("/feature-requests/comments/votes"); 5 5 } 6 6 7 - export function voteComment(id: string, sphereSlug: string) { 8 - return apiFetch<{ voteCount: number }>( 9 - `/api/feature-requests/comments/${encodeURIComponent(id)}/vote?sphereSlug=${encodeURIComponent(sphereSlug)}`, 7 + export function voteComment(id: string) { 8 + return moduleFetch<{ voteCount: number }>( 9 + `/feature-requests/comments/${encodeURIComponent(id)}/vote`, 10 10 { method: "POST" }, 11 11 ); 12 12 } 13 13 14 14 export function unvoteComment(id: string) { 15 - return apiFetch<{ voteCount: number }>( 16 - `/api/feature-requests/comments/${encodeURIComponent(id)}/vote`, 15 + return moduleFetch<{ voteCount: number }>( 16 + `/feature-requests/comments/${encodeURIComponent(id)}/vote`, 17 17 { method: "DELETE" }, 18 18 ); 19 19 }
+15 -16
packages/feature-requests/src/ui/api/comments.ts
··· 1 - import { apiFetch } from "@exosphere/client/api"; 1 + import { moduleFetch } from "@exosphere/client/api"; 2 2 import type { FeatureRequestComment, FeatureRequestCommentListItem } from "../../types.ts"; 3 3 4 4 export type { FeatureRequestComment, FeatureRequestCommentListItem }; ··· 8 8 if (sort) params.set("sort", sort); 9 9 if (order) params.set("order", order); 10 10 const qs = params.toString(); 11 - return apiFetch<{ comments: FeatureRequestCommentListItem[] }>( 12 - `/api/feature-requests/${encodeURIComponent(requestId)}/comments${qs ? `?${qs}` : ""}`, 11 + return moduleFetch<{ comments: FeatureRequestCommentListItem[] }>( 12 + `/feature-requests/${encodeURIComponent(requestId)}/comments${qs ? `?${qs}` : ""}`, 13 13 ); 14 14 } 15 15 16 - export function createComment(requestId: string, content: string, sphereSlug: string) { 17 - return apiFetch<{ comment: FeatureRequestComment }>( 18 - `/api/feature-requests/${encodeURIComponent(requestId)}/comments`, 16 + export function createComment(requestId: string, content: string) { 17 + return moduleFetch<{ comment: FeatureRequestComment }>( 18 + `/feature-requests/${encodeURIComponent(requestId)}/comments`, 19 19 { 20 20 method: "POST", 21 21 headers: { "Content-Type": "application/json" }, 22 - body: JSON.stringify({ content, sphereSlug }), 22 + body: JSON.stringify({ content }), 23 23 }, 24 24 ); 25 25 } 26 26 27 - export function updateComment(id: string, content: string, sphereSlug: string) { 28 - return apiFetch<{ comment: FeatureRequestComment }>( 29 - `/api/feature-requests/comments/${encodeURIComponent(id)}`, 27 + export function updateComment(id: string, content: string) { 28 + return moduleFetch<{ comment: FeatureRequestComment }>( 29 + `/feature-requests/comments/${encodeURIComponent(id)}`, 30 30 { 31 31 method: "PUT", 32 32 headers: { "Content-Type": "application/json" }, 33 - body: JSON.stringify({ content, sphereSlug }), 33 + body: JSON.stringify({ content }), 34 34 }, 35 35 ); 36 36 } 37 37 38 - export function deleteComment(id: string, sphereSlug: string) { 39 - return apiFetch<{ ok: true }>( 40 - `/api/feature-requests/comments/${encodeURIComponent(id)}?sphereSlug=${encodeURIComponent(sphereSlug)}`, 41 - { method: "DELETE" }, 42 - ); 38 + export function deleteComment(id: string) { 39 + return moduleFetch<{ ok: true }>(`/feature-requests/comments/${encodeURIComponent(id)}`, { 40 + method: "DELETE", 41 + }); 43 42 }
+15 -18
packages/feature-requests/src/ui/api/feature-requests.ts
··· 1 - import { apiFetch } from "@exosphere/client/api"; 1 + import { moduleFetch } from "@exosphere/client/api"; 2 2 import type { FeatureRequest, FeatureRequestListItem } from "../../types.ts"; 3 3 4 4 export type { FeatureRequest, FeatureRequestListItem }; 5 5 6 6 export function getFeatureRequest(number: number) { 7 - return apiFetch<{ 7 + return moduleFetch<{ 8 8 featureRequest: FeatureRequestListItem; 9 9 duplicateOf: { id: string; number: number; title: string } | null; 10 10 duplicateCount: number; 11 - }>(`/api/feature-requests/${number}`); 11 + }>(`/feature-requests/${number}`); 12 12 } 13 13 14 14 export type SortBy = "date" | "votes"; ··· 20 20 if (sort) params.set("sort", sort); 21 21 if (order) params.set("order", order); 22 22 const qs = params.toString(); 23 - const url = `/api/feature-requests${qs ? `?${qs}` : ""}`; 24 - return apiFetch<{ featureRequests: FeatureRequestListItem[] }>(url); 23 + const url = `/feature-requests${qs ? `?${qs}` : ""}`; 24 + return moduleFetch<{ featureRequests: FeatureRequestListItem[] }>(url); 25 25 } 26 26 27 27 export function createFeatureRequest(body: { 28 28 title: string; 29 29 description: string; 30 30 category: string; 31 - sphereSlug: string; 32 31 }) { 33 - return apiFetch<{ featureRequest: FeatureRequest }>("/api/feature-requests", { 32 + return moduleFetch<{ featureRequest: FeatureRequest }>("/feature-requests", { 34 33 method: "POST", 35 34 headers: { "Content-Type": "application/json" }, 36 35 body: JSON.stringify(body), ··· 38 37 } 39 38 40 39 export function deleteFeatureRequest(id: string) { 41 - return apiFetch<{ ok: true }>(`/api/feature-requests/${encodeURIComponent(id)}`, { 40 + return moduleFetch<{ ok: true }>(`/feature-requests/${encodeURIComponent(id)}`, { 42 41 method: "DELETE", 43 42 }); 44 43 } 45 44 46 - export function hideFeatureRequest(id: string, sphereSlug: string) { 47 - return apiFetch<{ ok: true }>( 48 - `/api/feature-requests/${encodeURIComponent(id)}/hide?sphereSlug=${encodeURIComponent(sphereSlug)}`, 49 - { method: "POST" }, 50 - ); 45 + export function hideFeatureRequest(id: string) { 46 + return moduleFetch<{ ok: true }>(`/feature-requests/${encodeURIComponent(id)}/hide`, { 47 + method: "POST", 48 + }); 51 49 } 52 50 53 - export function unhideFeatureRequest(id: string, sphereSlug: string) { 54 - return apiFetch<{ ok: true }>( 55 - `/api/feature-requests/${encodeURIComponent(id)}/unhide?sphereSlug=${encodeURIComponent(sphereSlug)}`, 56 - { method: "POST" }, 57 - ); 51 + export function unhideFeatureRequest(id: string) { 52 + return moduleFetch<{ ok: true }>(`/feature-requests/${encodeURIComponent(id)}/unhide`, { 53 + method: "POST", 54 + }); 58 55 }
+8 -8
packages/feature-requests/src/ui/api/search.ts
··· 1 - import { apiFetch } from "@exosphere/client/api"; 1 + import { moduleFetch } from "@exosphere/client/api"; 2 2 3 3 export function searchFeatureRequests(q: string, excludeId: string) { 4 - return apiFetch<{ 4 + return moduleFetch<{ 5 5 results: Array<{ id: string; number: number; title: string; status: string }>; 6 6 }>( 7 - `/api/feature-requests/search?q=${encodeURIComponent(q)}&excludeId=${encodeURIComponent(excludeId)}`, 7 + `/feature-requests/search?q=${encodeURIComponent(q)}&excludeId=${encodeURIComponent(excludeId)}`, 8 8 ); 9 9 } 10 10 11 - export function markAsDuplicate(id: string, duplicateOfId: string, sphereSlug: string) { 12 - return apiFetch<{ ok: true }>(`/api/feature-requests/${encodeURIComponent(id)}/duplicate`, { 11 + export function markAsDuplicate(id: string, duplicateOfId: string) { 12 + return moduleFetch<{ ok: true }>(`/feature-requests/${encodeURIComponent(id)}/duplicate`, { 13 13 method: "POST", 14 14 headers: { "Content-Type": "application/json" }, 15 - body: JSON.stringify({ duplicateOfId, sphereSlug }), 15 + body: JSON.stringify({ duplicateOfId }), 16 16 }); 17 17 } 18 18 19 19 export function getDuplicates(requestId: string) { 20 - return apiFetch<{ 20 + return moduleFetch<{ 21 21 duplicates: Array<{ id: string; number: number; title: string }>; 22 - }>(`/api/feature-requests/${encodeURIComponent(requestId)}/duplicates`); 22 + }>(`/feature-requests/${encodeURIComponent(requestId)}/duplicates`); 23 23 }
+6 -6
packages/feature-requests/src/ui/api/status.ts
··· 1 - import { apiFetch } from "@exosphere/client/api"; 1 + import { moduleFetch } from "@exosphere/client/api"; 2 2 import type { FeatureRequestStatus } from "../../types.ts"; 3 3 4 4 export type { FeatureRequestStatus }; 5 5 6 - export function updateFeatureRequestStatus(id: string, status: string, sphereSlug: string) { 7 - return apiFetch<{ status: string }>(`/api/feature-requests/${encodeURIComponent(id)}/status`, { 6 + export function updateFeatureRequestStatus(id: string, status: string) { 7 + return moduleFetch<{ status: string }>(`/feature-requests/${encodeURIComponent(id)}/status`, { 8 8 method: "POST", 9 9 headers: { "Content-Type": "application/json" }, 10 - body: JSON.stringify({ status, sphereSlug }), 10 + body: JSON.stringify({ status }), 11 11 }); 12 12 } 13 13 14 14 export function getStatusHistory(requestId: string) { 15 - return apiFetch<{ 15 + return moduleFetch<{ 16 16 statuses: Array<{ 17 17 id: string; 18 18 authorDid: string; ··· 20 20 status: string; 21 21 createdAt: string; 22 22 }>; 23 - }>(`/api/feature-requests/${encodeURIComponent(requestId)}/statuses`); 23 + }>(`/feature-requests/${encodeURIComponent(requestId)}/statuses`); 24 24 }
+7 -8
packages/feature-requests/src/ui/api/votes.ts
··· 1 - import { apiFetch } from "@exosphere/client/api"; 1 + import { moduleFetch } from "@exosphere/client/api"; 2 2 3 - export function voteFeatureRequest(id: string, sphereSlug: string) { 4 - return apiFetch<{ voteCount: number }>( 5 - `/api/feature-requests/${encodeURIComponent(id)}/vote?sphereSlug=${encodeURIComponent(sphereSlug)}`, 6 - { method: "POST" }, 7 - ); 3 + export function voteFeatureRequest(id: string) { 4 + return moduleFetch<{ voteCount: number }>(`/feature-requests/${encodeURIComponent(id)}/vote`, { 5 + method: "POST", 6 + }); 8 7 } 9 8 10 9 export function unvoteFeatureRequest(id: string) { 11 - return apiFetch<{ voteCount: number }>(`/api/feature-requests/${encodeURIComponent(id)}/vote`, { 10 + return moduleFetch<{ voteCount: number }>(`/feature-requests/${encodeURIComponent(id)}/vote`, { 12 11 method: "DELETE", 13 12 }); 14 13 } 15 14 16 15 export function getMyVotes() { 17 - return apiFetch<{ votes: string[] }>("/api/feature-requests/votes"); 16 + return moduleFetch<{ votes: string[] }>("/feature-requests/votes"); 18 17 }
+2 -10
packages/feature-requests/src/ui/components/merge-dropdown.tsx
··· 3 3 import * as ui from "@exosphere/client/ui.css"; 4 4 import { searchFeatureRequests, markAsDuplicate, statusLabels, type Status } from "../api/index.ts"; 5 5 6 - export function MergeDropdown({ 7 - frId, 8 - sphereSlug, 9 - onMarked, 10 - }: { 11 - frId: string; 12 - sphereSlug: string; 13 - onMarked: () => void; 14 - }) { 6 + export function MergeDropdown({ frId, onMarked }: { frId: string; onMarked: () => void }) { 15 7 const query = useSignal(""); 16 8 const results = useSignal<Array<{ id: string; number: number; title: string; status: string }>>( 17 9 [], ··· 44 36 if (submitting.value) return; 45 37 submitting.value = true; 46 38 try { 47 - await markAsDuplicate(frId, targetId, sphereSlug); 39 + await markAsDuplicate(frId, targetId); 48 40 onMarked(); 49 41 } catch (err) { 50 42 console.error("Failed to mark as duplicate:", err);
+2 -1
packages/feature-requests/src/ui/components/request-card.tsx
··· 1 1 import { useSignal } from "@preact/signals"; 2 2 import * as ui from "@exosphere/client/ui.css"; 3 + import { spherePath } from "@exosphere/client/router"; 3 4 import * as frUi from "../ui.css.ts"; 4 5 import { statusLabels, type FeatureRequestListItem, type Status } from "../api/index.ts"; 5 6 import { categoryLabels, type Category } from "../../schemas/feature-request.ts"; ··· 76 77 </h1> 77 78 ) : ( 78 79 <h3 class={ui.cardTitle}> 79 - <a href={`/infuse/${fr.number}`}> 80 + <a href={spherePath(`/infuse/${fr.number}`)}> 80 81 <span class={ui.muted}>#{fr.number}</span> {fr.title} 81 82 </a> 82 83 </h3>
+15 -24
packages/feature-requests/src/ui/pages/feature-request.tsx
··· 1 1 import { useSignal, useComputed } from "@preact/signals"; 2 2 import { auth } from "@exosphere/client/auth"; 3 3 import { useLocation, useRoute } from "@exosphere/client/router"; 4 - import { sphereState, sphereSlug } from "@exosphere/client/sphere"; 4 + import { sphereState } from "@exosphere/client/sphere"; 5 + import { spherePath } from "@exosphere/client/router"; 5 6 import { useQuery } from "@exosphere/client/hooks"; 6 7 import * as ui from "@exosphere/client/ui.css"; 7 8 import * as frUi from "../ui.css.ts"; ··· 49 50 50 51 function CommentForm({ 51 52 requestId, 52 - sphereSlug, 53 53 currentHandle, 54 54 onCreated, 55 55 }: { 56 56 requestId: string; 57 - sphereSlug: string; 58 57 currentHandle: string | null; 59 58 onCreated: (comment: FeatureRequestCommentListItem) => void; 60 59 }) { ··· 68 67 submitting.value = true; 69 68 error.value = ""; 70 69 try { 71 - const res = await createComment(requestId, content.value.trim(), sphereSlug); 70 + const res = await createComment(requestId, content.value.trim()); 72 71 content.value = ""; 73 72 onCreated({ ...res.comment, voteCount: 0, authorHandle: currentHandle }); 74 73 } catch (err: unknown) { ··· 270 269 271 270 function CommentsSection({ 272 271 requestId, 273 - sphereSlug, 274 272 status, 275 273 isAuthenticated, 276 274 isAdminOrOwner, ··· 278 276 currentHandle, 279 277 }: { 280 278 requestId: string; 281 - sphereSlug: string; 282 279 status: string; 283 280 isAuthenticated: boolean; 284 281 isAdminOrOwner: boolean; ··· 328 325 }; 329 326 330 327 const handleUpdate = async (id: string, content: string) => { 331 - const res = await updateComment(id, content, sphereSlug); 328 + const res = await updateComment(id, content); 332 329 comments.value = comments.value.map((c) => 333 330 c.id === id ? { ...res.comment, voteCount: c.voteCount, authorHandle: c.authorHandle } : c, 334 331 ); ··· 336 333 337 334 const handleDelete = async (id: string) => { 338 335 try { 339 - await deleteComment(id, sphereSlug); 336 + await deleteComment(id); 340 337 comments.value = comments.value.filter((c) => c.id !== id); 341 338 } catch (err) { 342 339 console.error("Failed to delete comment:", err); ··· 351 348 ); 352 349 353 350 try { 354 - await voteComment(id, sphereSlug); 351 + await voteComment(id); 355 352 } catch { 356 353 votedCommentIds.value = prev; 357 354 comments.value = comments.value.map((c) => ··· 416 413 status !== "duplicate" && ( 417 414 <CommentForm 418 415 requestId={requestId} 419 - sphereSlug={sphereSlug} 420 416 currentHandle={currentHandle} 421 417 onCreated={handleCreated} 422 418 /> ··· 429 425 requestId, 430 426 duplicateCount, 431 427 canMerge, 432 - sphereSlug, 433 428 onMarked, 434 429 }: { 435 430 requestId: string; 436 431 duplicateCount: number; 437 432 canMerge: boolean; 438 - sphereSlug: string; 439 433 onMarked: () => void; 440 434 }) { 441 435 const duplicates = useSignal<Array<{ id: string; number: number; title: string }>>([]); ··· 466 460 <div class={ui.stackSm}> 467 461 {duplicates.value.map((d) => ( 468 462 <div key={d.id}> 469 - <a href={`/infuse/${d.number}`}> 463 + <a href={spherePath(`/infuse/${d.number}`)}> 470 464 <span class={ui.muted}>#{d.number}</span> {d.title} 471 465 </a> 472 466 </div> 473 467 ))} 474 468 </div> 475 469 )} 476 - {canMerge && <MergeDropdown frId={requestId} sphereSlug={sphereSlug} onMarked={onMarked} />} 470 + {canMerge && <MergeDropdown frId={requestId} onMarked={onMarked} />} 477 471 </CollapsibleSection> 478 472 ); 479 473 } ··· 482 476 const { params } = useRoute(); 483 477 const { route } = useLocation(); 484 478 const number = parseInt(params.number, 10); 485 - const slug = sphereSlug.value!; 486 479 const votedIds = useSignal<Set<string>>(new Set()); 487 480 const voteCountAdjust = useSignal(0); 488 481 const statusVersion = useSignal(0); ··· 531 524 if (!fr) return; 532 525 try { 533 526 await deleteFeatureRequest(fr.id); 534 - route("/infuse"); 527 + route(spherePath("/infuse")); 535 528 } catch (err) { 536 529 console.error("Failed to delete feature request:", err); 537 530 } ··· 540 533 const handleHide = async () => { 541 534 if (!fr) return; 542 535 try { 543 - await hideFeatureRequest(fr.id, slug); 544 - route("/infuse"); 536 + await hideFeatureRequest(fr.id); 537 + route(spherePath("/infuse")); 545 538 } catch (err) { 546 539 console.error("Failed to hide feature request:", err); 547 540 } ··· 554 547 voteCountAdjust.value++; 555 548 556 549 try { 557 - await voteFeatureRequest(fr.id, slug); 550 + await voteFeatureRequest(fr.id); 558 551 } catch { 559 552 votedIds.value = prev; 560 553 voteCountAdjust.value--; ··· 582 575 const prev = fr.status; 583 576 fr.status = status as typeof fr.status; 584 577 try { 585 - await updateFeatureRequestStatus(fr.id, status, slug); 578 + await updateFeatureRequestStatus(fr.id, status); 586 579 statusVersion.value++; 587 580 // Refetch when changing from "duplicate" to clear duplicate info 588 581 if (prev === "duplicate") { ··· 597 590 <div class={ui.container}> 598 591 <div class={ui.section}> 599 592 <div> 600 - <a href="/infuse" class={ui.muted}> 593 + <a href={spherePath("/infuse")} class={ui.muted}> 601 594 &larr; All requests 602 595 </a> 603 596 </div> ··· 649 642 {data.duplicateOf && ( 650 643 <p class={ui.muted}> 651 644 Duplicate of{" "} 652 - <a href={`/infuse/${data.duplicateOf.number}`}> 645 + <a href={spherePath(`/infuse/${data.duplicateOf.number}`)}> 653 646 #{data.duplicateOf.number} &mdash; {data.duplicateOf.title} 654 647 </a> 655 648 </p> ··· 660 653 661 654 <CommentsSection 662 655 requestId={fr.id} 663 - sphereSlug={slug} 664 656 status={fr.status} 665 657 isAuthenticated={isAuthenticated} 666 658 isAdminOrOwner={isAdminOrOwner} ··· 680 672 data.duplicateCount === 0 && 681 673 fr.status === "requested" 682 674 } 683 - sphereSlug={slug} 684 675 onMarked={() => refetch()} 685 676 /> 686 677 ) : null}
+9 -10
packages/feature-requests/src/ui/pages/feature-requests.tsx
··· 1 1 import { useSignal } from "@preact/signals"; 2 2 import { auth } from "@exosphere/client/auth"; 3 - import { sphereState, sphereSlug } from "@exosphere/client/sphere"; 3 + import { sphereState } from "@exosphere/client/sphere"; 4 + import { spherePath } from "@exosphere/client/router"; 4 5 import { useQuery } from "@exosphere/client/hooks"; 5 6 import * as ui from "@exosphere/client/ui.css"; 6 7 import * as frUi from "../ui.css.ts"; ··· 25 26 label: categoryLabels[value], 26 27 })); 27 28 28 - function SubmitForm({ slug, onCreated }: { slug: string; onCreated: () => void }) { 29 + function SubmitForm({ onCreated }: { onCreated: () => void }) { 29 30 const title = useSignal(""); 30 31 const description = useSignal(""); 31 32 const category = useSignal("general"); ··· 58 59 title: title.value.trim(), 59 60 description: description.value.trim(), 60 61 category: category.value, 61 - sphereSlug: slug, 62 62 }); 63 63 title.value = ""; 64 64 description.value = ""; ··· 154 154 statuses?: string[]; 155 155 activeTab?: ActiveTab; 156 156 } = {}) { 157 - const slug = sphereSlug.value!; 158 157 const showForm = useSignal(false); 159 158 const votedIds = useSignal<Set<string>>(new Set()); 160 159 const { sortBy, sortOrder } = useSortParams(); ··· 212 211 213 212 const handleHide = async (id: string) => { 214 213 try { 215 - await hideFeatureRequest(id, slug); 214 + await hideFeatureRequest(id); 216 215 refetch(); 217 216 } catch (err) { 218 217 console.error("Failed to hide feature request:", err); ··· 229 228 } 230 229 231 230 try { 232 - await voteFeatureRequest(id, slug); 231 + await voteFeatureRequest(id); 233 232 } catch { 234 233 // Revert on failure 235 234 votedIds.value = prev; ··· 279 278 {activeTab === "requests" ? ( 280 279 <span class={ui.tabNavActive}>Requests</span> 281 280 ) : ( 282 - <a href="/infuse" class={ui.tabNavLink}> 281 + <a href={spherePath("/infuse")} class={ui.tabNavLink}> 283 282 Requests 284 283 </a> 285 284 )} 286 285 {activeTab === "done" ? ( 287 286 <span class={ui.tabNavActive}>Done</span> 288 287 ) : ( 289 - <a href="/infuse/done" class={ui.tabNavLink}> 288 + <a href={spherePath("/infuse/done")} class={ui.tabNavLink}> 290 289 Done 291 290 </a> 292 291 )} 293 292 {activeTab === "not-planned" ? ( 294 293 <span class={ui.tabNavActive}>Not planned</span> 295 294 ) : ( 296 - <a href="/infuse/not-planned" class={ui.tabNavLink}> 295 + <a href={spherePath("/infuse/not-planned")} class={ui.tabNavLink}> 297 296 Not planned 298 297 </a> 299 298 )} ··· 301 300 302 301 {showForm.value && ( 303 302 <div class={ui.card}> 304 - <SubmitForm slug={slug} onCreated={onCreated} /> 303 + <SubmitForm onCreated={onCreated} /> 305 304 </div> 306 305 )} 307 306 </div>
+61
review.md
··· 1 + # Multi-Sphere Code Review 2 + 3 + ## Warnings 4 + 5 + ### `api.ts` / `sphere.ts` — circular import 6 + 7 + - **File**: `packages/client/src/api.ts:2` and `packages/client/src/sphere.ts:3` 8 + - **Issue**: `api.ts` imports `sphereSlug` from `sphere.ts`, while `sphere.ts` imports `apiFetch` from `api.ts`. This works with ESM live bindings since neither import is used at module-initialization time, but it's fragile — any future top-level usage in either file will break. 9 + - **Fix**: Move `moduleFetch` to its own file (e.g. `module-api.ts`) that imports from both `api.ts` and `sphere.ts`, breaking the cycle. 10 + 11 + ### `SphereLoader` reads signal during render, causing unnecessary re-renders 12 + 13 + - **File**: `packages/app/src/app.tsx:73` 14 + - **Issue**: `const currentSlug = sphereSlug.value` subscribes the component to `sphereState` changes. Since `SphereLoader` returns `null`, these re-renders are wasted work. 15 + - **Fix**: Read the signal inside the effect: 16 + ```tsx 17 + function SphereLoader() { 18 + const { params } = useRoute(); 19 + useEffect(() => { 20 + const urlSlug = params.sphereSlug; 21 + if (urlSlug && urlSlug !== sphereSlug.peek()) { 22 + loadSphere(urlSlug); 23 + } 24 + }, [params.sphereSlug]); 25 + return null; 26 + } 27 + ``` 28 + 29 + ### `sphereId` has no FK constraint 30 + 31 + - **File**: `packages/feature-requests/src/db/schema.ts:15` 32 + - **Issue**: `sphereId: text("sphere_id").notNull()` doesn't reference the `spheres` table. If a sphere is deleted, orphaned feature requests would remain with no referential integrity enforcement. 33 + - **Fix**: Add a foreign key reference (both tables are in the same SQLite database): 34 + ```ts 35 + import { spheres } from "@exosphere/core/db/schema"; 36 + // ... 37 + sphereId: text("sphere_id").notNull().references(() => spheres.id), 38 + ``` 39 + 40 + ### `DELETE /:id/vote` not sphere-scoped 41 + 42 + - **File**: `packages/feature-requests/src/api/votes.ts` 43 + - **Issue**: The unvote handler (and similarly `DELETE /comments/:id/vote`) doesn't verify the vote's feature request belongs to the current sphere. A user could call `DELETE /api/s/sphere-a/feature-requests/{id}/vote` to remove a vote on a feature request that belongs to `sphere-b`. Not exploitable (the result is the same — the vote is removed), but inconsistent with the other endpoints that all verify sphere membership. 44 + - **Fix**: Add `eq(featureRequests.sphereId, sphereId)` to the vote lookup query in the DELETE handler, same as the POST handler. 45 + 46 + ## Suggestions 47 + 48 + ### Mutable module-level state shared across SSR requests 49 + 50 + - **File**: `packages/client/src/config.ts` 51 + - **Issue**: `export let isMultiSphere` is module-level mutable state. In SSR, `setMultiSphere` is called before each render, and since Bun is single-threaded with synchronous prerender, this is safe today. But if SSR ever becomes concurrent, this becomes a race. Worth a `// NOTE:` comment documenting this assumption. 52 + 53 + ### Empty render when `pending && !loading` 54 + 55 + - **File**: `packages/app/src/pages/dashboard.tsx:32` 56 + - **Issue**: When `pending` is true but `loading` is false (the brief initial state before the loading delay timer fires), the sphere list area renders `null` — the page shows the title and button but no content area at all. For SSR, this means the server-rendered HTML contains only the heading. 57 + - **Fix**: Consider rendering a minimal placeholder or skipping the loading delay for SSR. 58 + 59 + ## Summary 60 + 61 + Overall the changes are clean and well-structured. The sphere context middleware centralizes what was previously scattered sphere lookups across handlers. The `moduleFetch` abstraction is a nice simplification for the client API layer. The migration approach (regenerating the initial migration) is fine for a pre-launch project.
+1
tsconfig.json
··· 1 1 { 2 2 "compilerOptions": { 3 + "types": ["bun-types"], 3 4 "strict": true, 4 5 "target": "ESNext", 5 6 "module": "ESNext",