Skip to content

Commit e105b3f

Browse files
aspiersclaude
andcommitted
fix(permissions): rename sets to authWrite (single-segment NSID under namespace root)
The previous NSIDs `org.hypercerts.permissions.crud` / `app.certified.permissions.crud` were INVALID: a permission set may only grant permissions under its own NSID authority, and NSID.authority is the reversed domain of all-but-the-last segment. The `.permissions.` segment made the authority `permissions.hypercerts.org`, which does not parent `org.hypercerts.claim.*` — so IncludeScope.toScopes silently expanded the set to ZERO scopes. Rename to a single leaf directly under the namespace root, matching Bluesky's real published permission sets (app.bsky.authViewAll / authCreatePosts / authDeleteContent / authFullApp — single segment, auth-prefixed capability leaf) and the spec's illustrative com.example.authFull: - org.hypercerts.authWrite (lexicons/org/hypercerts/authWrite.json) - app.certified.authWrite (lexicons/app/certified/authWrite.json) Each now expands correctly to one combined repo: scope covering all its collections (verified). Also: replace the path-based codegen exclusion (which assumed a permissions/ subdir) with scripts/codegen-lexicon-files.js — a content-based file list (main.type === "permission-set") shared as the single source of truth with generate-exports.js, so the exclusion no longer breaks when set files move. Docs/skill/changeset updated for the new NSIDs, the single-segment-NSID rationale, and the combined-scope expansion shape. npm run check passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e9be8af commit e105b3f

8 files changed

Lines changed: 116 additions & 40 deletions

File tree

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -198,15 +198,15 @@ collection write needs a `repo:<collection>?action=…` scope. Rather than list
198198
every collection by hand, request a **permission set** — a published bundle —
199199
with a single `include:` scope:
200200

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 |
201+
| Permission set NSID | Grants write (create/update/delete) on |
202+
| -------------------------- | ----------------------------------------- |
203+
| `org.hypercerts.authWrite` | all `org.hypercerts.*` record collections |
204+
| `app.certified.authWrite` | all `app.certified.*` record collections |
205205

206206
In your OAuth client's authorization request, ask for the set(s) you need:
207207

208208
```text
209-
scope: include:org.hypercerts.permissions.crud
209+
scope: include:org.hypercerts.authWrite
210210
```
211211

212212
The user's PDS resolves the published set and expands it into the underlying

.changeset/permission-sets-crud.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"@hypercerts-org/lexicon": minor
33
---
44

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.
5+
Add two permission-set lexicons — `org.hypercerts.authWrite` and `app.certified.authWrite` — each granting create/update/delete over every record collection in its namespace.
66

77
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.
88

SCHEMAS.md

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

300300
---
301301

302+
### `org.hypercerts.permissions.crud`
303+
304+
**Description:** Permission set granting create, update, and delete on every Hypercerts (org.hypercerts) record collection.
305+
306+
---
307+
302308
### `org.hypercerts.workscope.cel`
303309

304310
**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.
@@ -541,6 +547,12 @@ The EIP-712 typed data message that was signed by the wallet. Contains the field
541547

542548
---
543549

550+
### `app.certified.permissions.crud`
551+
552+
**Description:** Permission set granting create, update, and delete on every Certified (app.certified) record collection.
553+
554+
---
555+
544556
### `app.certified.signature.defs`
545557

546558
**Description:** Common type definitions for cryptographic signatures attached to records, per the ATProtocol Attestation Specification.

docs/design/permission-sets.md

Lines changed: 42 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ These are general AT Protocol artifacts, not specific to any one service. The
3636
expected consumers, in rough order of generality:
3737

