Skip to content

Commit dd5967d

Browse files
aspiersclaude
andcommitted
feat(permissions): implement org.hypercerts and app.certified CRUD permission sets
Add the two permission-set lexicons designed in docs/design/permission-sets.md: - org.hypercerts.permissions.crud — CRUD over all org.hypercerts.* records - app.certified.permissions.crud — CRUD over all app.certified.* records Each enumerates every type:"record" collection in its namespace (the spec forbids wildcards inside a permission set) with action create/update/delete. permission-set is a lexicon primitive that lex gen-api cannot codegen (it has no TypeScript shape), so these files are published as-is but excluded from the codegen globs (gen-api/gen-md/gen-ts) and from generated/exports.ts. The full `npm run check` (gen-api, lint, typecheck, build, test, style) passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0d3aa70 commit dd5967d

7 files changed

Lines changed: 118 additions & 10 deletions

File tree

.changeset/permission-sets-crud.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@hypercerts-org/lexicon": minor
3+
---
4+
5+
Add two permission-set lexicons — `org.hypercerts.permissions.crud` and `app.certified.permissions.crud` — each granting create/update/delete over every record collection in its namespace.
6+
7+
A [permission set](https://atproto.com/specs/permission#permission-sets) lets any AT Protocol app request a whole bundle of `repo:` scopes with a single `include:<nsid>` OAuth scope, instead of enumerating each collection by hand. The user's PDS resolves and expands the set during the OAuth grant; the same published set can also be consumed by services (e.g. the Certified group service) when expanding API-key scopes.
8+
9+
Permission sets are published as-is (they are the source of truth for what gets published to AT Protocol) but have no TypeScript shape — `lex gen-api` cannot generate code for `permission-set` defs. They are therefore excluded from the codegen globs (`gen-api`/`gen-md`/`gen-ts`) and from `generated/exports.ts`, while still shipping as raw lexicon JSON.
10+
11+
Collection lists are enumerated explicitly because the spec forbids wildcards inside a permission set; they must be kept in sync as record types are added. See `docs/design/permission-sets.md`.

SCHEMAS.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,12 @@ A free-text string value (e.g. a display name, wallet address, or other identifi
289289

290290
---
291291

292+
### `org.hypercerts.permissions.crud`
293+
294+
**Description:** Permission set granting create, update, and delete on every Hypercerts (org.hypercerts) record collection.
295+
296+
---
297+
292298
### `org.hypercerts.workscope.cel`
293299

294300
**Description:** A structured, machine-evaluable work scope definition using CEL (Common Expression Language). Tags referenced in the expression correspond to org.hypercerts.workscope.tag keys. See https://github.com/google/cel-spec. Note: this is intentionally type 'object' (not 'record') so it can be directly embedded inline in union types (e.g., activity.workScope) without requiring a separate collection or strongRef indirection.
@@ -522,6 +528,12 @@ The EIP-712 typed data message that was signed by the wallet. Contains the field
522528

523529
---
524530

531+
### `app.certified.permissions.crud`
532+
533+
**Description:** Permission set granting create, update, and delete on every Certified (app.certified) record collection.
534+
535+
---
536+
525537
## Type Definitions
526538

527539
Common type definitions used across all protocols.

docs/design/permission-sets.md

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,18 +56,27 @@ key-creation — reads the same published definition.
5656

5757
## The sets
5858

59-
We define two **record-collection CRUD** sets — one per data namespace in this
60-
repo. Each grants create/update/delete over all of that namespace's record
61-
collections.
59+
We define two **record-collection write** sets — one per data namespace in this
60+
repo. Each grants the three `repo:` write actions — create, update, delete —
61+
over all of that namespace's record collections.
6262

63-
| Set NSID | Grants CRUD on |
63+
| Set NSID | Grants write (create/update/delete) on |
6464
| --------------------------------- | ----------------------------------------- |
6565
| `org.hypercerts.permissions.crud` | all `org.hypercerts.*` record collections |
6666
| `app.certified.permissions.crud` | all `app.certified.*` record collections |
6767

6868
Both are authored and published from **this repository**, which is the namespace
6969
authority for both — see _Namespace authority_ below.
7070

71+
> **"CRUD" but no read action.** The leaf is `crud` as a familiar shorthand, but
72+
> a `repo:` permission has only three actions — `create`, `update`, `delete`
73+
> (there is no `read` action in the scope grammar). Reading records needs **no
74+
> permission at all**: atproto repo records are public and world-readable via
75+
> `com.atproto.repo.getRecord` / `listRecords`, with no scope required. So a
76+
> "CRUD" set covers the only parts of CRUD that _are_ permissioned — the writes —
77+
> and "read" is implicitly available to everyone. The set titles say "write" to
78+
> avoid implying a grantable read scope exists.
79+
7180
> **Naming.** The spec prescribes no naming convention: a permission set is
7281
> identified only by `type: "permission-set"` in its `main` def, and its sole
7382
> examples are illustrative (`com.example.authFull`). The `permissions.crud`
@@ -84,7 +93,7 @@ Enumerates every `type: "record"` collection under `org.hypercerts.*`:
8493
"defs": {
8594
"main": {
8695
"type": "permission-set",
87-
"title": "Full CRUD on Hypercerts record collections",
96+
"title": "Full write access (create, update, delete) on Hypercerts record collections",
8897
"permissions": [
8998
{
9099
"type": "permission",
@@ -121,7 +130,7 @@ Enumerates every `type: "record"` collection under `app.certified.*`:
121130
"defs": {
122131
"main": {
123132
"type": "permission-set",
124-
"title": "Full CRUD on Certified record collections",
133+
"title": "Full write access (create, update, delete) on Certified record collections",
125134
"permissions": [
126135
{
127136
"type": "permission",
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"lexicon": 1,
3+
"id": "app.certified.permissions.crud",
4+
"defs": {
5+
"main": {
6+
"type": "permission-set",
7+
"description": "Permission set granting create, update, and delete on every Certified (app.certified) record collection.",
8+
"title": "Full write access (create, update, delete) on Certified record collections",
9+
"detail": "Create, update, and delete records in every Certified (app.certified) record collection. Requested as a single include: scope instead of enumerating each repo: scope.",
10+
"permissions": [
11+
{
12+
"type": "permission",
13+
"resource": "repo",
14+
"collection": [
15+
"app.certified.actor.organization",
16+
"app.certified.actor.profile",
17+
"app.certified.badge.award",
18+
"app.certified.badge.definition",
19+
"app.certified.badge.response",
20+
"app.certified.graph.follow",
21+
"app.certified.link.evm",
22+
"app.certified.location",
23+
"app.certified.signature.proof"
24+
],
25+
"action": ["create", "update", "delete"]
26+
}
27+
]
28+
}
29+
}
30+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"lexicon": 1,
3+
"id": "org.hypercerts.permissions.crud",
4+
"defs": {
5+
"main": {
6+
"type": "permission-set",
7+
"description": "Permission set granting create, update, and delete on every Hypercerts (org.hypercerts) record collection.",
8+
"title": "Full write access (create, update, delete) on Hypercerts record collections",
9+
"detail": "Create, update, and delete records in every Hypercerts (org.hypercerts) record collection. Requested as a single include: scope instead of enumerating each repo: scope.",
10+
"permissions": [
11+
{
12+
"type": "permission",
13+
"resource": "repo",
14+
"collection": [
15+
"org.hypercerts.claim.activity",
16+
"org.hypercerts.claim.contribution",
17+
"org.hypercerts.claim.contributorInformation",
18+
"org.hypercerts.claim.rights",
19+
"org.hypercerts.collection",
20+
"org.hypercerts.context.acknowledgement",
21+
"org.hypercerts.context.attachment",
22+
"org.hypercerts.context.evaluation",
23+
"org.hypercerts.context.measurement",
24+
"org.hypercerts.funding.receipt",
25+
"org.hypercerts.workscope.tag"
26+
],
27+
"action": ["create", "update", "delete"]
28+
}
29+
]
30+
}
31+
}
32+
}

package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,12 @@
4242
"check": "npm run gen-api && npm run lint && npm run typecheck && npm run build && npm run test",
4343
"build": "rollup -c && npm run build:types",
4444
"build:types": "tsc --project tsconfig.build.json",
45-
"gen-api": "find ./lexicons -name '*.json' | xargs lex gen-api --yes ./generated && npm run gen-index",
46-
"gen-md": "find ./lexicons -name '*.json' | xargs lex gen-md --yes ./lexicons.md",
45+
"//permissions": "permission-set lexicons (lexicons/**/permissions/*.json) are published as-is but have no TS shape; lex gen-* cannot codegen them, so they are excluded from the codegen globs below.",
46+
"gen-api": "find ./lexicons -name '*.json' -not -path '*/permissions/*' | xargs lex gen-api --yes ./generated && npm run gen-index",
47+
"gen-md": "find ./lexicons -name '*.json' -not -path '*/permissions/*' | xargs lex gen-md --yes ./lexicons.md",
4748
"gen-schemas-md": "node ./scripts/generate-schemas.js",
4849
"//gen-ts": "UNUSED - use gen-api instead",
49-
"gen-ts": "find ./lexicons -name '*.json' | xargs lex gen-ts-obj > generated/DO-NOT-USE-lexicons.ts",
50+
"gen-ts": "find ./lexicons -name '*.json' -not -path '*/permissions/*' | xargs lex gen-ts-obj > generated/DO-NOT-USE-lexicons.ts",
5051
"gen-index": "node ./scripts/generate-exports.js",
5152
"lex": "lex",
5253
"test": "vitest run",

scripts/generate-exports.js

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,11 +163,24 @@ function readNsid(filePath) {
163163
return lexicon.lexicon === 1 ? lexicon.id : null;
164164
}
165165

166+
/**
167+
* Is this lexicon a permission-set? Permission sets are published as-is but
168+
* have no generated TypeScript type (lex gen-api cannot process them), so they
169+
* are excluded from the generated exports.
170+
*/
171+
function isPermissionSet(filePath) {
172+
const fullPath = join(lexiconsDir, filePath);
173+
const lexicon = JSON.parse(readFileSync(fullPath, "utf-8"));
174+
return lexicon?.defs?.main?.type === "permission-set";
175+
}
176+
166177
/**
167178
* Generate generated/exports.ts content
168179
*/
169180
function generateIndex() {
170-
const jsonFiles = findJsonFiles(lexiconsDir).sort();
181+
const jsonFiles = findJsonFiles(lexiconsDir)
182+
.filter((filePath) => !isPermissionSet(filePath))
183+
.sort();
171184

172185
const lexicons = jsonFiles.map((filePath) => ({
173186
path: filePath,

0 commit comments

Comments
 (0)