Skip to content

Commit fa1c29a

Browse files
aspiersclaude
andcommitted
feat(permissions): implement org.hypercerts and app.certified write permission sets
Add the two permission-set lexicons designed in docs/design/permission-sets.md: - org.hypercerts.permissions.crud — write (create/update/delete) over all org.hypercerts.* record collections - app.certified.permissions.crud — write (create/update/delete) over all app.certified.* record collections TWO sets, not one combined set, because the spec mandates it: a permission set "is limited to expressing permissions that reference resources under the same NSID namespace as the set itself" and "can not address 'sibling groups' or 'parents'" (https://atproto.com/specs/permission#permission-sets). org.hypercerts and app.certified are different namespace authorities, so they cannot legally be combined in one set. An app needing both requests both include: scopes. Each set also enumerates its collections explicitly (the spec forbids wildcards inside a permission set). 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 (filtered by main def type, parsing each lexicon once). They still appear in SCHEMAS.md and are JSON-parsed by the schema/test pipeline. The full `npm run check` (gen-api, lint, typecheck, build, test, style) passes. The human-readable title/detail are written as authorization-dialog consent copy: plain-language, concise, naming recognizable record categories. Also documents the permission sets in the building-with-hypercerts-lexicons skill (how to request them as OAuth scopes). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e57a0b1 commit fa1c29a

7 files changed

Lines changed: 152 additions & 23 deletions

File tree

.agents/skills/building-with-hypercerts-lexicons/SKILL.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,40 @@ if (result.success) {
190190
}
191191
```
192192

193+
## Permission Sets (OAuth scopes)
194+
195+
To **write** Hypercerts or Certified records to a user's repo, your app needs
196+
the user's authorization. AT Protocol grants this via OAuth scopes: each
197+
collection write needs a `repo:<collection>?action=…` scope. Rather than list
198+
every collection by hand, request a **permission set** — a published bundle —
199+
with a single `include:` scope:
200+
201+
| Permission set NSID | Grants write (create/update/delete) on |
202+
| --------------------------------- | ----------------------------------------- |
203+
| `org.hypercerts.permissions.crud` | all `org.hypercerts.*` record collections |
204+
| `app.certified.permissions.crud` | all `app.certified.*` record collections |
205+
206+
In your OAuth client's authorization request, ask for the set(s) you need:
207+
208+
```text
209+
scope: include:org.hypercerts.permissions.crud
210+
```
211+
212+
The user's PDS resolves the published set and expands it into the underlying
213+
`repo:` scopes on the consent screen, where the user sees the set's plain-language
214+
description (e.g. "Manage your Hypercerts data"). Notes:
215+
216+
- **Two sets, never one.** A permission set may only reference its own namespace
217+
authority (atproto permission spec), so `org.hypercerts.*` and
218+
`app.certified.*` cannot share a set. An app needing both requests **both**
219+
`include:` scopes.
220+
- **Writes only.** These cover create/update/delete. **Reading** records needs no
221+
scope at all — atproto repo records are public.
222+
- **`repo:` scopes carry no `aud`** — request the `include:` without an `?aud=`.
223+
- These same published sets are also consumed by the Certified group service
224+
(CGS) when it expands an API key's `include:` scope — see the CGS integration
225+
guide.
226+
193227
## Lexicon Overview
194228

195229
### Claims — the core impact record
@@ -673,6 +707,8 @@ avoid typos.
673707
## Further Reading
674708

675709
- [SCHEMAS.md](https://github.com/hypercerts-org/hypercerts-lexicon/blob/main/SCHEMAS.md) — full property-level documentation for every lexicon
710+
- [docs/design/permission-sets.md](https://github.com/hypercerts-org/hypercerts-lexicon/blob/main/docs/design/permission-sets.md) — the permission sets and how they are consumed (OAuth + CGS API keys)
711+
- [ATProto permission spec](https://atproto.com/specs/permission#permission-sets) — OAuth scopes and permission sets
676712
- [CHANGELOG.md](https://github.com/hypercerts-org/hypercerts-lexicon/blob/main/CHANGELOG.md) — version history and migration guides
677713
- [ATProto Lexicon Guide](https://atproto.com/guides/lexicon) — AT Protocol lexicon fundamentals
678714
- [ATProto Lexicon Style Guide](https://atproto.com/guides/lexicon-style-guide) — schema design conventions

.changeset/permission-sets-crud.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
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+
There are **two** sets rather than one because the spec requires it: a permission set "is limited to expressing permissions that reference resources under the same NSID namespace as the set itself" and "can not address 'sibling groups' or 'parents'". `org.hypercerts` and `app.certified` are separate namespace authorities, so they cannot be combined in a single set — an app needing both requests both `include:` scopes.
10+
11+
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.
12+
13+
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`.

docs/design/permission-sets.md

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,18 +56,29 @@ 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 user-facing `title` /
78+
> `detail` (the authorization-dialog consent copy) describe this in plain terms —
79+
> "Manage your … data" / "Create, edit, and delete …" — deliberately not implying
80+
> any grantable read scope exists.
81+
7182
> **Naming.** The spec prescribes no naming convention: a permission set is
7283
> identified only by `type: "permission-set"` in its `main` def, and its sole
7384
> examples are illustrative (`com.example.authFull`). The `permissions.crud`
@@ -84,7 +95,9 @@ Enumerates every `type: "record"` collection under `org.hypercerts.*`:
8495
"defs": {
8596
"main": {
8697
"type": "permission-set",
87-
"title": "Full CRUD on Hypercerts record collections",
98+
"description": "Permission set granting create, update, and delete on every Hypercerts (org.hypercerts) record collection.",
99+
"title": "Manage your Hypercerts data",
100+
"detail": "Create, edit, and delete your Hypercerts records (impact claims, evaluations, funding, and related data).",
88101
"permissions": [
89102
{
90103
"type": "permission",
@@ -121,7 +134,9 @@ Enumerates every `type: "record"` collection under `app.certified.*`:
121134
"defs": {
122135
"main": {
123136
"type": "permission-set",
124-
"title": "Full CRUD on Certified record collections",
137+
"description": "Permission set granting create, update, and delete on every Certified (app.certified) record collection.",
138+
"title": "Manage your Certified data",
139+
"detail": "Create, edit, and delete your Certified records (profile, badges, follows, wallet links, and related data).",
125140
"permissions": [
126141
{
127142
"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": "Manage your Certified data",
9+
"detail": "Create, edit, and delete your Certified records (profile, badges, follows, wallet links, and related data).",
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": "Manage your Hypercerts data",
9+
"detail": "Create, edit, and delete your Hypercerts records (impact claims, evaluations, funding, and related data).",
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: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -153,28 +153,30 @@ function pathToGeneratedType(filePath) {
153153
return `./types/${withoutExt}.js`;
154154
}
155155

156-
/**
157-
* Read lexicon NSID from JSON file
158-
*/
159-
function readNsid(filePath) {
160-
const fullPath = join(lexiconsDir, filePath);
161-
const content = readFileSync(fullPath, "utf-8");
162-
const lexicon = JSON.parse(content);
163-
return lexicon.lexicon === 1 ? lexicon.id : null;
164-
}
165-
166156
/**
167157
* Generate generated/exports.ts content
168158
*/
169159
function generateIndex() {
170-
const jsonFiles = findJsonFiles(lexiconsDir).sort();
171-
172-
const lexicons = jsonFiles.map((filePath) => ({
160+
// Parse each lexicon once, then derive everything from the parsed doc — both
161+
// the permission-set check and the NSID — to avoid re-reading the same file.
162+
const jsonFiles = findJsonFiles(lexiconsDir)
163+
.sort()
164+
.map((filePath) => ({
165+
filePath,
166+
doc: JSON.parse(readFileSync(join(lexiconsDir, filePath), "utf-8")),
167+
}))
168+
// Permission-set lexicons (lexicons/**/permissions/*.json) are published
169+
// as-is but have no TS shape — lex gen-api cannot codegen them, so they are
170+
// excluded from `generated/` here just as they are from the gen-* globs in
171+
// package.json. The two exclusions must stay aligned.
172+
.filter(({ doc }) => doc?.defs?.main?.type !== "permission-set");
173+
174+
const lexicons = jsonFiles.map(({ filePath, doc }) => ({
173175
path: filePath,
174176
importName: pathToImportName(filePath),
175177
namespace: pathToNamespace(filePath),
176178
generatedTypePath: pathToGeneratedType(filePath),
177-
nsid: readNsid(filePath),
179+
nsid: doc.lexicon === 1 ? doc.id : null,
178180
}));
179181

180182
// Filter out lexicons without NSIDs (should be rare)

0 commit comments

Comments
 (0)