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

Configure Feed

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

chore: basic roles and permissions support

Hugo 6f948d90 fb6395e9

+2690 -131
+8
drizzle/0002_eager_silver_fox.sql
··· 1 + CREATE TABLE IF NOT EXISTS `sphere_permissions` ( 2 + `sphere_id` text NOT NULL, 3 + `action_key` text NOT NULL, 4 + `min_role` text NOT NULL, 5 + `updated_at` text DEFAULT (datetime('now')) NOT NULL, 6 + PRIMARY KEY(`sphere_id`, `action_key`), 7 + FOREIGN KEY (`sphere_id`) REFERENCES `spheres`(`id`) ON UPDATE no action ON DELETE no action 8 + );
+1
drizzle/0003_young_stone_men.sql
··· 1 + ALTER TABLE `spheres` DROP COLUMN `write_access`;
+940
drizzle/meta/0002_snapshot.json
··· 1 + { 2 + "version": "6", 3 + "dialect": "sqlite", 4 + "id": "502d639a-6773-40d0-b2b0-1057248daaf6", 5 + "prevId": "f4f4db59-eefb-4ebd-93d1-0b85cc219fe4", 6 + "tables": { 7 + "oauth_sessions": { 8 + "name": "oauth_sessions", 9 + "columns": { 10 + "key": { 11 + "name": "key", 12 + "type": "text", 13 + "primaryKey": true, 14 + "notNull": true, 15 + "autoincrement": false 16 + }, 17 + "session": { 18 + "name": "session", 19 + "type": "text", 20 + "primaryKey": false, 21 + "notNull": true, 22 + "autoincrement": false 23 + }, 24 + "created_at": { 25 + "name": "created_at", 26 + "type": "text", 27 + "primaryKey": false, 28 + "notNull": true, 29 + "autoincrement": false, 30 + "default": "(datetime('now'))" 31 + }, 32 + "updated_at": { 33 + "name": "updated_at", 34 + "type": "text", 35 + "primaryKey": false, 36 + "notNull": true, 37 + "autoincrement": false, 38 + "default": "(datetime('now'))" 39 + } 40 + }, 41 + "indexes": {}, 42 + "foreignKeys": {}, 43 + "compositePrimaryKeys": {}, 44 + "uniqueConstraints": {}, 45 + "checkConstraints": {} 46 + }, 47 + "oauth_states": { 48 + "name": "oauth_states", 49 + "columns": { 50 + "key": { 51 + "name": "key", 52 + "type": "text", 53 + "primaryKey": true, 54 + "notNull": true, 55 + "autoincrement": false 56 + }, 57 + "state": { 58 + "name": "state", 59 + "type": "text", 60 + "primaryKey": false, 61 + "notNull": true, 62 + "autoincrement": false 63 + }, 64 + "created_at": { 65 + "name": "created_at", 66 + "type": "text", 67 + "primaryKey": false, 68 + "notNull": true, 69 + "autoincrement": false, 70 + "default": "(datetime('now'))" 71 + } 72 + }, 73 + "indexes": {}, 74 + "foreignKeys": {}, 75 + "compositePrimaryKeys": {}, 76 + "uniqueConstraints": {}, 77 + "checkConstraints": {} 78 + }, 79 + "indexer_cursor": { 80 + "name": "indexer_cursor", 81 + "columns": { 82 + "id": { 83 + "name": "id", 84 + "type": "text", 85 + "primaryKey": true, 86 + "notNull": true, 87 + "autoincrement": false, 88 + "default": "'jetstream'" 89 + }, 90 + "cursor": { 91 + "name": "cursor", 92 + "type": "integer", 93 + "primaryKey": false, 94 + "notNull": true, 95 + "autoincrement": false 96 + }, 97 + "updated_at": { 98 + "name": "updated_at", 99 + "type": "text", 100 + "primaryKey": false, 101 + "notNull": true, 102 + "autoincrement": false, 103 + "default": "(datetime('now'))" 104 + } 105 + }, 106 + "indexes": {}, 107 + "foreignKeys": {}, 108 + "compositePrimaryKeys": {}, 109 + "uniqueConstraints": {}, 110 + "checkConstraints": {} 111 + }, 112 + "sphere_members": { 113 + "name": "sphere_members", 114 + "columns": { 115 + "sphere_id": { 116 + "name": "sphere_id", 117 + "type": "text", 118 + "primaryKey": false, 119 + "notNull": true, 120 + "autoincrement": false 121 + }, 122 + "did": { 123 + "name": "did", 124 + "type": "text", 125 + "primaryKey": false, 126 + "notNull": true, 127 + "autoincrement": false 128 + }, 129 + "role": { 130 + "name": "role", 131 + "type": "text", 132 + "primaryKey": false, 133 + "notNull": true, 134 + "autoincrement": false, 135 + "default": "'member'" 136 + }, 137 + "status": { 138 + "name": "status", 139 + "type": "text", 140 + "primaryKey": false, 141 + "notNull": true, 142 + "autoincrement": false, 143 + "default": "'invited'" 144 + }, 145 + "invited_by": { 146 + "name": "invited_by", 147 + "type": "text", 148 + "primaryKey": false, 149 + "notNull": false, 150 + "autoincrement": false 151 + }, 152 + "pds_uri": { 153 + "name": "pds_uri", 154 + "type": "text", 155 + "primaryKey": false, 156 + "notNull": false, 157 + "autoincrement": false 158 + }, 159 + "approval_pds_uri": { 160 + "name": "approval_pds_uri", 161 + "type": "text", 162 + "primaryKey": false, 163 + "notNull": false, 164 + "autoincrement": false 165 + }, 166 + "created_at": { 167 + "name": "created_at", 168 + "type": "text", 169 + "primaryKey": false, 170 + "notNull": true, 171 + "autoincrement": false, 172 + "default": "(datetime('now'))" 173 + } 174 + }, 175 + "indexes": { 176 + "idx_sphere_members_did": { 177 + "name": "idx_sphere_members_did", 178 + "columns": [ 179 + "did" 180 + ], 181 + "isUnique": false 182 + } 183 + }, 184 + "foreignKeys": { 185 + "sphere_members_sphere_id_spheres_id_fk": { 186 + "name": "sphere_members_sphere_id_spheres_id_fk", 187 + "tableFrom": "sphere_members", 188 + "tableTo": "spheres", 189 + "columnsFrom": [ 190 + "sphere_id" 191 + ], 192 + "columnsTo": [ 193 + "id" 194 + ], 195 + "onDelete": "no action", 196 + "onUpdate": "no action" 197 + } 198 + }, 199 + "compositePrimaryKeys": { 200 + "sphere_members_sphere_id_did_pk": { 201 + "columns": [ 202 + "sphere_id", 203 + "did" 204 + ], 205 + "name": "sphere_members_sphere_id_did_pk" 206 + } 207 + }, 208 + "uniqueConstraints": {}, 209 + "checkConstraints": {} 210 + }, 211 + "sphere_modules": { 212 + "name": "sphere_modules", 213 + "columns": { 214 + "sphere_id": { 215 + "name": "sphere_id", 216 + "type": "text", 217 + "primaryKey": false, 218 + "notNull": true, 219 + "autoincrement": false 220 + }, 221 + "module_name": { 222 + "name": "module_name", 223 + "type": "text", 224 + "primaryKey": false, 225 + "notNull": true, 226 + "autoincrement": false 227 + }, 228 + "enabled_at": { 229 + "name": "enabled_at", 230 + "type": "text", 231 + "primaryKey": false, 232 + "notNull": true, 233 + "autoincrement": false, 234 + "default": "(datetime('now'))" 235 + } 236 + }, 237 + "indexes": {}, 238 + "foreignKeys": { 239 + "sphere_modules_sphere_id_spheres_id_fk": { 240 + "name": "sphere_modules_sphere_id_spheres_id_fk", 241 + "tableFrom": "sphere_modules", 242 + "tableTo": "spheres", 243 + "columnsFrom": [ 244 + "sphere_id" 245 + ], 246 + "columnsTo": [ 247 + "id" 248 + ], 249 + "onDelete": "no action", 250 + "onUpdate": "no action" 251 + } 252 + }, 253 + "compositePrimaryKeys": { 254 + "sphere_modules_sphere_id_module_name_pk": { 255 + "columns": [ 256 + "sphere_id", 257 + "module_name" 258 + ], 259 + "name": "sphere_modules_sphere_id_module_name_pk" 260 + } 261 + }, 262 + "uniqueConstraints": {}, 263 + "checkConstraints": {} 264 + }, 265 + "sphere_permissions": { 266 + "name": "sphere_permissions", 267 + "columns": { 268 + "sphere_id": { 269 + "name": "sphere_id", 270 + "type": "text", 271 + "primaryKey": false, 272 + "notNull": true, 273 + "autoincrement": false 274 + }, 275 + "action_key": { 276 + "name": "action_key", 277 + "type": "text", 278 + "primaryKey": false, 279 + "notNull": true, 280 + "autoincrement": false 281 + }, 282 + "min_role": { 283 + "name": "min_role", 284 + "type": "text", 285 + "primaryKey": false, 286 + "notNull": true, 287 + "autoincrement": false 288 + }, 289 + "updated_at": { 290 + "name": "updated_at", 291 + "type": "text", 292 + "primaryKey": false, 293 + "notNull": true, 294 + "autoincrement": false, 295 + "default": "(datetime('now'))" 296 + } 297 + }, 298 + "indexes": {}, 299 + "foreignKeys": { 300 + "sphere_permissions_sphere_id_spheres_id_fk": { 301 + "name": "sphere_permissions_sphere_id_spheres_id_fk", 302 + "tableFrom": "sphere_permissions", 303 + "tableTo": "spheres", 304 + "columnsFrom": [ 305 + "sphere_id" 306 + ], 307 + "columnsTo": [ 308 + "id" 309 + ], 310 + "onDelete": "no action", 311 + "onUpdate": "no action" 312 + } 313 + }, 314 + "compositePrimaryKeys": { 315 + "sphere_permissions_sphere_id_action_key_pk": { 316 + "columns": [ 317 + "sphere_id", 318 + "action_key" 319 + ], 320 + "name": "sphere_permissions_sphere_id_action_key_pk" 321 + } 322 + }, 323 + "uniqueConstraints": {}, 324 + "checkConstraints": {} 325 + }, 326 + "spheres": { 327 + "name": "spheres", 328 + "columns": { 329 + "id": { 330 + "name": "id", 331 + "type": "text", 332 + "primaryKey": true, 333 + "notNull": true, 334 + "autoincrement": false 335 + }, 336 + "handle": { 337 + "name": "handle", 338 + "type": "text", 339 + "primaryKey": false, 340 + "notNull": true, 341 + "autoincrement": false 342 + }, 343 + "name": { 344 + "name": "name", 345 + "type": "text", 346 + "primaryKey": false, 347 + "notNull": true, 348 + "autoincrement": false 349 + }, 350 + "description": { 351 + "name": "description", 352 + "type": "text", 353 + "primaryKey": false, 354 + "notNull": false, 355 + "autoincrement": false 356 + }, 357 + "visibility": { 358 + "name": "visibility", 359 + "type": "text", 360 + "primaryKey": false, 361 + "notNull": true, 362 + "autoincrement": false, 363 + "default": "'public'" 364 + }, 365 + "write_access": { 366 + "name": "write_access", 367 + "type": "text", 368 + "primaryKey": false, 369 + "notNull": true, 370 + "autoincrement": false, 371 + "default": "'open'" 372 + }, 373 + "owner_did": { 374 + "name": "owner_did", 375 + "type": "text", 376 + "primaryKey": false, 377 + "notNull": true, 378 + "autoincrement": false 379 + }, 380 + "pds_uri": { 381 + "name": "pds_uri", 382 + "type": "text", 383 + "primaryKey": false, 384 + "notNull": false, 385 + "autoincrement": false 386 + }, 387 + "created_at": { 388 + "name": "created_at", 389 + "type": "text", 390 + "primaryKey": false, 391 + "notNull": true, 392 + "autoincrement": false, 393 + "default": "(datetime('now'))" 394 + }, 395 + "updated_at": { 396 + "name": "updated_at", 397 + "type": "text", 398 + "primaryKey": false, 399 + "notNull": true, 400 + "autoincrement": false, 401 + "default": "(datetime('now'))" 402 + } 403 + }, 404 + "indexes": { 405 + "spheres_handle_unique": { 406 + "name": "spheres_handle_unique", 407 + "columns": [ 408 + "handle" 409 + ], 410 + "isUnique": true 411 + } 412 + }, 413 + "foreignKeys": {}, 414 + "compositePrimaryKeys": {}, 415 + "uniqueConstraints": {}, 416 + "checkConstraints": {} 417 + }, 418 + "feature_request_comment_votes": { 419 + "name": "feature_request_comment_votes", 420 + "columns": { 421 + "comment_id": { 422 + "name": "comment_id", 423 + "type": "text", 424 + "primaryKey": false, 425 + "notNull": true, 426 + "autoincrement": false 427 + }, 428 + "author_did": { 429 + "name": "author_did", 430 + "type": "text", 431 + "primaryKey": false, 432 + "notNull": true, 433 + "autoincrement": false 434 + }, 435 + "pds_uri": { 436 + "name": "pds_uri", 437 + "type": "text", 438 + "primaryKey": false, 439 + "notNull": false, 440 + "autoincrement": false 441 + }, 442 + "created_at": { 443 + "name": "created_at", 444 + "type": "text", 445 + "primaryKey": false, 446 + "notNull": true, 447 + "autoincrement": false, 448 + "default": "(datetime('now'))" 449 + } 450 + }, 451 + "indexes": { 452 + "idx_feature_request_comment_votes_comment": { 453 + "name": "idx_feature_request_comment_votes_comment", 454 + "columns": [ 455 + "comment_id" 456 + ], 457 + "isUnique": false 458 + } 459 + }, 460 + "foreignKeys": { 461 + "feature_request_comment_votes_comment_id_feature_request_comments_id_fk": { 462 + "name": "feature_request_comment_votes_comment_id_feature_request_comments_id_fk", 463 + "tableFrom": "feature_request_comment_votes", 464 + "tableTo": "feature_request_comments", 465 + "columnsFrom": [ 466 + "comment_id" 467 + ], 468 + "columnsTo": [ 469 + "id" 470 + ], 471 + "onDelete": "no action", 472 + "onUpdate": "no action" 473 + } 474 + }, 475 + "compositePrimaryKeys": { 476 + "feature_request_comment_votes_comment_id_author_did_pk": { 477 + "columns": [ 478 + "comment_id", 479 + "author_did" 480 + ], 481 + "name": "feature_request_comment_votes_comment_id_author_did_pk" 482 + } 483 + }, 484 + "uniqueConstraints": {}, 485 + "checkConstraints": {} 486 + }, 487 + "feature_request_comments": { 488 + "name": "feature_request_comments", 489 + "columns": { 490 + "id": { 491 + "name": "id", 492 + "type": "text", 493 + "primaryKey": true, 494 + "notNull": true, 495 + "autoincrement": false 496 + }, 497 + "request_id": { 498 + "name": "request_id", 499 + "type": "text", 500 + "primaryKey": false, 501 + "notNull": true, 502 + "autoincrement": false 503 + }, 504 + "author_did": { 505 + "name": "author_did", 506 + "type": "text", 507 + "primaryKey": false, 508 + "notNull": true, 509 + "autoincrement": false 510 + }, 511 + "content": { 512 + "name": "content", 513 + "type": "text", 514 + "primaryKey": false, 515 + "notNull": true, 516 + "autoincrement": false 517 + }, 518 + "pds_uri": { 519 + "name": "pds_uri", 520 + "type": "text", 521 + "primaryKey": false, 522 + "notNull": false, 523 + "autoincrement": false 524 + }, 525 + "updated_at": { 526 + "name": "updated_at", 527 + "type": "text", 528 + "primaryKey": false, 529 + "notNull": true, 530 + "autoincrement": false, 531 + "default": "(datetime('now'))" 532 + }, 533 + "hidden_at": { 534 + "name": "hidden_at", 535 + "type": "text", 536 + "primaryKey": false, 537 + "notNull": false, 538 + "autoincrement": false 539 + }, 540 + "moderated_by": { 541 + "name": "moderated_by", 542 + "type": "text", 543 + "primaryKey": false, 544 + "notNull": false, 545 + "autoincrement": false 546 + } 547 + }, 548 + "indexes": { 549 + "idx_feature_request_comments_request": { 550 + "name": "idx_feature_request_comments_request", 551 + "columns": [ 552 + "request_id" 553 + ], 554 + "isUnique": false 555 + }, 556 + "idx_feature_request_comments_author_request": { 557 + "name": "idx_feature_request_comments_author_request", 558 + "columns": [ 559 + "author_did", 560 + "request_id" 561 + ], 562 + "isUnique": false 563 + } 564 + }, 565 + "foreignKeys": { 566 + "feature_request_comments_request_id_feature_requests_id_fk": { 567 + "name": "feature_request_comments_request_id_feature_requests_id_fk", 568 + "tableFrom": "feature_request_comments", 569 + "tableTo": "feature_requests", 570 + "columnsFrom": [ 571 + "request_id" 572 + ], 573 + "columnsTo": [ 574 + "id" 575 + ], 576 + "onDelete": "no action", 577 + "onUpdate": "no action" 578 + } 579 + }, 580 + "compositePrimaryKeys": {}, 581 + "uniqueConstraints": {}, 582 + "checkConstraints": {} 583 + }, 584 + "feature_request_statuses": { 585 + "name": "feature_request_statuses", 586 + "columns": { 587 + "id": { 588 + "name": "id", 589 + "type": "text", 590 + "primaryKey": true, 591 + "notNull": true, 592 + "autoincrement": false 593 + }, 594 + "request_id": { 595 + "name": "request_id", 596 + "type": "text", 597 + "primaryKey": false, 598 + "notNull": true, 599 + "autoincrement": false 600 + }, 601 + "author_did": { 602 + "name": "author_did", 603 + "type": "text", 604 + "primaryKey": false, 605 + "notNull": true, 606 + "autoincrement": false 607 + }, 608 + "status": { 609 + "name": "status", 610 + "type": "text", 611 + "primaryKey": false, 612 + "notNull": true, 613 + "autoincrement": false 614 + }, 615 + "pds_uri": { 616 + "name": "pds_uri", 617 + "type": "text", 618 + "primaryKey": false, 619 + "notNull": false, 620 + "autoincrement": false 621 + } 622 + }, 623 + "indexes": { 624 + "idx_feature_request_statuses_request": { 625 + "name": "idx_feature_request_statuses_request", 626 + "columns": [ 627 + "request_id" 628 + ], 629 + "isUnique": false 630 + } 631 + }, 632 + "foreignKeys": { 633 + "feature_request_statuses_request_id_feature_requests_id_fk": { 634 + "name": "feature_request_statuses_request_id_feature_requests_id_fk", 635 + "tableFrom": "feature_request_statuses", 636 + "tableTo": "feature_requests", 637 + "columnsFrom": [ 638 + "request_id" 639 + ], 640 + "columnsTo": [ 641 + "id" 642 + ], 643 + "onDelete": "no action", 644 + "onUpdate": "no action" 645 + } 646 + }, 647 + "compositePrimaryKeys": {}, 648 + "uniqueConstraints": {}, 649 + "checkConstraints": {} 650 + }, 651 + "feature_request_votes": { 652 + "name": "feature_request_votes", 653 + "columns": { 654 + "request_id": { 655 + "name": "request_id", 656 + "type": "text", 657 + "primaryKey": false, 658 + "notNull": true, 659 + "autoincrement": false 660 + }, 661 + "author_did": { 662 + "name": "author_did", 663 + "type": "text", 664 + "primaryKey": false, 665 + "notNull": true, 666 + "autoincrement": false 667 + }, 668 + "pds_uri": { 669 + "name": "pds_uri", 670 + "type": "text", 671 + "primaryKey": false, 672 + "notNull": false, 673 + "autoincrement": false 674 + }, 675 + "created_at": { 676 + "name": "created_at", 677 + "type": "text", 678 + "primaryKey": false, 679 + "notNull": true, 680 + "autoincrement": false, 681 + "default": "(datetime('now'))" 682 + } 683 + }, 684 + "indexes": { 685 + "idx_feature_request_votes_request": { 686 + "name": "idx_feature_request_votes_request", 687 + "columns": [ 688 + "request_id" 689 + ], 690 + "isUnique": false 691 + } 692 + }, 693 + "foreignKeys": { 694 + "feature_request_votes_request_id_feature_requests_id_fk": { 695 + "name": "feature_request_votes_request_id_feature_requests_id_fk", 696 + "tableFrom": "feature_request_votes", 697 + "tableTo": "feature_requests", 698 + "columnsFrom": [ 699 + "request_id" 700 + ], 701 + "columnsTo": [ 702 + "id" 703 + ], 704 + "onDelete": "no action", 705 + "onUpdate": "no action" 706 + } 707 + }, 708 + "compositePrimaryKeys": { 709 + "feature_request_votes_request_id_author_did_pk": { 710 + "columns": [ 711 + "request_id", 712 + "author_did" 713 + ], 714 + "name": "feature_request_votes_request_id_author_did_pk" 715 + } 716 + }, 717 + "uniqueConstraints": {}, 718 + "checkConstraints": {} 719 + }, 720 + "feature_requests": { 721 + "name": "feature_requests", 722 + "columns": { 723 + "id": { 724 + "name": "id", 725 + "type": "text", 726 + "primaryKey": true, 727 + "notNull": true, 728 + "autoincrement": false 729 + }, 730 + "sphere_id": { 731 + "name": "sphere_id", 732 + "type": "text", 733 + "primaryKey": false, 734 + "notNull": true, 735 + "autoincrement": false 736 + }, 737 + "number": { 738 + "name": "number", 739 + "type": "integer", 740 + "primaryKey": false, 741 + "notNull": true, 742 + "autoincrement": false 743 + }, 744 + "author_did": { 745 + "name": "author_did", 746 + "type": "text", 747 + "primaryKey": false, 748 + "notNull": true, 749 + "autoincrement": false 750 + }, 751 + "title": { 752 + "name": "title", 753 + "type": "text", 754 + "primaryKey": false, 755 + "notNull": true, 756 + "autoincrement": false 757 + }, 758 + "description": { 759 + "name": "description", 760 + "type": "text", 761 + "primaryKey": false, 762 + "notNull": true, 763 + "autoincrement": false 764 + }, 765 + "category": { 766 + "name": "category", 767 + "type": "text", 768 + "primaryKey": false, 769 + "notNull": true, 770 + "autoincrement": false, 771 + "default": "'general'" 772 + }, 773 + "status": { 774 + "name": "status", 775 + "type": "text", 776 + "primaryKey": false, 777 + "notNull": true, 778 + "autoincrement": false, 779 + "default": "'requested'" 780 + }, 781 + "duplicate_of_id": { 782 + "name": "duplicate_of_id", 783 + "type": "text", 784 + "primaryKey": false, 785 + "notNull": false, 786 + "autoincrement": false 787 + }, 788 + "pds_uri": { 789 + "name": "pds_uri", 790 + "type": "text", 791 + "primaryKey": false, 792 + "notNull": false, 793 + "autoincrement": false 794 + }, 795 + "hidden_at": { 796 + "name": "hidden_at", 797 + "type": "text", 798 + "primaryKey": false, 799 + "notNull": false, 800 + "autoincrement": false 801 + }, 802 + "moderated_by": { 803 + "name": "moderated_by", 804 + "type": "text", 805 + "primaryKey": false, 806 + "notNull": false, 807 + "autoincrement": false 808 + }, 809 + "updated_at": { 810 + "name": "updated_at", 811 + "type": "text", 812 + "primaryKey": false, 813 + "notNull": true, 814 + "autoincrement": false, 815 + "default": "(datetime('now'))" 816 + } 817 + }, 818 + "indexes": { 819 + "idx_feature_requests_sphere_number": { 820 + "name": "idx_feature_requests_sphere_number", 821 + "columns": [ 822 + "sphere_id", 823 + "number" 824 + ], 825 + "isUnique": true 826 + }, 827 + "idx_feature_requests_sphere": { 828 + "name": "idx_feature_requests_sphere", 829 + "columns": [ 830 + "sphere_id" 831 + ], 832 + "isUnique": false 833 + }, 834 + "idx_feature_requests_status": { 835 + "name": "idx_feature_requests_status", 836 + "columns": [ 837 + "status" 838 + ], 839 + "isUnique": false 840 + }, 841 + "idx_feature_requests_category": { 842 + "name": "idx_feature_requests_category", 843 + "columns": [ 844 + "category" 845 + ], 846 + "isUnique": false 847 + } 848 + }, 849 + "foreignKeys": { 850 + "feature_requests_sphere_id_spheres_id_fk": { 851 + "name": "feature_requests_sphere_id_spheres_id_fk", 852 + "tableFrom": "feature_requests", 853 + "tableTo": "spheres", 854 + "columnsFrom": [ 855 + "sphere_id" 856 + ], 857 + "columnsTo": [ 858 + "id" 859 + ], 860 + "onDelete": "no action", 861 + "onUpdate": "no action" 862 + } 863 + }, 864 + "compositePrimaryKeys": {}, 865 + "uniqueConstraints": {}, 866 + "checkConstraints": {} 867 + }, 868 + "feed_posts": { 869 + "name": "feed_posts", 870 + "columns": { 871 + "id": { 872 + "name": "id", 873 + "type": "text", 874 + "primaryKey": true, 875 + "notNull": true, 876 + "autoincrement": false 877 + }, 878 + "author_did": { 879 + "name": "author_did", 880 + "type": "text", 881 + "primaryKey": false, 882 + "notNull": true, 883 + "autoincrement": false 884 + }, 885 + "content": { 886 + "name": "content", 887 + "type": "text", 888 + "primaryKey": false, 889 + "notNull": true, 890 + "autoincrement": false 891 + }, 892 + "parent_id": { 893 + "name": "parent_id", 894 + "type": "text", 895 + "primaryKey": false, 896 + "notNull": false, 897 + "autoincrement": false 898 + }, 899 + "pds_uri": { 900 + "name": "pds_uri", 901 + "type": "text", 902 + "primaryKey": false, 903 + "notNull": false, 904 + "autoincrement": false 905 + }, 906 + "updated_at": { 907 + "name": "updated_at", 908 + "type": "text", 909 + "primaryKey": false, 910 + "notNull": true, 911 + "autoincrement": false, 912 + "default": "(datetime('now'))" 913 + } 914 + }, 915 + "indexes": { 916 + "idx_feed_posts_parent": { 917 + "name": "idx_feed_posts_parent", 918 + "columns": [ 919 + "parent_id" 920 + ], 921 + "isUnique": false 922 + } 923 + }, 924 + "foreignKeys": {}, 925 + "compositePrimaryKeys": {}, 926 + "uniqueConstraints": {}, 927 + "checkConstraints": {} 928 + } 929 + }, 930 + "views": {}, 931 + "enums": {}, 932 + "_meta": { 933 + "schemas": {}, 934 + "tables": {}, 935 + "columns": {} 936 + }, 937 + "internal": { 938 + "indexes": {} 939 + } 940 + }
+932
drizzle/meta/0003_snapshot.json
··· 1 + { 2 + "version": "6", 3 + "dialect": "sqlite", 4 + "id": "9a325eac-107b-4a67-9580-14699a1769c5", 5 + "prevId": "502d639a-6773-40d0-b2b0-1057248daaf6", 6 + "tables": { 7 + "oauth_sessions": { 8 + "name": "oauth_sessions", 9 + "columns": { 10 + "key": { 11 + "name": "key", 12 + "type": "text", 13 + "primaryKey": true, 14 + "notNull": true, 15 + "autoincrement": false 16 + }, 17 + "session": { 18 + "name": "session", 19 + "type": "text", 20 + "primaryKey": false, 21 + "notNull": true, 22 + "autoincrement": false 23 + }, 24 + "created_at": { 25 + "name": "created_at", 26 + "type": "text", 27 + "primaryKey": false, 28 + "notNull": true, 29 + "autoincrement": false, 30 + "default": "(datetime('now'))" 31 + }, 32 + "updated_at": { 33 + "name": "updated_at", 34 + "type": "text", 35 + "primaryKey": false, 36 + "notNull": true, 37 + "autoincrement": false, 38 + "default": "(datetime('now'))" 39 + } 40 + }, 41 + "indexes": {}, 42 + "foreignKeys": {}, 43 + "compositePrimaryKeys": {}, 44 + "uniqueConstraints": {}, 45 + "checkConstraints": {} 46 + }, 47 + "oauth_states": { 48 + "name": "oauth_states", 49 + "columns": { 50 + "key": { 51 + "name": "key", 52 + "type": "text", 53 + "primaryKey": true, 54 + "notNull": true, 55 + "autoincrement": false 56 + }, 57 + "state": { 58 + "name": "state", 59 + "type": "text", 60 + "primaryKey": false, 61 + "notNull": true, 62 + "autoincrement": false 63 + }, 64 + "created_at": { 65 + "name": "created_at", 66 + "type": "text", 67 + "primaryKey": false, 68 + "notNull": true, 69 + "autoincrement": false, 70 + "default": "(datetime('now'))" 71 + } 72 + }, 73 + "indexes": {}, 74 + "foreignKeys": {}, 75 + "compositePrimaryKeys": {}, 76 + "uniqueConstraints": {}, 77 + "checkConstraints": {} 78 + }, 79 + "indexer_cursor": { 80 + "name": "indexer_cursor", 81 + "columns": { 82 + "id": { 83 + "name": "id", 84 + "type": "text", 85 + "primaryKey": true, 86 + "notNull": true, 87 + "autoincrement": false, 88 + "default": "'jetstream'" 89 + }, 90 + "cursor": { 91 + "name": "cursor", 92 + "type": "integer", 93 + "primaryKey": false, 94 + "notNull": true, 95 + "autoincrement": false 96 + }, 97 + "updated_at": { 98 + "name": "updated_at", 99 + "type": "text", 100 + "primaryKey": false, 101 + "notNull": true, 102 + "autoincrement": false, 103 + "default": "(datetime('now'))" 104 + } 105 + }, 106 + "indexes": {}, 107 + "foreignKeys": {}, 108 + "compositePrimaryKeys": {}, 109 + "uniqueConstraints": {}, 110 + "checkConstraints": {} 111 + }, 112 + "sphere_members": { 113 + "name": "sphere_members", 114 + "columns": { 115 + "sphere_id": { 116 + "name": "sphere_id", 117 + "type": "text", 118 + "primaryKey": false, 119 + "notNull": true, 120 + "autoincrement": false 121 + }, 122 + "did": { 123 + "name": "did", 124 + "type": "text", 125 + "primaryKey": false, 126 + "notNull": true, 127 + "autoincrement": false 128 + }, 129 + "role": { 130 + "name": "role", 131 + "type": "text", 132 + "primaryKey": false, 133 + "notNull": true, 134 + "autoincrement": false, 135 + "default": "'member'" 136 + }, 137 + "status": { 138 + "name": "status", 139 + "type": "text", 140 + "primaryKey": false, 141 + "notNull": true, 142 + "autoincrement": false, 143 + "default": "'invited'" 144 + }, 145 + "invited_by": { 146 + "name": "invited_by", 147 + "type": "text", 148 + "primaryKey": false, 149 + "notNull": false, 150 + "autoincrement": false 151 + }, 152 + "pds_uri": { 153 + "name": "pds_uri", 154 + "type": "text", 155 + "primaryKey": false, 156 + "notNull": false, 157 + "autoincrement": false 158 + }, 159 + "approval_pds_uri": { 160 + "name": "approval_pds_uri", 161 + "type": "text", 162 + "primaryKey": false, 163 + "notNull": false, 164 + "autoincrement": false 165 + }, 166 + "created_at": { 167 + "name": "created_at", 168 + "type": "text", 169 + "primaryKey": false, 170 + "notNull": true, 171 + "autoincrement": false, 172 + "default": "(datetime('now'))" 173 + } 174 + }, 175 + "indexes": { 176 + "idx_sphere_members_did": { 177 + "name": "idx_sphere_members_did", 178 + "columns": [ 179 + "did" 180 + ], 181 + "isUnique": false 182 + } 183 + }, 184 + "foreignKeys": { 185 + "sphere_members_sphere_id_spheres_id_fk": { 186 + "name": "sphere_members_sphere_id_spheres_id_fk", 187 + "tableFrom": "sphere_members", 188 + "tableTo": "spheres", 189 + "columnsFrom": [ 190 + "sphere_id" 191 + ], 192 + "columnsTo": [ 193 + "id" 194 + ], 195 + "onDelete": "no action", 196 + "onUpdate": "no action" 197 + } 198 + }, 199 + "compositePrimaryKeys": { 200 + "sphere_members_sphere_id_did_pk": { 201 + "columns": [ 202 + "sphere_id", 203 + "did" 204 + ], 205 + "name": "sphere_members_sphere_id_did_pk" 206 + } 207 + }, 208 + "uniqueConstraints": {}, 209 + "checkConstraints": {} 210 + }, 211 + "sphere_modules": { 212 + "name": "sphere_modules", 213 + "columns": { 214 + "sphere_id": { 215 + "name": "sphere_id", 216 + "type": "text", 217 + "primaryKey": false, 218 + "notNull": true, 219 + "autoincrement": false 220 + }, 221 + "module_name": { 222 + "name": "module_name", 223 + "type": "text", 224 + "primaryKey": false, 225 + "notNull": true, 226 + "autoincrement": false 227 + }, 228 + "enabled_at": { 229 + "name": "enabled_at", 230 + "type": "text", 231 + "primaryKey": false, 232 + "notNull": true, 233 + "autoincrement": false, 234 + "default": "(datetime('now'))" 235 + } 236 + }, 237 + "indexes": {}, 238 + "foreignKeys": { 239 + "sphere_modules_sphere_id_spheres_id_fk": { 240 + "name": "sphere_modules_sphere_id_spheres_id_fk", 241 + "tableFrom": "sphere_modules", 242 + "tableTo": "spheres", 243 + "columnsFrom": [ 244 + "sphere_id" 245 + ], 246 + "columnsTo": [ 247 + "id" 248 + ], 249 + "onDelete": "no action", 250 + "onUpdate": "no action" 251 + } 252 + }, 253 + "compositePrimaryKeys": { 254 + "sphere_modules_sphere_id_module_name_pk": { 255 + "columns": [ 256 + "sphere_id", 257 + "module_name" 258 + ], 259 + "name": "sphere_modules_sphere_id_module_name_pk" 260 + } 261 + }, 262 + "uniqueConstraints": {}, 263 + "checkConstraints": {} 264 + }, 265 + "sphere_permissions": { 266 + "name": "sphere_permissions", 267 + "columns": { 268 + "sphere_id": { 269 + "name": "sphere_id", 270 + "type": "text", 271 + "primaryKey": false, 272 + "notNull": true, 273 + "autoincrement": false 274 + }, 275 + "action_key": { 276 + "name": "action_key", 277 + "type": "text", 278 + "primaryKey": false, 279 + "notNull": true, 280 + "autoincrement": false 281 + }, 282 + "min_role": { 283 + "name": "min_role", 284 + "type": "text", 285 + "primaryKey": false, 286 + "notNull": true, 287 + "autoincrement": false 288 + }, 289 + "updated_at": { 290 + "name": "updated_at", 291 + "type": "text", 292 + "primaryKey": false, 293 + "notNull": true, 294 + "autoincrement": false, 295 + "default": "(datetime('now'))" 296 + } 297 + }, 298 + "indexes": {}, 299 + "foreignKeys": { 300 + "sphere_permissions_sphere_id_spheres_id_fk": { 301 + "name": "sphere_permissions_sphere_id_spheres_id_fk", 302 + "tableFrom": "sphere_permissions", 303 + "tableTo": "spheres", 304 + "columnsFrom": [ 305 + "sphere_id" 306 + ], 307 + "columnsTo": [ 308 + "id" 309 + ], 310 + "onDelete": "no action", 311 + "onUpdate": "no action" 312 + } 313 + }, 314 + "compositePrimaryKeys": { 315 + "sphere_permissions_sphere_id_action_key_pk": { 316 + "columns": [ 317 + "sphere_id", 318 + "action_key" 319 + ], 320 + "name": "sphere_permissions_sphere_id_action_key_pk" 321 + } 322 + }, 323 + "uniqueConstraints": {}, 324 + "checkConstraints": {} 325 + }, 326 + "spheres": { 327 + "name": "spheres", 328 + "columns": { 329 + "id": { 330 + "name": "id", 331 + "type": "text", 332 + "primaryKey": true, 333 + "notNull": true, 334 + "autoincrement": false 335 + }, 336 + "handle": { 337 + "name": "handle", 338 + "type": "text", 339 + "primaryKey": false, 340 + "notNull": true, 341 + "autoincrement": false 342 + }, 343 + "name": { 344 + "name": "name", 345 + "type": "text", 346 + "primaryKey": false, 347 + "notNull": true, 348 + "autoincrement": false 349 + }, 350 + "description": { 351 + "name": "description", 352 + "type": "text", 353 + "primaryKey": false, 354 + "notNull": false, 355 + "autoincrement": false 356 + }, 357 + "visibility": { 358 + "name": "visibility", 359 + "type": "text", 360 + "primaryKey": false, 361 + "notNull": true, 362 + "autoincrement": false, 363 + "default": "'public'" 364 + }, 365 + "owner_did": { 366 + "name": "owner_did", 367 + "type": "text", 368 + "primaryKey": false, 369 + "notNull": true, 370 + "autoincrement": false 371 + }, 372 + "pds_uri": { 373 + "name": "pds_uri", 374 + "type": "text", 375 + "primaryKey": false, 376 + "notNull": false, 377 + "autoincrement": false 378 + }, 379 + "created_at": { 380 + "name": "created_at", 381 + "type": "text", 382 + "primaryKey": false, 383 + "notNull": true, 384 + "autoincrement": false, 385 + "default": "(datetime('now'))" 386 + }, 387 + "updated_at": { 388 + "name": "updated_at", 389 + "type": "text", 390 + "primaryKey": false, 391 + "notNull": true, 392 + "autoincrement": false, 393 + "default": "(datetime('now'))" 394 + } 395 + }, 396 + "indexes": { 397 + "spheres_handle_unique": { 398 + "name": "spheres_handle_unique", 399 + "columns": [ 400 + "handle" 401 + ], 402 + "isUnique": true 403 + } 404 + }, 405 + "foreignKeys": {}, 406 + "compositePrimaryKeys": {}, 407 + "uniqueConstraints": {}, 408 + "checkConstraints": {} 409 + }, 410 + "feature_request_comment_votes": { 411 + "name": "feature_request_comment_votes", 412 + "columns": { 413 + "comment_id": { 414 + "name": "comment_id", 415 + "type": "text", 416 + "primaryKey": false, 417 + "notNull": true, 418 + "autoincrement": false 419 + }, 420 + "author_did": { 421 + "name": "author_did", 422 + "type": "text", 423 + "primaryKey": false, 424 + "notNull": true, 425 + "autoincrement": false 426 + }, 427 + "pds_uri": { 428 + "name": "pds_uri", 429 + "type": "text", 430 + "primaryKey": false, 431 + "notNull": false, 432 + "autoincrement": false 433 + }, 434 + "created_at": { 435 + "name": "created_at", 436 + "type": "text", 437 + "primaryKey": false, 438 + "notNull": true, 439 + "autoincrement": false, 440 + "default": "(datetime('now'))" 441 + } 442 + }, 443 + "indexes": { 444 + "idx_feature_request_comment_votes_comment": { 445 + "name": "idx_feature_request_comment_votes_comment", 446 + "columns": [ 447 + "comment_id" 448 + ], 449 + "isUnique": false 450 + } 451 + }, 452 + "foreignKeys": { 453 + "feature_request_comment_votes_comment_id_feature_request_comments_id_fk": { 454 + "name": "feature_request_comment_votes_comment_id_feature_request_comments_id_fk", 455 + "tableFrom": "feature_request_comment_votes", 456 + "tableTo": "feature_request_comments", 457 + "columnsFrom": [ 458 + "comment_id" 459 + ], 460 + "columnsTo": [ 461 + "id" 462 + ], 463 + "onDelete": "no action", 464 + "onUpdate": "no action" 465 + } 466 + }, 467 + "compositePrimaryKeys": { 468 + "feature_request_comment_votes_comment_id_author_did_pk": { 469 + "columns": [ 470 + "comment_id", 471 + "author_did" 472 + ], 473 + "name": "feature_request_comment_votes_comment_id_author_did_pk" 474 + } 475 + }, 476 + "uniqueConstraints": {}, 477 + "checkConstraints": {} 478 + }, 479 + "feature_request_comments": { 480 + "name": "feature_request_comments", 481 + "columns": { 482 + "id": { 483 + "name": "id", 484 + "type": "text", 485 + "primaryKey": true, 486 + "notNull": true, 487 + "autoincrement": false 488 + }, 489 + "request_id": { 490 + "name": "request_id", 491 + "type": "text", 492 + "primaryKey": false, 493 + "notNull": true, 494 + "autoincrement": false 495 + }, 496 + "author_did": { 497 + "name": "author_did", 498 + "type": "text", 499 + "primaryKey": false, 500 + "notNull": true, 501 + "autoincrement": false 502 + }, 503 + "content": { 504 + "name": "content", 505 + "type": "text", 506 + "primaryKey": false, 507 + "notNull": true, 508 + "autoincrement": false 509 + }, 510 + "pds_uri": { 511 + "name": "pds_uri", 512 + "type": "text", 513 + "primaryKey": false, 514 + "notNull": false, 515 + "autoincrement": false 516 + }, 517 + "updated_at": { 518 + "name": "updated_at", 519 + "type": "text", 520 + "primaryKey": false, 521 + "notNull": true, 522 + "autoincrement": false, 523 + "default": "(datetime('now'))" 524 + }, 525 + "hidden_at": { 526 + "name": "hidden_at", 527 + "type": "text", 528 + "primaryKey": false, 529 + "notNull": false, 530 + "autoincrement": false 531 + }, 532 + "moderated_by": { 533 + "name": "moderated_by", 534 + "type": "text", 535 + "primaryKey": false, 536 + "notNull": false, 537 + "autoincrement": false 538 + } 539 + }, 540 + "indexes": { 541 + "idx_feature_request_comments_request": { 542 + "name": "idx_feature_request_comments_request", 543 + "columns": [ 544 + "request_id" 545 + ], 546 + "isUnique": false 547 + }, 548 + "idx_feature_request_comments_author_request": { 549 + "name": "idx_feature_request_comments_author_request", 550 + "columns": [ 551 + "author_did", 552 + "request_id" 553 + ], 554 + "isUnique": false 555 + } 556 + }, 557 + "foreignKeys": { 558 + "feature_request_comments_request_id_feature_requests_id_fk": { 559 + "name": "feature_request_comments_request_id_feature_requests_id_fk", 560 + "tableFrom": "feature_request_comments", 561 + "tableTo": "feature_requests", 562 + "columnsFrom": [ 563 + "request_id" 564 + ], 565 + "columnsTo": [ 566 + "id" 567 + ], 568 + "onDelete": "no action", 569 + "onUpdate": "no action" 570 + } 571 + }, 572 + "compositePrimaryKeys": {}, 573 + "uniqueConstraints": {}, 574 + "checkConstraints": {} 575 + }, 576 + "feature_request_statuses": { 577 + "name": "feature_request_statuses", 578 + "columns": { 579 + "id": { 580 + "name": "id", 581 + "type": "text", 582 + "primaryKey": true, 583 + "notNull": true, 584 + "autoincrement": false 585 + }, 586 + "request_id": { 587 + "name": "request_id", 588 + "type": "text", 589 + "primaryKey": false, 590 + "notNull": true, 591 + "autoincrement": false 592 + }, 593 + "author_did": { 594 + "name": "author_did", 595 + "type": "text", 596 + "primaryKey": false, 597 + "notNull": true, 598 + "autoincrement": false 599 + }, 600 + "status": { 601 + "name": "status", 602 + "type": "text", 603 + "primaryKey": false, 604 + "notNull": true, 605 + "autoincrement": false 606 + }, 607 + "pds_uri": { 608 + "name": "pds_uri", 609 + "type": "text", 610 + "primaryKey": false, 611 + "notNull": false, 612 + "autoincrement": false 613 + } 614 + }, 615 + "indexes": { 616 + "idx_feature_request_statuses_request": { 617 + "name": "idx_feature_request_statuses_request", 618 + "columns": [ 619 + "request_id" 620 + ], 621 + "isUnique": false 622 + } 623 + }, 624 + "foreignKeys": { 625 + "feature_request_statuses_request_id_feature_requests_id_fk": { 626 + "name": "feature_request_statuses_request_id_feature_requests_id_fk", 627 + "tableFrom": "feature_request_statuses", 628 + "tableTo": "feature_requests", 629 + "columnsFrom": [ 630 + "request_id" 631 + ], 632 + "columnsTo": [ 633 + "id" 634 + ], 635 + "onDelete": "no action", 636 + "onUpdate": "no action" 637 + } 638 + }, 639 + "compositePrimaryKeys": {}, 640 + "uniqueConstraints": {}, 641 + "checkConstraints": {} 642 + }, 643 + "feature_request_votes": { 644 + "name": "feature_request_votes", 645 + "columns": { 646 + "request_id": { 647 + "name": "request_id", 648 + "type": "text", 649 + "primaryKey": false, 650 + "notNull": true, 651 + "autoincrement": false 652 + }, 653 + "author_did": { 654 + "name": "author_did", 655 + "type": "text", 656 + "primaryKey": false, 657 + "notNull": true, 658 + "autoincrement": false 659 + }, 660 + "pds_uri": { 661 + "name": "pds_uri", 662 + "type": "text", 663 + "primaryKey": false, 664 + "notNull": false, 665 + "autoincrement": false 666 + }, 667 + "created_at": { 668 + "name": "created_at", 669 + "type": "text", 670 + "primaryKey": false, 671 + "notNull": true, 672 + "autoincrement": false, 673 + "default": "(datetime('now'))" 674 + } 675 + }, 676 + "indexes": { 677 + "idx_feature_request_votes_request": { 678 + "name": "idx_feature_request_votes_request", 679 + "columns": [ 680 + "request_id" 681 + ], 682 + "isUnique": false 683 + } 684 + }, 685 + "foreignKeys": { 686 + "feature_request_votes_request_id_feature_requests_id_fk": { 687 + "name": "feature_request_votes_request_id_feature_requests_id_fk", 688 + "tableFrom": "feature_request_votes", 689 + "tableTo": "feature_requests", 690 + "columnsFrom": [ 691 + "request_id" 692 + ], 693 + "columnsTo": [ 694 + "id" 695 + ], 696 + "onDelete": "no action", 697 + "onUpdate": "no action" 698 + } 699 + }, 700 + "compositePrimaryKeys": { 701 + "feature_request_votes_request_id_author_did_pk": { 702 + "columns": [ 703 + "request_id", 704 + "author_did" 705 + ], 706 + "name": "feature_request_votes_request_id_author_did_pk" 707 + } 708 + }, 709 + "uniqueConstraints": {}, 710 + "checkConstraints": {} 711 + }, 712 + "feature_requests": { 713 + "name": "feature_requests", 714 + "columns": { 715 + "id": { 716 + "name": "id", 717 + "type": "text", 718 + "primaryKey": true, 719 + "notNull": true, 720 + "autoincrement": false 721 + }, 722 + "sphere_id": { 723 + "name": "sphere_id", 724 + "type": "text", 725 + "primaryKey": false, 726 + "notNull": true, 727 + "autoincrement": false 728 + }, 729 + "number": { 730 + "name": "number", 731 + "type": "integer", 732 + "primaryKey": false, 733 + "notNull": true, 734 + "autoincrement": false 735 + }, 736 + "author_did": { 737 + "name": "author_did", 738 + "type": "text", 739 + "primaryKey": false, 740 + "notNull": true, 741 + "autoincrement": false 742 + }, 743 + "title": { 744 + "name": "title", 745 + "type": "text", 746 + "primaryKey": false, 747 + "notNull": true, 748 + "autoincrement": false 749 + }, 750 + "description": { 751 + "name": "description", 752 + "type": "text", 753 + "primaryKey": false, 754 + "notNull": true, 755 + "autoincrement": false 756 + }, 757 + "category": { 758 + "name": "category", 759 + "type": "text", 760 + "primaryKey": false, 761 + "notNull": true, 762 + "autoincrement": false, 763 + "default": "'general'" 764 + }, 765 + "status": { 766 + "name": "status", 767 + "type": "text", 768 + "primaryKey": false, 769 + "notNull": true, 770 + "autoincrement": false, 771 + "default": "'requested'" 772 + }, 773 + "duplicate_of_id": { 774 + "name": "duplicate_of_id", 775 + "type": "text", 776 + "primaryKey": false, 777 + "notNull": false, 778 + "autoincrement": false 779 + }, 780 + "pds_uri": { 781 + "name": "pds_uri", 782 + "type": "text", 783 + "primaryKey": false, 784 + "notNull": false, 785 + "autoincrement": false 786 + }, 787 + "hidden_at": { 788 + "name": "hidden_at", 789 + "type": "text", 790 + "primaryKey": false, 791 + "notNull": false, 792 + "autoincrement": false 793 + }, 794 + "moderated_by": { 795 + "name": "moderated_by", 796 + "type": "text", 797 + "primaryKey": false, 798 + "notNull": false, 799 + "autoincrement": false 800 + }, 801 + "updated_at": { 802 + "name": "updated_at", 803 + "type": "text", 804 + "primaryKey": false, 805 + "notNull": true, 806 + "autoincrement": false, 807 + "default": "(datetime('now'))" 808 + } 809 + }, 810 + "indexes": { 811 + "idx_feature_requests_sphere_number": { 812 + "name": "idx_feature_requests_sphere_number", 813 + "columns": [ 814 + "sphere_id", 815 + "number" 816 + ], 817 + "isUnique": true 818 + }, 819 + "idx_feature_requests_sphere": { 820 + "name": "idx_feature_requests_sphere", 821 + "columns": [ 822 + "sphere_id" 823 + ], 824 + "isUnique": false 825 + }, 826 + "idx_feature_requests_status": { 827 + "name": "idx_feature_requests_status", 828 + "columns": [ 829 + "status" 830 + ], 831 + "isUnique": false 832 + }, 833 + "idx_feature_requests_category": { 834 + "name": "idx_feature_requests_category", 835 + "columns": [ 836 + "category" 837 + ], 838 + "isUnique": false 839 + } 840 + }, 841 + "foreignKeys": { 842 + "feature_requests_sphere_id_spheres_id_fk": { 843 + "name": "feature_requests_sphere_id_spheres_id_fk", 844 + "tableFrom": "feature_requests", 845 + "tableTo": "spheres", 846 + "columnsFrom": [ 847 + "sphere_id" 848 + ], 849 + "columnsTo": [ 850 + "id" 851 + ], 852 + "onDelete": "no action", 853 + "onUpdate": "no action" 854 + } 855 + }, 856 + "compositePrimaryKeys": {}, 857 + "uniqueConstraints": {}, 858 + "checkConstraints": {} 859 + }, 860 + "feed_posts": { 861 + "name": "feed_posts", 862 + "columns": { 863 + "id": { 864 + "name": "id", 865 + "type": "text", 866 + "primaryKey": true, 867 + "notNull": true, 868 + "autoincrement": false 869 + }, 870 + "author_did": { 871 + "name": "author_did", 872 + "type": "text", 873 + "primaryKey": false, 874 + "notNull": true, 875 + "autoincrement": false 876 + }, 877 + "content": { 878 + "name": "content", 879 + "type": "text", 880 + "primaryKey": false, 881 + "notNull": true, 882 + "autoincrement": false 883 + }, 884 + "parent_id": { 885 + "name": "parent_id", 886 + "type": "text", 887 + "primaryKey": false, 888 + "notNull": false, 889 + "autoincrement": false 890 + }, 891 + "pds_uri": { 892 + "name": "pds_uri", 893 + "type": "text", 894 + "primaryKey": false, 895 + "notNull": false, 896 + "autoincrement": false 897 + }, 898 + "updated_at": { 899 + "name": "updated_at", 900 + "type": "text", 901 + "primaryKey": false, 902 + "notNull": true, 903 + "autoincrement": false, 904 + "default": "(datetime('now'))" 905 + } 906 + }, 907 + "indexes": { 908 + "idx_feed_posts_parent": { 909 + "name": "idx_feed_posts_parent", 910 + "columns": [ 911 + "parent_id" 912 + ], 913 + "isUnique": false 914 + } 915 + }, 916 + "foreignKeys": {}, 917 + "compositePrimaryKeys": {}, 918 + "uniqueConstraints": {}, 919 + "checkConstraints": {} 920 + } 921 + }, 922 + "views": {}, 923 + "enums": {}, 924 + "_meta": { 925 + "schemas": {}, 926 + "tables": {}, 927 + "columns": {} 928 + }, 929 + "internal": { 930 + "indexes": {} 931 + } 932 + }
+15 -1
drizzle/meta/_journal.json
··· 15 15 "when": 1774458610448, 16 16 "tag": "0001_hard_vulture", 17 17 "breakpoints": true 18 + }, 19 + { 20 + "idx": 2, 21 + "version": "6", 22 + "when": 1774528277236, 23 + "tag": "0002_eager_silver_fox", 24 + "breakpoints": true 25 + }, 26 + { 27 + "idx": 3, 28 + "version": "6", 29 + "when": 1774540430211, 30 + "tag": "0003_young_stone_men", 31 + "breakpoints": true 18 32 } 19 33 ] 20 - } 34 + }
+6 -6
packages/app/e2e/seed.ts
··· 50 50 const SPHERE_ID = "e2e-sphere-001"; 51 51 52 52 db.run( 53 - `INSERT INTO spheres (id, handle, name, description, visibility, write_access, owner_did) 54 - VALUES (?, ?, ?, ?, 'public', 'open', ?)`, 53 + `INSERT INTO spheres (id, handle, name, description, visibility, owner_did) 54 + VALUES (?, ?, ?, ?, 'public', ?)`, 55 55 [SPHERE_ID, "test.bsky.social", "Test Sphere", "A sphere for E2E testing", OWNER_DID], 56 56 ); 57 57 ··· 158 158 // ---- Sphere A: alpha.test ---- 159 159 160 160 db.run( 161 - `INSERT INTO spheres (id, handle, name, description, visibility, write_access, owner_did) 162 - VALUES (?, ?, ?, ?, 'public', 'open', ?)`, 161 + `INSERT INTO spheres (id, handle, name, description, visibility, owner_did) 162 + VALUES (?, ?, ?, ?, 'public', ?)`, 163 163 ["sphere-alpha", "alpha.test", "Alpha Sphere", "First test sphere", OWNER_DID], 164 164 ); 165 165 db.run( ··· 244 244 // ---- Sphere B: beta.test ---- 245 245 246 246 db.run( 247 - `INSERT INTO spheres (id, handle, name, description, visibility, write_access, owner_did) 248 - VALUES (?, ?, ?, ?, 'public', 'open', ?)`, 247 + `INSERT INTO spheres (id, handle, name, description, visibility, owner_did) 248 + VALUES (?, ?, ?, ?, 'public', ?)`, 249 249 ["sphere-beta", "beta.test", "Beta Sphere", "Second test sphere", MEMBER_DID], 250 250 ); 251 251 db.run(
+61 -1
packages/app/src/api/spheres.ts
··· 17 17 name: string; 18 18 description?: string; 19 19 visibility: "public" | "private"; 20 - writeAccess: "open" | "members"; 21 20 }) { 22 21 return apiFetch<{ sphere: Sphere }>("/api/spheres", { 23 22 method: "POST", ··· 43 42 { method: "DELETE" }, 44 43 ); 45 44 } 45 + 46 + // ---- Permissions ---- 47 + 48 + export interface PermissionAction { 49 + label: string; 50 + defaultRole: string; 51 + effectiveRole: string; 52 + isOverridden: boolean; 53 + } 54 + 55 + export interface PermissionsData { 56 + modules: Record<string, { actions: Record<string, PermissionAction> }>; 57 + } 58 + 59 + export function getSpherePermissions(handle: string) { 60 + return apiFetch<PermissionsData>(`/api/spheres/${encodeURIComponent(handle)}/permissions`); 61 + } 62 + 63 + export function updateSpherePermissions(handle: string, overrides: Record<string, string>) { 64 + return apiFetch<{ ok: true }>(`/api/spheres/${encodeURIComponent(handle)}/permissions`, { 65 + method: "PUT", 66 + headers: { "Content-Type": "application/json" }, 67 + body: JSON.stringify({ overrides }), 68 + }); 69 + } 70 + 71 + // ---- Members ---- 72 + 73 + export interface MemberData { 74 + did: string; 75 + role: "owner" | "admin" | "member"; 76 + status: "active" | "invited" | "revoked"; 77 + invitedBy: string | null; 78 + createdAt: string; 79 + } 80 + 81 + export function getSphereMembers(handle: string) { 82 + return apiFetch<{ members: MemberData[] }>(`/api/spheres/${encodeURIComponent(handle)}/members`); 83 + } 84 + 85 + export function inviteMember(handle: string, did: string, role: "admin" | "member" = "member") { 86 + return apiFetch(`/api/spheres/${encodeURIComponent(handle)}/members`, { 87 + method: "POST", 88 + headers: { "Content-Type": "application/json" }, 89 + body: JSON.stringify({ did, role }), 90 + }); 91 + } 92 + 93 + export function revokeMember(handle: string, did: string) { 94 + return apiFetch(`/api/spheres/${encodeURIComponent(handle)}/members/${encodeURIComponent(did)}`, { 95 + method: "DELETE", 96 + }); 97 + } 98 + 99 + export function updateMemberRole(handle: string, did: string, role: "admin" | "member") { 100 + return apiFetch(`/api/spheres/${encodeURIComponent(handle)}/members/${encodeURIComponent(did)}`, { 101 + method: "PUT", 102 + headers: { "Content-Type": "application/json" }, 103 + body: JSON.stringify({ role }), 104 + }); 105 + }
-19
packages/app/src/pages/create-sphere.tsx
··· 10 10 const name = useSignal(""); 11 11 const description = useSignal(""); 12 12 const visibility = useSignal("public"); 13 - const writeAccess = useSignal("open"); 14 13 const error = useSignal(""); 15 14 const submitting = useSignal(false); 16 15 const { route } = useLocation(); ··· 28 27 const body: Record<string, string> = { 29 28 name: name.value.trim(), 30 29 visibility: visibility.value, 31 - writeAccess: writeAccess.value, 32 30 }; 33 31 if (description.value.trim()) body.description = description.value.trim(); 34 32 ··· 100 98 > 101 99 <option value="public">Public — anyone can view</option> 102 100 <option value="private">Private — members only</option> 103 - </select> 104 - </div> 105 - 106 - <div> 107 - <label class={ui.label} htmlFor="writeAccess"> 108 - Write access (only Open is available for now) 109 - </label> 110 - <select 111 - id="writeAccess" 112 - class={ui.select} 113 - value={writeAccess.value} 114 - onChange={(e) => (writeAccess.value = (e.target as HTMLSelectElement).value)} 115 - // can only be Open for now 116 - disabled 117 - > 118 - <option value="open">Open — any authenticated user</option> 119 - <option value="members">Members only</option> 120 101 </select> 121 102 </div> 122 103
+238 -2
packages/app/src/pages/sphere.tsx
··· 1 + import { useSignal } from "@preact/signals"; 1 2 import { auth } from "@exosphere/client/auth"; 2 3 import { sphereState, sphereHandle, refreshSphere } from "@exosphere/client/sphere"; 3 4 import { useQuery } from "@exosphere/client/hooks"; 4 5 import { spherePath } from "@exosphere/client/router"; 6 + import { CollapsibleSection } from "@exosphere/client/components/collapsible-section"; 5 7 import * as ui from "@exosphere/client/ui.css"; 6 8 import { 7 9 getSphereModules, 8 10 enableModule as apiEnableModule, 9 11 disableModule as apiDisableModule, 12 + getSpherePermissions, 13 + updateSpherePermissions, 14 + getSphereMembers, 15 + inviteMember, 16 + revokeMember, 17 + updateMemberRole, 18 + type MemberData, 10 19 } from "../api/spheres.ts"; 11 20 12 21 /** Map internal module names to user-facing labels (used for display and URL paths). */ ··· 23 32 }, 24 33 }; 25 34 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 + 26 48 export function SpherePage() { 27 49 const { data } = sphereState.value; 28 50 const handle = sphereHandle.value; ··· 31 53 32 54 const modules = useQuery(() => getSphereModules(handle), [handle]); 33 55 56 + const isOwner = () => { 57 + if (!auth.value.authenticated) return false; 58 + return data.sphere.ownerDid === auth.value.did; 59 + }; 60 + 34 61 const isAdminOrOwner = () => { 35 62 if (!auth.value.authenticated) return false; 36 63 const role = data.role; 37 - if (role === "owner" || role === "admin") return true; 38 - return data.sphere.ownerDid === auth.value.did; 64 + return role === "owner" || role === "admin"; 39 65 }; 40 66 41 67 const enableModule = async (moduleName: string) => { ··· 119 145 </div> 120 146 )} 121 147 </div> 148 + 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 && ( 206 + <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.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.subsectionTitle}> 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> 354 + )} 355 + </div> 356 + )} 357 + </CollapsibleSection> 122 358 </div> 123 359 ); 124 360 }
+1
packages/client/package.json
··· 8 8 "./auth": "./src/auth.ts", 9 9 "./router": "./src/router.tsx", 10 10 "./sphere": "./src/sphere.ts", 11 + "./permissions": "./src/permissions.ts", 11 12 "./hooks": "./src/hooks.ts", 12 13 "./ssr-data": "./src/ssr-data.ts", 13 14 "./theme.css": "./src/theme.css.ts",
+14
packages/client/src/permissions.ts
··· 1 + import { computed } from "@preact/signals"; 2 + import { sphereState } from "./sphere.ts"; 3 + 4 + /** Check if the current user can perform an action. */ 5 + export function canDo(module: string, action: string): boolean { 6 + const data = sphereState.value.data; 7 + if (!data?.permissions) return false; 8 + return data.permissions[`${module}:${action}`] === true; 9 + } 10 + 11 + /** Reactive computed for use in Preact components. */ 12 + export function useCanDo(module: string, action: string) { 13 + return computed(() => canDo(module, action)); 14 + }
+24
packages/client/src/ui.css.ts
··· 504 504 marginInlineStart: vars.space.sm, 505 505 }); 506 506 507 + // ---- Compact controls (admin panels) ---- 508 + 509 + export const selectCompact = style({ 510 + ...inputBase, 511 + width: "auto", 512 + minBlockSize: "36px", 513 + fontSize: "0.75rem", 514 + ":focus": inputFocus, 515 + }); 516 + 517 + export const didText = style({ 518 + fontSize: "0.875rem", 519 + fontWeight: 600, 520 + wordBreak: "break-all", 521 + }); 522 + 523 + export const permissionLabel = style({ 524 + fontSize: "0.875rem", 525 + }); 526 + 527 + export const flexGrow = style({ 528 + flex: 1, 529 + }); 530 + 507 531 // ---- Responsive utilities ---- 508 532 509 533 export const hiddenMobile = style({
+1
packages/core/package.json
··· 12 12 "./pds": "./src/pds.ts", 13 13 "./sphere": "./src/sphere/index.ts", 14 14 "./identity": "./src/identity/index.ts", 15 + "./permissions": "./src/permissions/index.ts", 15 16 "./types": "./src/types/index.ts", 16 17 "./config": "./src/config.ts" 17 18 },
-1
packages/core/src/__tests__/helpers/test-db.ts
··· 29 29 const values = { 30 30 name: overrides.handle, 31 31 visibility: "public" as const, 32 - writeAccess: "open" as const, 33 32 ...overrides, 34 33 }; 35 34 db.insert(spheres).values(values).run();
-1
packages/core/src/__tests__/sphere-operations.test.ts
··· 88 88 handle: SPHERE_HANDLE, 89 89 name: "My Sphere", 90 90 visibility: "public", 91 - writeAccess: "open", 92 91 }, 93 92 pdsUri: "at://did:plc:owner1/com.exosphere.sphere/sphere-1", 94 93 });
+3 -6
packages/core/src/__tests__/sphere-schemas.test.ts
··· 11 11 it("accepts valid input with defaults", () => { 12 12 const result = createSphereSchema.parse({ name: "My Sphere" }); 13 13 expect(result.visibility).toBe("public"); 14 - expect(result.writeAccess).toBe("open"); 15 14 }); 16 15 17 - it("accepts explicit visibility and writeAccess", () => { 16 + it("accepts explicit visibility", () => { 18 17 const result = createSphereSchema.parse({ 19 18 name: "Private Sphere", 20 19 visibility: "private", 21 - writeAccess: "members", 22 20 }); 23 21 expect(result.visibility).toBe("private"); 24 - expect(result.writeAccess).toBe("members"); 25 22 }); 26 23 27 24 it("rejects empty name", () => { ··· 57 54 expect(() => updateSphereSchema.parse({ name: "" })).toThrow(); 58 55 }); 59 56 60 - it("rejects unknown writeAccess value", () => { 61 - expect(() => updateSphereSchema.parse({ writeAccess: "closed" })).toThrow(); 57 + it("rejects unknown visibility value", () => { 58 + expect(() => updateSphereSchema.parse({ visibility: "secret" })).toThrow(); 62 59 }); 63 60 }); 64 61
+2 -2
packages/core/src/db/schema/index.ts
··· 1 - export { spheres, sphereMembers, sphereModules } from "./spheres.ts"; 2 - export type { Sphere, SphereMember, SphereModule } from "./spheres.ts"; 1 + export { spheres, sphereMembers, sphereModules, spherePermissions } from "./spheres.ts"; 2 + export type { Sphere, SphereMember, SphereModule, SpherePermission } from "./spheres.ts"; 3 3 export { oauthStates, oauthSessions } from "./auth.ts"; 4 4 export { indexerCursor } from "./cursor.ts";
+18 -3
packages/core/src/db/schema/spheres.ts
··· 10 10 visibility: text("visibility", { enum: ["public", "private"] }) 11 11 .notNull() 12 12 .default("public"), 13 - writeAccess: text("write_access", { enum: ["open", "members"] }) 14 - .notNull() 15 - .default("open"), 16 13 ownerDid: text("owner_did").notNull(), 17 14 pdsUri: text("pds_uri"), 18 15 createdAt: text("created_at") ··· 63 60 (table) => [primaryKey({ columns: [table.sphereId, table.moduleName] })], 64 61 ); 65 62 63 + export const spherePermissions = sqliteTable( 64 + "sphere_permissions", 65 + { 66 + sphereId: text("sphere_id") 67 + .notNull() 68 + .references(() => spheres.id), 69 + /** Format: "module-name:action", e.g. "feature-requests:create" */ 70 + actionKey: text("action_key").notNull(), 71 + /** Minimum role required: "owner" | "admin" | "member" | "authenticated" */ 72 + minRole: text("min_role").notNull(), 73 + updatedAt: text("updated_at") 74 + .notNull() 75 + .default(sql`(datetime('now'))`), 76 + }, 77 + (table) => [primaryKey({ columns: [table.sphereId, table.actionKey] })], 78 + ); 79 + 66 80 export type Sphere = InferSelectModel<typeof spheres>; 67 81 export type SphereMember = InferSelectModel<typeof sphereMembers>; 68 82 export type SphereModule = InferSelectModel<typeof sphereModules>; 83 + export type SpherePermission = InferSelectModel<typeof spherePermissions>;
+2 -2
packages/core/src/generated/lexicon-records.ts
··· 50 50 description?: string; 51 51 /** Whether the Sphere's content is publicly readable. */ 52 52 visibility: string; 53 - /** Who can create content in this Sphere. */ 54 - writeAccess: string; 55 53 /** Module names enabled for this Sphere. */ 56 54 modules?: string[]; 55 + /** Per-module permission overrides. Key: "module:action", value: minimum role. */ 56 + permissions?: Record<string, string>; 57 57 /** datetime */ 58 58 createdAt: string; 59 59 }
+70
packages/core/src/permissions/check.ts
··· 1 + import { eq, and } from "../db/drizzle.ts"; 2 + import { getDb } from "../db/index.ts"; 3 + import { spherePermissions } from "../db/schema/index.ts"; 4 + import { hasMinimumRole, type Role, type MemberRole } from "./roles.ts"; 5 + import { getDefaultRole, getAllModulePermissions } from "./registry.ts"; 6 + 7 + /** Resolve the effective minimum role for an action in a sphere. */ 8 + export function getRequiredRole(sphereId: string, moduleName: string, action: string): Role { 9 + const actionKey = `${moduleName}:${action}`; 10 + 11 + // 1. Check explicit override 12 + const override = getDb() 13 + .select({ minRole: spherePermissions.minRole }) 14 + .from(spherePermissions) 15 + .where( 16 + and(eq(spherePermissions.sphereId, sphereId), eq(spherePermissions.actionKey, actionKey)), 17 + ) 18 + .get(); 19 + if (override) return override.minRole as Role; 20 + 21 + // 2. Module default 22 + const defaultRole = getDefaultRole(moduleName, action); 23 + if (defaultRole) return defaultRole; 24 + 25 + // 3. Safe fallback 26 + return "admin"; 27 + } 28 + 29 + /** Check if a user has permission to perform an action. */ 30 + export function checkPermission( 31 + sphereId: string, 32 + moduleName: string, 33 + action: string, 34 + userRole: MemberRole | null, 35 + ): boolean { 36 + const required = getRequiredRole(sphereId, moduleName, action); 37 + return hasMinimumRole(userRole, required); 38 + } 39 + 40 + /** Compute all permissions for a user in a sphere (for client-side SphereData). 41 + * Uses a single DB query for all overrides instead of one per action. */ 42 + export function computeUserPermissions( 43 + sphereId: string, 44 + userRole: MemberRole | null, 45 + enabledModules: string[], 46 + ): Record<string, boolean> { 47 + const allPerms = getAllModulePermissions(); 48 + 49 + // Single query for all overrides in this sphere 50 + const overrides = getDb() 51 + .select({ actionKey: spherePermissions.actionKey, minRole: spherePermissions.minRole }) 52 + .from(spherePermissions) 53 + .where(eq(spherePermissions.sphereId, sphereId)) 54 + .all(); 55 + const overrideMap = new Map(overrides.map((r) => [r.actionKey, r.minRole as Role])); 56 + 57 + const result: Record<string, boolean> = {}; 58 + 59 + for (const moduleName of enabledModules) { 60 + const modulePerms = allPerms.get(moduleName); 61 + if (!modulePerms) continue; 62 + for (const [action, perm] of Object.entries(modulePerms)) { 63 + const key = `${moduleName}:${action}`; 64 + const required = overrideMap.get(key) ?? perm.defaultRole; 65 + result[key] = hasMinimumRole(userRole, required); 66 + } 67 + } 68 + 69 + return result; 70 + }
+6
packages/core/src/permissions/index.ts
··· 1 + export { ROLE_LEVELS, ROLES, hasMinimumRole } from "./roles.ts"; 2 + export type { Role, MemberRole } from "./roles.ts"; 3 + export { registerModulePermissions, getAllModulePermissions } from "./registry.ts"; 4 + export type { ModulePermission } from "./registry.ts"; 5 + export { getRequiredRole, checkPermission, computeUserPermissions } from "./check.ts"; 6 + export { requirePermission } from "./middleware.ts";
+20
packages/core/src/permissions/middleware.ts
··· 1 + import { createMiddleware } from "hono/factory"; 2 + import type { AuthEnv } from "../auth/middleware.ts"; 3 + import type { SphereEnv } from "../types/index.ts"; 4 + import { getActiveMemberRole } from "../sphere/operations.ts"; 5 + import { checkPermission } from "./check.ts"; 6 + 7 + /** Hono middleware factory that checks if the authenticated user has a specific permission. */ 8 + export function requirePermission(moduleName: string, action: string) { 9 + return createMiddleware<AuthEnv & SphereEnv>(async (c, next) => { 10 + const did = c.var.did; 11 + const sphereId = c.var.sphereId; 12 + const userRole = getActiveMemberRole(sphereId, did); 13 + 14 + if (!checkPermission(sphereId, moduleName, action, userRole)) { 15 + return c.json({ error: "Forbidden" }, 403); 16 + } 17 + 18 + await next(); 19 + }); 20 + }
+28
packages/core/src/permissions/registry.ts
··· 1 + import type { Role } from "./roles.ts"; 2 + 3 + export interface ModulePermission { 4 + /** Human-readable label shown in admin UI */ 5 + label: string; 6 + /** Default minimum role needed */ 7 + defaultRole: Role; 8 + } 9 + 10 + const modulePermissions = new Map<string, Record<string, ModulePermission>>(); 11 + 12 + /** Register a module's permission declarations. Called at server startup. */ 13 + export function registerModulePermissions( 14 + moduleName: string, 15 + perms: Record<string, ModulePermission>, 16 + ): void { 17 + modulePermissions.set(moduleName, perms); 18 + } 19 + 20 + /** Get the default role for a specific module action. */ 21 + export function getDefaultRole(moduleName: string, action: string): Role | null { 22 + return modulePermissions.get(moduleName)?.[action]?.defaultRole ?? null; 23 + } 24 + 25 + /** Get all registered module permissions (for admin API). */ 26 + export function getAllModulePermissions(): ReadonlyMap<string, Record<string, ModulePermission>> { 27 + return modulePermissions; 28 + }
+22
packages/core/src/permissions/roles.ts
··· 1 + /** Role hierarchy: owner > admin > member > authenticated */ 2 + export const ROLE_LEVELS = { 3 + owner: 4, 4 + admin: 3, 5 + member: 2, 6 + authenticated: 1, 7 + } as const; 8 + 9 + /** Any role that can be set as a minimum permission level. */ 10 + export type Role = keyof typeof ROLE_LEVELS; 11 + 12 + /** Roles stored in the sphere_members table. */ 13 + export type MemberRole = "owner" | "admin" | "member"; 14 + 15 + /** Check if a user's actual role meets or exceeds the required minimum role. */ 16 + export function hasMinimumRole(userRole: MemberRole | null, requiredRole: Role): boolean { 17 + if (requiredRole === "authenticated") return true; 18 + if (userRole === null) return false; 19 + return ROLE_LEVELS[userRole] >= ROLE_LEVELS[requiredRole]; 20 + } 21 + 22 + export const ROLES: Role[] = ["owner", "admin", "member", "authenticated"];
-1
packages/core/src/sphere/api/modules.ts
··· 20 20 name: sphere.name, 21 21 description: sphere.description ?? undefined, 22 22 visibility: sphere.visibility, 23 - writeAccess: sphere.writeAccess, 24 23 modules, 25 24 createdAt: sphere.createdAt, 26 25 });
+165
packages/core/src/sphere/api/permissions.ts
··· 1 + import { Hono } from "hono"; 2 + import { z } from "zod"; 3 + import { eq, and } from "../../db/drizzle.ts"; 4 + import { getDb } from "../../db/index.ts"; 5 + import { spherePermissions } from "../../db/schema/index.ts"; 6 + import { requireAuth, type AuthEnv } from "../../auth/index.ts"; 7 + import { putPdsRecord } from "../../pds.ts"; 8 + import { 9 + getAllModulePermissions, 10 + getRequiredRole, 11 + type Role, 12 + } from "../../permissions/index.ts"; 13 + import { getActiveMemberRole, isAdminOrOwner } from "../operations.ts"; 14 + import { findSphere, getEnabledModules } from "./helpers.ts"; 15 + 16 + const SPHERE_COLLECTION = "site.exosphere.sphere" as const; 17 + 18 + const updatePermissionsSchema = z.object({ 19 + overrides: z.record(z.string(), z.enum(["owner", "admin", "member", "authenticated"])), 20 + }); 21 + 22 + const app = new Hono<AuthEnv>(); 23 + 24 + // Get full permissions configuration for a sphere (admin/owner only, for the admin panel) 25 + app.get("/:handle/permissions", requireAuth, (c) => { 26 + const sphere = findSphere(c.req.param("handle")); 27 + if (!sphere) { 28 + return c.json({ error: "Sphere not found" }, 404); 29 + } 30 + 31 + const role = getActiveMemberRole(sphere.id, c.var.did); 32 + if (!isAdminOrOwner(role)) { 33 + return c.json({ error: "Forbidden" }, 403); 34 + } 35 + 36 + const enabledModules = getEnabledModules(sphere.id).map((m) => m.moduleName); 37 + const allPerms = getAllModulePermissions(); 38 + const modules: Record< 39 + string, 40 + { 41 + actions: Record< 42 + string, 43 + { label: string; defaultRole: Role; effectiveRole: Role; isOverridden: boolean } 44 + >; 45 + } 46 + > = {}; 47 + 48 + for (const moduleName of enabledModules) { 49 + const modulePerms = allPerms.get(moduleName); 50 + if (!modulePerms) continue; 51 + 52 + const actions: Record< 53 + string, 54 + { label: string; defaultRole: Role; effectiveRole: Role; isOverridden: boolean } 55 + > = {}; 56 + 57 + for (const [action, perm] of Object.entries(modulePerms)) { 58 + const effectiveRole = getRequiredRole(sphere.id, moduleName, action); 59 + actions[action] = { 60 + label: perm.label, 61 + defaultRole: perm.defaultRole, 62 + effectiveRole, 63 + isOverridden: effectiveRole !== perm.defaultRole, 64 + }; 65 + } 66 + 67 + modules[moduleName] = { actions }; 68 + } 69 + 70 + return c.json({ modules }); 71 + }); 72 + 73 + // Update permission overrides (owner only) 74 + app.put("/:handle/permissions", requireAuth, async (c) => { 75 + const sphere = findSphere(c.req.param("handle")); 76 + if (!sphere) { 77 + return c.json({ error: "Sphere not found" }, 404); 78 + } 79 + 80 + // Owner only — only the owner can write to their PDS 81 + if (c.var.did !== sphere.ownerDid) { 82 + return c.json({ error: "Forbidden" }, 403); 83 + } 84 + 85 + const body = await c.req.json(); 86 + const parsed = updatePermissionsSchema.safeParse(body); 87 + if (!parsed.success) { 88 + return c.json({ error: z.flattenError(parsed.error) }, 400); 89 + } 90 + 91 + const { overrides } = parsed.data; 92 + const allPerms = getAllModulePermissions(); 93 + const db = getDb(); 94 + 95 + // Parse and validate all action keys up front 96 + const validatedOverrides: { actionKey: string; moduleName: string; action: string; minRole: string }[] = []; 97 + for (const [actionKey, minRole] of Object.entries(overrides)) { 98 + const sepIdx = actionKey.indexOf(":"); 99 + if (sepIdx === -1) { 100 + return c.json({ error: `Invalid permission key: ${actionKey}` }, 400); 101 + } 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]) { 106 + return c.json({ error: `Unknown permission: ${actionKey}` }, 400); 107 + } 108 + validatedOverrides.push({ actionKey, moduleName, action, minRole }); 109 + } 110 + 111 + // Upsert overrides — delete if matching the default 112 + db.transaction((tx) => { 113 + for (const { actionKey, moduleName, action, minRole } of validatedOverrides) { 114 + const defaultRole = allPerms.get(moduleName)?.[action]?.defaultRole; 115 + 116 + if (minRole === defaultRole) { 117 + // Remove override — use the default 118 + tx.delete(spherePermissions) 119 + .where( 120 + and( 121 + eq(spherePermissions.sphereId, sphere.id), 122 + eq(spherePermissions.actionKey, actionKey), 123 + ), 124 + ) 125 + .run(); 126 + } else { 127 + // Upsert override 128 + tx.insert(spherePermissions) 129 + .values({ sphereId: sphere.id, actionKey, minRole }) 130 + .onConflictDoUpdate({ 131 + target: [spherePermissions.sphereId, spherePermissions.actionKey], 132 + set: { minRole, updatedAt: new Date().toISOString() }, 133 + }) 134 + .run(); 135 + } 136 + } 137 + }); 138 + 139 + // Build the permissions map for PDS sync (only overrides) 140 + const allOverrides = db 141 + .select({ actionKey: spherePermissions.actionKey, minRole: spherePermissions.minRole }) 142 + .from(spherePermissions) 143 + .where(eq(spherePermissions.sphereId, sphere.id)) 144 + .all(); 145 + 146 + const permissionsMap: Record<string, string> = {}; 147 + for (const row of allOverrides) { 148 + permissionsMap[row.actionKey] = row.minRole; 149 + } 150 + 151 + // Sync sphere record to PDS 152 + const enabledModules = getEnabledModules(sphere.id).map((m) => m.moduleName); 153 + await putPdsRecord(c.var.session, SPHERE_COLLECTION, "self", { 154 + name: sphere.name, 155 + description: sphere.description ?? undefined, 156 + visibility: sphere.visibility, 157 + modules: enabledModules, 158 + permissions: Object.keys(permissionsMap).length > 0 ? permissionsMap : undefined, 159 + createdAt: sphere.createdAt, 160 + }); 161 + 162 + return c.json({ ok: true }); 163 + }); 164 + 165 + export { app as permissionsApi };
+19 -9
packages/core/src/sphere/api/spheres.ts
··· 7 7 import { putPdsRecord, generateRkey } from "../../pds.ts"; 8 8 import { createSphereSchema, updateSphereSchema } from "../schemas.ts"; 9 9 import { getActiveMemberRole } from "../operations.ts"; 10 + import { computeUserPermissions } from "../../permissions/check.ts"; 10 11 import { findSphere, getEnabledModules, formatModules } from "./helpers.ts"; 11 12 12 13 const SPHERE_COLLECTION = "site.exosphere.sphere"; ··· 30 31 return rows.map((r) => ({ ...r.sphere, role: r.role })); 31 32 } 32 33 33 - /** Load the current sphere with its modules, member count, and caller's role. 34 + /** Load the current sphere with its modules, member count, caller's role, and permissions. 34 35 * If `handle` is provided, loads that specific sphere; otherwise loads the first sphere. */ 35 36 export function getCurrentSphere(did: string | null, handle?: string) { 36 37 const sphere = handle ··· 38 39 : getDb().select().from(spheres).orderBy(spheres.createdAt).limit(1).get(); 39 40 if (!sphere) return null; 40 41 const modules = getEnabledModules(sphere.id); 42 + const enabledNames = modules.map((m) => m.moduleName); 41 43 const memberCount = getDb() 42 44 .select({ count: count() }) 43 45 .from(sphereMembers) 44 46 .where(and(eq(sphereMembers.sphereId, sphere.id), eq(sphereMembers.status, "active"))) 45 47 .get(); 46 48 const role = did ? getActiveMemberRole(sphere.id, did) : null; 47 - return { sphere, modules: formatModules(modules), memberCount: memberCount?.count ?? 0, role }; 49 + const permissions = did ? computeUserPermissions(sphere.id, role, enabledNames) : undefined; 50 + return { 51 + sphere, 52 + modules: formatModules(modules), 53 + memberCount: memberCount?.count ?? 0, 54 + role, 55 + permissions, 56 + }; 48 57 } 49 58 50 59 const app = new Hono<AuthEnv>(); ··· 57 66 return c.json({ error: z.flattenError(parsed.error) }, 400); 58 67 } 59 68 60 - const { name, description, visibility, writeAccess } = parsed.data; 69 + const { name, description, visibility } = parsed.data; 61 70 const did = c.var.did; 62 71 const id = generateRkey(); 63 72 ··· 91 100 name, 92 101 description: description ?? undefined, 93 102 visibility, 94 - writeAccess, 95 103 createdAt: now, 96 104 }); 97 105 ··· 103 111 name, 104 112 description: description ?? null, 105 113 visibility, 106 - writeAccess, 107 114 ownerDid: did, 108 115 pdsUri, 109 116 }) ··· 150 157 } 151 158 152 159 const modules = getEnabledModules(sphere.id); 160 + const enabledNames = modules.map((m) => m.moduleName); 153 161 const memberCount = getDb() 154 162 .select({ count: count() }) 155 163 .from(sphereMembers) ··· 158 166 159 167 const did = c.var.did; 160 168 const role = did ? getActiveMemberRole(sphere.id, did) : null; 169 + const permissions = did ? computeUserPermissions(sphere.id, role, enabledNames) : undefined; 161 170 162 171 return c.json({ 163 172 sphere, 164 173 modules: formatModules(modules), 165 174 memberCount: memberCount?.count ?? 0, 166 175 role, 176 + permissions, 167 177 }); 168 178 }); 169 179 ··· 175 185 } 176 186 177 187 const modules = getEnabledModules(sphere.id); 188 + const enabledNames = modules.map((m) => m.moduleName); 178 189 const memberCount = getDb() 179 190 .select({ count: count() }) 180 191 .from(sphereMembers) ··· 183 194 184 195 const did = c.var.did; 185 196 const role = did ? getActiveMemberRole(sphere.id, did) : null; 197 + const permissions = did ? computeUserPermissions(sphere.id, role, enabledNames) : undefined; 186 198 187 199 return c.json({ 188 - sphere: sphere, 200 + sphere, 189 201 modules: formatModules(modules), 190 202 memberCount: memberCount?.count ?? 0, 191 203 role, 204 + permissions, 192 205 }); 193 206 }); 194 207 ··· 217 230 if (updates.name !== undefined) set.name = updates.name; 218 231 if (updates.description !== undefined) set.description = updates.description; 219 232 if (updates.visibility !== undefined) set.visibility = updates.visibility; 220 - if (updates.writeAccess !== undefined) set.writeAccess = updates.writeAccess; 221 - 222 233 // Sync Sphere declaration to owner's PDS 223 234 const modules = getEnabledModules(sphere.id).map((m) => m.moduleName); 224 235 const pdsUri = await putPdsRecord(c.var.session, SPHERE_COLLECTION, "self", { 225 236 name: updates.name ?? sphere.name, 226 237 description: updates.description ?? sphere.description ?? undefined, 227 238 visibility: updates.visibility ?? sphere.visibility, 228 - writeAccess: updates.writeAccess ?? sphere.writeAccess, 229 239 modules, 230 240 createdAt: sphere.createdAt, 231 241 });
+1 -2
packages/core/src/sphere/middleware.ts
··· 12 12 * - Self-hosted mode: loads the first (and only) sphere. 13 13 * 14 14 * Sets `c.var.sphereId`, `c.var.sphereHandle`, `c.var.sphereVisibility`, 15 - * `c.var.sphereWriteAccess`, `c.var.sphereOwnerDid`, `c.var.spherePdsUri`. 15 + * `c.var.sphereOwnerDid`, `c.var.spherePdsUri`. 16 16 */ 17 17 export const sphereContext = createMiddleware<SphereEnv>(async (c, next) => { 18 18 const db = getDb(); ··· 35 35 c.set("sphereId", sphere.id); 36 36 c.set("sphereHandle", sphere.handle); 37 37 c.set("sphereVisibility", sphere.visibility as "public" | "private"); 38 - c.set("sphereWriteAccess", sphere.writeAccess as "open" | "members"); 39 38 c.set("sphereOwnerDid", sphere.ownerDid); 40 39 c.set("spherePdsUri", sphere.pdsUri); 41 40
+22 -2
packages/core/src/sphere/operations.ts
··· 1 1 import { eq, and } from "drizzle-orm"; 2 2 import { getDb } from "../db/index.ts"; 3 - import { spheres, sphereMembers } from "../db/schema/index.ts"; 3 + import { spheres, sphereMembers, spherePermissions } from "../db/schema/index.ts"; 4 4 import { parseAtUri } from "../indexer/uri.ts"; 5 + import { ROLES, type Role } from "../permissions/roles.ts"; 5 6 6 7 // ---- Membership utilities ---- 7 8 ··· 70 71 if (record.name) set.name = record.name; 71 72 if (record.description !== undefined) set.description = record.description; 72 73 if (record.visibility) set.visibility = record.visibility; 73 - if (record.writeAccess) set.writeAccess = record.writeAccess; 74 74 set.updatedAt = new Date().toISOString(); 75 75 76 76 db.update(spheres).set(set).where(eq(spheres.id, existing.id)).run(); 77 + 78 + // Sync permission overrides from PDS 79 + if (record.permissions && typeof record.permissions === "object") { 80 + const perms = record.permissions as Record<string, string>; 81 + const validRoles = new Set<string>(ROLES); 82 + 83 + // Clear existing overrides and replace 84 + db.delete(spherePermissions) 85 + .where(eq(spherePermissions.sphereId, existing.id)) 86 + .run(); 87 + 88 + for (const [actionKey, minRole] of Object.entries(perms)) { 89 + if (typeof actionKey === "string" && actionKey.includes(":") && validRoles.has(minRole)) { 90 + db.insert(spherePermissions) 91 + .values({ sphereId: existing.id, actionKey, minRole }) 92 + .onConflictDoNothing() 93 + .run(); 94 + } 95 + } 96 + } 77 97 } else { 78 98 // Ignore sphere records for DIDs not on this instance — 79 99 // spheres are created locally, not via Jetstream from other instances.
+2
packages/core/src/sphere/routes.ts
··· 3 3 import { spheresCrudApi } from "./api/spheres.ts"; 4 4 import { createModuleRoutes } from "./api/modules.ts"; 5 5 import { membersApi } from "./api/members.ts"; 6 + import { permissionsApi } from "./api/permissions.ts"; 6 7 7 8 export { getCurrentSphere, getMemberSpheres } from "./api/spheres.ts"; 8 9 ··· 12 13 app.route("/", spheresCrudApi); 13 14 app.route("/", createModuleRoutes(availableModules)); 14 15 app.route("/", membersApi); 16 + app.route("/", permissionsApi); 15 17 16 18 return app; 17 19 }
-2
packages/core/src/sphere/schemas.ts
··· 4 4 name: z.string().min(1).max(100), 5 5 description: z.string().max(500).optional(), 6 6 visibility: z.enum(["public", "private"]).default("public"), 7 - writeAccess: z.enum(["open", "members"]).default("open"), 8 7 }); 9 8 10 9 export const updateSphereSchema = z.object({ 11 10 name: z.string().min(1).max(100).optional(), 12 11 description: z.string().max(500).optional(), 13 12 visibility: z.enum(["public", "private"]).optional(), 14 - writeAccess: z.enum(["open", "members"]).optional(), 15 13 }); 16 14 17 15 export const enableModuleSchema = z.object({
+5 -1
packages/core/src/types/index.ts
··· 1 1 import type { Hono } from "hono"; 2 2 import type { BlankSchema } from "hono/types"; 3 3 import type { SphereMember } from "../db/schema/spheres.ts"; 4 + import type { ModulePermission } from "../permissions/registry.ts"; 4 5 5 6 /** Hono env variables set by the sphereContext middleware. */ 6 7 export type SphereEnv = { ··· 8 9 sphereId: string; 9 10 sphereHandle: string; 10 11 sphereVisibility: "public" | "private"; 11 - sphereWriteAccess: "open" | "members"; 12 12 sphereOwnerDid: string; 13 13 spherePdsUri: string | null; 14 14 }; ··· 43 43 api: Hono<any, BlankSchema, "/">; 44 44 /** Optional indexer for consuming Jetstream events */ 45 45 indexer?: ModuleIndexer; 46 + /** Named actions and their default minimum role, displayed in the admin panel */ 47 + permissions?: Record<string, ModulePermission>; 46 48 } 47 49 48 50 /** Formatted module info as returned by the API (subset of SphereModule DB row). */ ··· 57 59 modules: SphereModuleInfo[]; 58 60 memberCount: number; 59 61 role: SphereMember["role"] | null; 62 + /** Pre-computed permission map for the current user: "module:action" → boolean. */ 63 + permissions?: Record<string, boolean>; 60 64 }
+8 -12
packages/feature-requests/src/api/comments.ts
··· 3 3 import { getDb } from "@exosphere/core/db"; 4 4 import { eq, and, count, sql, inArray, desc, asc } from "@exosphere/core/db/drizzle"; 5 5 import { requireAuth, type AuthEnv } from "@exosphere/core/auth"; 6 - import { getActiveMemberRole, isAdminOrOwner } from "@exosphere/core/sphere"; 6 + import { getActiveMemberRole } from "@exosphere/core/sphere"; 7 + import { requirePermission, checkPermission } from "@exosphere/core/permissions"; 7 8 import { putPdsRecord, deletePdsRecord, generateRkey, tidToDate } from "@exosphere/core/pds"; 8 9 import { resolveDidHandles } from "@exosphere/core/identity"; 9 10 import type { SphereEnv } from "@exosphere/core/types"; ··· 126 127 }); 127 128 128 129 // Create a comment (one top-level comment per user per request) 129 - app.post("/:id/comments", requireAuth, async (c) => { 130 + app.post("/:id/comments", requireAuth, requirePermission("feature-requests", "comment"), async (c) => { 130 131 const requestId = c.req.param("id"); 131 132 const body = await c.req.json(); 132 133 const result = createCommentSchema.safeParse(body); ··· 265 266 const isAuthor = comment.authorDid === did; 266 267 267 268 if (!isAuthor) { 268 - // Only admin/owner can moderate other users' comments 269 + // Only users with moderate permission can hide other users' comments 269 270 const role = getActiveMemberRole(sphereId, did); 270 - if (!isAdminOrOwner(role)) { 271 + if (!checkPermission(sphereId, "feature-requests", "moderate", role)) { 271 272 return c.json({ error: "Forbidden" }, 403); 272 273 } 273 274 ··· 326 327 }); 327 328 328 329 // Admin/owner-only: unhide a moderated comment 329 - app.post("/comments/:id/unhide", requireAuth, async (c) => { 330 + app.post("/comments/:id/unhide", requireAuth, requirePermission("feature-requests", "moderate"), async (c) => { 330 331 const id = c.req.param("id"); 331 332 const sphereId = c.var.sphereId; 332 333 const did = c.var.did; 333 334 334 - const role = getActiveMemberRole(sphereId, did); 335 - if (!isAdminOrOwner(role)) { 336 - return c.json({ error: "Forbidden" }, 403); 337 - } 338 - 339 335 const row = findCommentInSphere(id, sphereId); 340 336 if (!row) { 341 337 return c.json({ error: "Comment not found" }, 404); ··· 381 377 }); 382 378 383 379 // Cast a vote on a comment 384 - app.post("/comments/:id/vote", requireAuth, async (c) => { 380 + app.post("/comments/:id/vote", requireAuth, requirePermission("feature-requests", "vote"), async (c) => { 385 381 const id = c.req.param("id"); 386 382 const db = getDb(); 387 383 const did = c.var.did; ··· 429 425 }); 430 426 431 427 // Remove a vote from a comment 432 - app.delete("/comments/:id/vote", requireAuth, async (c) => { 428 + app.delete("/comments/:id/vote", requireAuth, requirePermission("feature-requests", "vote"), async (c) => { 433 429 const id = c.req.param("id"); 434 430 const db = getDb(); 435 431 const did = c.var.did;
+4 -14
packages/feature-requests/src/api/requests.ts
··· 3 3 import { getDb } from "@exosphere/core/db"; 4 4 import { eq, and, count, sql, inArray, like, desc, asc } from "@exosphere/core/db/drizzle"; 5 5 import { requireAuth, type AuthEnv } from "@exosphere/core/auth"; 6 - import { getActiveMemberRole, isAdminOrOwner } from "@exosphere/core/sphere"; 6 + import { requirePermission } from "@exosphere/core/permissions"; 7 7 import { putPdsRecord, deletePdsRecord, generateRkey, tidToDate } from "@exosphere/core/pds"; 8 8 import { resolveDidHandles } from "@exosphere/core/identity"; 9 9 import type { SphereEnv } from "@exosphere/core/types"; ··· 143 143 return c.json({ featureRequests: rows.map((r) => ({ ...r, createdAt: tidToDate(r.id) })) }); 144 144 }); 145 145 146 - app.post("/", requireAuth, async (c) => { 146 + app.post("/", requireAuth, requirePermission("feature-requests", "create"), async (c) => { 147 147 const body = await c.req.json(); 148 148 const result = createFeatureRequestSchema.safeParse(body); 149 149 if (!result.success) { ··· 235 235 }); 236 236 237 237 // Admin/owner-only: hide a feature request 238 - app.post("/:id/hide", requireAuth, async (c) => { 238 + app.post("/:id/hide", requireAuth, requirePermission("feature-requests", "moderate"), async (c) => { 239 239 const id = c.req.param("id"); 240 240 const db = getDb(); 241 241 const did = c.var.did; ··· 243 243 const sphereOwnerDid = c.var.sphereOwnerDid; 244 244 const spherePdsUri = c.var.spherePdsUri; 245 245 246 - const role = getActiveMemberRole(sphereId, did); 247 - if (!isAdminOrOwner(role)) { 248 - return c.json({ error: "Forbidden" }, 403); 249 - } 250 - 251 246 const existing = db 252 247 .select({ 253 248 id: featureRequests.id, ··· 289 284 }); 290 285 291 286 // Admin/owner-only: unhide a feature request 292 - app.post("/:id/unhide", requireAuth, async (c) => { 287 + app.post("/:id/unhide", requireAuth, requirePermission("feature-requests", "moderate"), async (c) => { 293 288 const id = c.req.param("id"); 294 289 const db = getDb(); 295 290 const did = c.var.did; 296 291 const sphereId = c.var.sphereId; 297 - 298 - const role = getActiveMemberRole(sphereId, did); 299 - if (!isAdminOrOwner(role)) { 300 - return c.json({ error: "Forbidden" }, 403); 301 - } 302 292 303 293 const existing = db 304 294 .select({
+4 -8
packages/feature-requests/src/api/statuses.ts
··· 3 3 import { getDb } from "@exosphere/core/db"; 4 4 import { eq, and, sql } from "@exosphere/core/db/drizzle"; 5 5 import { requireAuth, type AuthEnv } from "@exosphere/core/auth"; 6 - import { getActiveMemberRole, isAdminOrOwner } from "@exosphere/core/sphere"; 6 + import { getActiveMemberRole } from "@exosphere/core/sphere"; 7 + import { requirePermission, checkPermission } from "@exosphere/core/permissions"; 7 8 import { putPdsRecord, generateRkey, tidToDate } from "@exosphere/core/pds"; 8 9 import { resolveDidHandles } from "@exosphere/core/identity"; 9 10 import type { SphereEnv } from "@exosphere/core/types"; ··· 59 60 60 61 // Permission: admin/owner OR author 61 62 const role = getActiveMemberRole(sphereId, did); 62 - const canMark = isAdminOrOwner(role) || fr.authorDid === did; 63 + const canMark = checkPermission(sphereId, "feature-requests", "mark-duplicate", role) || fr.authorDid === did; 63 64 if (!canMark) { 64 65 return c.json({ error: "Forbidden" }, 403); 65 66 } ··· 128 129 // ---- Statuses ---- 129 130 130 131 // Admin/owner-only: set status on a feature request 131 - app.post("/:id/status", requireAuth, async (c) => { 132 + app.post("/:id/status", requireAuth, requirePermission("feature-requests", "change-status"), async (c) => { 132 133 const id = c.req.param("id"); 133 134 const body = await c.req.json(); 134 135 const result = updateStatusSchema.safeParse(body); ··· 153 154 .get(); 154 155 if (!existing) { 155 156 return c.json({ error: "Feature request not found" }, 404); 156 - } 157 - 158 - const role = getActiveMemberRole(sphereId, did); 159 - if (!isAdminOrOwner(role)) { 160 - return c.json({ error: "Forbidden" }, 403); 161 157 } 162 158 163 159 let pdsUri: string | null = null;
+3 -2
packages/feature-requests/src/api/votes.ts
··· 2 2 import { getDb } from "@exosphere/core/db"; 3 3 import { eq, and, count, sql } from "@exosphere/core/db/drizzle"; 4 4 import { requireAuth, type AuthEnv } from "@exosphere/core/auth"; 5 + import { requirePermission } from "@exosphere/core/permissions"; 5 6 import { putPdsRecord } from "@exosphere/core/pds"; 6 7 import type { SphereEnv } from "@exosphere/core/types"; 7 8 import { featureRequests, featureRequestVotes } from "../db/schema.ts"; ··· 27 28 }); 28 29 29 30 // Cast a vote on a feature request 30 - app.post("/:id/vote", requireAuth, async (c) => { 31 + app.post("/:id/vote", requireAuth, requirePermission("feature-requests", "vote"), async (c) => { 31 32 const id = c.req.param("id"); 32 33 const db = getDb(); 33 34 const did = c.var.did; ··· 84 85 }); 85 86 86 87 // Remove a vote from a feature request 87 - app.delete("/:id/vote", requireAuth, async (c) => { 88 + app.delete("/:id/vote", requireAuth, requirePermission("feature-requests", "vote"), async (c) => { 88 89 const id = c.req.param("id"); 89 90 const db = getDb(); 90 91 const did = c.var.did;
+8
packages/feature-requests/src/index.ts
··· 6 6 name: "feature-requests", 7 7 api: featureRequestsApi, 8 8 indexer: featureRequestsIndexer, 9 + permissions: { 10 + create: { label: "Create feature request", defaultRole: "authenticated" }, 11 + vote: { label: "Vote on feature requests", defaultRole: "authenticated" }, 12 + comment: { label: "Comment on feature requests", defaultRole: "authenticated" }, 13 + "change-status": { label: "Change status", defaultRole: "admin" }, 14 + "mark-duplicate": { label: "Mark as duplicate", defaultRole: "authenticated" }, 15 + moderate: { label: "Hide/unhide content", defaultRole: "admin" }, 16 + }, 9 17 };
+7 -4
packages/feature-requests/src/indexer.ts
··· 1 1 import type { ModuleIndexer, JetstreamCommitEvent } from "@exosphere/core/types"; 2 2 import { buildAtUri, parseAtUri } from "@exosphere/core/indexer"; 3 3 import { registerModerationHandler, getActiveMemberRole } from "@exosphere/core/sphere"; 4 + import { checkPermission } from "@exosphere/core/permissions"; 4 5 import { getDb } from "@exosphere/core/db"; 5 6 import { eq, and } from "@exosphere/core/db/drizzle"; 6 7 import { spheres } from "@exosphere/core/db/schema"; ··· 23 24 // Register this module's moderation handler with core 24 25 registerModerationHandler(handleFeatureRequestModeration); 25 26 27 + const MODULE_NAME = "feature-requests"; 26 28 const COLLECTION = "site.exosphere.featureRequest"; 27 29 const VOTE_COLLECTION = "site.exosphere.featureRequestVote"; 28 30 const COMMENT_COLLECTION = "site.exosphere.featureRequestComment"; ··· 32 34 function findSphereForAccess( 33 35 sphereOwnerDid: string, 34 36 did: string, 37 + action: string, 35 38 ): { allowed: boolean; sphereId: string | null } { 36 39 const db = getDb(); 37 40 const sphere = db 38 - .select({ id: spheres.id, writeAccess: spheres.writeAccess }) 41 + .select({ id: spheres.id }) 39 42 .from(spheres) 40 43 .where(eq(spheres.ownerDid, sphereOwnerDid)) 41 44 .get(); 42 45 if (!sphere) return { allowed: false, sphereId: null }; 43 - if (sphere.writeAccess === "open") return { allowed: true, sphereId: sphere.id }; 44 46 const role = getActiveMemberRole(sphere.id, did); 45 - return { allowed: role !== null, sphereId: sphere.id }; 47 + const allowed = checkPermission(sphere.id, MODULE_NAME, action, role); 48 + return { allowed, sphereId: sphere.id }; 46 49 } 47 50 48 51 export const featureRequestsIndexer: ModuleIndexer = { ··· 65 68 const subject = record.subject as string; 66 69 if (!subject || !subject.startsWith("did:")) return; 67 70 68 - const access = findSphereForAccess(subject, did); 71 + const access = findSphereForAccess(subject, did, "create"); 69 72 if (!access.allowed || !access.sphereId) return; 70 73 const sphereId = access.sphereId; 71 74
+3 -3
packages/feature-requests/src/ui/components/request-card.tsx
··· 13 13 export function RequestCard({ 14 14 fr, 15 15 isAuthor = false, 16 - isAdminOrOwner = false, 16 + canModerate = false, 17 17 hasVoted, 18 18 isAuthenticated, 19 19 isDetail = false, ··· 24 24 }: { 25 25 fr: FeatureRequestListItem; 26 26 isAuthor?: boolean; 27 - isAdminOrOwner?: boolean; 27 + canModerate?: boolean; 28 28 hasVoted: boolean; 29 29 isAuthenticated: boolean; 30 30 isDetail?: boolean; ··· 109 109 Delete 110 110 </button> 111 111 )} 112 - {isAdminOrOwner && !isAuthor && onHide && ( 112 + {canModerate && !isAuthor && onHide && ( 113 113 <button class={ui.buttonDangerInline} onClick={() => onHide(fr.id)}> 114 114 Hide 115 115 </button>
+15 -18
packages/feature-requests/src/ui/pages/feature-request.tsx
··· 1 1 import { useSignal, useComputed } from "@preact/signals"; 2 2 import { auth } from "@exosphere/client/auth"; 3 3 import { useLocation, useRoute, spherePath } from "@exosphere/client/router"; 4 - import { sphereState } from "@exosphere/client/sphere"; 4 + import { useCanDo } from "@exosphere/client/permissions"; 5 5 import { useQuery } from "@exosphere/client/hooks"; 6 6 import * as ui from "@exosphere/client/ui.css"; 7 7 import * as frUi from "../ui.css.ts"; ··· 113 113 function CommentItem({ 114 114 comment, 115 115 isAuthor, 116 - isAdminOrOwner, 116 + canModerate, 117 117 isAuthenticated, 118 118 hasVoted, 119 119 onDelete, ··· 123 123 }: { 124 124 comment: FeatureRequestCommentListItem; 125 125 isAuthor: boolean; 126 - isAdminOrOwner: boolean; 126 + canModerate: boolean; 127 127 isAuthenticated: boolean; 128 128 hasVoted: boolean; 129 129 onDelete: (id: string) => void; ··· 179 179 Edit 180 180 </button> 181 181 )} 182 - {(isAuthor || isAdminOrOwner) && !editing.value && ( 182 + {(isAuthor || canModerate) && !editing.value && ( 183 183 <button class={ui.buttonDangerInline} onClick={() => onDelete(comment.id)}> 184 184 Delete 185 185 </button> ··· 320 320 requestId, 321 321 status, 322 322 isAuthenticated, 323 - isAdminOrOwner, 323 + canModerate, 324 324 currentDid, 325 325 currentHandle, 326 326 }: { 327 327 requestId: string; 328 328 status: string; 329 329 isAuthenticated: boolean; 330 - isAdminOrOwner: boolean; 330 + canModerate: boolean; 331 331 currentDid: string | null; 332 332 currentHandle: string | null; 333 333 }) { ··· 429 429 key={comment.id} 430 430 comment={comment} 431 431 isAuthor={currentDid === comment.authorDid} 432 - isAdminOrOwner={isAdminOrOwner} 432 + canModerate={canModerate} 433 433 isAuthenticated={isAuthenticated} 434 434 hasVoted={votedCommentIds.value.has(comment.id)} 435 435 onDelete={handleDelete} ··· 549 549 } 550 550 }, [votesQuery.data]); 551 551 552 - const isAdminOrOwner = useComputed(() => { 553 - const sphereData = sphereState.value.data; 554 - if (!isAuthenticated || !sphereData) return false; 555 - const role = sphereData.role; 556 - return role === "owner" || role === "admin"; 557 - }); 552 + const canModerate = useCanDo("feature-requests", "moderate"); 553 + const canChangeStatus = useCanDo("feature-requests", "change-status"); 554 + const canMarkDuplicate = useCanDo("feature-requests", "mark-duplicate"); 558 555 559 556 const fr = data?.featureRequest; 560 557 ··· 636 633 <RequestCard 637 634 fr={fr} 638 635 isAuthor={currentDid === fr.authorDid} 639 - isAdminOrOwner={isAdminOrOwner.value} 636 + canModerate={canModerate.value} 640 637 hasVoted={votedIds.value.has(fr.id)} 641 638 isAuthenticated={isAuthenticated} 642 639 isDetail ··· 646 643 onUnvote={toggleVote} 647 644 /> 648 645 649 - {isAdminOrOwner.value && ( 646 + {canChangeStatus.value && ( 650 647 <div class={ui.metaRow}> 651 648 <span class={ui.muted}>Status:</span> 652 649 <select ··· 682 679 requestId={fr.id} 683 680 status={fr.status} 684 681 isAuthenticated={isAuthenticated} 685 - isAdminOrOwner={isAdminOrOwner.value} 682 + canModerate={canModerate.value} 686 683 currentDid={currentDid} 687 684 currentHandle={currentHandle} 688 685 /> ··· 695 692 authorHandle={fr.authorHandle ?? null} 696 693 /> 697 694 698 - {((currentDid === fr.authorDid || isAdminOrOwner.value) && fr.status === "requested") || 695 + {((currentDid === fr.authorDid || canMarkDuplicate.value) && fr.status === "requested") || 699 696 data.duplicateCount > 0 ? ( 700 697 <MergedRequestsSection 701 698 requestId={fr.id} 702 699 duplicateCount={data.duplicateCount} 703 700 canMerge={ 704 - (currentDid === fr.authorDid || isAdminOrOwner.value) && 701 + (currentDid === fr.authorDid || canMarkDuplicate.value) && 705 702 data.duplicateCount === 0 && 706 703 fr.status === "requested" 707 704 }
+4 -9
packages/feature-requests/src/ui/pages/feature-requests.tsx
··· 1 - import { useComputed, useSignal } from "@preact/signals"; 1 + import { useSignal } from "@preact/signals"; 2 2 import { auth } from "@exosphere/client/auth"; 3 - import { sphereState } from "@exosphere/client/sphere"; 3 + import { useCanDo } from "@exosphere/client/permissions"; 4 4 import { spherePath, useLocation } from "@exosphere/client/router"; 5 5 import { useQuery } from "@exosphere/client/hooks"; 6 6 import * as ui from "@exosphere/client/ui.css"; ··· 192 192 } 193 193 }, [votesQuery.data]); 194 194 195 - const isAdminOrOwner = useComputed(() => { 196 - const sphereData = sphereState.value.data; 197 - if (!isAuthenticated || !sphereData) return false; 198 - const role = sphereData.role; 199 - return role === "owner" || role === "admin"; 200 - }); 195 + const canModerate = useCanDo("feature-requests", "moderate"); 201 196 202 197 const onCreated = () => { 203 198 showForm.value = false; ··· 287 282 key={fr.id} 288 283 fr={fr} 289 284 isAuthor={currentDid === fr.authorDid} 290 - isAdminOrOwner={isAdminOrOwner.value} 285 + canModerate={canModerate.value} 291 286 hasVoted={votedIds.value.has(fr.id)} 292 287 isAuthenticated={isAuthenticated} 293 288 onDelete={handleDelete}
+8
packages/indexer/src/modules.ts
··· 1 1 import type { ExosphereModule } from "@exosphere/core/types"; 2 2 import { coreIndexer } from "@exosphere/core/sphere"; 3 + import { registerModulePermissions } from "@exosphere/core/permissions"; 3 4 // import { feedsModule } from "@exosphere/feeds"; 4 5 import { featureRequestsModule } from "@exosphere/feature-requests"; 5 6 6 7 export const modules: ExosphereModule[] = [featureRequestsModule]; 7 8 export { coreIndexer }; 9 + 10 + // Register module permissions so the indexer can check them 11 + for (const mod of modules) { 12 + if (mod.permissions) { 13 + registerModulePermissions(mod.name, mod.permissions); 14 + } 15 + }