prototypey.org - atproto lexicon typescript toolkit - mirror https://github.com/tylersayshi/prototypey
1
fork

Configure Feed

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

implement permission-set types

Adds support for permission-set lexicons with `lx.permissionSet()` and permission entry builders:

- `lx.repoPermission()`
- `lx.rpcPermission()`
- `lx.blobPermission()`
- `lx.accountPermission()`
- `lx.identityPermission()`

Collections and endpoints accept both lexicon schema objects and plain NSID strings.

authored by

JP Hastings-Spital and committed by
Tyler Lawson
b23cb325 fe0548b3

+520 -7
+35
.changeset/permission-set-lexicons.md
··· 1 + --- 2 + "prototypey": minor 3 + --- 4 + 5 + Add support for permission-set lexicons with `lx.permissionSet()` and permission entry builders: 6 + 7 + - `lx.repoPermission()` 8 + - `lx.rpcPermission()` 9 + - `lx.blobPermission()` 10 + - `lx.accountPermission()` 11 + - `lx.identityPermission()` 12 + 13 + Collections and endpoints accept both lexicon schema objects and plain NSID strings. 14 + 15 + ```ts 16 + const authCore = lx.lexicon("com.example.authCore", { 17 + main: lx.permissionSet({ 18 + title: "Example: Core functionality", 19 + detail: "The core functionality for Example", 20 + permissions: [ 21 + lx.repoPermission({ 22 + collection: [myRecord, "com.example.otherRecord"], 23 + action: ["create", "update"], 24 + }), 25 + lx.rpcPermission({ 26 + lxm: [myProcedure], 27 + aud: "did:web:example.com", 28 + }), 29 + lx.blobPermission({ 30 + accept: ["image/*"], 31 + }), 32 + ], 33 + }), 34 + }); 35 + ```
+25
packages/prototypey/README.md
··· 13 13 - the really cool part of this is that it fills in the refs from the defs all at the type level 14 14 - `lx.lexicon(...).validate(data)` for validating data using `@atproto/lexicon` 15 15 - `fromJSON()` helper for creating lexicons directly from JSON objects with full type inference 16 + - permission-set lexicon support with builders for all resource types (repo, rpc, blob, account, identity) 16 17 17 18 ## Installation 18 19 ··· 71 72 ``` 72 73 73 74 you could also access the json definition with `lex.json()`. 75 + 76 + ### Permission Sets 77 + 78 + You can define [permission-set lexicons](https://atproto.com/specs/permission), crucial for implementing OAuth in your app, using `lx.permissionSet()` and the permission entry builders. 79 + 80 + Collections and endpoints accept both lexicon schema objects and plain NSID strings: 81 + 82 + ```ts 83 + const authCore = lx.lexicon("com.example.authCore", { 84 + main: lx.permissionSet({ 85 + title: "MyApp: Core functionality", 86 + detail: "The core functionality for MyApp", 87 + permissions: [ 88 + lx.repoPermission({ 89 + collection: [myRecord, "com.example.otherRecord"], 90 + action: ["create", "update"], 91 + }), 92 + lx.blobPermission({ 93 + accept: ["image/*"], 94 + }), 95 + ], 96 + }), 97 + }); 98 + ``` 74 99 75 100 ### Runtime Validation 76 101
+231 -1
packages/prototypey/core/lib.ts
··· 27 27 | "record" 28 28 | "query" 29 29 | "procedure" 30 - | "subscription"; 30 + | "subscription" 31 + // Permission types 32 + | "permission-set"; 31 33 32 34 /** 33 35 * Common options available for lexicon items. ··· 345 347 }; 346 348 347 349 /** 350 + * A lexicon schema object or a plain NSID string, used to reference 351 + * collections or endpoints in permission definitions. 352 + */ 353 + type NsidResolvable = string | { json: { id: string } }; 354 + 355 + function resolveNsid(ref: NsidResolvable): string { 356 + return typeof ref === "string" ? ref : ref.json.id; 357 + } 358 + 359 + /** 360 + * Options for a repo-resource permission. 361 + * @see https://atproto.com/specs/permission#repo 362 + */ 363 + type RepoPermissionOptions = { 364 + /** Collections this permission applies to (lexicon schemas or NSID strings) */ 365 + collection: NsidResolvable[]; 366 + /** Allowed actions on the collections */ 367 + action?: readonly ("create" | "update" | "delete")[]; 368 + }; 369 + 370 + /** 371 + * Options for an RPC-resource permission. 372 + * @see https://atproto.com/specs/permission#rpc 373 + */ 374 + type RpcPermissionOptions = { 375 + /** API endpoints this permission applies to (lexicon schemas or NSID strings) */ 376 + lxm?: NsidResolvable[]; 377 + /** DID of the target service */ 378 + aud?: string; 379 + /** Whether to inherit the audience from a parent include permission */ 380 + inheritAud?: boolean; 381 + }; 382 + 383 + /** 384 + * Options for a blob-resource permission. 385 + * @see https://atproto.com/specs/permission#blob 386 + */ 387 + type BlobPermissionOptions = { 388 + /** Accepted MIME types or patterns (e.g. "image/*") */ 389 + accept: string[]; 390 + }; 391 + 392 + /** 393 + * Options for an account-resource permission. 394 + * @see https://atproto.com/specs/permission#account 395 + */ 396 + type AccountPermissionOptions = { 397 + /** Account attribute: "email" or "repo" */ 398 + attr: "email" | "repo"; 399 + /** Allowed action on the attribute */ 400 + action?: "read" | "manage"; 401 + }; 402 + 403 + /** 404 + * Options for an identity-resource permission. 405 + * @see https://atproto.com/specs/permission#identity 406 + */ 407 + type IdentityPermissionOptions = { 408 + /** Identity attribute: "handle" or "*" for all */ 409 + attr: "handle" | "*"; 410 + }; 411 + 412 + /** 413 + * Permission granting access to records in specified collections. 414 + * @see https://atproto.com/specs/permission#repo 415 + */ 416 + type RepoPermissionEntry = { 417 + type: "permission"; 418 + resource: "repo"; 419 + collection: string[]; 420 + action?: readonly ("create" | "update" | "delete")[]; 421 + }; 422 + 423 + /** 424 + * Permission granting access to call API endpoints on a specified service. 425 + * @see https://atproto.com/specs/permission#rpc 426 + */ 427 + type RpcPermissionEntry = { 428 + type: "permission"; 429 + resource: "rpc"; 430 + lxm?: string[]; 431 + aud?: string; 432 + inheritAud?: boolean; 433 + }; 434 + 435 + /** 436 + * Permission granting access to upload blobs with specified MIME types. 437 + * @see https://atproto.com/specs/permission#blob 438 + */ 439 + type BlobPermissionEntry = { 440 + type: "permission"; 441 + resource: "blob"; 442 + accept: string[]; 443 + }; 444 + 445 + /** 446 + * Permission granting access to account-level attributes; read/update the associated email address, or replacing the entire repo (with a CAR file). 447 + * @see https://atproto.com/specs/permission#account 448 + */ 449 + type AccountPermissionEntry = { 450 + type: "permission"; 451 + resource: "account"; 452 + attr: "email" | "repo"; 453 + action?: "read" | "manage"; 454 + }; 455 + 456 + /** 457 + * Permission granting access to identity attributes like handle management. 458 + * @see https://atproto.com/specs/permission#identity 459 + */ 460 + type IdentityPermissionEntry = { 461 + type: "permission"; 462 + resource: "identity"; 463 + attr: "handle" | "*"; 464 + }; 465 + 466 + /** 467 + * Union of all permission entry types. 468 + * @see https://atproto.com/specs/permission 469 + */ 470 + type PermissionEntry = 471 + | RepoPermissionEntry 472 + | RpcPermissionEntry 473 + | BlobPermissionEntry 474 + | AccountPermissionEntry 475 + | IdentityPermissionEntry; 476 + 477 + /** 478 + * Options for a permission-set definition. 479 + * @see https://atproto.com/specs/permission 480 + */ 481 + type PermissionSetOptions = { 482 + /** Human-readable title */ 483 + title: string; 484 + /** Internationalized title translations */ 485 + "title:lang"?: Record<string, string>; 486 + /** Human-readable detail/description of what this permission set grants */ 487 + detail: string; 488 + /** Internationalized detail translations */ 489 + "detail:lang"?: Record<string, string>; 490 + /** List of permissions in this set */ 491 + permissions: PermissionEntry[]; 492 + /** Human-readable description */ 493 + description?: string; 494 + }; 495 + 496 + /** 348 497 * Public interface for Lexicon to avoid exposing private implementation details 349 498 */ 350 499 export type LexiconSchema<T extends LexiconNamespace> = { ··· 614 763 type: "subscription", 615 764 ...options, 616 765 } as T & { type: "subscription" }; 766 + }, 767 + /** 768 + * Creates a repo-resource permission entry. 769 + * @see https://atproto.com/specs/permission#repo 770 + */ 771 + repoPermission(options: RepoPermissionOptions): RepoPermissionEntry { 772 + return { 773 + type: "permission", 774 + resource: "repo", 775 + collection: options.collection.map(resolveNsid), 776 + ...(options.action ? { action: options.action } : {}), 777 + }; 778 + }, 779 + /** 780 + * Creates an RPC-resource permission entry. 781 + * @see https://atproto.com/specs/permission#rpc 782 + */ 783 + rpcPermission(options: RpcPermissionOptions): RpcPermissionEntry { 784 + return { 785 + type: "permission", 786 + resource: "rpc", 787 + ...(options.lxm ? { lxm: options.lxm.map(resolveNsid) } : {}), 788 + ...(options.aud !== undefined ? { aud: options.aud } : {}), 789 + ...(options.inheritAud !== undefined 790 + ? { inheritAud: options.inheritAud } 791 + : {}), 792 + }; 793 + }, 794 + /** 795 + * Creates a blob-resource permission entry. 796 + * @see https://atproto.com/specs/permission#blob 797 + */ 798 + blobPermission(options: BlobPermissionOptions): BlobPermissionEntry { 799 + return { 800 + type: "permission", 801 + resource: "blob", 802 + accept: options.accept, 803 + }; 804 + }, 805 + /** 806 + * Creates an account-resource permission entry. 807 + * @see https://atproto.com/specs/permission#account 808 + */ 809 + accountPermission(options: AccountPermissionOptions): AccountPermissionEntry { 810 + return { 811 + type: "permission", 812 + resource: "account", 813 + attr: options.attr, 814 + ...(options.action !== undefined ? { action: options.action } : {}), 815 + }; 816 + }, 817 + /** 818 + * Creates an identity-resource permission entry. 819 + * @see https://atproto.com/specs/permission#identity 820 + */ 821 + identityPermission( 822 + options: IdentityPermissionOptions, 823 + ): IdentityPermissionEntry { 824 + return { 825 + type: "permission", 826 + resource: "identity", 827 + attr: options.attr, 828 + }; 829 + }, 830 + /** 831 + * Creates a permission-set definition. 832 + * @see https://atproto.com/specs/permission#permission-sets 833 + */ 834 + permissionSet(options: PermissionSetOptions) { 835 + return { 836 + type: "permission-set" as const, 837 + key: "literal:self" as const, 838 + title: options.title, 839 + ...(options["title:lang"] ? { "title:lang": options["title:lang"] } : {}), 840 + detail: options.detail, 841 + ...(options["detail:lang"] 842 + ? { "detail:lang": options["detail:lang"] } 843 + : {}), 844 + permissions: options.permissions, 845 + ...(options.description ? { description: options.description } : {}), 846 + }; 617 847 }, 618 848 /** 619 849 * Creates a lexicon schema document.
+3 -3
packages/prototypey/core/tests/from-json-infer.test.ts
··· 350 350 351 351 attest(lexicon["~infer"]).type.toString.snap(`{ 352 352 $type: "test.mixed" 353 - age?: number | undefined 354 353 email?: string | undefined 354 + age?: number | undefined 355 355 id: string 356 356 name: string 357 357 }`); ··· 659 659 660 660 attest(lexicon["~infer"]).type.toString.snap(`{ 661 661 $type: "test.nested" 662 - user?: { name: string; email: string } | undefined 662 + user?: { email: string; name: string } | undefined 663 663 }`); 664 664 }); 665 665 ··· 976 976 attest(ns["~infer"]).type.toString.snap(`{ 977 977 $type: "test" 978 978 content: string 979 - author: { name: string; email: string; $type: "#user" } 979 + author: { email: string; name: string; $type: "#user" } 980 980 }`); 981 981 }); 982 982
+3 -3
packages/prototypey/core/tests/infer.test.ts
··· 238 238 239 239 attest(lexicon["~infer"]).type.toString.snap(`{ 240 240 $type: "test.mixed" 241 - age?: number | undefined 242 241 email?: string | undefined 242 + age?: number | undefined 243 243 id: string 244 244 name: string 245 245 }`); ··· 531 531 532 532 attest(lexicon["~infer"]).type.toString.snap(`{ 533 533 $type: "test.nested" 534 - user?: { name: string; email: string } | undefined 534 + user?: { email: string; name: string } | undefined 535 535 }`); 536 536 }); 537 537 ··· 724 724 attest(ns["~infer"]).type.toString.snap(`{ 725 725 $type: "test" 726 726 author?: 727 - | { name: string; email: string; $type: "#user" } 727 + | { email: string; name: string; $type: "#user" } 728 728 | undefined 729 729 content: string 730 730 }`);
+223
packages/prototypey/core/tests/permission-set.test.ts
··· 1 + import { expect, test } from "vitest"; 2 + import { lx } from "../lib.ts"; 3 + 4 + test("permission-set with repo permissions", () => { 5 + const recordA = lx.lexicon("com.example.recordA", { 6 + main: lx.record({ 7 + key: "tid", 8 + record: lx.object({ name: lx.string({ required: true }) }), 9 + }), 10 + }); 11 + 12 + const recordB = lx.lexicon("com.example.recordB", { 13 + main: lx.record({ 14 + key: "tid", 15 + record: lx.object({ value: lx.integer() }), 16 + }), 17 + }); 18 + 19 + const authCore = lx.lexicon("com.example.authCore", { 20 + main: lx.permissionSet({ 21 + title: "Example: Core functionality", 22 + detail: "The core functionality for Example", 23 + permissions: [ 24 + lx.repoPermission({ 25 + collection: [recordA], 26 + action: ["create"], 27 + }), 28 + lx.repoPermission({ 29 + collection: [recordB, "com.example.recordC"], 30 + action: ["create", "update", "delete"], 31 + }), 32 + ], 33 + }), 34 + }); 35 + 36 + expect(authCore.json).toEqual({ 37 + lexicon: 1, 38 + id: "com.example.authCore", 39 + defs: { 40 + main: { 41 + type: "permission-set", 42 + key: "literal:self", 43 + title: "Example: Core functionality", 44 + detail: "The core functionality for Example", 45 + permissions: [ 46 + { 47 + type: "permission", 48 + resource: "repo", 49 + collection: ["com.example.recordA"], 50 + action: ["create"], 51 + }, 52 + { 53 + type: "permission", 54 + resource: "repo", 55 + collection: ["com.example.recordB", "com.example.recordC"], 56 + action: ["create", "update", "delete"], 57 + }, 58 + ], 59 + }, 60 + }, 61 + }); 62 + }); 63 + 64 + test("permission-set with rpc permissions", () => { 65 + const endpoint = lx.lexicon("com.example.doThing", { 66 + main: lx.procedure({ 67 + input: { encoding: "application/json" }, 68 + }), 69 + }); 70 + 71 + const permSet = lx.lexicon("com.example.rpcPerms", { 72 + main: lx.permissionSet({ 73 + title: "RPC permissions", 74 + detail: "Grants access to call endpoints", 75 + permissions: [ 76 + lx.rpcPermission({ 77 + lxm: [endpoint], 78 + aud: "did:web:example.com", 79 + }), 80 + lx.rpcPermission({ 81 + lxm: ["com.example.otherEndpoint"], 82 + inheritAud: true, 83 + }), 84 + ], 85 + }), 86 + }); 87 + 88 + expect(permSet.json.defs.main.permissions).toEqual([ 89 + { 90 + type: "permission", 91 + resource: "rpc", 92 + lxm: ["com.example.doThing"], 93 + aud: "did:web:example.com", 94 + }, 95 + { 96 + type: "permission", 97 + resource: "rpc", 98 + lxm: ["com.example.otherEndpoint"], 99 + inheritAud: true, 100 + }, 101 + ]); 102 + }); 103 + 104 + test("permission-set with blob permission", () => { 105 + const permSet = lx.lexicon("com.example.blobPerms", { 106 + main: lx.permissionSet({ 107 + title: "Blob permissions", 108 + detail: "Grants blob upload access", 109 + permissions: [ 110 + lx.blobPermission({ 111 + accept: ["image/*", "video/mp4"], 112 + }), 113 + ], 114 + }), 115 + }); 116 + 117 + expect(permSet.json.defs.main.permissions[0]).toEqual({ 118 + type: "permission", 119 + resource: "blob", 120 + accept: ["image/*", "video/mp4"], 121 + }); 122 + }); 123 + 124 + test("permission-set with account permission", () => { 125 + const permSet = lx.lexicon("com.example.accountPerms", { 126 + main: lx.permissionSet({ 127 + title: "Account permissions", 128 + detail: "Grants account access", 129 + permissions: [ 130 + lx.accountPermission({ 131 + attr: "email", 132 + action: "read", 133 + }), 134 + lx.accountPermission({ 135 + attr: "repo", 136 + }), 137 + ], 138 + }), 139 + }); 140 + 141 + expect(permSet.json.defs.main.permissions).toEqual([ 142 + { 143 + type: "permission", 144 + resource: "account", 145 + attr: "email", 146 + action: "read", 147 + }, 148 + { 149 + type: "permission", 150 + resource: "account", 151 + attr: "repo", 152 + }, 153 + ]); 154 + }); 155 + 156 + test("permission-set with identity permission", () => { 157 + const permSet = lx.lexicon("com.example.identityPerms", { 158 + main: lx.permissionSet({ 159 + title: "Identity permissions", 160 + detail: "Grants identity access", 161 + permissions: [ 162 + lx.identityPermission({ 163 + attr: "handle", 164 + }), 165 + ], 166 + }), 167 + }); 168 + 169 + expect(permSet.json.defs.main.permissions[0]).toEqual({ 170 + type: "permission", 171 + resource: "identity", 172 + attr: "handle", 173 + }); 174 + }); 175 + 176 + test("permission-set with i18n fields", () => { 177 + const permSet = lx.lexicon("com.example.i18nPerms", { 178 + main: lx.permissionSet({ 179 + title: "My App: Core", 180 + "title:lang": { 181 + es: "Mi App: Núcleo", 182 + fr: "Mon App: Noyau", 183 + }, 184 + detail: "Core functionality", 185 + "detail:lang": { 186 + es: "Funcionalidad principal", 187 + fr: "Fonctionnalité principale", 188 + }, 189 + permissions: [ 190 + lx.repoPermission({ 191 + collection: ["com.example.post"], 192 + action: ["create"], 193 + }), 194 + ], 195 + }), 196 + }); 197 + 198 + expect(permSet.json.defs.main).toMatchObject({ 199 + title: "My App: Core", 200 + "title:lang": { 201 + es: "Mi App: Núcleo", 202 + fr: "Mon App: Noyau", 203 + }, 204 + detail: "Core functionality", 205 + "detail:lang": { 206 + es: "Funcionalidad principal", 207 + fr: "Fonctionnalité principale", 208 + }, 209 + }); 210 + }); 211 + 212 + test("permission-set validation is not supported by @atproto/lexicon yet", () => { 213 + const permSet = lx.lexicon("com.example.noValidate", { 214 + main: lx.permissionSet({ 215 + title: "Test", 216 + detail: "Test", 217 + permissions: [], 218 + }), 219 + }); 220 + 221 + // @atproto/lexicon doesn't support permission-set validation yet 222 + expect(() => permSet.validate({})).toThrow(); 223 + });