3838
1. **Any atproto app working with Hypercerts / Certified data directly.** An
39-
OAuth client requests `include:org.hypercerts.permissions.crud` (and/or the
39+
OAuth client requests `include:org.hypercerts.authWrite` (and/or the
4040
`app.certified.*` set) in its authorization request; the user's PDS expands it
4141
and the app reads/writes those collections in the user's own repo. No
4242
intermediary is involved. This is the broadest case.
@@ -60,38 +60,49 @@ We define two **record-collection write** sets — one per data namespace in thi
6060
repo. Each grants the three `repo:` write actions — create, update, delete —
6161
over all of that namespace's record collections.
6262

63-
| Set NSID | Grants write (create/update/delete) on |
64-
| --------------------------------- | ----------------------------------------- |
65-
| `org.hypercerts.permissions.crud` | all `org.hypercerts.*` record collections |
66-
| `app.certified.permissions.crud` | all `app.certified.*` record collections |
63+
| Set NSID | Grants write (create/update/delete) on |
64+
| -------------------------- | ----------------------------------------- |
65+
| `org.hypercerts.authWrite` | all `org.hypercerts.*` record collections |
66+
| `app.certified.authWrite` | 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-
82-
> **Naming.** The spec prescribes no naming convention: a permission set is
83-
> identified only by `type: "permission-set"` in its `main` def, and its sole
84-
> examples are illustrative (`com.example.authFull`). The `permissions.crud`
85-
> leaf is a house choice for this repo; rename freely.
86-
87-
### `org.hypercerts.permissions.crud`
71+
> **Write only — no read action.** The leaf is `authWrite` because a `repo:`
72+
> permission has only three actions — `create`, `update`, `delete` (there is no
73+
> `read` action in the scope grammar). Reading records needs **no permission at
74+
> all**: atproto repo records are public and world-readable via
75+
> `com.atproto.repo.getRecord` / `listRecords`, with no scope required. So these
76+
> sets cover the only repo operations that _are_ permissioned — the writes — and
77+
> "read" is implicitly available to everyone. The user-facing `title` / `detail`
78+
> (the authorization-dialog consent copy) say "Manage your … data" / "Create,
79+
> edit, and delete …" accordingly.
80+
81+
> **Naming — and why the NSID is a single segment under the namespace root.**
82+
> Two constraints, one of them hard:
83+
>
84+
> 1. **The set NSID's authority must parent the collections it grants** (the
85+
> namespace-authority rule below). `NSID.authority` is the reversed domain of
86+
> _all but the last segment_, so a set named `org.hypercerts.permissions.crud`
87+
> has authority `permissions.hypercerts.org`, which does **not** parent
88+
> `org.hypercerts.claim.*``IncludeScope.toScopes` would silently return an
89+
> empty list. The set NSID must therefore be a **single leaf directly under the
90+
> namespace root** (`org.hypercerts.<leaf>`), whose authority is `hypercerts.org`.
91+
> 2. **Convention follows real precedent.** Bluesky publishes its own permission
92+
> sets exactly this way — single segment under the namespace, `auth`-prefixed
93+
> capability leaf: `app.bsky.authViewAll`, `app.bsky.authCreatePosts`,
94+
> `app.bsky.authDeleteContent`, `app.bsky.authFullApp`. The spec's illustrative
95+
> examples (`com.example.authFull`, `com.example.authBasicFeatures`) match. So
96+
> `authWrite` mirrors that style: `auth`-prefixed, names the capability.
97+
98+
### `org.hypercerts.authWrite`
8899

89100
Enumerates every `type: "record"` collection under `org.hypercerts.*`:
90101

91102
```jsonc
92103
{
93104
"lexicon": 1,
94-
"id": "org.hypercerts.permissions.crud",
105+
"id": "org.hypercerts.authWrite",
95106
"defs": {
96107
"main": {
97108
"type": "permission-set",
@@ -123,14 +134,14 @@ Enumerates every `type: "record"` collection under `org.hypercerts.*`:
123134
}
124135
```
125136

126-
### `app.certified.permissions.crud`
137+
### `app.certified.authWrite`
127138

128139
Enumerates every `type: "record"` collection under `app.certified.*`:
129140

