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

Configure Feed

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

chore: review, fmt, improve ui

exosphere.site 29e78481 e41295f9

verified
+1014 -824
+8 -8
bun.lock
··· 428 428 429 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=="], 430 430 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 + "@vitest/expect": ["@vitest/expect@4.1.2", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.2", "@vitest/utils": "4.1.2", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ=="], 432 432 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 + "@vitest/mocker": ["@vitest/mocker@4.1.2", "", { "dependencies": { "@vitest/spy": "4.1.2", "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-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q=="], 434 434 435 - "@vitest/pretty-format": ["@vitest/pretty-format@4.1.1", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ=="], 435 + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.2", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA=="], 436 436 437 - "@vitest/runner": ["@vitest/runner@4.1.1", "", { "dependencies": { "@vitest/utils": "4.1.1", "pathe": "^2.0.3" } }, "sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A=="], 437 + "@vitest/runner": ["@vitest/runner@4.1.2", "", { "dependencies": { "@vitest/utils": "4.1.2", "pathe": "^2.0.3" } }, "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ=="], 438 438 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 + "@vitest/snapshot": ["@vitest/snapshot@4.1.2", "", { "dependencies": { "@vitest/pretty-format": "4.1.2", "@vitest/utils": "4.1.2", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A=="], 440 440 441 - "@vitest/spy": ["@vitest/spy@4.1.1", "", {}, "sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA=="], 441 + "@vitest/spy": ["@vitest/spy@4.1.2", "", {}, "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA=="], 442 442 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 + "@vitest/utils": ["@vitest/utils@4.1.2", "", { "dependencies": { "@vitest/pretty-format": "4.1.2", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ=="], 444 444 445 445 "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], 446 446 ··· 754 754 755 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=="], 756 756 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 + "vitest": ["vitest@4.1.2", "", { "dependencies": { "@vitest/expect": "4.1.2", "@vitest/mocker": "4.1.2", "@vitest/pretty-format": "4.1.2", "@vitest/runner": "4.1.2", "@vitest/snapshot": "4.1.2", "@vitest/spy": "4.1.2", "@vitest/utils": "4.1.2", "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.1.0", "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.2", "@vitest/browser-preview": "4.1.2", "@vitest/browser-webdriverio": "4.1.2", "@vitest/ui": "4.1.2", "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-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg=="], 758 758 759 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=="], 760 760
+34 -107
drizzle/meta/0002_snapshot.json
··· 175 175 "indexes": { 176 176 "idx_sphere_members_did": { 177 177 "name": "idx_sphere_members_did", 178 - "columns": [ 179 - "did" 180 - ], 178 + "columns": ["did"], 181 179 "isUnique": false 182 180 } 183 181 }, ··· 186 184 "name": "sphere_members_sphere_id_spheres_id_fk", 187 185 "tableFrom": "sphere_members", 188 186 "tableTo": "spheres", 189 - "columnsFrom": [ 190 - "sphere_id" 191 - ], 192 - "columnsTo": [ 193 - "id" 194 - ], 187 + "columnsFrom": ["sphere_id"], 188 + "columnsTo": ["id"], 195 189 "onDelete": "no action", 196 190 "onUpdate": "no action" 197 191 } 198 192 }, 199 193 "compositePrimaryKeys": { 200 194 "sphere_members_sphere_id_did_pk": { 201 - "columns": [ 202 - "sphere_id", 203 - "did" 204 - ], 195 + "columns": ["sphere_id", "did"], 205 196 "name": "sphere_members_sphere_id_did_pk" 206 197 } 207 198 }, ··· 240 231 "name": "sphere_modules_sphere_id_spheres_id_fk", 241 232 "tableFrom": "sphere_modules", 242 233 "tableTo": "spheres", 243 - "columnsFrom": [ 244 - "sphere_id" 245 - ], 246 - "columnsTo": [ 247 - "id" 248 - ], 234 + "columnsFrom": ["sphere_id"], 235 + "columnsTo": ["id"], 249 236 "onDelete": "no action", 250 237 "onUpdate": "no action" 251 238 } 252 239 }, 253 240 "compositePrimaryKeys": { 254 241 "sphere_modules_sphere_id_module_name_pk": { 255 - "columns": [ 256 - "sphere_id", 257 - "module_name" 258 - ], 242 + "columns": ["sphere_id", "module_name"], 259 243 "name": "sphere_modules_sphere_id_module_name_pk" 260 244 } 261 245 }, ··· 301 285 "name": "sphere_permissions_sphere_id_spheres_id_fk", 302 286 "tableFrom": "sphere_permissions", 303 287 "tableTo": "spheres", 304 - "columnsFrom": [ 305 - "sphere_id" 306 - ], 307 - "columnsTo": [ 308 - "id" 309 - ], 288 + "columnsFrom": ["sphere_id"], 289 + "columnsTo": ["id"], 310 290 "onDelete": "no action", 311 291 "onUpdate": "no action" 312 292 } 313 293 }, 314 294 "compositePrimaryKeys": { 315 295 "sphere_permissions_sphere_id_action_key_pk": { 316 - "columns": [ 317 - "sphere_id", 318 - "action_key" 319 - ], 296 + "columns": ["sphere_id", "action_key"], 320 297 "name": "sphere_permissions_sphere_id_action_key_pk" 321 298 } 322 299 }, ··· 404 381 "indexes": { 405 382 "spheres_handle_unique": { 406 383 "name": "spheres_handle_unique", 407 - "columns": [ 408 - "handle" 409 - ], 384 + "columns": ["handle"], 410 385 "isUnique": true 411 386 } 412 387 }, ··· 451 426 "indexes": { 452 427 "idx_feature_request_comment_votes_comment": { 453 428 "name": "idx_feature_request_comment_votes_comment", 454 - "columns": [ 455 - "comment_id" 456 - ], 429 + "columns": ["comment_id"], 457 430 "isUnique": false 458 431 } 459 432 }, ··· 462 435 "name": "feature_request_comment_votes_comment_id_feature_request_comments_id_fk", 463 436 "tableFrom": "feature_request_comment_votes", 464 437 "tableTo": "feature_request_comments", 465 - "columnsFrom": [ 466 - "comment_id" 467 - ], 468 - "columnsTo": [ 469 - "id" 470 - ], 438 + "columnsFrom": ["comment_id"], 439 + "columnsTo": ["id"], 471 440 "onDelete": "no action", 472 441 "onUpdate": "no action" 473 442 } 474 443 }, 475 444 "compositePrimaryKeys": { 476 445 "feature_request_comment_votes_comment_id_author_did_pk": { 477 - "columns": [ 478 - "comment_id", 479 - "author_did" 480 - ], 446 + "columns": ["comment_id", "author_did"], 481 447 "name": "feature_request_comment_votes_comment_id_author_did_pk" 482 448 } 483 449 }, ··· 548 514 "indexes": { 549 515 "idx_feature_request_comments_request": { 550 516 "name": "idx_feature_request_comments_request", 551 - "columns": [ 552 - "request_id" 553 - ], 517 + "columns": ["request_id"], 554 518 "isUnique": false 555 519 }, 556 520 "idx_feature_request_comments_author_request": { 557 521 "name": "idx_feature_request_comments_author_request", 558 - "columns": [ 559 - "author_did", 560 - "request_id" 561 - ], 522 + "columns": ["author_did", "request_id"], 562 523 "isUnique": false 563 524 } 564 525 }, ··· 567 528 "name": "feature_request_comments_request_id_feature_requests_id_fk", 568 529 "tableFrom": "feature_request_comments", 569 530 "tableTo": "feature_requests", 570 - "columnsFrom": [ 571 - "request_id" 572 - ], 573 - "columnsTo": [ 574 - "id" 575 - ], 531 + "columnsFrom": ["request_id"], 532 + "columnsTo": ["id"], 576 533 "onDelete": "no action", 577 534 "onUpdate": "no action" 578 535 } ··· 623 580 "indexes": { 624 581 "idx_feature_request_statuses_request": { 625 582 "name": "idx_feature_request_statuses_request", 626 - "columns": [ 627 - "request_id" 628 - ], 583 + "columns": ["request_id"], 629 584 "isUnique": false 630 585 } 631 586 }, ··· 634 589 "name": "feature_request_statuses_request_id_feature_requests_id_fk", 635 590 "tableFrom": "feature_request_statuses", 636 591 "tableTo": "feature_requests", 637 - "columnsFrom": [ 638 - "request_id" 639 - ], 640 - "columnsTo": [ 641 - "id" 642 - ], 592 + "columnsFrom": ["request_id"], 593 + "columnsTo": ["id"], 643 594 "onDelete": "no action", 644 595 "onUpdate": "no action" 645 596 } ··· 684 635 "indexes": { 685 636 "idx_feature_request_votes_request": { 686 637 "name": "idx_feature_request_votes_request", 687 - "columns": [ 688 - "request_id" 689 - ], 638 + "columns": ["request_id"], 690 639 "isUnique": false 691 640 } 692 641 }, ··· 695 644 "name": "feature_request_votes_request_id_feature_requests_id_fk", 696 645 "tableFrom": "feature_request_votes", 697 646 "tableTo": "feature_requests", 698 - "columnsFrom": [ 699 - "request_id" 700 - ], 701 - "columnsTo": [ 702 - "id" 703 - ], 647 + "columnsFrom": ["request_id"], 648 + "columnsTo": ["id"], 704 649 "onDelete": "no action", 705 650 "onUpdate": "no action" 706 651 } 707 652 }, 708 653 "compositePrimaryKeys": { 709 654 "feature_request_votes_request_id_author_did_pk": { 710 - "columns": [ 711 - "request_id", 712 - "author_did" 713 - ], 655 + "columns": ["request_id", "author_did"], 714 656 "name": "feature_request_votes_request_id_author_did_pk" 715 657 } 716 658 }, ··· 818 760 "indexes": { 819 761 "idx_feature_requests_sphere_number": { 820 762 "name": "idx_feature_requests_sphere_number", 821 - "columns": [ 822 - "sphere_id", 823 - "number" 824 - ], 763 + "columns": ["sphere_id", "number"], 825 764 "isUnique": true 826 765 }, 827 766 "idx_feature_requests_sphere": { 828 767 "name": "idx_feature_requests_sphere", 829 - "columns": [ 830 - "sphere_id" 831 - ], 768 + "columns": ["sphere_id"], 832 769 "isUnique": false 833 770 }, 834 771 "idx_feature_requests_status": { 835 772 "name": "idx_feature_requests_status", 836 - "columns": [ 837 - "status" 838 - ], 773 + "columns": ["status"], 839 774 "isUnique": false 840 775 }, 841 776 "idx_feature_requests_category": { 842 777 "name": "idx_feature_requests_category", 843 - "columns": [ 844 - "category" 845 - ], 778 + "columns": ["category"], 846 779 "isUnique": false 847 780 } 848 781 }, ··· 851 784 "name": "feature_requests_sphere_id_spheres_id_fk", 852 785 "tableFrom": "feature_requests", 853 786 "tableTo": "spheres", 854 - "columnsFrom": [ 855 - "sphere_id" 856 - ], 857 - "columnsTo": [ 858 - "id" 859 - ], 787 + "columnsFrom": ["sphere_id"], 788 + "columnsTo": ["id"], 860 789 "onDelete": "no action", 861 790 "onUpdate": "no action" 862 791 } ··· 915 844 "indexes": { 916 845 "idx_feed_posts_parent": { 917 846 "name": "idx_feed_posts_parent", 918 - "columns": [ 919 - "parent_id" 920 - ], 847 + "columns": ["parent_id"], 921 848 "isUnique": false 922 849 } 923 850 }, ··· 937 864 "internal": { 938 865 "indexes": {} 939 866 } 940 - } 867 + }
+34 -107
drizzle/meta/0003_snapshot.json
··· 175 175 "indexes": { 176 176 "idx_sphere_members_did": { 177 177 "name": "idx_sphere_members_did", 178 - "columns": [ 179 - "did" 180 - ], 178 + "columns": ["did"], 181 179 "isUnique": false 182 180 } 183 181 }, ··· 186 184 "name": "sphere_members_sphere_id_spheres_id_fk", 187 185 "tableFrom": "sphere_members", 188 186 "tableTo": "spheres", 189 - "columnsFrom": [ 190 - "sphere_id" 191 - ], 192 - "columnsTo": [ 193 - "id" 194 - ], 187 + "columnsFrom": ["sphere_id"], 188 + "columnsTo": ["id"], 195 189 "onDelete": "no action", 196 190 "onUpdate": "no action" 197 191 } 198 192 }, 199 193 "compositePrimaryKeys": { 200 194 "sphere_members_sphere_id_did_pk": { 201 - "columns": [ 202 - "sphere_id", 203 - "did" 204 - ], 195 + "columns": ["sphere_id", "did"], 205 196 "name": "sphere_members_sphere_id_did_pk" 206 197 } 207 198 }, ··· 240 231 "name": "sphere_modules_sphere_id_spheres_id_fk", 241 232 "tableFrom": "sphere_modules", 242 233 "tableTo": "spheres", 243 - "columnsFrom": [ 244 - "sphere_id" 245 - ], 246 - "columnsTo": [ 247 - "id" 248 - ], 234 + "columnsFrom": ["sphere_id"], 235 + "columnsTo": ["id"], 249 236 "onDelete": "no action", 250 237 "onUpdate": "no action" 251 238 } 252 239 }, 253 240 "compositePrimaryKeys": { 254 241 "sphere_modules_sphere_id_module_name_pk": { 255 - "columns": [ 256 - "sphere_id", 257 - "module_name" 258 - ], 242 + "columns": ["sphere_id", "module_name"], 259 243 "name": "sphere_modules_sphere_id_module_name_pk" 260 244 } 261 245 }, ··· 301 285 "name": "sphere_permissions_sphere_id_spheres_id_fk", 302 286 "tableFrom": "sphere_permissions", 303 287 "tableTo": "spheres", 304 - "columnsFrom": [ 305 - "sphere_id" 306 - ], 307 - "columnsTo": [ 308 - "id" 309 - ], 288 + "columnsFrom": ["sphere_id"], 289 + "columnsTo": ["id"], 310 290 "onDelete": "no action", 311 291 "onUpdate": "no action" 312 292 } 313 293 }, 314 294 "compositePrimaryKeys": { 315 295 "sphere_permissions_sphere_id_action_key_pk": { 316 - "columns": [ 317 - "sphere_id", 318 - "action_key" 319 - ], 296 + "columns": ["sphere_id", "action_key"], 320 297 "name": "sphere_permissions_sphere_id_action_key_pk" 321 298 } 322 299 }, ··· 396 373 "indexes": { 397 374 "spheres_handle_unique": { 398 375 "name": "spheres_handle_unique", 399 - "columns": [ 400 - "handle" 401 - ], 376 + "columns": ["handle"], 402 377 "isUnique": true 403 378 } 404 379 }, ··· 443 418 "indexes": { 444 419 "idx_feature_request_comment_votes_comment": { 445 420 "name": "idx_feature_request_comment_votes_comment", 446 - "columns": [ 447 - "comment_id" 448 - ], 421 + "columns": ["comment_id"], 449 422 "isUnique": false 450 423 } 451 424 }, ··· 454 427 "name": "feature_request_comment_votes_comment_id_feature_request_comments_id_fk", 455 428 "tableFrom": "feature_request_comment_votes", 456 429 "tableTo": "feature_request_comments", 457 - "columnsFrom": [ 458 - "comment_id" 459 - ], 460 - "columnsTo": [ 461 - "id" 462 - ], 430 + "columnsFrom": ["comment_id"], 431 + "columnsTo": ["id"], 463 432 "onDelete": "no action", 464 433 "onUpdate": "no action" 465 434 } 466 435 }, 467 436 "compositePrimaryKeys": { 468 437 "feature_request_comment_votes_comment_id_author_did_pk": { 469 - "columns": [ 470 - "comment_id", 471 - "author_did" 472 - ], 438 + "columns": ["comment_id", "author_did"], 473 439 "name": "feature_request_comment_votes_comment_id_author_did_pk" 474 440 } 475 441 }, ··· 540 506 "indexes": { 541 507 "idx_feature_request_comments_request": { 542 508 "name": "idx_feature_request_comments_request", 543 - "columns": [ 544 - "request_id" 545 - ], 509 + "columns": ["request_id"], 546 510 "isUnique": false 547 511 }, 548 512 "idx_feature_request_comments_author_request": { 549 513 "name": "idx_feature_request_comments_author_request", 550 - "columns": [ 551 - "author_did", 552 - "request_id" 553 - ], 514 + "columns": ["author_did", "request_id"], 554 515 "isUnique": false 555 516 } 556 517 }, ··· 559 520 "name": "feature_request_comments_request_id_feature_requests_id_fk", 560 521 "tableFrom": "feature_request_comments", 561 522 "tableTo": "feature_requests", 562 - "columnsFrom": [ 563 - "request_id" 564 - ], 565 - "columnsTo": [ 566 - "id" 567 - ], 523 + "columnsFrom": ["request_id"], 524 + "columnsTo": ["id"], 568 525 "onDelete": "no action", 569 526 "onUpdate": "no action" 570 527 } ··· 615 572 "indexes": { 616 573 "idx_feature_request_statuses_request": { 617 574 "name": "idx_feature_request_statuses_request", 618 - "columns": [ 619 - "request_id" 620 - ], 575 + "columns": ["request_id"], 621 576 "isUnique": false 622 577 } 623 578 }, ··· 626 581 "name": "feature_request_statuses_request_id_feature_requests_id_fk", 627 582 "tableFrom": "feature_request_statuses", 628 583 "tableTo": "feature_requests", 629 - "columnsFrom": [ 630 - "request_id" 631 - ], 632 - "columnsTo": [ 633 - "id" 634 - ], 584 + "columnsFrom": ["request_id"], 585 + "columnsTo": ["id"], 635 586 "onDelete": "no action", 636 587 "onUpdate": "no action" 637 588 } ··· 676 627 "indexes": { 677 628 "idx_feature_request_votes_request": { 678 629 "name": "idx_feature_request_votes_request", 679 - "columns": [ 680 - "request_id" 681 - ], 630 + "columns": ["request_id"], 682 631 "isUnique": false 683 632 } 684 633 }, ··· 687 636 "name": "feature_request_votes_request_id_feature_requests_id_fk", 688 637 "tableFrom": "feature_request_votes", 689 638 "tableTo": "feature_requests", 690 - "columnsFrom": [ 691 - "request_id" 692 - ], 693 - "columnsTo": [ 694 - "id" 695 - ], 639 + "columnsFrom": ["request_id"], 640 + "columnsTo": ["id"], 696 641 "onDelete": "no action", 697 642 "onUpdate": "no action" 698 643 } 699 644 }, 700 645 "compositePrimaryKeys": { 701 646 "feature_request_votes_request_id_author_did_pk": { 702 - "columns": [ 703 - "request_id", 704 - "author_did" 705 - ], 647 + "columns": ["request_id", "author_did"], 706 648 "name": "feature_request_votes_request_id_author_did_pk" 707 649 } 708 650 }, ··· 810 752 "indexes": { 811 753 "idx_feature_requests_sphere_number": { 812 754 "name": "idx_feature_requests_sphere_number", 813 - "columns": [ 814 - "sphere_id", 815 - "number" 816 - ], 755 + "columns": ["sphere_id", "number"], 817 756 "isUnique": true 818 757 }, 819 758 "idx_feature_requests_sphere": { 820 759 "name": "idx_feature_requests_sphere", 821 - "columns": [ 822 - "sphere_id" 823 - ], 760 + "columns": ["sphere_id"], 824 761 "isUnique": false 825 762 }, 826 763 "idx_feature_requests_status": { 827 764 "name": "idx_feature_requests_status", 828 - "columns": [ 829 - "status" 830 - ], 765 + "columns": ["status"], 831 766 "isUnique": false 832 767 }, 833 768 "idx_feature_requests_category": { 834 769 "name": "idx_feature_requests_category", 835 - "columns": [ 836 - "category" 837 - ], 770 + "columns": ["category"], 838 771 "isUnique": false 839 772 } 840 773 }, ··· 843 776 "name": "feature_requests_sphere_id_spheres_id_fk", 844 777 "tableFrom": "feature_requests", 845 778 "tableTo": "spheres", 846 - "columnsFrom": [ 847 - "sphere_id" 848 - ], 849 - "columnsTo": [ 850 - "id" 851 - ], 779 + "columnsFrom": ["sphere_id"], 780 + "columnsTo": ["id"], 852 781 "onDelete": "no action", 853 782 "onUpdate": "no action" 854 783 } ··· 907 836 "indexes": { 908 837 "idx_feed_posts_parent": { 909 838 "name": "idx_feed_posts_parent", 910 - "columns": [ 911 - "parent_id" 912 - ], 839 + "columns": ["parent_id"], 913 840 "isUnique": false 914 841 } 915 842 }, ··· 929 856 "internal": { 930 857 "indexes": {} 931 858 } 932 - } 859 + }
+1 -1
drizzle/meta/_journal.json
··· 31 31 "breakpoints": true 32 32 } 33 33 ] 34 - } 34 + }
+1 -1
package.json
··· 34 34 "bun-types": "^1.3.11", 35 35 "drizzle-kit": "^0.31.10", 36 36 "oxfmt": "^0.41.0", 37 - "vitest": "^4.1.1" 37 + "vitest": "^4.1.2" 38 38 } 39 39 }
+12
packages/app/src/app.tsx
··· 12 12 import { SignIn } from "./pages/sign-in.tsx"; 13 13 import { CreateSphere } from "./pages/create-sphere.tsx"; 14 14 import { SpherePage } from "./pages/sphere.tsx"; 15 + import { SphereMembersPage } from "./pages/sphere-members.tsx"; 16 + import { SpherePermissionsPage } from "./pages/sphere-permissions.tsx"; 15 17 import { Dashboard } from "./pages/dashboard.tsx"; 16 18 import type { ModuleRoute } from "@exosphere/client/types"; 17 19 import { feedsModule } from "@exosphere/feeds/client"; ··· 113 115 {routes.map((r) => ( 114 116 <Route key={r.path} path={r.path} component={r.component} /> 115 117 ))} 118 + <Route 119 + path="/s/:sphereHandle/settings/members" 120 + component={withSphereLoader(SphereMembersPage)} 121 + /> 122 + <Route 123 + path="/s/:sphereHandle/settings/permissions" 124 + component={withSphereLoader(SpherePermissionsPage)} 125 + /> 116 126 <Route path="/s/:sphereHandle" component={withSphereLoader(SpherePage)} /> 117 127 <Route path="/" component={MultiSphereDefaultPage} /> 118 128 <Route default component={MultiSphereDefaultPage} /> ··· 145 155 return ( 146 156 <Router> 147 157 <Route path="/sign-in" component={SignInPage} /> 158 + <Route path="/settings/members" component={SphereMembersPage} /> 159 + <Route path="/settings/permissions" component={SpherePermissionsPage} /> 148 160 {moduleRoutes.map((r) => ( 149 161 <Route key={r.path} path={r.path} component={r.component} /> 150 162 ))}
+7 -2
packages/app/src/pages/dashboard.tsx
··· 61 61 initialData: ssrData, 62 62 }); 63 63 const accepting = useSignal<string | null>(null); 64 + const acceptError = useSignal(""); 64 65 65 66 if (!data || data.invitations.length === 0) return null; 66 67 67 68 const handleAccept = async (inv: InvitationData) => { 68 69 accepting.value = inv.sphere.handle; 70 + acceptError.value = ""; 69 71 try { 70 72 await acceptInvite(inv.sphere.handle); 71 73 refetch(); 72 74 sphereListVersion.value++; 73 75 } catch (err) { 74 - console.error("Accept failed:", err); 76 + acceptError.value = err instanceof Error ? err.message : "Failed to accept invitation."; 75 77 } finally { 76 78 accepting.value = null; 77 79 } ··· 81 83 <div class={ui.section}> 82 84 <h2 class={ui.sectionTitle}>Pending Invitations</h2> 83 85 <div class={ui.stackSm}> 86 + {acceptError.value && <p class={ui.errorText}>{acceptError.value}</p>} 84 87 {data.invitations.map((inv) => ( 85 88 <div class={ui.card} key={inv.sphere.id}> 86 89 <div class={ui.row}> 87 90 <div> 88 - <a href={`/s/${inv.sphere.handle}`}><strong>{inv.sphere.name}</strong></a> 91 + <a href={`/s/${inv.sphere.handle}`}> 92 + <strong>{inv.sphere.name}</strong> 93 + </a> 89 94 <span class={`${ui.muted} ${ui.inlineTag}`}>{inv.role}</span> 90 95 </div> 91 96 <button
+206
packages/app/src/pages/sphere-members.tsx
··· 1 + import { useSignal } from "@preact/signals"; 2 + import { sphereState, sphereHandle } from "@exosphere/client/sphere"; 3 + import { canDo } from "@exosphere/client/permissions"; 4 + import { useQuery } from "@exosphere/client/hooks"; 5 + import { spherePath } from "@exosphere/client/router"; 6 + import * as ui from "@exosphere/client/ui.css"; 7 + import { 8 + getSphereMembers, 9 + inviteMember, 10 + revokeMember, 11 + updateMemberRole, 12 + type MemberData, 13 + } from "../api/spheres.ts"; 14 + 15 + const roleLabels: Record<string, string> = { 16 + owner: "Owner", 17 + admin: "Admin", 18 + member: "Member", 19 + }; 20 + 21 + const statusLabels: Record<string, string> = { 22 + active: "Active", 23 + invited: "Invited", 24 + revoked: "Revoked", 25 + }; 26 + 27 + export function SphereMembersPage() { 28 + const { data } = sphereState.value; 29 + const handle = sphereHandle.value; 30 + 31 + if (!data || !handle) return null; 32 + // Allow access if user has any member management permission 33 + const canManageMembers = 34 + canDo("sphere", "invite-member") || 35 + canDo("sphere", "revoke-member") || 36 + canDo("sphere", "update-member-role"); 37 + if (!canManageMembers) return null; 38 + 39 + return <MembersContent handle={handle} sphereName={data.sphere.name} />; 40 + } 41 + 42 + function MembersContent({ handle, sphereName }: { handle: string; sphereName: string }) { 43 + const members = useQuery(() => getSphereMembers(handle), [handle]); 44 + const inviteDid = useSignal(""); 45 + const inviteRole = useSignal<"admin" | "member">("member"); 46 + const inviteError = useSignal(""); 47 + const memberError = useSignal(""); 48 + const inviting = useSignal(false); 49 + // Track pending role change: { did, role } — null when nothing pending 50 + const pendingRole = useSignal<{ did: string; role: "admin" | "member" } | null>(null); 51 + 52 + const handleInvite = async (e: Event) => { 53 + e.preventDefault(); 54 + const did = inviteDid.value.trim(); 55 + if (!did) return; 56 + inviting.value = true; 57 + inviteError.value = ""; 58 + try { 59 + await inviteMember(handle, did, inviteRole.value); 60 + inviteDid.value = ""; 61 + members.refetch(); 62 + } catch (err) { 63 + inviteError.value = err instanceof Error ? err.message : "Failed to invite member."; 64 + } finally { 65 + inviting.value = false; 66 + } 67 + }; 68 + 69 + const handleRevoke = async (did: string) => { 70 + memberError.value = ""; 71 + try { 72 + await revokeMember(handle, did); 73 + members.refetch(); 74 + } catch (err) { 75 + memberError.value = err instanceof Error ? err.message : "Failed to revoke member."; 76 + } 77 + }; 78 + 79 + const handleRoleChange = async (did: string, role: "admin" | "member") => { 80 + memberError.value = ""; 81 + try { 82 + await updateMemberRole(handle, did, role); 83 + pendingRole.value = null; 84 + members.refetch(); 85 + } catch (err) { 86 + memberError.value = err instanceof Error ? err.message : "Failed to update role."; 87 + } 88 + }; 89 + 90 + return ( 91 + <div class={ui.container}> 92 + <div class={ui.section}> 93 + <a href={spherePath("/")} class={ui.muted}> 94 + &larr; {sphereName} 95 + </a> 96 + <h1 class={ui.pageTitle}>Members</h1> 97 + </div> 98 + 99 + {members.loading && <p class={ui.muted}>Loading members...</p>} 100 + {memberError.value && <p class={ui.errorText}>{memberError.value}</p>} 101 + 102 + {members.data && ( 103 + <div class={ui.section}> 104 + <div class={ui.stackSm}> 105 + {members.data.members.map((m: MemberData) => ( 106 + <div class={ui.card} key={m.did}> 107 + <div class={ui.row}> 108 + <div> 109 + <strong class={ui.didText}>{m.handle ? `@${m.handle}` : m.did}</strong> 110 + <span class={`${ui.muted} ${ui.inlineTag}`}> 111 + {roleLabels[m.role] ?? m.role} 112 + </span> 113 + <span class={`${ui.muted} ${ui.inlineTag}`}> 114 + {statusLabels[m.status] ?? m.status} 115 + </span> 116 + </div> 117 + {m.role !== "owner" && 118 + m.status === "active" && 119 + (canDo("sphere", "update-member-role") || canDo("sphere", "revoke-member")) && ( 120 + <div class={ui.cluster}> 121 + {canDo("sphere", "update-member-role") && ( 122 + <> 123 + <select 124 + class={ui.selectCompact} 125 + value={ 126 + pendingRole.value?.did === m.did ? pendingRole.value.role : m.role 127 + } 128 + onChange={(e) => { 129 + const newRole = (e.target as HTMLSelectElement).value as 130 + | "admin" 131 + | "member"; 132 + if (newRole !== m.role) { 133 + pendingRole.value = { did: m.did, role: newRole }; 134 + } else { 135 + pendingRole.value = null; 136 + } 137 + }} 138 + > 139 + <option value="member">Member</option> 140 + <option value="admin">Admin</option> 141 + </select> 142 + {pendingRole.value?.did === m.did && ( 143 + <> 144 + <button 145 + class={ui.buttonCompact} 146 + onClick={() => handleRoleChange(m.did, pendingRole.value!.role)} 147 + > 148 + Confirm 149 + </button> 150 + <button 151 + class={ui.buttonCompactSecondary} 152 + onClick={() => (pendingRole.value = null)} 153 + > 154 + Cancel 155 + </button> 156 + </> 157 + )} 158 + </> 159 + )} 160 + {canDo("sphere", "revoke-member") && ( 161 + <button class={ui.buttonDanger} onClick={() => handleRevoke(m.did)}> 162 + Revoke 163 + </button> 164 + )} 165 + </div> 166 + )} 167 + </div> 168 + </div> 169 + ))} 170 + </div> 171 + </div> 172 + )} 173 + 174 + {canDo("sphere", "invite-member") && ( 175 + <div class={ui.section}> 176 + <form onSubmit={handleInvite} class={ui.stack}> 177 + <h2 class={ui.sectionTitle}>Invite member</h2> 178 + <div class={ui.cluster}> 179 + <input 180 + class={`${ui.input} ${ui.flexGrow}`} 181 + type="text" 182 + placeholder="did:plc:..." 183 + value={inviteDid.value} 184 + onInput={(e) => (inviteDid.value = (e.target as HTMLInputElement).value)} 185 + /> 186 + <select 187 + class={ui.selectCompact} 188 + value={inviteRole.value} 189 + onChange={(e) => 190 + (inviteRole.value = (e.target as HTMLSelectElement).value as "admin" | "member") 191 + } 192 + > 193 + <option value="member">Member</option> 194 + <option value="admin">Admin</option> 195 + </select> 196 + <button type="submit" class={ui.button} disabled={inviting.value}> 197 + {inviting.value ? "Inviting..." : "Invite"} 198 + </button> 199 + </div> 200 + {inviteError.value && <p class={ui.errorText}>{inviteError.value}</p>} 201 + </form> 202 + </div> 203 + )} 204 + </div> 205 + ); 206 + }
+127
packages/app/src/pages/sphere-permissions.tsx
··· 1 + import { useSignal } from "@preact/signals"; 2 + import { sphereState, sphereHandle, refreshSphere } from "@exosphere/client/sphere"; 3 + import { canDo } from "@exosphere/client/permissions"; 4 + import { useQuery } from "@exosphere/client/hooks"; 5 + import { spherePath } from "@exosphere/client/router"; 6 + import * as ui from "@exosphere/client/ui.css"; 7 + import { getSpherePermissions, updateSpherePermissions } from "../api/spheres.ts"; 8 + 9 + const roleLabels: Record<string, string> = { 10 + owner: "Owner", 11 + admin: "Admin", 12 + member: "Member", 13 + authenticated: "Any user", 14 + }; 15 + 16 + // Must match CORE_MODULE from @exosphere/core/permissions 17 + const CORE_MODULE = "sphere"; 18 + 19 + const moduleLabels: Record<string, string> = { 20 + sphere: "Sphere", 21 + "feature-requests": "Infuse", 22 + }; 23 + 24 + export function SpherePermissionsPage() { 25 + const { data } = sphereState.value; 26 + const handle = sphereHandle.value; 27 + 28 + if (!data || !handle) return null; 29 + if (!canDo("sphere", "update-permissions")) return null; 30 + 31 + return <PermissionsContent handle={handle} sphereName={data.sphere.name} />; 32 + } 33 + 34 + function PermissionsContent({ handle, sphereName }: { handle: string; sphereName: string }) { 35 + const perms = useQuery(() => getSpherePermissions(handle), [handle]); 36 + const saving = useSignal(false); 37 + const saveError = useSignal(""); 38 + const pendingChanges = useSignal<Record<string, string>>({}); 39 + 40 + const handleChange = (actionKey: string, role: string) => { 41 + pendingChanges.value = { ...pendingChanges.value, [actionKey]: role }; 42 + }; 43 + 44 + const handleSave = async () => { 45 + if (Object.keys(pendingChanges.value).length === 0) return; 46 + saving.value = true; 47 + saveError.value = ""; 48 + try { 49 + await updateSpherePermissions(handle, pendingChanges.value); 50 + pendingChanges.value = {}; 51 + perms.refetch(); 52 + refreshSphere(); 53 + } catch (err) { 54 + saveError.value = err instanceof Error ? err.message : "Failed to save permissions."; 55 + } finally { 56 + saving.value = false; 57 + } 58 + }; 59 + 60 + const hasChanges = Object.keys(pendingChanges.value).length > 0; 61 + 62 + return ( 63 + <div class={ui.container}> 64 + <div class={ui.section}> 65 + <a href={spherePath("/")} class={ui.muted}> 66 + &larr; {sphereName} 67 + </a> 68 + <h1 class={ui.pageTitle}>Permissions</h1> 69 + </div> 70 + 71 + {perms.loading && <p class={ui.muted}>Loading permissions...</p>} 72 + 73 + {perms.data && 74 + Object.entries(perms.data.modules).map(([moduleName, mod]) => ( 75 + <div class={ui.section} key={moduleName}> 76 + <h2 class={ui.cardHeading}>{moduleLabels[moduleName] ?? moduleName}</h2> 77 + <div class={ui.stackSm}> 78 + {Object.entries(mod.actions).map(([action, info]) => { 79 + const actionKey = `${moduleName}:${action}`; 80 + const currentValue = pendingChanges.value[actionKey] ?? info.effectiveRole; 81 + const isCoreModule = moduleName === CORE_MODULE; 82 + return ( 83 + <div class={ui.row} key={action}> 84 + <span class={ui.permissionLabel}>{info.label}</span> 85 + <div class={ui.cluster}> 86 + <select 87 + class={ui.selectCompact} 88 + value={currentValue} 89 + onChange={(e) => 90 + handleChange(actionKey, (e.target as HTMLSelectElement).value) 91 + } 92 + > 93 + {!isCoreModule && <option value="authenticated">Any user</option>} 94 + {!isCoreModule && <option value="member">Member</option>} 95 + <option value="admin">Admin</option> 96 + <option value="owner">Owner</option> 97 + </select> 98 + {info.isOverridden && !(actionKey in pendingChanges.value) && ( 99 + <span class={ui.muted}>(default: {roleLabels[info.defaultRole]})</span> 100 + )} 101 + </div> 102 + </div> 103 + ); 104 + })} 105 + </div> 106 + </div> 107 + ))} 108 + 109 + {perms.data && (hasChanges || saveError.value) && ( 110 + <div class={ui.section}> 111 + {saveError.value && <p class={ui.errorText}>{saveError.value}</p>} 112 + 113 + {hasChanges && ( 114 + <div class={ui.row}> 115 + <button class={ui.buttonSecondary} onClick={() => (pendingChanges.value = {})}> 116 + Discard 117 + </button> 118 + <button class={ui.button} onClick={handleSave} disabled={saving.value}> 119 + {saving.value ? "Saving..." : "Save permissions"} 120 + </button> 121 + </div> 122 + )} 123 + </div> 124 + )} 125 + </div> 126 + ); 127 + }
+24 -241
packages/app/src/pages/sphere.tsx
··· 1 - import { useSignal } from "@preact/signals"; 2 - import { auth } from "@exosphere/client/auth"; 3 1 import { sphereState, sphereHandle, refreshSphere } from "@exosphere/client/sphere"; 2 + import { canDo } from "@exosphere/client/permissions"; 4 3 import { useQuery } from "@exosphere/client/hooks"; 5 4 import { spherePath } from "@exosphere/client/router"; 6 - import { CollapsibleSection } from "@exosphere/client/components/collapsible-section"; 7 5 import * as ui from "@exosphere/client/ui.css"; 8 6 import { 9 7 getSphereModules, 10 8 enableModule as apiEnableModule, 11 9 disableModule as apiDisableModule, 12 - getSpherePermissions, 13 - updateSpherePermissions, 14 - getSphereMembers, 15 - inviteMember, 16 - revokeMember, 17 - updateMemberRole, 18 - type MemberData, 19 10 } from "../api/spheres.ts"; 20 11 21 12 /** Map internal module names to user-facing labels (used for display and URL paths). */ ··· 32 23 }, 33 24 }; 34 25 35 - const roleLabels: Record<string, string> = { 36 - owner: "Owner", 37 - admin: "Admin", 38 - member: "Member", 39 - authenticated: "Any user", 40 - }; 41 - 42 - const statusLabels: Record<string, string> = { 43 - active: "Active", 44 - invited: "Invited", 45 - revoked: "Revoked", 46 - }; 47 - 48 26 export function SpherePage() { 49 27 const { data } = sphereState.value; 50 28 const handle = sphereHandle.value; ··· 53 31 54 32 const modules = useQuery(() => getSphereModules(handle), [handle]); 55 33 56 - const isOwner = () => { 57 - if (!auth.value.authenticated) return false; 58 - return data.sphere.ownerDid === auth.value.did; 59 - }; 60 - 61 - const isAdminOrOwner = () => { 62 - if (!auth.value.authenticated) return false; 63 - const role = data.role; 64 - return role === "owner" || role === "admin"; 65 - }; 34 + const canManageMembers = 35 + canDo("sphere", "invite-member") || 36 + canDo("sphere", "revoke-member") || 37 + canDo("sphere", "update-member-role"); 66 38 67 39 const enableModule = async (moduleName: string) => { 68 40 await apiEnableModule(handle, moduleName); ··· 110 82 </a> 111 83 <span class={`${ui.muted} ${ui.inlineTag}`}>enabled</span> 112 84 </div> 113 - {isAdminOrOwner() && ( 85 + {canDo("sphere", "disable-module") && ( 114 86 <button class={ui.buttonDanger} onClick={() => disableModule(mod.name)}> 115 87 Disable 116 88 </button> ··· 124 96 </div> 125 97 )} 126 98 127 - {isAdminOrOwner() && availableToEnable.length > 0 && ( 99 + {canDo("sphere", "enable-module") && availableToEnable.length > 0 && ( 128 100 <div class={ui.stackSm}> 129 101 <h3 class={ui.subsectionTitle}>Available modules</h3> 130 102 <div class={ui.stackSm}> ··· 146 118 )} 147 119 </div> 148 120 149 - {/* Members section — admin/owner only */} 150 - {isAdminOrOwner() && <MembersSection handle={handle} />} 151 - 152 - {/* Permissions section — owner only */} 153 - {isOwner() && <PermissionsSection handle={handle} />} 154 - </div> 155 - ); 156 - } 157 - 158 - function MembersSection({ handle }: { handle: string }) { 159 - const members = useQuery(() => getSphereMembers(handle), [handle]); 160 - const inviteDid = useSignal(""); 161 - const inviteRole = useSignal<"admin" | "member">("member"); 162 - const inviteError = useSignal(""); 163 - const inviting = useSignal(false); 164 - 165 - const handleInvite = async (e: Event) => { 166 - e.preventDefault(); 167 - const did = inviteDid.value.trim(); 168 - if (!did) return; 169 - inviting.value = true; 170 - inviteError.value = ""; 171 - try { 172 - await inviteMember(handle, did, inviteRole.value); 173 - inviteDid.value = ""; 174 - members.refetch(); 175 - } catch (err) { 176 - inviteError.value = err instanceof Error ? err.message : "Failed to invite member."; 177 - } finally { 178 - inviting.value = false; 179 - } 180 - }; 181 - 182 - const handleRevoke = async (did: string) => { 183 - try { 184 - await revokeMember(handle, did); 185 - members.refetch(); 186 - } catch (err) { 187 - console.error("Revoke failed:", err); 188 - } 189 - }; 190 - 191 - const handleRoleChange = async (did: string, role: "admin" | "member") => { 192 - try { 193 - await updateMemberRole(handle, did, role); 194 - members.refetch(); 195 - } catch (err) { 196 - console.error("Role change failed:", err); 197 - } 198 - }; 199 - 200 - return ( 201 - <div class={ui.section}> 202 - <CollapsibleSection title="Members"> 203 - {members.loading && <p class={ui.muted}>Loading members...</p>} 204 - 205 - {members.data && ( 121 + {/* Settings — visible to users with relevant permissions */} 122 + {(canManageMembers || canDo("sphere", "update-permissions")) && ( 123 + <div class={ui.section}> 124 + <h2 class={ui.sectionTitle}>Settings</h2> 206 125 <div class={ui.stackSm}> 207 - {members.data.members.map((m: MemberData) => ( 208 - <div class={ui.card} key={m.did}> 209 - <div class={ui.row}> 210 - <div> 211 - <strong class={ui.didText}>{m.handle ? `@${m.handle}` : m.did}</strong> 212 - <span class={`${ui.muted} ${ui.inlineTag}`}> 213 - {roleLabels[m.role] ?? m.role} 214 - </span> 215 - <span class={`${ui.muted} ${ui.inlineTag}`}> 216 - {statusLabels[m.status] ?? m.status} 217 - </span> 218 - </div> 219 - {m.role !== "owner" && m.status === "active" && ( 220 - <div class={ui.cluster}> 221 - <select 222 - class={ui.selectCompact} 223 - value={m.role} 224 - onChange={(e) => 225 - handleRoleChange(m.did, (e.target as HTMLSelectElement).value as "admin" | "member") 226 - } 227 - > 228 - <option value="member">Member</option> 229 - <option value="admin">Admin</option> 230 - </select> 231 - <button class={ui.buttonDanger} onClick={() => handleRevoke(m.did)}> 232 - Revoke 233 - </button> 234 - </div> 235 - )} 236 - </div> 237 - </div> 238 - ))} 239 - </div> 240 - )} 241 - 242 - <form onSubmit={handleInvite} class={ui.stack}> 243 - <h3 class={ui.subsectionTitle}>Invite member</h3> 244 - <div class={ui.cluster}> 245 - <input 246 - class={`${ui.input} ${ui.flexGrow}`} 247 - type="text" 248 - placeholder="did:plc:..." 249 - value={inviteDid.value} 250 - onInput={(e) => (inviteDid.value = (e.target as HTMLInputElement).value)} 251 - /> 252 - <select 253 - class={ui.selectCompact} 254 - value={inviteRole.value} 255 - onChange={(e) => (inviteRole.value = (e.target as HTMLSelectElement).value as "admin" | "member")} 256 - > 257 - <option value="member">Member</option> 258 - <option value="admin">Admin</option> 259 - </select> 260 - <button type="submit" class={ui.button} disabled={inviting.value}> 261 - {inviting.value ? "Inviting..." : "Invite"} 262 - </button> 263 - </div> 264 - {inviteError.value && <p class={ui.errorText}>{inviteError.value}</p>} 265 - </form> 266 - </CollapsibleSection> 267 - </div> 268 - ); 269 - } 270 - 271 - function PermissionsSection({ handle }: { handle: string }) { 272 - const perms = useQuery(() => getSpherePermissions(handle), [handle]); 273 - const saving = useSignal(false); 274 - const pendingChanges = useSignal<Record<string, string>>({}); 275 - 276 - const handleChange = (actionKey: string, role: string) => { 277 - pendingChanges.value = { ...pendingChanges.value, [actionKey]: role }; 278 - }; 279 - 280 - const handleSave = async () => { 281 - if (Object.keys(pendingChanges.value).length === 0) return; 282 - saving.value = true; 283 - try { 284 - await updateSpherePermissions(handle, pendingChanges.value); 285 - pendingChanges.value = {}; 286 - perms.refetch(); 287 - refreshSphere(); 288 - } catch (err) { 289 - console.error("Save permissions failed:", err); 290 - } finally { 291 - saving.value = false; 292 - } 293 - }; 294 - 295 - const hasChanges = Object.keys(pendingChanges.value).length > 0; 296 - 297 - return ( 298 - <div class={ui.section}> 299 - <CollapsibleSection title="Permissions"> 300 - {perms.loading && <p class={ui.muted}>Loading permissions...</p>} 301 - 302 - {perms.data && ( 303 - <div class={ui.stackSm}> 304 - {Object.entries(perms.data.modules).map(([moduleName, mod]) => ( 305 - <div key={moduleName}> 306 - <h3 class={ui.sectionSubheading}> 307 - {moduleLabels[moduleName]?.label ?? moduleName} 308 - </h3> 309 - <div class={ui.stackSm}> 310 - {Object.entries(mod.actions).map(([action, info]) => { 311 - const actionKey = `${moduleName}:${action}`; 312 - const currentValue = pendingChanges.value[actionKey] ?? info.effectiveRole; 313 - return ( 314 - <div class={ui.row} key={action}> 315 - <span class={ui.permissionLabel}>{info.label}</span> 316 - <div class={ui.cluster}> 317 - <select 318 - class={ui.selectCompact} 319 - value={currentValue} 320 - onChange={(e) => 321 - handleChange(actionKey, (e.target as HTMLSelectElement).value) 322 - } 323 - > 324 - <option value="authenticated">Any user</option> 325 - <option value="member">Member</option> 326 - <option value="admin">Admin</option> 327 - <option value="owner">Owner</option> 328 - </select> 329 - {info.isOverridden && !(actionKey in pendingChanges.value) && ( 330 - <span class={ui.muted}> 331 - (default: {roleLabels[info.defaultRole]}) 332 - </span> 333 - )} 334 - </div> 335 - </div> 336 - ); 337 - })} 338 - </div> 339 - </div> 340 - ))} 341 - 342 - {hasChanges && ( 343 - <div class={ui.row}> 344 - <button 345 - class={ui.buttonSecondary} 346 - onClick={() => (pendingChanges.value = {})} 347 - > 348 - Discard 349 - </button> 350 - <button class={ui.button} onClick={handleSave} disabled={saving.value}> 351 - {saving.value ? "Saving..." : "Save permissions"} 352 - </button> 353 - </div> 126 + {canManageMembers && ( 127 + <a href={spherePath("/settings/members")} class={ui.cardLink}> 128 + <strong>Members</strong> 129 + <p class={ui.muted}>Invite, manage roles, and revoke members.</p> 130 + </a> 131 + )} 132 + {canDo("sphere", "update-permissions") && ( 133 + <a href={spherePath("/settings/permissions")} class={ui.cardLink}> 134 + <strong>Permissions</strong> 135 + <p class={ui.muted}>Configure who can perform actions in this sphere.</p> 136 + </a> 354 137 )} 355 138 </div> 356 - )} 357 - </CollapsibleSection> 139 + </div> 140 + )} 358 141 </div> 359 142 ); 360 143 }
+3 -3
packages/client/src/components/invitation-banner.tsx
··· 5 5 import * as ui from "../ui.css.ts"; 6 6 7 7 export function InvitationBanner() { 8 + const accepting = useSignal(false); 9 + const error = useSignal(""); 10 + 8 11 const { data } = sphereState.value; 9 12 const handle = sphereHandle.value; 10 13 const { authenticated } = auth.value; 11 14 12 15 if (!data || !handle || !authenticated || data.memberStatus !== "invited") return null; 13 - 14 - const accepting = useSignal(false); 15 - const error = useSignal(""); 16 16 17 17 const accept = async () => { 18 18 accepting.value = true;
+26
packages/client/src/ui.css.ts
··· 530 530 fontSize: "0.875rem", 531 531 }); 532 532 533 + export const buttonCompact = style({ 534 + ...btnBase, 535 + paddingBlock: "6px", 536 + paddingInline: vars.space.md, 537 + border: "none", 538 + fontSize: "0.75rem", 539 + minBlockSize: "36px", 540 + backgroundColor: vars.color.primary, 541 + color: "#fff", 542 + ":hover": { backgroundColor: vars.color.primaryHover }, 543 + ":active": { transform: "scale(0.98)" }, 544 + }); 545 + 546 + export const buttonCompactSecondary = style({ 547 + ...btnBase, 548 + paddingBlock: "6px", 549 + paddingInline: vars.space.md, 550 + border: `1px solid ${vars.color.border}`, 551 + fontSize: "0.75rem", 552 + minBlockSize: "36px", 553 + backgroundColor: vars.color.surface, 554 + color: vars.color.text, 555 + ":hover": { borderColor: vars.color.primary }, 556 + ":active": { transform: "scale(0.98)" }, 557 + }); 558 + 533 559 export const flexGrow = style({ 534 560 flex: 1, 535 561 });
+32 -16
packages/core/src/generated/lexicon-records.ts
··· 24 24 subject: string; 25 25 } 26 26 27 + export interface FeatureRequestPermissionsRecord { 28 + /** Minimum role to create feature requests. */ 29 + create?: string; 30 + /** Minimum role to vote on feature requests. */ 31 + vote?: string; 32 + /** Minimum role to comment on feature requests. */ 33 + comment?: string; 34 + /** Minimum role to change feature request status. */ 35 + "change-status"?: string; 36 + /** Minimum role to mark feature requests as duplicate. */ 37 + "mark-duplicate"?: string; 38 + /** Minimum role to hide/unhide content. */ 39 + moderate?: string; 40 + } 41 + 27 42 export interface FeatureRequestStatusRecord { 28 43 /** at-uri — AT URI of the feature request whose status is being changed. */ 29 44 subject: string; ··· 56 71 createdAt: string; 57 72 } 58 73 59 - export interface FeatureRequestPermissionsRecord { 60 - /** Minimum role to create feature requests. */ 61 - create?: string; 62 - /** Minimum role to vote on feature requests. */ 63 - vote?: string; 64 - /** Minimum role to comment on feature requests. */ 65 - comment?: string; 66 - /** Minimum role to change feature request status. */ 67 - "change-status"?: string; 68 - /** Minimum role to mark feature requests as duplicate. */ 69 - "mark-duplicate"?: string; 70 - /** Minimum role to hide/unhide content. */ 71 - moderate?: string; 72 - } 73 - 74 74 export interface SphereMemberRecord { 75 75 /** at-uri — AT URI of the Sphere record (site.exosphere.sphere) on the owner's PDS. */ 76 76 sphere: string; ··· 85 85 role: string; 86 86 } 87 87 88 + export interface SpherePermissionsRecord { 89 + /** Minimum role to invite members. */ 90 + "invite-member"?: string; 91 + /** Minimum role to revoke members. */ 92 + "revoke-member"?: string; 93 + /** Minimum role to change member roles. */ 94 + "update-member-role"?: string; 95 + /** Minimum role to enable modules. */ 96 + "enable-module"?: string; 97 + /** Minimum role to disable modules. */ 98 + "disable-module"?: string; 99 + /** Minimum role to update permissions. */ 100 + "update-permissions"?: string; 101 + } 102 + 88 103 export interface PdsRecordMap { 89 104 "site.exosphere.featureRequest": FeatureRequestRecord; 90 105 "site.exosphere.featureRequestComment": FeatureRequestCommentRecord; 91 106 "site.exosphere.featureRequestCommentVote": FeatureRequestCommentVoteRecord; 107 + "site.exosphere.featureRequestPermissions": FeatureRequestPermissionsRecord; 92 108 "site.exosphere.featureRequestStatus": FeatureRequestStatusRecord; 93 109 "site.exosphere.featureRequestVote": FeatureRequestVoteRecord; 94 110 "site.exosphere.moderation": ModerationRecord; 95 111 "site.exosphere.sphere": SphereRecord; 96 - "site.exosphere.featureRequestPermissions": FeatureRequestPermissionsRecord; 97 112 "site.exosphere.sphereMember": SphereMemberRecord; 98 113 "site.exosphere.sphereMemberApproval": SphereMemberApprovalRecord; 114 + "site.exosphere.spherePermissions": SpherePermissionsRecord; 99 115 }
+14 -4
packages/core/src/permissions/check.ts
··· 1 1 import { eq, and } from "../db/drizzle.ts"; 2 2 import { getDb } from "../db/index.ts"; 3 3 import { spherePermissions } from "../db/schema/index.ts"; 4 - import { hasMinimumRole, type Role, type MemberRole } from "./roles.ts"; 4 + import { hasMinimumRole, ROLE_LEVELS, type Role, type MemberRole } from "./roles.ts"; 5 5 import { getDefaultRole, getAllModulePermissions } from "./registry.ts"; 6 + import { CORE_MODULE } from "./core.ts"; 6 7 7 8 /** Resolve the effective minimum role for an action in a sphere. */ 8 9 export function getRequiredRole(sphereId: string, moduleName: string, action: string): Role { ··· 16 17 and(eq(spherePermissions.sphereId, sphereId), eq(spherePermissions.actionKey, actionKey)), 17 18 ) 18 19 .get(); 19 - if (override) return override.minRole as Role; 20 + if (override) { 21 + if (override.minRole in ROLE_LEVELS) return override.minRole as Role; 22 + return "admin"; // invalid DB value — safe fallback 23 + } 20 24 21 25 // 2. Module default 22 26 const defaultRole = getDefaultRole(moduleName, action); ··· 52 56 .from(spherePermissions) 53 57 .where(eq(spherePermissions.sphereId, sphereId)) 54 58 .all(); 55 - const overrideMap = new Map(overrides.map((r) => [r.actionKey, r.minRole as Role])); 59 + const overrideMap = new Map( 60 + overrides.filter((r) => r.minRole in ROLE_LEVELS).map((r) => [r.actionKey, r.minRole as Role]), 61 + ); 56 62 57 63 const result: Record<string, boolean> = {}; 58 64 59 - for (const moduleName of enabledModules) { 65 + // Always include core sphere permissions 66 + const modulesToProcess = new Set(enabledModules); 67 + if (allPerms.has(CORE_MODULE)) modulesToProcess.add(CORE_MODULE); 68 + 69 + for (const moduleName of modulesToProcess) { 60 70 const modulePerms = allPerms.get(moduleName); 61 71 if (!modulePerms) continue; 62 72 for (const [action, perm] of Object.entries(modulePerms)) {
+17
packages/core/src/permissions/core.ts
··· 1 + import type { ModulePermission } from "./registry.ts"; 2 + 3 + /** Module name used for core sphere-level permissions. */ 4 + export const CORE_MODULE = "sphere"; 5 + 6 + /** AT Protocol collection ID for core sphere permission overrides. */ 7 + export const CORE_PERMISSIONS_COLLECTION = "site.exosphere.spherePermissions"; 8 + 9 + /** Core sphere-level permission actions (configurable between owner and admin). */ 10 + export const corePermissions = { 11 + "invite-member": { label: "Invite members", defaultRole: "admin" }, 12 + "revoke-member": { label: "Revoke members", defaultRole: "admin" }, 13 + "update-member-role": { label: "Change member roles", defaultRole: "admin" }, 14 + "enable-module": { label: "Enable modules", defaultRole: "owner" }, 15 + "disable-module": { label: "Disable modules", defaultRole: "owner" }, 16 + "update-permissions": { label: "Update permissions", defaultRole: "owner" }, 17 + } satisfies Record<string, ModulePermission>;
+1
packages/core/src/permissions/index.ts
··· 9 9 export type { ModulePermission } from "./registry.ts"; 10 10 export { getRequiredRole, checkPermission, computeUserPermissions } from "./check.ts"; 11 11 export { requirePermission } from "./middleware.ts"; 12 + export { CORE_MODULE, CORE_PERMISSIONS_COLLECTION, corePermissions } from "./core.ts";
+4
packages/core/src/permissions/middleware.ts
··· 8 8 export function requirePermission(moduleName: string, action: string) { 9 9 return createMiddleware<AuthEnv & SphereEnv>(async (c, next) => { 10 10 const did = c.var.did; 11 + if (!did) { 12 + return c.json({ error: "Unauthorized" }, 401); 13 + } 14 + 11 15 const sphereId = c.var.sphereId; 12 16 const userRole = getActiveMemberRole(sphereId, did); 13 17
+4
packages/core/src/permissions/registry.ts
··· 16 16 perms: Record<string, ModulePermission>, 17 17 permissionsCollection?: string, 18 18 ): void { 19 + if (modulePermissions.has(moduleName)) { 20 + console.warn(`[permissions] Module "${moduleName}" already registered, skipping.`); 21 + return; 22 + } 19 23 modulePermissions.set(moduleName, perms); 20 24 if (permissionsCollection) { 21 25 moduleCollections.set(moduleName, permissionsCollection);
+18 -16
packages/core/src/sphere/api/members.ts
··· 5 5 import { sphereMembers } from "../../db/schema/index.ts"; 6 6 import { requireAuth, type AuthEnv } from "../../auth/index.ts"; 7 7 import { putPdsRecord, deletePdsRecord, generateRkey } from "../../pds.ts"; 8 + import { parseAtUri } from "../../indexer/uri.ts"; 8 9 import { inviteMemberSchema } from "../schemas.ts"; 9 - import { 10 - getActiveMemberRole, 11 - isAdminOrOwner, 12 - upsertMemberInvite, 13 - activateMember, 14 - } from "../operations.ts"; 10 + import { getActiveMemberRole, upsertMemberInvite, activateMember } from "../operations.ts"; 11 + import { checkPermission } from "../../permissions/check.ts"; 15 12 import { findSphere } from "./helpers.ts"; 16 13 import { resolveDidHandles } from "../../identity/index.ts"; 17 14 ··· 28 25 return c.json({ error: "Sphere not found" }, 404); 29 26 } 30 27 28 + // Allow access if user has any member management permission 31 29 const role = getActiveMemberRole(sphere.id, c.var.did); 32 - if (!isAdminOrOwner(role)) { 30 + if ( 31 + !checkPermission(sphere.id, "sphere", "invite-member", role) && 32 + !checkPermission(sphere.id, "sphere", "revoke-member", role) && 33 + !checkPermission(sphere.id, "sphere", "update-member-role", role) 34 + ) { 33 35 return c.json({ error: "Forbidden" }, 403); 34 36 } 35 37 ··· 66 68 67 69 const inviterDid = c.var.did; 68 70 const inviterRole = getActiveMemberRole(sphere.id, inviterDid); 69 - if (!isAdminOrOwner(inviterRole)) { 71 + if (!checkPermission(sphere.id, "sphere", "invite-member", inviterRole)) { 70 72 return c.json({ error: "Forbidden" }, 403); 71 73 } 72 74 ··· 162 164 163 165 const revokerDid = c.var.did; 164 166 const revokerRole = getActiveMemberRole(sphere.id, revokerDid); 165 - if (!isAdminOrOwner(revokerRole)) { 167 + if (!checkPermission(sphere.id, "sphere", "revoke-member", revokerRole)) { 166 168 return c.json({ error: "Forbidden" }, 403); 167 169 } 168 170 ··· 188 190 // Note: only works if the current admin is the one who published the approval 189 191 if (membership.approvalPdsUri) { 190 192 const session = c.var.session; 191 - const rkey = membership.approvalPdsUri.split("/").pop(); 192 - if (rkey) { 193 - await deletePdsRecord(session, APPROVAL_COLLECTION, rkey); 193 + const parsed = parseAtUri(membership.approvalPdsUri); 194 + if (parsed) { 195 + await deletePdsRecord(session, APPROVAL_COLLECTION, parsed.rkey); 194 196 } 195 197 } 196 198 ··· 213 215 } 214 216 215 217 const callerRole = getActiveMemberRole(sphere.id, c.var.did); 216 - if (!isAdminOrOwner(callerRole)) { 218 + if (!checkPermission(sphere.id, "sphere", "update-member-role", callerRole)) { 217 219 return c.json({ error: "Forbidden" }, 403); 218 220 } 219 221 ··· 249 251 250 252 // Delete the old approval record before publishing the new one 251 253 if (membership.approvalPdsUri) { 252 - const oldRkey = membership.approvalPdsUri.split("/").pop(); 253 - if (oldRkey) { 254 - await deletePdsRecord(session, APPROVAL_COLLECTION, oldRkey); 254 + const parsed = parseAtUri(membership.approvalPdsUri); 255 + if (parsed) { 256 + await deletePdsRecord(session, APPROVAL_COLLECTION, parsed.rkey); 255 257 } 256 258 } 257 259
+18 -4
packages/core/src/sphere/api/modules.ts
··· 7 7 import { requireAuth, type AuthEnv } from "../../auth/index.ts"; 8 8 import { putPdsRecord } from "../../pds.ts"; 9 9 import { enableModuleSchema } from "../schemas.ts"; 10 + import { getActiveMemberRole } from "../operations.ts"; 11 + import { checkPermission } from "../../permissions/check.ts"; 10 12 import { findSphere, getEnabledModules, formatModules } from "./helpers.ts"; 11 13 12 14 const SPHERE_COLLECTION = "site.exosphere.sphere" as const; ··· 48 50 return c.json({ error: "Sphere not found" }, 404); 49 51 } 50 52 51 - if (c.var.did !== sphere.ownerDid) { 53 + const callerRole = getActiveMemberRole(sphere.id, c.var.did); 54 + if (!checkPermission(sphere.id, "sphere", "enable-module", callerRole)) { 52 55 return c.json({ error: "Forbidden" }, 403); 53 56 } 54 57 ··· 74 77 .onConflictDoNothing() 75 78 .run(); 76 79 77 - await syncSpherePds(c, sphere); 80 + // PDS sync requires the owner's session — skip if caller is an admin 81 + if (c.var.did === sphere.ownerDid) { 82 + await syncSpherePds(c, sphere); 83 + } else { 84 + console.info("[modules] PDS sync skipped — caller is not the owner"); 85 + } 78 86 79 87 return c.json({ 80 88 modules: formatModules(getEnabledModules(sphere.id)), ··· 88 96 return c.json({ error: "Sphere not found" }, 404); 89 97 } 90 98 91 - if (c.var.did !== sphere.ownerDid) { 99 + const callerRole = getActiveMemberRole(sphere.id, c.var.did); 100 + if (!checkPermission(sphere.id, "sphere", "disable-module", callerRole)) { 92 101 return c.json({ error: "Forbidden" }, 403); 93 102 } 94 103 ··· 102 111 ) 103 112 .run(); 104 113 105 - await syncSpherePds(c, sphere); 114 + // PDS sync requires the owner's session — skip if caller is an admin 115 + if (c.var.did === sphere.ownerDid) { 116 + await syncSpherePds(c, sphere); 117 + } else { 118 + console.info("[modules] PDS sync skipped — caller is not the owner"); 119 + } 106 120 107 121 return c.json({ 108 122 modules: formatModules(getEnabledModules(sphere.id)),
+86 -48
packages/core/src/sphere/api/permissions.ts
··· 10 10 getAllModulePermissions, 11 11 getModulePermissionsCollection, 12 12 getRequiredRole, 13 + checkPermission, 14 + CORE_MODULE, 13 15 type Role, 14 16 } from "../../permissions/index.ts"; 15 - import { getActiveMemberRole, isAdminOrOwner } from "../operations.ts"; 17 + import { getActiveMemberRole } from "../operations.ts"; 16 18 import { findSphere, getEnabledModules } from "./helpers.ts"; 17 19 18 20 const updatePermissionsSchema = z.object({ ··· 21 23 22 24 const app = new Hono<AuthEnv>(); 23 25 24 - // Get full permissions configuration for a sphere (admin/owner only, for the admin panel) 26 + // Get full permissions configuration for a sphere (requires update-permissions permission) 25 27 app.get("/:handle/permissions", requireAuth, (c) => { 26 28 const sphere = findSphere(c.req.param("handle")); 27 29 if (!sphere) { ··· 29 31 } 30 32 31 33 const role = getActiveMemberRole(sphere.id, c.var.did); 32 - if (!isAdminOrOwner(role)) { 34 + if (!checkPermission(sphere.id, "sphere", "update-permissions", role)) { 33 35 return c.json({ error: "Forbidden" }, 403); 34 36 } 35 37 ··· 45 47 } 46 48 > = {}; 47 49 48 - for (const moduleName of enabledModules) { 50 + // Include core sphere permissions + enabled module permissions 51 + const modulesToInclude = [CORE_MODULE, ...enabledModules]; 52 + for (const moduleName of modulesToInclude) { 49 53 const modulePerms = allPerms.get(moduleName); 50 54 if (!modulePerms) continue; 51 55 ··· 70 74 return c.json({ modules }); 71 75 }); 72 76 73 - // Update permission overrides (owner only) 77 + // Update permission overrides (requires update-permissions permission) 74 78 app.put("/:handle/permissions", requireAuth, async (c) => { 75 79 const sphere = findSphere(c.req.param("handle")); 76 80 if (!sphere) { 77 81 return c.json({ error: "Sphere not found" }, 404); 78 82 } 79 83 80 - // Owner only — only the owner can write to their PDS 81 - if (c.var.did !== sphere.ownerDid) { 84 + const callerRole = getActiveMemberRole(sphere.id, c.var.did); 85 + if (!checkPermission(sphere.id, "sphere", "update-permissions", callerRole)) { 82 86 return c.json({ error: "Forbidden" }, 403); 83 87 } 84 88 85 - const body = await c.req.json(); 89 + let body: unknown; 90 + try { 91 + body = await c.req.json(); 92 + } catch { 93 + return c.json({ error: "Invalid JSON" }, 400); 94 + } 86 95 const parsed = updatePermissionsSchema.safeParse(body); 87 96 if (!parsed.success) { 88 97 return c.json({ error: z.flattenError(parsed.error) }, 400); ··· 93 102 const db = getDb(); 94 103 95 104 // Parse and validate all action keys up front 96 - const validatedOverrides: { actionKey: string; moduleName: string; action: string; minRole: string }[] = []; 105 + const validatedOverrides: { 106 + actionKey: string; 107 + moduleName: string; 108 + action: string; 109 + minRole: string; 110 + }[] = []; 97 111 for (const [actionKey, minRole] of Object.entries(overrides)) { 98 - const sepIdx = actionKey.indexOf(":"); 99 - if (sepIdx === -1) { 112 + const parsed = parseActionKey(actionKey); 113 + if (!parsed) { 100 114 return c.json({ error: `Invalid permission key: ${actionKey}` }, 400); 101 115 } 102 - const moduleName = actionKey.slice(0, sepIdx); 103 - const action = actionKey.slice(sepIdx + 1); 104 - const modulePerms = allPerms.get(moduleName); 105 - if (!modulePerms || !modulePerms[action]) { 116 + const modulePerms = allPerms.get(parsed.moduleName); 117 + if (!modulePerms || !modulePerms[parsed.action]) { 106 118 return c.json({ error: `Unknown permission: ${actionKey}` }, 400); 107 119 } 108 - validatedOverrides.push({ actionKey, moduleName, action, minRole }); 120 + // Core permissions can only be set to owner or admin 121 + if (parsed.moduleName === CORE_MODULE && minRole !== "owner" && minRole !== "admin") { 122 + return c.json( 123 + { error: `Core permission "${actionKey}" can only be set to "owner" or "admin"` }, 124 + 400, 125 + ); 126 + } 127 + // Only the owner can change core permissions that default to owner (prevents privilege escalation) 128 + const defaultRole = modulePerms[parsed.action]?.defaultRole; 129 + if ( 130 + parsed.moduleName === CORE_MODULE && 131 + defaultRole === "owner" && 132 + c.var.did !== sphere.ownerDid 133 + ) { 134 + return c.json({ error: "Only the owner can change owner-level core permissions" }, 403); 135 + } 136 + validatedOverrides.push({ actionKey, ...parsed, minRole }); 109 137 } 110 138 111 139 // Upsert overrides — delete if matching the default ··· 136 164 } 137 165 }); 138 166 139 - // Sync per-module permission records to PDS 140 - // Group all overrides by module 141 - const allOverrides = db 142 - .select({ actionKey: spherePermissions.actionKey, minRole: spherePermissions.minRole }) 143 - .from(spherePermissions) 144 - .where(eq(spherePermissions.sphereId, sphere.id)) 145 - .all(); 167 + // PDS sync requires the owner's session — skip if caller is an admin 168 + if (c.var.did !== sphere.ownerDid) { 169 + console.info("[permissions] PDS sync skipped — caller is not the owner"); 170 + } else { 171 + // Group all overrides by module 172 + const allOverrides = db 173 + .select({ actionKey: spherePermissions.actionKey, minRole: spherePermissions.minRole }) 174 + .from(spherePermissions) 175 + .where(eq(spherePermissions.sphereId, sphere.id)) 176 + .all(); 146 177 147 - const byModule = new Map<string, Record<string, string>>(); 148 - for (const row of allOverrides) { 149 - const sepIdx = row.actionKey.indexOf(":"); 150 - if (sepIdx === -1) continue; 151 - const moduleName = row.actionKey.slice(0, sepIdx); 152 - const action = row.actionKey.slice(sepIdx + 1); 153 - if (!byModule.has(moduleName)) byModule.set(moduleName, {}); 154 - byModule.get(moduleName)![action] = row.minRole; 155 - } 178 + const byModule = new Map<string, Record<string, string>>(); 179 + for (const row of allOverrides) { 180 + const parsed = parseActionKey(row.actionKey); 181 + if (!parsed) continue; 182 + if (!byModule.has(parsed.moduleName)) byModule.set(parsed.moduleName, {}); 183 + byModule.get(parsed.moduleName)![parsed.action] = row.minRole; 184 + } 156 185 157 - // Write (or delete) a PDS record for each module that has a permissions collection 158 - const enabledModules = getEnabledModules(sphere.id).map((m) => m.moduleName); 159 - for (const moduleName of enabledModules) { 160 - const collection = getModulePermissionsCollection(moduleName); 161 - if (!collection) continue; 186 + // Write (or delete) a PDS record for each module that has a permissions collection 187 + const enabledModules = getEnabledModules(sphere.id).map((m) => m.moduleName); 188 + const allModules = [CORE_MODULE, ...enabledModules]; 189 + for (const moduleName of allModules) { 190 + const collection = getModulePermissionsCollection(moduleName); 191 + if (!collection) continue; 162 192 163 - const moduleOverrides = byModule.get(moduleName); 164 - if (moduleOverrides && Object.keys(moduleOverrides).length > 0) { 165 - await putPdsRecord( 166 - c.var.session, 167 - collection as "site.exosphere.featureRequestPermissions", 168 - "self", 169 - moduleOverrides as PdsRecordMap["site.exosphere.featureRequestPermissions"], 170 - ); 171 - } else { 172 - // No overrides — delete the record so PDS only holds actual overrides 173 - await deletePdsRecord(c.var.session, collection, "self"); 193 + const moduleOverrides = byModule.get(moduleName); 194 + if (moduleOverrides && Object.keys(moduleOverrides).length > 0) { 195 + // Type assertions required: collection comes from the permissions registry at runtime 196 + await putPdsRecord( 197 + c.var.session, 198 + collection as keyof PdsRecordMap, 199 + "self", 200 + moduleOverrides as PdsRecordMap[keyof PdsRecordMap], 201 + ); 202 + } else { 203 + // No overrides — delete the record so PDS only holds actual overrides 204 + await deletePdsRecord(c.var.session, collection, "self"); 205 + } 174 206 } 175 207 } 176 208 177 209 return c.json({ ok: true }); 178 210 }); 211 + 212 + function parseActionKey(key: string): { moduleName: string; action: string } | null { 213 + const sepIdx = key.indexOf(":"); 214 + if (sepIdx === -1) return null; 215 + return { moduleName: key.slice(0, sepIdx), action: key.slice(sepIdx + 1) }; 216 + } 179 217 180 218 export { app as permissionsApi };
+1 -3
packages/core/src/sphere/api/spheres.ts
··· 192 192 }) 193 193 .from(sphereMembers) 194 194 .innerJoin(spheres, eq(spheres.id, sphereMembers.sphereId)) 195 - .where( 196 - and(eq(sphereMembers.did, did), eq(sphereMembers.status, "invited")), 197 - ) 195 + .where(and(eq(sphereMembers.did, did), eq(sphereMembers.status, "invited"))) 198 196 .orderBy(sphereMembers.createdAt) 199 197 .all(); 200 198
+6 -1
packages/core/src/sphere/index.ts
··· 1 - export { createSphereRoutes, getCurrentSphere, getMemberSpheres, getPendingInvitations } from "./routes.ts"; 1 + export { 2 + createSphereRoutes, 3 + getCurrentSphere, 4 + getMemberSpheres, 5 + getPendingInvitations, 6 + } from "./routes.ts"; 2 7 export { createCoreIndexer } from "./indexer.ts"; 3 8 export { 4 9 getActiveMemberRole,
+6 -2
packages/core/src/sphere/indexer.ts
··· 26 26 collectionToModule.set(collection, moduleName); 27 27 } 28 28 29 - const baseCollections = [SPHERE_COLLECTION, MEMBER_COLLECTION, APPROVAL_COLLECTION, MODERATION_COLLECTION]; 29 + const baseCollections = [ 30 + SPHERE_COLLECTION, 31 + MEMBER_COLLECTION, 32 + APPROVAL_COLLECTION, 33 + MODERATION_COLLECTION, 34 + ]; 30 35 const allCollections = [...baseCollections, ...collectionToModule.keys()]; 31 36 32 37 return { ··· 86 91 }, 87 92 }; 88 93 } 89 -
+5 -9
packages/core/src/sphere/operations.ts
··· 30 30 const row = getDb() 31 31 .select({ role: sphereMembers.role, status: sphereMembers.status }) 32 32 .from(sphereMembers) 33 - .where( 34 - and(eq(sphereMembers.sphereId, sphereId), eq(sphereMembers.did, did)), 35 - ) 33 + .where(and(eq(sphereMembers.sphereId, sphereId), eq(sphereMembers.did, did))) 36 34 .get(); 37 35 return row ?? null; 38 36 } ··· 89 87 } else { 90 88 // Ignore sphere records for DIDs not on this instance — 91 89 // spheres are created locally, not via Jetstream from other instances. 90 + console.warn("[sphere] Ignoring sphere record from external DID:", did); 92 91 return; 93 92 } 94 93 } ··· 212 211 // ---- Per-module permission sync from PDS ---- 213 212 214 213 function findSphereByOwner(did: string): { id: string } | undefined { 215 - return getDb() 216 - .select({ id: spheres.id }) 217 - .from(spheres) 218 - .where(eq(spheres.ownerDid, did)) 219 - .get(); 214 + return getDb().select({ id: spheres.id }).from(spheres).where(eq(spheres.ownerDid, did)).get(); 220 215 } 221 216 222 217 /** Sync a module's permission overrides from a PDS record into the local DB. */ ··· 262 257 const sphere = findSphereByOwner(did); 263 258 if (!sphere) return; 264 259 265 - getDb().delete(spherePermissions) 260 + getDb() 261 + .delete(spherePermissions) 266 262 .where( 267 263 and( 268 264 eq(spherePermissions.sphereId, sphere.id),
+193 -169
packages/feature-requests/src/api/comments.ts
··· 127 127 }); 128 128 129 129 // Create a comment (one top-level comment per user per request) 130 - app.post("/:id/comments", requireAuth, requirePermission("feature-requests", "comment"), async (c) => { 131 - const requestId = c.req.param("id"); 132 - const body = await c.req.json(); 133 - const result = createCommentSchema.safeParse(body); 134 - if (!result.success) { 135 - return c.json({ error: z.flattenError(result.error) }, 400); 136 - } 130 + app.post( 131 + "/:id/comments", 132 + requireAuth, 133 + requirePermission("feature-requests", "comment"), 134 + async (c) => { 135 + const requestId = c.req.param("id"); 136 + const body = await c.req.json(); 137 + const result = createCommentSchema.safeParse(body); 138 + if (!result.success) { 139 + return c.json({ error: z.flattenError(result.error) }, 400); 140 + } 137 141 138 - const { content } = result.data; 139 - const sphereId = c.var.sphereId; 140 - const sphereVisibility = c.var.sphereVisibility; 141 - const db = getDb(); 142 - const did = c.var.did; 142 + const { content } = result.data; 143 + const sphereId = c.var.sphereId; 144 + const sphereVisibility = c.var.sphereVisibility; 145 + const db = getDb(); 146 + const did = c.var.did; 143 147 144 - // Check feature request exists, belongs to this sphere, and is visible 145 - const existing = db 146 - .select({ id: featureRequests.id, pdsUri: featureRequests.pdsUri }) 147 - .from(featureRequests) 148 - .where( 149 - and( 150 - eq(featureRequests.id, requestId), 151 - eq(featureRequests.sphereId, sphereId), 152 - sql`${featureRequests.hiddenAt} is null`, 153 - ), 154 - ) 155 - .get(); 156 - if (!existing) { 157 - return c.json({ error: "Feature request not found" }, 404); 158 - } 148 + // Check feature request exists, belongs to this sphere, and is visible 149 + const existing = db 150 + .select({ id: featureRequests.id, pdsUri: featureRequests.pdsUri }) 151 + .from(featureRequests) 152 + .where( 153 + and( 154 + eq(featureRequests.id, requestId), 155 + eq(featureRequests.sphereId, sphereId), 156 + sql`${featureRequests.hiddenAt} is null`, 157 + ), 158 + ) 159 + .get(); 160 + if (!existing) { 161 + return c.json({ error: "Feature request not found" }, 404); 162 + } 159 163 160 - // Enforce one top-level comment per user per request (ignore moderated comments) 161 - const alreadyCommented = db 162 - .select({ id: featureRequestComments.id }) 163 - .from(featureRequestComments) 164 - .where( 165 - and( 166 - eq(featureRequestComments.requestId, requestId), 167 - eq(featureRequestComments.authorDid, did), 168 - sql`${featureRequestComments.hiddenAt} is null`, 169 - ), 170 - ) 171 - .get(); 172 - if (alreadyCommented) { 173 - return c.json({ error: "You have already commented on this request" }, 409); 174 - } 164 + // Enforce one top-level comment per user per request (ignore moderated comments) 165 + const alreadyCommented = db 166 + .select({ id: featureRequestComments.id }) 167 + .from(featureRequestComments) 168 + .where( 169 + and( 170 + eq(featureRequestComments.requestId, requestId), 171 + eq(featureRequestComments.authorDid, did), 172 + sql`${featureRequestComments.hiddenAt} is null`, 173 + ), 174 + ) 175 + .get(); 176 + if (alreadyCommented) { 177 + return c.json({ error: "You have already commented on this request" }, 409); 178 + } 175 179 176 - const id = generateRkey(); 177 - let pdsUri: string | null = null; 180 + const id = generateRkey(); 181 + let pdsUri: string | null = null; 178 182 179 - // Write to PDS for public spheres 180 - if (sphereVisibility === "public") { 181 - const session = c.var.session; 182 - pdsUri = await putPdsRecord(session, COMMENT_COLLECTION, id, { 183 - subject: existing.pdsUri!, 184 - content, 185 - }); 186 - } 183 + // Write to PDS for public spheres 184 + if (sphereVisibility === "public") { 185 + const session = c.var.session; 186 + pdsUri = await putPdsRecord(session, COMMENT_COLLECTION, id, { 187 + subject: existing.pdsUri!, 188 + content, 189 + }); 190 + } 187 191 188 - insertComment({ id, requestId, authorDid: did, content, pdsUri }); 192 + insertComment({ id, requestId, authorDid: did, content, pdsUri }); 189 193 190 - const comment = db 191 - .select() 192 - .from(featureRequestComments) 193 - .where(eq(featureRequestComments.id, id)) 194 - .get(); 194 + const comment = db 195 + .select() 196 + .from(featureRequestComments) 197 + .where(eq(featureRequestComments.id, id)) 198 + .get(); 195 199 196 - return c.json({ comment: comment ? { ...comment, createdAt: tidToDate(id) } : comment }, 201); 197 - }); 200 + return c.json({ comment: comment ? { ...comment, createdAt: tidToDate(id) } : comment }, 201); 201 + }, 202 + ); 198 203 199 204 // Update own comment (author only) 200 205 app.put("/comments/:id", requireAuth, async (c) => { ··· 230 235 .where(eq(featureRequests.id, comment.requestId)) 231 236 .get(); 232 237 238 + if (!parent?.pdsUri) { 239 + return c.json({ error: "Parent feature request not found" }, 404); 240 + } 241 + 233 242 await putPdsRecord(session, COMMENT_COLLECTION, id, { 234 - subject: parent!.pdsUri!, 243 + subject: parent.pdsUri, 235 244 content, 236 245 updatedAt: new Date().toISOString(), 237 246 }); ··· 327 336 }); 328 337 329 338 // Admin/owner-only: unhide a moderated comment 330 - app.post("/comments/:id/unhide", requireAuth, requirePermission("feature-requests", "moderate"), async (c) => { 331 - const id = c.req.param("id"); 332 - const sphereId = c.var.sphereId; 333 - const did = c.var.did; 339 + app.post( 340 + "/comments/:id/unhide", 341 + requireAuth, 342 + requirePermission("feature-requests", "moderate"), 343 + async (c) => { 344 + const id = c.req.param("id"); 345 + const sphereId = c.var.sphereId; 346 + const did = c.var.did; 334 347 335 - const row = findCommentInSphere(id, sphereId); 336 - if (!row) { 337 - return c.json({ error: "Comment not found" }, 404); 338 - } 339 - const comment = row.feature_request_comments; 340 - if (!comment.hiddenAt) { 341 - return c.json({ ok: true }); 342 - } 348 + const row = findCommentInSphere(id, sphereId); 349 + if (!row) { 350 + return c.json({ error: "Comment not found" }, 404); 351 + } 352 + const comment = row.feature_request_comments; 353 + if (!comment.hiddenAt) { 354 + return c.json({ ok: true }); 355 + } 343 356 344 - // Delete the moderation record from PDS if the current admin is the one who hid it 345 - if (comment.moderatedBy === did) { 346 - const session = c.var.session; 347 - await deletePdsRecord(session, MODERATION_COLLECTION, id); 348 - } 357 + // Delete the moderation record from PDS if the current admin is the one who hid it 358 + if (comment.moderatedBy === did) { 359 + const session = c.var.session; 360 + await deletePdsRecord(session, MODERATION_COLLECTION, id); 361 + } 349 362 350 - unhideComment(id); 363 + unhideComment(id); 351 364 352 - return c.json({ ok: true }); 353 - }); 365 + return c.json({ ok: true }); 366 + }, 367 + ); 354 368 355 369 // ---- Comment Votes ---- 356 370 ··· 377 391 }); 378 392 379 393 // Cast a vote on a comment 380 - app.post("/comments/:id/vote", requireAuth, requirePermission("feature-requests", "vote"), async (c) => { 381 - const id = c.req.param("id"); 382 - const db = getDb(); 383 - const did = c.var.did; 384 - const sphereId = c.var.sphereId; 385 - const sphereVisibility = c.var.sphereVisibility; 394 + app.post( 395 + "/comments/:id/vote", 396 + requireAuth, 397 + requirePermission("feature-requests", "vote"), 398 + async (c) => { 399 + const id = c.req.param("id"); 400 + const db = getDb(); 401 + const did = c.var.did; 402 + const sphereId = c.var.sphereId; 403 + const sphereVisibility = c.var.sphereVisibility; 386 404 387 - const row = findCommentInSphere(id, sphereId); 388 - if (!row) { 389 - return c.json({ error: "Comment not found" }, 404); 390 - } 391 - const comment = row.feature_request_comments; 405 + const row = findCommentInSphere(id, sphereId); 406 + if (!row) { 407 + return c.json({ error: "Comment not found" }, 404); 408 + } 409 + const comment = row.feature_request_comments; 392 410 393 - const alreadyVoted = db 394 - .select({ commentId: featureRequestCommentVotes.commentId }) 395 - .from(featureRequestCommentVotes) 396 - .where( 397 - and( 398 - eq(featureRequestCommentVotes.commentId, id), 399 - eq(featureRequestCommentVotes.authorDid, did), 400 - ), 401 - ) 402 - .get(); 403 - if (alreadyVoted) { 404 - return c.json({ error: "Already voted" }, 409); 405 - } 411 + const alreadyVoted = db 412 + .select({ commentId: featureRequestCommentVotes.commentId }) 413 + .from(featureRequestCommentVotes) 414 + .where( 415 + and( 416 + eq(featureRequestCommentVotes.commentId, id), 417 + eq(featureRequestCommentVotes.authorDid, did), 418 + ), 419 + ) 420 + .get(); 421 + if (alreadyVoted) { 422 + return c.json({ error: "Already voted" }, 409); 423 + } 406 424 407 - let votePdsUri: string | null = null; 425 + let votePdsUri: string | null = null; 408 426 409 - if (sphereVisibility === "public" && comment.pdsUri) { 410 - const session = c.var.session; 411 - votePdsUri = await putPdsRecord(session, COMMENT_VOTE_COLLECTION, id, { 412 - subject: comment.pdsUri, 413 - }); 414 - } 427 + if (sphereVisibility === "public" && comment.pdsUri) { 428 + const session = c.var.session; 429 + votePdsUri = await putPdsRecord(session, COMMENT_VOTE_COLLECTION, id, { 430 + subject: comment.pdsUri, 431 + }); 432 + } 415 433 416 - insertCommentVote(id, did, votePdsUri); 434 + insertCommentVote(id, did, votePdsUri); 417 435 418 - const result = db 419 - .select({ voteCount: count() }) 420 - .from(featureRequestCommentVotes) 421 - .where(eq(featureRequestCommentVotes.commentId, id)) 422 - .get(); 436 + const result = db 437 + .select({ voteCount: count() }) 438 + .from(featureRequestCommentVotes) 439 + .where(eq(featureRequestCommentVotes.commentId, id)) 440 + .get(); 423 441 424 - return c.json({ voteCount: result?.voteCount ?? 0 }, 201); 425 - }); 442 + return c.json({ voteCount: result?.voteCount ?? 0 }, 201); 443 + }, 444 + ); 426 445 427 446 // Remove a vote from a comment 428 - app.delete("/comments/:id/vote", requireAuth, requirePermission("feature-requests", "vote"), async (c) => { 429 - const id = c.req.param("id"); 430 - const db = getDb(); 431 - const did = c.var.did; 432 - const sphereId = c.var.sphereId; 447 + app.delete( 448 + "/comments/:id/vote", 449 + requireAuth, 450 + requirePermission("feature-requests", "vote"), 451 + async (c) => { 452 + const id = c.req.param("id"); 453 + const db = getDb(); 454 + const did = c.var.did; 455 + const sphereId = c.var.sphereId; 433 456 434 - // Verify the comment's FR belongs to this sphere 435 - const row = findCommentInSphere(id, sphereId); 436 - if (!row) { 437 - return c.json({ error: "Comment not found" }, 404); 438 - } 457 + // Verify the comment's FR belongs to this sphere 458 + const row = findCommentInSphere(id, sphereId); 459 + if (!row) { 460 + return c.json({ error: "Comment not found" }, 404); 461 + } 439 462 440 - const vote = db 441 - .select({ pdsUri: featureRequestCommentVotes.pdsUri }) 442 - .from(featureRequestCommentVotes) 443 - .where( 444 - and( 445 - eq(featureRequestCommentVotes.commentId, id), 446 - eq(featureRequestCommentVotes.authorDid, did), 447 - ), 448 - ) 449 - .get(); 450 - if (!vote) { 451 - return c.json({ error: "Vote not found" }, 404); 452 - } 463 + const vote = db 464 + .select({ pdsUri: featureRequestCommentVotes.pdsUri }) 465 + .from(featureRequestCommentVotes) 466 + .where( 467 + and( 468 + eq(featureRequestCommentVotes.commentId, id), 469 + eq(featureRequestCommentVotes.authorDid, did), 470 + ), 471 + ) 472 + .get(); 473 + if (!vote) { 474 + return c.json({ error: "Vote not found" }, 404); 475 + } 453 476 454 - if (vote.pdsUri) { 455 - const session = c.var.session; 456 - const res = await session.fetchHandler("/xrpc/com.atproto.repo.deleteRecord", { 457 - method: "POST", 458 - headers: { "Content-Type": "application/json" }, 459 - body: JSON.stringify({ 460 - repo: session.did, 461 - collection: COMMENT_VOTE_COLLECTION, 462 - rkey: id, 463 - }), 464 - }); 477 + if (vote.pdsUri) { 478 + const session = c.var.session; 479 + const res = await session.fetchHandler("/xrpc/com.atproto.repo.deleteRecord", { 480 + method: "POST", 481 + headers: { "Content-Type": "application/json" }, 482 + body: JSON.stringify({ 483 + repo: session.did, 484 + collection: COMMENT_VOTE_COLLECTION, 485 + rkey: id, 486 + }), 487 + }); 465 488 466 - if (!res.ok) { 467 - console.error( 468 - "[feature-requests] PDS comment vote delete failed:", 469 - res.status, 470 - await res.text().catch(() => ""), 471 - ); 489 + if (!res.ok) { 490 + console.error( 491 + "[feature-requests] PDS comment vote delete failed:", 492 + res.status, 493 + await res.text().catch(() => ""), 494 + ); 495 + } 472 496 } 473 - } 474 497 475 - deleteCommentVoteByAuthor(id, did); 498 + deleteCommentVoteByAuthor(id, did); 476 499 477 - const result = db 478 - .select({ voteCount: count() }) 479 - .from(featureRequestCommentVotes) 480 - .where(eq(featureRequestCommentVotes.commentId, id)) 481 - .get(); 500 + const result = db 501 + .select({ voteCount: count() }) 502 + .from(featureRequestCommentVotes) 503 + .where(eq(featureRequestCommentVotes.commentId, id)) 504 + .get(); 482 505 483 - return c.json({ voteCount: result?.voteCount ?? 0 }); 484 - }); 506 + return c.json({ voteCount: result?.voteCount ?? 0 }); 507 + }, 508 + ); 485 509 486 510 export { app as commentsApi };
+33 -28
packages/feature-requests/src/api/requests.ts
··· 284 284 }); 285 285 286 286 // Admin/owner-only: unhide a feature request 287 - app.post("/:id/unhide", requireAuth, requirePermission("feature-requests", "moderate"), async (c) => { 288 - const id = c.req.param("id"); 289 - const db = getDb(); 290 - const did = c.var.did; 291 - const sphereId = c.var.sphereId; 287 + app.post( 288 + "/:id/unhide", 289 + requireAuth, 290 + requirePermission("feature-requests", "moderate"), 291 + async (c) => { 292 + const id = c.req.param("id"); 293 + const db = getDb(); 294 + const did = c.var.did; 295 + const sphereId = c.var.sphereId; 292 296 293 - const existing = db 294 - .select({ 295 - id: featureRequests.id, 296 - hiddenAt: featureRequests.hiddenAt, 297 - moderatedBy: featureRequests.moderatedBy, 298 - }) 299 - .from(featureRequests) 300 - .where(and(eq(featureRequests.id, id), eq(featureRequests.sphereId, sphereId))) 301 - .get(); 302 - if (!existing) { 303 - return c.json({ error: "Feature request not found" }, 404); 304 - } 297 + const existing = db 298 + .select({ 299 + id: featureRequests.id, 300 + hiddenAt: featureRequests.hiddenAt, 301 + moderatedBy: featureRequests.moderatedBy, 302 + }) 303 + .from(featureRequests) 304 + .where(and(eq(featureRequests.id, id), eq(featureRequests.sphereId, sphereId))) 305 + .get(); 306 + if (!existing) { 307 + return c.json({ error: "Feature request not found" }, 404); 308 + } 305 309 306 - if (!existing.hiddenAt) { 307 - return c.json({ ok: true }); 308 - } 310 + if (!existing.hiddenAt) { 311 + return c.json({ ok: true }); 312 + } 309 313 310 - // Delete the moderation record from PDS if the current admin is the one who hid it 311 - if (existing.moderatedBy === did) { 312 - const session = c.var.session; 313 - await deletePdsRecord(session, MODERATION_COLLECTION, id); 314 - } 314 + // Delete the moderation record from PDS if the current admin is the one who hid it 315 + if (existing.moderatedBy === did) { 316 + const session = c.var.session; 317 + await deletePdsRecord(session, MODERATION_COLLECTION, id); 318 + } 315 319 316 - unhideFeatureRequest(id); 320 + unhideFeatureRequest(id); 317 321 318 - return c.json({ ok: true }); 319 - }); 322 + return c.json({ ok: true }); 323 + }, 324 + ); 320 325 321 326 // ---- Search ---- 322 327
+50 -44
packages/feature-requests/src/api/statuses.ts
··· 60 60 61 61 // Permission: admin/owner OR author 62 62 const role = getActiveMemberRole(sphereId, did); 63 - const canMark = checkPermission(sphereId, "feature-requests", "mark-duplicate", role) || fr.authorDid === did; 63 + const canMark = 64 + checkPermission(sphereId, "feature-requests", "mark-duplicate", role) || fr.authorDid === did; 64 65 if (!canMark) { 65 66 return c.json({ error: "Forbidden" }, 403); 66 67 } ··· 129 130 // ---- Statuses ---- 130 131 131 132 // Admin/owner-only: set status on a feature request 132 - app.post("/:id/status", requireAuth, requirePermission("feature-requests", "change-status"), async (c) => { 133 - const id = c.req.param("id"); 134 - const body = await c.req.json(); 135 - const result = updateStatusSchema.safeParse(body); 136 - if (!result.success) { 137 - return c.json({ error: z.flattenError(result.error) }, 400); 138 - } 133 + app.post( 134 + "/:id/status", 135 + requireAuth, 136 + requirePermission("feature-requests", "change-status"), 137 + async (c) => { 138 + const id = c.req.param("id"); 139 + const body = await c.req.json(); 140 + const result = updateStatusSchema.safeParse(body); 141 + if (!result.success) { 142 + return c.json({ error: z.flattenError(result.error) }, 400); 143 + } 139 144 140 - const { status } = result.data; 141 - const db = getDb(); 142 - const did = c.var.did; 143 - const sphereId = c.var.sphereId; 144 - const sphereVisibility = c.var.sphereVisibility; 145 + const { status } = result.data; 146 + const db = getDb(); 147 + const did = c.var.did; 148 + const sphereId = c.var.sphereId; 149 + const sphereVisibility = c.var.sphereVisibility; 145 150 146 - const existing = db 147 - .select({ 148 - id: featureRequests.id, 149 - status: featureRequests.status, 150 - pdsUri: featureRequests.pdsUri, 151 - }) 152 - .from(featureRequests) 153 - .where(and(eq(featureRequests.id, id), eq(featureRequests.sphereId, sphereId))) 154 - .get(); 155 - if (!existing) { 156 - return c.json({ error: "Feature request not found" }, 404); 157 - } 151 + const existing = db 152 + .select({ 153 + id: featureRequests.id, 154 + status: featureRequests.status, 155 + pdsUri: featureRequests.pdsUri, 156 + }) 157 + .from(featureRequests) 158 + .where(and(eq(featureRequests.id, id), eq(featureRequests.sphereId, sphereId))) 159 + .get(); 160 + if (!existing) { 161 + return c.json({ error: "Feature request not found" }, 404); 162 + } 163 + 164 + let pdsUri: string | null = null; 158 165 159 - let pdsUri: string | null = null; 166 + // Write to PDS for public spheres 167 + if (sphereVisibility === "public" && existing.pdsUri) { 168 + const session = c.var.session; 169 + pdsUri = await putPdsRecord(session, STATUS_COLLECTION, id, { 170 + subject: existing.pdsUri, 171 + status, 172 + }); 173 + } 160 174 161 - // Write to PDS for public spheres 162 - if (sphereVisibility === "public" && existing.pdsUri) { 163 - const session = c.var.session; 164 - pdsUri = await putPdsRecord(session, STATUS_COLLECTION, id, { 165 - subject: existing.pdsUri, 175 + const statusId = generateRkey(); 176 + insertStatusAndUpdateFR({ 177 + id: statusId, 178 + requestId: id, 179 + authorDid: did, 166 180 status, 181 + pdsUri, 182 + clearDuplicateOfId: existing.status === "duplicate", 167 183 }); 168 - } 169 184 170 - const statusId = generateRkey(); 171 - insertStatusAndUpdateFR({ 172 - id: statusId, 173 - requestId: id, 174 - authorDid: did, 175 - status, 176 - pdsUri, 177 - clearDuplicateOfId: existing.status === "duplicate", 178 - }); 179 - 180 - return c.json({ status }); 181 - }); 185 + return c.json({ status }); 186 + }, 187 + ); 182 188 183 189 // Get status change history for a feature request 184 190 app.get("/:id/statuses", async (c) => {
+9 -4
packages/feature-requests/src/ui/pages/feature-request.tsx
··· 253 253 authorDid: string; 254 254 authorHandle: string | null; 255 255 }) { 256 - const statuses = useSignal< 257 - Array<{ id: string; authorDid: string; authorHandle: string | null; status: string; createdAt: string }> | null 258 - >(null); 256 + const statuses = useSignal<Array<{ 257 + id: string; 258 + authorDid: string; 259 + authorHandle: string | null; 260 + status: string; 261 + createdAt: string; 262 + }> | null>(null); 259 263 const loading = useSignal(false); 260 264 const expandedRef = useRef(false); 261 265 ··· 692 696 authorHandle={fr.authorHandle ?? null} 693 697 /> 694 698 695 - {((currentDid === fr.authorDid || canMarkDuplicate.value) && fr.status === "requested") || 699 + {((currentDid === fr.authorDid || canMarkDuplicate.value) && 700 + fr.status === "requested") || 696 701 data.duplicateCount > 0 ? ( 697 702 <MergedRequestsSection 698 703 requestId={fr.id}
+11 -1
packages/indexer/src/modules.ts
··· 1 1 import type { ExosphereModule } from "@exosphere/core/types"; 2 2 import { createCoreIndexer } from "@exosphere/core/sphere"; 3 - import { registerModulePermissions } from "@exosphere/core/permissions"; 3 + import { 4 + registerModulePermissions, 5 + CORE_MODULE, 6 + CORE_PERMISSIONS_COLLECTION, 7 + corePermissions, 8 + } from "@exosphere/core/permissions"; 4 9 // import { feedsModule } from "@exosphere/feeds"; 5 10 import { featureRequestsModule } from "@exosphere/feature-requests"; 6 11 7 12 export const modules: ExosphereModule[] = [featureRequestsModule]; 13 + 14 + // Register core sphere-level permissions. 15 + // This runs as a side effect on import — the server (app/src/server.ts) imports from 16 + // this module, so both the indexer and server share the same registration. 17 + registerModulePermissions(CORE_MODULE, corePermissions, CORE_PERMISSIONS_COLLECTION); 8 18 9 19 // Register module permissions so the indexer can check them 10 20 for (const mod of modules) {
+23 -5
scripts/generate-lexicon-types.ts
··· 23 23 items?: { type: string }; 24 24 knownValues?: string[]; 25 25 maxLength?: number; 26 + ref?: string; 27 + } 28 + 29 + interface LexiconDef { 30 + type: string; 31 + description?: string; 32 + knownValues?: string[]; 26 33 } 27 34 28 35 interface LexiconSchema { 29 36 lexicon: number; 30 37 id: string; 31 - defs: { 38 + defs: Record<string, LexiconDef> & { 32 39 main: { 33 40 type: string; 34 41 record: { 35 42 type: string; 36 - required: string[]; 43 + required?: string[]; 37 44 properties: Record<string, LexiconProperty>; 38 45 }; 39 46 }; ··· 45 52 return name.charAt(0).toUpperCase() + name.slice(1) + "Record"; 46 53 } 47 54 48 - function tsType(prop: LexiconProperty): string { 55 + /** Quote property names that aren't valid JS identifiers. */ 56 + function formatKey(key: string): string { 57 + return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : `"${key}"`; 58 + } 59 + 60 + function tsType(prop: LexiconProperty, defs: Record<string, LexiconDef>): string { 49 61 if (prop.type === "array" && prop.items?.type === "string") return "string[]"; 50 62 if (prop.type === "string") return "string"; 63 + // Resolve local refs like "#role" → defs.role 64 + if (prop.type === "ref" && prop.ref) { 65 + const defName = prop.ref.replace(/^#/, ""); 66 + const resolved = defs[defName]; 67 + if (resolved) return tsType({ type: resolved.type } as LexiconProperty, defs); 68 + } 51 69 throw new Error(`Unsupported lexicon property type: ${JSON.stringify(prop)}`); 52 70 } 53 71 ··· 76 94 for (const schema of schemas) { 77 95 const name = toInterfaceName(schema.id); 78 96 const record = schema.defs.main.record; 79 - const required = new Set(record.required); 97 + const required = new Set(record.required ?? []); 80 98 81 99 lines.push(`export interface ${name} {`); 82 100 for (const [key, prop] of Object.entries(record.properties)) { ··· 85 103 lines.push(` /** ${comment} */`); 86 104 } 87 105 const optional = required.has(key) ? "" : "?"; 88 - lines.push(` ${key}${optional}: ${tsType(prop)};`); 106 + lines.push(` ${formatKey(key)}${optional}: ${tsType(prop, schema.defs)};`); 89 107 } 90 108 lines.push("}"); 91 109 lines.push("");