130141
```jsonc
131142
{
132143
"lexicon": 1,
133-
"id": "app.certified.permissions.crud",
144+
"id": "app.certified.authWrite",
134145
"defs": {
135146
"main": {
136147
"type": "permission-set",
@@ -202,8 +213,11 @@ not what these sets are for.)
202213

203214
A `repo:` scope targets the **user's own repo**, so it has no audience. Both sets
204215
here are `repo:`-only, so an `include:` for them is requested **without** any
205-
`?aud=` parameter, and expands to plain
206-
`repo:<collection>?action=create&action=update&action=delete` scopes.
216+
`?aud=` parameter. Each set expands to a **single combined `repo:` scope** that
217+
lists every collection — `IncludeScope.toScopes` coalesces the permission's
218+
collection array into one scope string
219+
(`repo:?collection=<a>&collection=<b>&…&action=create&action=update&action=delete`),
220+
rather than emitting one scope per collection.
207221

208222
(For completeness: an `rpc:` permission _does_ take an `aud` naming the target
209223
service, and a set can let the `include:`'s `?aud=` flow into `rpc:` permissions
@@ -270,7 +284,7 @@ the `type: "record"` defs in its namespace).
270284
> [#219](https://github.com/hypercerts-org/hypercerts-lexicon/pull/219)
271285
> (`feat/signature-support-v2`, HYPER-181) adds the `app.certified.signature.proof`
272286
> record lexicon, which does **not** exist on `main` yet and so is **not** in
273-
> `app.certified.permissions.crud` here. When #219 merges,
287+
> `app.certified.authWrite` here. When #219 merges,
274288
> `app.certified.signature.proof` must be added to the set's `collection` list
275289
> (and re-published). The two PRs touch different files, so neither blocks the
276290
> other — this is just a follow-up to land after both.

lexicons/app/certified/permissions/crud.json renamed to lexicons/app/certified/authWrite.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"lexicon": 1,
3-
"id": "app.certified.permissions.crud",
3+
"id": "app.certified.authWrite",
44
"defs": {
55
"main": {
66
"type": "permission-set",

lexicons/org/hypercerts/permissions/crud.json renamed to lexicons/org/hypercerts/authWrite.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"lexicon": 1,
3-
"id": "org.hypercerts.permissions.crud",
3+
"id": "org.hypercerts.authWrite",
44
"defs": {
55
"main": {
66
"type": "permission-set",

package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +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-
"//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",
45+
"//permissions": "permission-set lexicons are published as-is but have no TS shape; lex gen-* cannot codegen them. scripts/codegen-lexicon-files.js lists the lexicons to codegen, excluding permission-sets by content (single source of truth shared with generate-exports.js).",
46+
"gen-api": "lex gen-api --yes ./generated $(node ./scripts/codegen-lexicon-files.js) && npm run gen-index",
47+
"gen-md": "lex gen-md --yes ./lexicons.md $(node ./scripts/codegen-lexicon-files.js)",
4848
"gen-schemas-md": "node ./scripts/generate-schemas.js",
4949
"//gen-ts": "UNUSED - use gen-api instead",
50-
"gen-ts": "find ./lexicons -name '*.json' -not -path '*/permissions/*' | xargs lex gen-ts-obj > generated/DO-NOT-USE-lexicons.ts",
50+
"gen-ts": "lex gen-ts-obj $(node ./scripts/codegen-lexicon-files.js) > generated/DO-NOT-USE-lexicons.ts",
5151
"gen-index": "node ./scripts/generate-exports.js",
5252
"lex": "lex",
5353
"test": "vitest run",

scripts/codegen-lexicon-files.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Print the list of lexicon JSON files that `lex gen-*` should process, one per
5+
* line — i.e. every lexicon under `lexicons/` EXCEPT permission-set lexicons.
6+
*
7+
* `lex gen-api` / `gen-md` / `gen-ts` cannot codegen a `type: "permission-set"`
8+
* def (it has no TypeScript shape and the CLI throws on it). Permission sets are
9+
* published as-is, so they must be excluded from the codegen globs.
10+
*
11+
* This is the **single source of truth** for that exclusion — both the codegen
12+
* scripts (via this list) and `scripts/generate-exports.js` (via the same
13+
* `main.type === "permission-set"` check) skip the same files by **content**,
14+
* not by path, so they cannot drift as files move.
15+
*
16+
* Usage (in package.json): `lex gen-api --yes ./generated $(node ./scripts/codegen-lexicon-files.js)`
17+
*/
18+
19+
import { readdirSync, readFileSync } from "node:fs";
20+
import { join } from "node:path";
21+
import { fileURLToPath } from "node:url";
22+
23+
const lexiconsDir = fileURLToPath(new URL("../lexicons", import.meta.url));
24+
25+
function findJsonFiles(dir) {
26+
const out = [];
27+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
28+
const full = join(dir, entry.name);
29+
if (entry.isDirectory()) out.push(...findJsonFiles(full));
30+
else if (entry.isFile() && entry.name.endsWith(".json")) out.push(full);
31+
}
32+
return out;
33+
}
34+
35+
function isPermissionSet(file) {
36+
try {
37+
return (
38+
JSON.parse(readFileSync(file, "utf-8"))?.defs?.main?.type ===
39+
"permission-set"
40+
);
41+
} catch {
42+
return false; // let lex surface a parse error rather than silently skip
43+
}
44+
}
45+
46+
const files = findJsonFiles(lexiconsDir)
47+
.filter((f) => !isPermissionSet(f))
48+
.sort();
49+
50+
process.stdout.write(files.join("\n") + "\n");

0 commit comments

Comments
 (0)