From e57a0b101f1cad71d7909d1f50465aaf1b088b3b Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Tue, 9 Jun 2026 13:22:30 +0000 Subject: [PATCH 01/17] docs(design): add permission-sets design for hypercerts & certified data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design for two record-collection CRUD permission sets — org.hypercerts.permissions.crud and app.certified.permissions.crud — that any AT Protocol app can request via a single include: OAuth scope instead of enumerating every repo: scope by hand. Holistic: covers direct OAuth clients (the broadest case), the Certified group service via OAuth proxying, and CGS API keys — all as consumers of the same published Lexicon artifact, which is the single source of truth. Records the spec constraints the sets follow: own-namespace authority (hence two sets, not one combined), no wildcards inside a set (collections enumerated), and repo: scopes carrying no aud. Collection lists derived from the type:"record" defs under lexicons/{org/hypercerts,app/certified}. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/design/permission-sets.md | 261 +++++++++++++++++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 docs/design/permission-sets.md diff --git a/docs/design/permission-sets.md b/docs/design/permission-sets.md new file mode 100644 index 0000000..f271a41 --- /dev/null +++ b/docs/design/permission-sets.md @@ -0,0 +1,261 @@ +# Design: Permission Sets for Hypercerts & Certified data + +Status: **Proposed** + +This document designs **permission sets** for the `org.hypercerts.*` and +`app.certified.*` record collections defined in this repository — published, +reusable bundles of AT Protocol OAuth scopes that any application can request +with a single `include:` scope, instead of enumerating every underlying +`repo:` scope by hand. + +## Background: what a permission set is + +AT Protocol clients act on a user's data with **OAuth scopes**. Writing records +of a given collection requires a `repo:?action=…` scope; calling a +service method requires an `rpc:?aud=…` scope. (See the +[AT Protocol permission spec](https://atproto.com/specs/permission).) + +A **permission set** ([spec](https://atproto.com/specs/permission#permission-sets)) +is a named, published Lexicon schema that bundles several such scopes under one +NSID. A client requests the whole bundle with one scope string — +`include:` — and the user's Authorization Server (their PDS) resolves the +set and expands it into the underlying scopes when granting access. Per the +spec, permission sets are _"published publicly and can be used by any client +developer"_, and _"Authorization Servers resolve, authenticate, and process +permission-sets dynamically."_ + +The motivation is plain: an application that does CRUD over a family of +collections — say, all the records that make up a hypercert — would otherwise +have to list every `repo:org.hypercerts.…?action=…` scope itself, keep that list +correct, and re-derive it independently from every other integrator. A published +set replaces that with one curated, versioned name. + +## Who uses these sets + +These are general AT Protocol artifacts, not specific to any one service. The +expected consumers, in rough order of generality: + +1. **Any atproto app working with Hypercerts / Certified data directly.** An + OAuth client requests `include:org.hypercerts.permissions.crud` (and/or the + `app.certified.*` set) in its authorization request; the user's PDS expands it + and the app reads/writes those collections in the user's own repo. No + intermediary is involved. This is the broadest case. +2. **The Certified group service (CGS), via OAuth.** CGS is used as a standard + OAuth resource (reached through AT Protocol service proxying). A client + calling CGS on a user's behalf requests the same sets; the user's PDS expands + them the same way. CGS is a likely-popular consumer, but it consumes the sets + like any other client — it does not own them. +3. **CGS API keys.** CGS also issues long-lived API keys whose scopes can be + specified as `include:`; CGS resolves the **same** published set and + expands it to concrete scopes on the key. (Detailed in CGS's own design doc; + summarised under _Consumption by CGS_ below.) + +The common thread: the **published Lexicon record is the single source of +truth.** Whatever resolves a set — a PDS during an OAuth grant, or CGS at +key-creation — reads the same published definition. + +## The sets + +We define two **record-collection CRUD** sets — one per data namespace in this +repo. Each grants create/update/delete over all of that namespace's record +collections. + +| Set NSID | Grants CRUD on | +| --------------------------------- | ----------------------------------------- | +| `org.hypercerts.permissions.crud` | all `org.hypercerts.*` record collections | +| `app.certified.permissions.crud` | all `app.certified.*` record collections | + +Both are authored and published from **this repository**, which is the namespace +authority for both — see _Namespace authority_ below. + +> **Naming.** The spec prescribes no naming convention: a permission set is +> identified only by `type: "permission-set"` in its `main` def, and its sole +> examples are illustrative (`com.example.authFull`). The `permissions.crud` +> leaf is a house choice for this repo; rename freely. + +### `org.hypercerts.permissions.crud` + +Enumerates every `type: "record"` collection under `org.hypercerts.*`: + +```jsonc +{ + "lexicon": 1, + "id": "org.hypercerts.permissions.crud", + "defs": { + "main": { + "type": "permission-set", + "title": "Full CRUD on Hypercerts record collections", + "permissions": [ + { + "type": "permission", + "resource": "repo", + "collection": [ + "org.hypercerts.claim.activity", + "org.hypercerts.claim.contribution", + "org.hypercerts.claim.contributorInformation", + "org.hypercerts.claim.rights", + "org.hypercerts.collection", + "org.hypercerts.context.acknowledgement", + "org.hypercerts.context.attachment", + "org.hypercerts.context.evaluation", + "org.hypercerts.context.measurement", + "org.hypercerts.funding.receipt", + "org.hypercerts.workscope.tag", + ], + "action": ["create", "update", "delete"], + }, + ], + }, + }, +} +``` + +### `app.certified.permissions.crud` + +Enumerates every `type: "record"` collection under `app.certified.*`: + +```jsonc +{ + "lexicon": 1, + "id": "app.certified.permissions.crud", + "defs": { + "main": { + "type": "permission-set", + "title": "Full CRUD on Certified record collections", + "permissions": [ + { + "type": "permission", + "resource": "repo", + "collection": [ + "app.certified.actor.organization", + "app.certified.actor.profile", + "app.certified.badge.award", + "app.certified.badge.definition", + "app.certified.badge.response", + "app.certified.graph.follow", + "app.certified.link.evm", + "app.certified.location", + "app.certified.signature.proof", + ], + "action": ["create", "update", "delete"], + }, + ], + }, + }, +} +``` + +> The collection lists are derived from this repo's `lexicons/org/hypercerts/` +> and `lexicons/app/certified/` (`type: "record"` defs at time of writing). They +> must be **kept in sync** as record types are added — see _Maintenance_. + +## Design rules these sets follow + +### Namespace authority + +A permission set may only contain permissions under its **own** namespace +authority. Spec, verbatim: _"Permission sets are limited to expressing +permissions that reference resources under the same NSID namespace as the set +itself"_ — it may address its own NSID group and children, never siblings or +parents. The rule applies uniformly to `repo:` permissions (by collection NSID) +and `rpc:` permissions (by lxm NSID). + +Consequences here: + +- A set under `org.hypercerts.*` may grant `repo:org.hypercerts.*` collections, + and **only** those — it cannot reference `app.certified.*` collections, or any + third party's, or any service's `rpc:` methods. Likewise the `app.certified.*` + set is confined to `app.certified.*`. +- This is why there are **two** sets rather than one combined set: the two + namespaces are distinct authorities and cannot legally be mixed in a single + set. An app that needs both requests both `include:` scopes. +- The authority confinement is also what makes a published set **safe for any + client to resolve**: an `include:` can only ever widen access to the set's own + namespace, never smuggle in foreign permissions. + +### Collections are enumerated, never wildcarded + +Inside a permission set, a `repo:` permission names collections by **exact +NSID**. Spec, verbatim: _"Wildcards are not supported in permissions within a +permission set."_ So a CRUD set cannot say "all `org.hypercerts.*` collections" — +it lists each one. The enumerated list **is** the grant, and is the security +boundary: a newly-added collection is not covered until it is added to the set +and the set is re-published. (Contrast a standalone scope outside a set, where +`repo:*` is permitted — but that is "all collections", not "a namespace", and is +not what these sets are for.) + +### `repo:` permissions carry no `aud` + +A `repo:` scope targets the **user's own repo**, so it has no audience. Both sets +here are `repo:`-only, so an `include:` for them is requested **without** any +`?aud=` parameter, and expands to plain +`repo:?action=create&action=update&action=delete` scopes. + +(For completeness: an `rpc:` permission _does_ take an `aud` naming the target +service, and a set can let the `include:`'s `?aud=` flow into `rpc:` permissions +marked `inheritAud: true`. Neither CRUD set uses `rpc:`, so this does not apply — +it is noted only because a future Hypercerts/Certified _service-method_ set +would.) + +## Publication and resolution + +Both sets are published like any other Lexicon schema in this repo (see +[`PUBLISHING.md`](../PUBLISHING.md)) and resolved at runtime via the standard +**Lexicon resolution system**, to which the permission spec defers: +_"Permission sets are Lexicon schemas and are published and fetched using the +Lexicon resolution system, which includes cryptographic authentication."_ + +The resolution chain a consumer follows (per +[`/specs/lexicon`](https://atproto.com/specs/lexicon), core protocol): + +1. **NSID → authority DID via DNS.** Reverse the NSID's authority portion (all + but the final name), prepend `_lexicon.`, query that domain for a `TXT` record + `did=`. +2. **DID → PDS endpoint** via standard DID resolution. +3. **Fetch the schema record**: `com.atproto.repo.getRecord` with + `repo=`, `collection=com.atproto.lexicon.schema`, + `rkey=` (the rkey **is** the NSID). +4. Validate the record's `main` def is `type: "permission-set"`, then expand. + +For a direct OAuth client this is done by the **user's PDS** during the grant. +The publication of the `_lexicon` DNS records and schema records for these +namespaces is a maintenance responsibility of this repo's operators. + +## Consumption by CGS + +CGS is expected to be a prominent consumer, in two ways. Neither changes the +sets — they are recorded here so the sets are designed with these uses in mind; +full detail lives in the CGS design doc +([`api-key-permission-sets.md`](https://github.com/hypercerts-org/certified-group-service/blob/main/docs/design/api-key-permission-sets.md)). + +- **Via OAuth + service proxying.** A client calls CGS on a user's behalf; the + user's PDS has already expanded the requested `include:` set into the grant. + CGS sees ordinary `repo:` scopes. Identical to any other OAuth resource. +- **Via CGS API keys.** CGS lets an owner specify an API key's scopes as + `include:`. CGS resolves the published set (same chain as above) and + expands it to concrete scopes stored on the key, **once at key-creation time** + (a snapshot — a later change to the set does not retroactively alter issued + keys until they are re-issued). CGS then gates its own write methods + (`app.certified.group.repo.*`, which proxy to the group's PDS) by matching the + request's collection against those `repo:` scopes. + +The point for _this_ repo: the sets must be **self-contained and +deployment-agnostic** — no CGS-specific assumptions baked into the definitions. +The two CRUD sets satisfy this trivially (pure `repo:` collection grants). + +## Maintenance + +The enumerated `collection` lists are the one ongoing obligation. Whenever a +`type: "record"` lexicon is added to or removed from `org.hypercerts.*` or +`app.certified.*`, the corresponding set must be updated and re-published, or the +new collection will be silently uncovered by the set. Worth a check in the +release process (and ideally an automated test asserting each set lists exactly +the `type: "record"` defs in its namespace). + +## References + +- AT Protocol permission spec — permission sets: + https://atproto.com/specs/permission#permission-sets +- AT Protocol lexicon resolution: https://atproto.com/specs/lexicon +- CGS consumption design: + https://github.com/hypercerts-org/certified-group-service/blob/main/docs/design/api-key-permission-sets.md From fa1c29afedf93661d733f12b0ab1eff2a5ba417e Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Tue, 9 Jun 2026 13:34:12 +0000 Subject: [PATCH 02/17] feat(permissions): implement org.hypercerts and app.certified write permission sets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../SKILL.md | 36 +++++++++++++++++++ .changeset/permission-sets-crud.md | 13 +++++++ docs/design/permission-sets.md | 27 ++++++++++---- lexicons/app/certified/permissions/crud.json | 30 ++++++++++++++++ lexicons/org/hypercerts/permissions/crud.json | 32 +++++++++++++++++ package.json | 7 ++-- scripts/generate-exports.js | 30 ++++++++-------- 7 files changed, 152 insertions(+), 23 deletions(-) create mode 100644 .changeset/permission-sets-crud.md create mode 100644 lexicons/app/certified/permissions/crud.json create mode 100644 lexicons/org/hypercerts/permissions/crud.json diff --git a/.agents/skills/building-with-hypercerts-lexicons/SKILL.md b/.agents/skills/building-with-hypercerts-lexicons/SKILL.md index a2cf7dd..241ea98 100644 --- a/.agents/skills/building-with-hypercerts-lexicons/SKILL.md +++ b/.agents/skills/building-with-hypercerts-lexicons/SKILL.md @@ -190,6 +190,40 @@ if (result.success) { } ``` +## Permission Sets (OAuth scopes) + +To **write** Hypercerts or Certified records to a user's repo, your app needs +the user's authorization. AT Protocol grants this via OAuth scopes: each +collection write needs a `repo:?action=…` scope. Rather than list +every collection by hand, request a **permission set** — a published bundle — +with a single `include:` scope: + +| Permission set NSID | Grants write (create/update/delete) on | +| --------------------------------- | ----------------------------------------- | +| `org.hypercerts.permissions.crud` | all `org.hypercerts.*` record collections | +| `app.certified.permissions.crud` | all `app.certified.*` record collections | + +In your OAuth client's authorization request, ask for the set(s) you need: + +```text +scope: include:org.hypercerts.permissions.crud +``` + +The user's PDS resolves the published set and expands it into the underlying +`repo:` scopes on the consent screen, where the user sees the set's plain-language +description (e.g. "Manage your Hypercerts data"). Notes: + +- **Two sets, never one.** A permission set may only reference its own namespace + authority (atproto permission spec), so `org.hypercerts.*` and + `app.certified.*` cannot share a set. An app needing both requests **both** + `include:` scopes. +- **Writes only.** These cover create/update/delete. **Reading** records needs no + scope at all — atproto repo records are public. +- **`repo:` scopes carry no `aud`** — request the `include:` without an `?aud=`. +- These same published sets are also consumed by the Certified group service + (CGS) when it expands an API key's `include:` scope — see the CGS integration + guide. + ## Lexicon Overview ### Claims — the core impact record @@ -673,6 +707,8 @@ avoid typos. ## Further Reading - [SCHEMAS.md](https://github.com/hypercerts-org/hypercerts-lexicon/blob/main/SCHEMAS.md) — full property-level documentation for every lexicon +- [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) +- [ATProto permission spec](https://atproto.com/specs/permission#permission-sets) — OAuth scopes and permission sets - [CHANGELOG.md](https://github.com/hypercerts-org/hypercerts-lexicon/blob/main/CHANGELOG.md) — version history and migration guides - [ATProto Lexicon Guide](https://atproto.com/guides/lexicon) — AT Protocol lexicon fundamentals - [ATProto Lexicon Style Guide](https://atproto.com/guides/lexicon-style-guide) — schema design conventions diff --git a/.changeset/permission-sets-crud.md b/.changeset/permission-sets-crud.md new file mode 100644 index 0000000..5e43107 --- /dev/null +++ b/.changeset/permission-sets-crud.md @@ -0,0 +1,13 @@ +--- +"@hypercerts-org/lexicon": minor +--- + +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. + +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:` 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. + +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. + +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. + +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`. diff --git a/docs/design/permission-sets.md b/docs/design/permission-sets.md index f271a41..2055bd5 100644 --- a/docs/design/permission-sets.md +++ b/docs/design/permission-sets.md @@ -56,11 +56,11 @@ key-creation — reads the same published definition. ## The sets -We define two **record-collection CRUD** sets — one per data namespace in this -repo. Each grants create/update/delete over all of that namespace's record -collections. +We define two **record-collection write** sets — one per data namespace in this +repo. Each grants the three `repo:` write actions — create, update, delete — +over all of that namespace's record collections. -| Set NSID | Grants CRUD on | +| Set NSID | Grants write (create/update/delete) on | | --------------------------------- | ----------------------------------------- | | `org.hypercerts.permissions.crud` | all `org.hypercerts.*` record collections | | `app.certified.permissions.crud` | all `app.certified.*` record collections | @@ -68,6 +68,17 @@ collections. Both are authored and published from **this repository**, which is the namespace authority for both — see _Namespace authority_ below. +> **"CRUD" but no read action.** The leaf is `crud` as a familiar shorthand, but +> a `repo:` permission has only three actions — `create`, `update`, `delete` +> (there is no `read` action in the scope grammar). Reading records needs **no +> permission at all**: atproto repo records are public and world-readable via +> `com.atproto.repo.getRecord` / `listRecords`, with no scope required. So a +> "CRUD" set covers the only parts of CRUD that _are_ permissioned — the writes — +> and "read" is implicitly available to everyone. The user-facing `title` / +> `detail` (the authorization-dialog consent copy) describe this in plain terms — +> "Manage your … data" / "Create, edit, and delete …" — deliberately not implying +> any grantable read scope exists. + > **Naming.** The spec prescribes no naming convention: a permission set is > identified only by `type: "permission-set"` in its `main` def, and its sole > examples are illustrative (`com.example.authFull`). The `permissions.crud` @@ -84,7 +95,9 @@ Enumerates every `type: "record"` collection under `org.hypercerts.*`: "defs": { "main": { "type": "permission-set", - "title": "Full CRUD on Hypercerts record collections", + "description": "Permission set granting create, update, and delete on every Hypercerts (org.hypercerts) record collection.", + "title": "Manage your Hypercerts data", + "detail": "Create, edit, and delete your Hypercerts records (impact claims, evaluations, funding, and related data).", "permissions": [ { "type": "permission", @@ -121,7 +134,9 @@ Enumerates every `type: "record"` collection under `app.certified.*`: "defs": { "main": { "type": "permission-set", - "title": "Full CRUD on Certified record collections", + "description": "Permission set granting create, update, and delete on every Certified (app.certified) record collection.", + "title": "Manage your Certified data", + "detail": "Create, edit, and delete your Certified records (profile, badges, follows, wallet links, and related data).", "permissions": [ { "type": "permission", diff --git a/lexicons/app/certified/permissions/crud.json b/lexicons/app/certified/permissions/crud.json new file mode 100644 index 0000000..0737847 --- /dev/null +++ b/lexicons/app/certified/permissions/crud.json @@ -0,0 +1,30 @@ +{ + "lexicon": 1, + "id": "app.certified.permissions.crud", + "defs": { + "main": { + "type": "permission-set", + "description": "Permission set granting create, update, and delete on every Certified (app.certified) record collection.", + "title": "Manage your Certified data", + "detail": "Create, edit, and delete your Certified records (profile, badges, follows, wallet links, and related data).", + "permissions": [ + { + "type": "permission", + "resource": "repo", + "collection": [ + "app.certified.actor.organization", + "app.certified.actor.profile", + "app.certified.badge.award", + "app.certified.badge.definition", + "app.certified.badge.response", + "app.certified.graph.follow", + "app.certified.link.evm", + "app.certified.location", + "app.certified.signature.proof" + ], + "action": ["create", "update", "delete"] + } + ] + } + } +} diff --git a/lexicons/org/hypercerts/permissions/crud.json b/lexicons/org/hypercerts/permissions/crud.json new file mode 100644 index 0000000..dd3caeb --- /dev/null +++ b/lexicons/org/hypercerts/permissions/crud.json @@ -0,0 +1,32 @@ +{ + "lexicon": 1, + "id": "org.hypercerts.permissions.crud", + "defs": { + "main": { + "type": "permission-set", + "description": "Permission set granting create, update, and delete on every Hypercerts (org.hypercerts) record collection.", + "title": "Manage your Hypercerts data", + "detail": "Create, edit, and delete your Hypercerts records (impact claims, evaluations, funding, and related data).", + "permissions": [ + { + "type": "permission", + "resource": "repo", + "collection": [ + "org.hypercerts.claim.activity", + "org.hypercerts.claim.contribution", + "org.hypercerts.claim.contributorInformation", + "org.hypercerts.claim.rights", + "org.hypercerts.collection", + "org.hypercerts.context.acknowledgement", + "org.hypercerts.context.attachment", + "org.hypercerts.context.evaluation", + "org.hypercerts.context.measurement", + "org.hypercerts.funding.receipt", + "org.hypercerts.workscope.tag" + ], + "action": ["create", "update", "delete"] + } + ] + } + } +} diff --git a/package.json b/package.json index 95bf35d..36c8b5d 100644 --- a/package.json +++ b/package.json @@ -42,11 +42,12 @@ "check": "npm run gen-api && npm run lint && npm run typecheck && npm run build && npm run test", "build": "rollup -c && npm run build:types", "build:types": "tsc --project tsconfig.build.json", - "gen-api": "find ./lexicons -name '*.json' | xargs lex gen-api --yes ./generated && npm run gen-index", - "gen-md": "find ./lexicons -name '*.json' | xargs lex gen-md --yes ./lexicons.md", + "//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.", + "gen-api": "find ./lexicons -name '*.json' -not -path '*/permissions/*' | xargs lex gen-api --yes ./generated && npm run gen-index", + "gen-md": "find ./lexicons -name '*.json' -not -path '*/permissions/*' | xargs lex gen-md --yes ./lexicons.md", "gen-schemas-md": "node ./scripts/generate-schemas.js", "//gen-ts": "UNUSED - use gen-api instead", - "gen-ts": "find ./lexicons -name '*.json' | xargs lex gen-ts-obj > generated/DO-NOT-USE-lexicons.ts", + "gen-ts": "find ./lexicons -name '*.json' -not -path '*/permissions/*' | xargs lex gen-ts-obj > generated/DO-NOT-USE-lexicons.ts", "gen-index": "node ./scripts/generate-exports.js", "lex": "lex", "test": "vitest run", diff --git a/scripts/generate-exports.js b/scripts/generate-exports.js index 3449801..54b3315 100644 --- a/scripts/generate-exports.js +++ b/scripts/generate-exports.js @@ -153,28 +153,30 @@ function pathToGeneratedType(filePath) { return `./types/${withoutExt}.js`; } -/** - * Read lexicon NSID from JSON file - */ -function readNsid(filePath) { - const fullPath = join(lexiconsDir, filePath); - const content = readFileSync(fullPath, "utf-8"); - const lexicon = JSON.parse(content); - return lexicon.lexicon === 1 ? lexicon.id : null; -} - /** * Generate generated/exports.ts content */ function generateIndex() { - const jsonFiles = findJsonFiles(lexiconsDir).sort(); - - const lexicons = jsonFiles.map((filePath) => ({ + // Parse each lexicon once, then derive everything from the parsed doc — both + // the permission-set check and the NSID — to avoid re-reading the same file. + const jsonFiles = findJsonFiles(lexiconsDir) + .sort() + .map((filePath) => ({ + filePath, + doc: JSON.parse(readFileSync(join(lexiconsDir, filePath), "utf-8")), + })) + // Permission-set lexicons (lexicons/**/permissions/*.json) are published + // as-is but have no TS shape — lex gen-api cannot codegen them, so they are + // excluded from `generated/` here just as they are from the gen-* globs in + // package.json. The two exclusions must stay aligned. + .filter(({ doc }) => doc?.defs?.main?.type !== "permission-set"); + + const lexicons = jsonFiles.map(({ filePath, doc }) => ({ path: filePath, importName: pathToImportName(filePath), namespace: pathToNamespace(filePath), generatedTypePath: pathToGeneratedType(filePath), - nsid: readNsid(filePath), + nsid: doc.lexicon === 1 ? doc.id : null, })); // Filter out lexicons without NSIDs (should be rare) From e9be8af45ab5ecd33f1db16344e3c77d4dfada18 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Tue, 9 Jun 2026 14:06:39 +0000 Subject: [PATCH 03/17] fix(permissions): remove app.certified.signature.proof from the Certified set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app.certified.signature.proof does not exist as a record lexicon on main — it is added by PR #219 (feat/signature-support-v2, HYPER-181). Including it here made app.certified.permissions.crud reference a non-existent collection (correctly flagged in review). Drop it, leaving the 8 app.certified.* record collections that actually exist on main. Documents in the Maintenance section that the set must gain app.certified.signature.proof once #219 merges. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/design/permission-sets.md | 10 +++++++++- lexicons/app/certified/permissions/crud.json | 3 +-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/design/permission-sets.md b/docs/design/permission-sets.md index 2055bd5..e998280 100644 --- a/docs/design/permission-sets.md +++ b/docs/design/permission-sets.md @@ -150,7 +150,6 @@ Enumerates every `type: "record"` collection under `app.certified.*`: "app.certified.graph.follow", "app.certified.link.evm", "app.certified.location", - "app.certified.signature.proof", ], "action": ["create", "update", "delete"], }, @@ -267,6 +266,15 @@ new collection will be silently uncovered by the set. Worth a check in the release process (and ideally an automated test asserting each set lists exactly the `type: "record"` defs in its namespace). +> **Known pending update.** PR +> [#219](https://github.com/hypercerts-org/hypercerts-lexicon/pull/219) +> (`feat/signature-support-v2`, HYPER-181) adds the `app.certified.signature.proof` +> record lexicon, which does **not** exist on `main` yet and so is **not** in +> `app.certified.permissions.crud` here. When #219 merges, +> `app.certified.signature.proof` must be added to the set's `collection` list +> (and re-published). The two PRs touch different files, so neither blocks the +> other — this is just a follow-up to land after both. + ## References - AT Protocol permission spec — permission sets: diff --git a/lexicons/app/certified/permissions/crud.json b/lexicons/app/certified/permissions/crud.json index 0737847..21b88b1 100644 --- a/lexicons/app/certified/permissions/crud.json +++ b/lexicons/app/certified/permissions/crud.json @@ -19,8 +19,7 @@ "app.certified.badge.response", "app.certified.graph.follow", "app.certified.link.evm", - "app.certified.location", - "app.certified.signature.proof" + "app.certified.location" ], "action": ["create", "update", "delete"] } From e105b3f135f4524468632c70259adb9ff2739ca4 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Tue, 9 Jun 2026 14:44:26 +0000 Subject: [PATCH 04/17] fix(permissions): rename sets to authWrite (single-segment NSID under namespace root) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../SKILL.md | 10 +-- .changeset/permission-sets-crud.md | 2 +- SCHEMAS.md | 12 ++++ docs/design/permission-sets.md | 70 +++++++++++-------- .../{permissions/crud.json => authWrite.json} | 2 +- .../{permissions/crud.json => authWrite.json} | 2 +- package.json | 8 +-- scripts/codegen-lexicon-files.js | 50 +++++++++++++ 8 files changed, 116 insertions(+), 40 deletions(-) rename lexicons/app/certified/{permissions/crud.json => authWrite.json} (95%) rename lexicons/org/hypercerts/{permissions/crud.json => authWrite.json} (96%) create mode 100644 scripts/codegen-lexicon-files.js diff --git a/.agents/skills/building-with-hypercerts-lexicons/SKILL.md b/.agents/skills/building-with-hypercerts-lexicons/SKILL.md index 241ea98..98f1e34 100644 --- a/.agents/skills/building-with-hypercerts-lexicons/SKILL.md +++ b/.agents/skills/building-with-hypercerts-lexicons/SKILL.md @@ -198,15 +198,15 @@ collection write needs a `repo:?action=…` scope. Rather than list every collection by hand, request a **permission set** — a published bundle — with a single `include:` scope: -| Permission set NSID | Grants write (create/update/delete) on | -| --------------------------------- | ----------------------------------------- | -| `org.hypercerts.permissions.crud` | all `org.hypercerts.*` record collections | -| `app.certified.permissions.crud` | all `app.certified.*` record collections | +| Permission set NSID | Grants write (create/update/delete) on | +| -------------------------- | ----------------------------------------- | +| `org.hypercerts.authWrite` | all `org.hypercerts.*` record collections | +| `app.certified.authWrite` | all `app.certified.*` record collections | In your OAuth client's authorization request, ask for the set(s) you need: ```text -scope: include:org.hypercerts.permissions.crud +scope: include:org.hypercerts.authWrite ``` The user's PDS resolves the published set and expands it into the underlying diff --git a/.changeset/permission-sets-crud.md b/.changeset/permission-sets-crud.md index 5e43107..dcce2b0 100644 --- a/.changeset/permission-sets-crud.md +++ b/.changeset/permission-sets-crud.md @@ -2,7 +2,7 @@ "@hypercerts-org/lexicon": minor --- -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. +Add two permission-set lexicons — `org.hypercerts.authWrite` and `app.certified.authWrite` — each granting create/update/delete over every record collection in its namespace. 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:` 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. diff --git a/SCHEMAS.md b/SCHEMAS.md index 1c16ca9..08ea5e4 100644 --- a/SCHEMAS.md +++ b/SCHEMAS.md @@ -299,6 +299,12 @@ A free-text string value (e.g. a display name, wallet address, or other identifi --- +### `org.hypercerts.permissions.crud` + +**Description:** Permission set granting create, update, and delete on every Hypercerts (org.hypercerts) record collection. + +--- + ### `org.hypercerts.workscope.cel` **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 --- +### `app.certified.permissions.crud` + +**Description:** Permission set granting create, update, and delete on every Certified (app.certified) record collection. + +--- + ### `app.certified.signature.defs` **Description:** Common type definitions for cryptographic signatures attached to records, per the ATProtocol Attestation Specification. diff --git a/docs/design/permission-sets.md b/docs/design/permission-sets.md index e998280..566e838 100644 --- a/docs/design/permission-sets.md +++ b/docs/design/permission-sets.md @@ -36,7 +36,7 @@ These are general AT Protocol artifacts, not specific to any one service. The expected consumers, in rough order of generality: 1. **Any atproto app working with Hypercerts / Certified data directly.** An - OAuth client requests `include:org.hypercerts.permissions.crud` (and/or the + OAuth client requests `include:org.hypercerts.authWrite` (and/or the `app.certified.*` set) in its authorization request; the user's PDS expands it and the app reads/writes those collections in the user's own repo. No 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 repo. Each grants the three `repo:` write actions — create, update, delete — over all of that namespace's record collections. -| Set NSID | Grants write (create/update/delete) on | -| --------------------------------- | ----------------------------------------- | -| `org.hypercerts.permissions.crud` | all `org.hypercerts.*` record collections | -| `app.certified.permissions.crud` | all `app.certified.*` record collections | +| Set NSID | Grants write (create/update/delete) on | +| -------------------------- | ----------------------------------------- | +| `org.hypercerts.authWrite` | all `org.hypercerts.*` record collections | +| `app.certified.authWrite` | all `app.certified.*` record collections | Both are authored and published from **this repository**, which is the namespace authority for both — see _Namespace authority_ below. -> **"CRUD" but no read action.** The leaf is `crud` as a familiar shorthand, but -> a `repo:` permission has only three actions — `create`, `update`, `delete` -> (there is no `read` action in the scope grammar). Reading records needs **no -> permission at all**: atproto repo records are public and world-readable via -> `com.atproto.repo.getRecord` / `listRecords`, with no scope required. So a -> "CRUD" set covers the only parts of CRUD that _are_ permissioned — the writes — -> and "read" is implicitly available to everyone. The user-facing `title` / -> `detail` (the authorization-dialog consent copy) describe this in plain terms — -> "Manage your … data" / "Create, edit, and delete …" — deliberately not implying -> any grantable read scope exists. - -> **Naming.** The spec prescribes no naming convention: a permission set is -> identified only by `type: "permission-set"` in its `main` def, and its sole -> examples are illustrative (`com.example.authFull`). The `permissions.crud` -> leaf is a house choice for this repo; rename freely. - -### `org.hypercerts.permissions.crud` +> **Write only — no read action.** The leaf is `authWrite` because a `repo:` +> permission has only three actions — `create`, `update`, `delete` (there is no +> `read` action in the scope grammar). Reading records needs **no permission at +> all**: atproto repo records are public and world-readable via +> `com.atproto.repo.getRecord` / `listRecords`, with no scope required. So these +> sets cover the only repo operations that _are_ permissioned — the writes — and +> "read" is implicitly available to everyone. The user-facing `title` / `detail` +> (the authorization-dialog consent copy) say "Manage your … data" / "Create, +> edit, and delete …" accordingly. + +> **Naming — and why the NSID is a single segment under the namespace root.** +> Two constraints, one of them hard: +> +> 1. **The set NSID's authority must parent the collections it grants** (the +> namespace-authority rule below). `NSID.authority` is the reversed domain of +> _all but the last segment_, so a set named `org.hypercerts.permissions.crud` +> has authority `permissions.hypercerts.org`, which does **not** parent +> `org.hypercerts.claim.*` — `IncludeScope.toScopes` would silently return an +> empty list. The set NSID must therefore be a **single leaf directly under the +> namespace root** (`org.hypercerts.`), whose authority is `hypercerts.org`. +> 2. **Convention follows real precedent.** Bluesky publishes its own permission +> sets exactly this way — single segment under the namespace, `auth`-prefixed +> capability leaf: `app.bsky.authViewAll`, `app.bsky.authCreatePosts`, +> `app.bsky.authDeleteContent`, `app.bsky.authFullApp`. The spec's illustrative +> examples (`com.example.authFull`, `com.example.authBasicFeatures`) match. So +> `authWrite` mirrors that style: `auth`-prefixed, names the capability. + +### `org.hypercerts.authWrite` Enumerates every `type: "record"` collection under `org.hypercerts.*`: ```jsonc { "lexicon": 1, - "id": "org.hypercerts.permissions.crud", + "id": "org.hypercerts.authWrite", "defs": { "main": { "type": "permission-set", @@ -123,14 +134,14 @@ Enumerates every `type: "record"` collection under `org.hypercerts.*`: } ``` -### `app.certified.permissions.crud` +### `app.certified.authWrite` Enumerates every `type: "record"` collection under `app.certified.*`: ```jsonc { "lexicon": 1, - "id": "app.certified.permissions.crud", + "id": "app.certified.authWrite", "defs": { "main": { "type": "permission-set", @@ -202,8 +213,11 @@ not what these sets are for.) A `repo:` scope targets the **user's own repo**, so it has no audience. Both sets here are `repo:`-only, so an `include:` for them is requested **without** any -`?aud=` parameter, and expands to plain -`repo:?action=create&action=update&action=delete` scopes. +`?aud=` parameter. Each set expands to a **single combined `repo:` scope** that +lists every collection — `IncludeScope.toScopes` coalesces the permission's +collection array into one scope string +(`repo:?collection=&collection=&…&action=create&action=update&action=delete`), +rather than emitting one scope per collection. (For completeness: an `rpc:` permission _does_ take an `aud` naming the target 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). > [#219](https://github.com/hypercerts-org/hypercerts-lexicon/pull/219) > (`feat/signature-support-v2`, HYPER-181) adds the `app.certified.signature.proof` > record lexicon, which does **not** exist on `main` yet and so is **not** in -> `app.certified.permissions.crud` here. When #219 merges, +> `app.certified.authWrite` here. When #219 merges, > `app.certified.signature.proof` must be added to the set's `collection` list > (and re-published). The two PRs touch different files, so neither blocks the > other — this is just a follow-up to land after both. diff --git a/lexicons/app/certified/permissions/crud.json b/lexicons/app/certified/authWrite.json similarity index 95% rename from lexicons/app/certified/permissions/crud.json rename to lexicons/app/certified/authWrite.json index 21b88b1..08aaa24 100644 --- a/lexicons/app/certified/permissions/crud.json +++ b/lexicons/app/certified/authWrite.json @@ -1,6 +1,6 @@ { "lexicon": 1, - "id": "app.certified.permissions.crud", + "id": "app.certified.authWrite", "defs": { "main": { "type": "permission-set", diff --git a/lexicons/org/hypercerts/permissions/crud.json b/lexicons/org/hypercerts/authWrite.json similarity index 96% rename from lexicons/org/hypercerts/permissions/crud.json rename to lexicons/org/hypercerts/authWrite.json index dd3caeb..9fd03dd 100644 --- a/lexicons/org/hypercerts/permissions/crud.json +++ b/lexicons/org/hypercerts/authWrite.json @@ -1,6 +1,6 @@ { "lexicon": 1, - "id": "org.hypercerts.permissions.crud", + "id": "org.hypercerts.authWrite", "defs": { "main": { "type": "permission-set", diff --git a/package.json b/package.json index 36c8b5d..ddec51e 100644 --- a/package.json +++ b/package.json @@ -42,12 +42,12 @@ "check": "npm run gen-api && npm run lint && npm run typecheck && npm run build && npm run test", "build": "rollup -c && npm run build:types", "build:types": "tsc --project tsconfig.build.json", - "//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.", - "gen-api": "find ./lexicons -name '*.json' -not -path '*/permissions/*' | xargs lex gen-api --yes ./generated && npm run gen-index", - "gen-md": "find ./lexicons -name '*.json' -not -path '*/permissions/*' | xargs lex gen-md --yes ./lexicons.md", + "//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).", + "gen-api": "lex gen-api --yes ./generated $(node ./scripts/codegen-lexicon-files.js) && npm run gen-index", + "gen-md": "lex gen-md --yes ./lexicons.md $(node ./scripts/codegen-lexicon-files.js)", "gen-schemas-md": "node ./scripts/generate-schemas.js", "//gen-ts": "UNUSED - use gen-api instead", - "gen-ts": "find ./lexicons -name '*.json' -not -path '*/permissions/*' | xargs lex gen-ts-obj > generated/DO-NOT-USE-lexicons.ts", + "gen-ts": "lex gen-ts-obj $(node ./scripts/codegen-lexicon-files.js) > generated/DO-NOT-USE-lexicons.ts", "gen-index": "node ./scripts/generate-exports.js", "lex": "lex", "test": "vitest run", diff --git a/scripts/codegen-lexicon-files.js b/scripts/codegen-lexicon-files.js new file mode 100644 index 0000000..8abb462 --- /dev/null +++ b/scripts/codegen-lexicon-files.js @@ -0,0 +1,50 @@ +#!/usr/bin/env node + +/** + * Print the list of lexicon JSON files that `lex gen-*` should process, one per + * line — i.e. every lexicon under `lexicons/` EXCEPT permission-set lexicons. + * + * `lex gen-api` / `gen-md` / `gen-ts` cannot codegen a `type: "permission-set"` + * def (it has no TypeScript shape and the CLI throws on it). Permission sets are + * published as-is, so they must be excluded from the codegen globs. + * + * This is the **single source of truth** for that exclusion — both the codegen + * scripts (via this list) and `scripts/generate-exports.js` (via the same + * `main.type === "permission-set"` check) skip the same files by **content**, + * not by path, so they cannot drift as files move. + * + * Usage (in package.json): `lex gen-api --yes ./generated $(node ./scripts/codegen-lexicon-files.js)` + */ + +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const lexiconsDir = fileURLToPath(new URL("../lexicons", import.meta.url)); + +function findJsonFiles(dir) { + const out = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) out.push(...findJsonFiles(full)); + else if (entry.isFile() && entry.name.endsWith(".json")) out.push(full); + } + return out; +} + +function isPermissionSet(file) { + try { + return ( + JSON.parse(readFileSync(file, "utf-8"))?.defs?.main?.type === + "permission-set" + ); + } catch { + return false; // let lex surface a parse error rather than silently skip + } +} + +const files = findJsonFiles(lexiconsDir) + .filter((f) => !isPermissionSet(f)) + .sort(); + +process.stdout.write(files.join("\n") + "\n"); From 7cb3ce43079c265e457610cd6ee009e0fd69097f Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Tue, 9 Jun 2026 14:57:57 +0000 Subject: [PATCH 05/17] refactor(scripts): clarify generate-exports permission-set exclusion comment + rename var Rename jsonFiles -> lexiconDocs (it now holds {filePath, doc} objects, not paths), and update the exclusion comment to describe the content-based check shared with codegen-lexicon-files.js rather than the old permissions/ path (review feedback). Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/generate-exports.js | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/scripts/generate-exports.js b/scripts/generate-exports.js index 54b3315..f39eb58 100644 --- a/scripts/generate-exports.js +++ b/scripts/generate-exports.js @@ -159,19 +159,20 @@ function pathToGeneratedType(filePath) { function generateIndex() { // Parse each lexicon once, then derive everything from the parsed doc — both // the permission-set check and the NSID — to avoid re-reading the same file. - const jsonFiles = findJsonFiles(lexiconsDir) + const lexiconDocs = findJsonFiles(lexiconsDir) .sort() .map((filePath) => ({ filePath, doc: JSON.parse(readFileSync(join(lexiconsDir, filePath), "utf-8")), })) - // Permission-set lexicons (lexicons/**/permissions/*.json) are published - // as-is but have no TS shape — lex gen-api cannot codegen them, so they are - // excluded from `generated/` here just as they are from the gen-* globs in - // package.json. The two exclusions must stay aligned. + // Permission-set lexicons have no TS shape — lex gen-api cannot codegen them + // — so they are excluded from `generated/` here, by the SAME content check + // (`main.type === "permission-set"`) that scripts/codegen-lexicon-files.js + // uses to exclude them from the gen-* CLI invocations. Excluding by content + // (not by path) keeps the two in lock-step regardless of where set files live. .filter(({ doc }) => doc?.defs?.main?.type !== "permission-set"); - const lexicons = jsonFiles.map(({ filePath, doc }) => ({ + const lexicons = lexiconDocs.map(({ filePath, doc }) => ({ path: filePath, importName: pathToImportName(filePath), namespace: pathToNamespace(filePath), From c327ada5491c0a488be9143cb8d552981a9adeb9 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Tue, 9 Jun 2026 15:08:17 +0000 Subject: [PATCH 06/17] =?UTF-8?q?fix(scripts):=20codegen=20via=20`node=20?= =?UTF-8?q?=E2=80=A6=20|=20xargs=20-0=20-r`=20under=20pipefail,=20not=20$(?= =?UTF-8?q?=E2=80=A6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot was right that command substitution is the wrong shape here. A `$(…)` arg list silently swallows a producer failure (the script could exit non-zero and lex would still run on partial/empty input and "succeed"). Switch to the pipe + xargs form the original gen-* used: node ./scripts/codegen-lexicon-files.js | xargs -0 -r lex gen-… - `set -o pipefail` makes a producer failure abort the pipeline (verified: a plain pipe reports exit 0 on producer failure; pipefail reports 1). dash — the sh on Ubuntu CI and here — supports it. - `-r` skips lex on empty input; `-0` (NUL-delimited from the script) is robust to any path chars; xargs also sidesteps ARG_MAX. - The script now exits non-zero + writes nothing if it finds no lexicons, so an internal bug surfaces rather than codegenning nothing. Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 8 ++++---- scripts/codegen-lexicon-files.js | 17 +++++++++++++++-- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index ddec51e..0e76f23 100644 --- a/package.json +++ b/package.json @@ -42,12 +42,12 @@ "check": "npm run gen-api && npm run lint && npm run typecheck && npm run build && npm run test", "build": "rollup -c && npm run build:types", "build:types": "tsc --project tsconfig.build.json", - "//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).", - "gen-api": "lex gen-api --yes ./generated $(node ./scripts/codegen-lexicon-files.js) && npm run gen-index", - "gen-md": "lex gen-md --yes ./lexicons.md $(node ./scripts/codegen-lexicon-files.js)", + "//permissions": "permission-set lexicons are published as-is but have no TS shape; lex gen-* cannot codegen them. scripts/codegen-lexicon-files.js emits the NUL-delimited lexicon list to codegen, excluding permission-sets by content (single source of truth shared with generate-exports.js). Piped into `xargs -0 -r` under `set -o pipefail`: pipefail propagates a producer failure (xargs alone cannot — it would run lex on partial/empty input and report success); -r skips lex if the list is somehow empty; xargs avoids ARG_MAX.", + "gen-api": "set -o pipefail; node ./scripts/codegen-lexicon-files.js | xargs -0 -r lex gen-api --yes ./generated && npm run gen-index", + "gen-md": "set -o pipefail; node ./scripts/codegen-lexicon-files.js | xargs -0 -r lex gen-md --yes ./lexicons.md", "gen-schemas-md": "node ./scripts/generate-schemas.js", "//gen-ts": "UNUSED - use gen-api instead", - "gen-ts": "lex gen-ts-obj $(node ./scripts/codegen-lexicon-files.js) > generated/DO-NOT-USE-lexicons.ts", + "gen-ts": "set -o pipefail; node ./scripts/codegen-lexicon-files.js | xargs -0 -r lex gen-ts-obj > generated/DO-NOT-USE-lexicons.ts", "gen-index": "node ./scripts/generate-exports.js", "lex": "lex", "test": "vitest run", diff --git a/scripts/codegen-lexicon-files.js b/scripts/codegen-lexicon-files.js index 8abb462..7e5785b 100644 --- a/scripts/codegen-lexicon-files.js +++ b/scripts/codegen-lexicon-files.js @@ -13,7 +13,11 @@ * `main.type === "permission-set"` check) skip the same files by **content**, * not by path, so they cannot drift as files move. * - * Usage (in package.json): `lex gen-api --yes ./generated $(node ./scripts/codegen-lexicon-files.js)` + * Usage (in package.json): pipe into `xargs -0`, e.g. + * `node ./scripts/codegen-lexicon-files.js | xargs -0 lex gen-api --yes ./generated` + * Piping (not `$(…)`) is deliberate: a failure or empty output here propagates + * through the pipeline instead of being swallowed by command substitution, and + * `xargs` avoids ARG_MAX limits. Output is NUL-delimited for `xargs -0`. */ import { readdirSync, readFileSync } from "node:fs"; @@ -47,4 +51,13 @@ const files = findJsonFiles(lexiconsDir) .filter((f) => !isPermissionSet(f)) .sort(); -process.stdout.write(files.join("\n") + "\n"); +// Fail loudly rather than let the codegen run on an empty arg list (which could +// otherwise "succeed" producing nothing). The pipeline (`node … | xargs lex …`) +// already propagates this non-zero exit, unlike a `$(…)` substitution. +if (files.length === 0) { + process.stderr.write("codegen-lexicon-files: no lexicons found\n"); + process.exit(1); +} + +// NUL-delimited for `xargs -0` — robust against any path characters. +process.stdout.write(files.join("\0") + "\0"); From 1fe9bd34835d9d58ef1d69a4edce4ba1df19b9db Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Tue, 9 Jun 2026 15:16:12 +0000 Subject: [PATCH 07/17] fix(scripts): run lex codegen via a Node runner, not a shell pipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `set -o pipefail` is NOT portable: GitHub's Ubuntu `sh` (dash) rejects it (`set: Illegal option -o pipefail`), which broke CI. And no `xargs` option replaces it — `-r` only skips empty input, it does not propagate a producer's non-zero exit, so a crashed list-generator still yields a "successful" pipeline. Replace the pipe entirely with scripts/run-lex-codegen.js: a single Node process that builds the permission-set-excluded file list and spawns `lex ` with the list as argv, forwarding lex's exit code. One authoritative exit code, no shell builtins, no ARG_MAX (argv, not a command line), no pipefail. Empty list or spawn failure → non-zero exit. (Renamed from codegen-lexicon-files.js, which only printed the list.) Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 8 +-- scripts/codegen-lexicon-files.js | 63 ----------------------- scripts/run-lex-codegen.js | 85 ++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 67 deletions(-) delete mode 100644 scripts/codegen-lexicon-files.js create mode 100644 scripts/run-lex-codegen.js diff --git a/package.json b/package.json index 0e76f23..0dc5622 100644 --- a/package.json +++ b/package.json @@ -42,12 +42,12 @@ "check": "npm run gen-api && npm run lint && npm run typecheck && npm run build && npm run test", "build": "rollup -c && npm run build:types", "build:types": "tsc --project tsconfig.build.json", - "//permissions": "permission-set lexicons are published as-is but have no TS shape; lex gen-* cannot codegen them. scripts/codegen-lexicon-files.js emits the NUL-delimited lexicon list to codegen, excluding permission-sets by content (single source of truth shared with generate-exports.js). Piped into `xargs -0 -r` under `set -o pipefail`: pipefail propagates a producer failure (xargs alone cannot — it would run lex on partial/empty input and report success); -r skips lex if the list is somehow empty; xargs avoids ARG_MAX.", - "gen-api": "set -o pipefail; node ./scripts/codegen-lexicon-files.js | xargs -0 -r lex gen-api --yes ./generated && npm run gen-index", - "gen-md": "set -o pipefail; node ./scripts/codegen-lexicon-files.js | xargs -0 -r lex gen-md --yes ./lexicons.md", + "//permissions": "permission-set lexicons are published as-is but have no TS shape; lex gen-* cannot codegen them. scripts/run-lex-codegen.js runs a lex subcommand over all lexicons EXCEPT permission-sets (excluded by content — the same check generate-exports.js uses). It is a single Node process that spawns lex with the file list as argv and forwards lex's exit code, so a producer failure propagates without `set -o pipefail` (not portable across npm's sh) and there is no ARG_MAX limit.", + "gen-api": "node ./scripts/run-lex-codegen.js gen-api --yes ./generated && npm run gen-index", + "gen-md": "node ./scripts/run-lex-codegen.js gen-md --yes ./lexicons.md", "gen-schemas-md": "node ./scripts/generate-schemas.js", "//gen-ts": "UNUSED - use gen-api instead", - "gen-ts": "set -o pipefail; node ./scripts/codegen-lexicon-files.js | xargs -0 -r lex gen-ts-obj > generated/DO-NOT-USE-lexicons.ts", + "gen-ts": "node ./scripts/run-lex-codegen.js gen-ts-obj > generated/DO-NOT-USE-lexicons.ts", "gen-index": "node ./scripts/generate-exports.js", "lex": "lex", "test": "vitest run", diff --git a/scripts/codegen-lexicon-files.js b/scripts/codegen-lexicon-files.js deleted file mode 100644 index 7e5785b..0000000 --- a/scripts/codegen-lexicon-files.js +++ /dev/null @@ -1,63 +0,0 @@ -#!/usr/bin/env node - -/** - * Print the list of lexicon JSON files that `lex gen-*` should process, one per - * line — i.e. every lexicon under `lexicons/` EXCEPT permission-set lexicons. - * - * `lex gen-api` / `gen-md` / `gen-ts` cannot codegen a `type: "permission-set"` - * def (it has no TypeScript shape and the CLI throws on it). Permission sets are - * published as-is, so they must be excluded from the codegen globs. - * - * This is the **single source of truth** for that exclusion — both the codegen - * scripts (via this list) and `scripts/generate-exports.js` (via the same - * `main.type === "permission-set"` check) skip the same files by **content**, - * not by path, so they cannot drift as files move. - * - * Usage (in package.json): pipe into `xargs -0`, e.g. - * `node ./scripts/codegen-lexicon-files.js | xargs -0 lex gen-api --yes ./generated` - * Piping (not `$(…)`) is deliberate: a failure or empty output here propagates - * through the pipeline instead of being swallowed by command substitution, and - * `xargs` avoids ARG_MAX limits. Output is NUL-delimited for `xargs -0`. - */ - -import { readdirSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const lexiconsDir = fileURLToPath(new URL("../lexicons", import.meta.url)); - -function findJsonFiles(dir) { - const out = []; - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const full = join(dir, entry.name); - if (entry.isDirectory()) out.push(...findJsonFiles(full)); - else if (entry.isFile() && entry.name.endsWith(".json")) out.push(full); - } - return out; -} - -function isPermissionSet(file) { - try { - return ( - JSON.parse(readFileSync(file, "utf-8"))?.defs?.main?.type === - "permission-set" - ); - } catch { - return false; // let lex surface a parse error rather than silently skip - } -} - -const files = findJsonFiles(lexiconsDir) - .filter((f) => !isPermissionSet(f)) - .sort(); - -// Fail loudly rather than let the codegen run on an empty arg list (which could -// otherwise "succeed" producing nothing). The pipeline (`node … | xargs lex …`) -// already propagates this non-zero exit, unlike a `$(…)` substitution. -if (files.length === 0) { - process.stderr.write("codegen-lexicon-files: no lexicons found\n"); - process.exit(1); -} - -// NUL-delimited for `xargs -0` — robust against any path characters. -process.stdout.write(files.join("\0") + "\0"); diff --git a/scripts/run-lex-codegen.js b/scripts/run-lex-codegen.js new file mode 100644 index 0000000..2a68b3a --- /dev/null +++ b/scripts/run-lex-codegen.js @@ -0,0 +1,85 @@ +#!/usr/bin/env node + +/** + * Run a `lex` codegen subcommand over every lexicon under `lexicons/` EXCEPT + * permission-set lexicons. + * + * `lex gen-api` / `gen-md` / `gen-ts` cannot codegen a `type: "permission-set"` + * def — it has no TypeScript shape and the CLI throws on it. Permission sets are + * published as-is, so they are excluded from codegen. The exclusion is by + * **content** (`defs.main.type === "permission-set"`), the same check + * `scripts/generate-exports.js` uses — so the two cannot drift as files move. + * + * Why a Node runner rather than a shell pipeline: + * - `lex … $(node …)` swallows a producer failure (command substitution discards + * its exit code) and can hit ARG_MAX. + * - `node … | xargs -0 lex …` needs `set -o pipefail` to propagate a producer + * failure, and `pipefail` is NOT portable across the `sh` npm runs scripts + * with (rejected by the dash on GitHub's Ubuntu runners). + * This runner is a single process with one authoritative exit code, passes the + * file list to `lex` as argv (no shell, no ARG_MAX), and forwards lex's exit + * code. No shell builtins involved. + * + * Usage (in package.json): + * node ./scripts/run-lex-codegen.js gen-api --yes ./generated + * node ./scripts/run-lex-codegen.js gen-ts-obj > generated/DO-NOT-USE-lexicons.ts + */ + +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +const lexiconsDir = fileURLToPath(new URL("../lexicons", import.meta.url)); + +function findJsonFiles(dir) { + const out = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) out.push(...findJsonFiles(full)); + else if (entry.isFile() && entry.name.endsWith(".json")) out.push(full); + } + return out; +} + +function isPermissionSet(file) { + try { + return ( + JSON.parse(readFileSync(file, "utf-8"))?.defs?.main?.type === + "permission-set" + ); + } catch { + return false; // let lex surface a parse error rather than silently skip + } +} + +const lexArgs = process.argv.slice(2); +if (lexArgs.length === 0) { + process.stderr.write( + "run-lex-codegen: expected a lex subcommand, e.g. `gen-api --yes ./generated`\n", + ); + process.exit(2); +} + +const files = findJsonFiles(lexiconsDir) + .filter((f) => !isPermissionSet(f)) + .sort(); + +// Fail loudly rather than run lex on an empty list (which could "succeed" +// producing nothing). +if (files.length === 0) { + process.stderr.write("run-lex-codegen: no lexicons found\n"); + process.exit(1); +} + +// `shell: false` (the default) — args are passed as argv, so there is no shell +// word-splitting, no ARG_MAX limit from a command line, and lex's exit code is +// ours. stdio inherited so `> file` redirection in the npm script still works. +const result = spawnSync("lex", [...lexArgs, ...files], { stdio: "inherit" }); +if (result.error) { + process.stderr.write( + `run-lex-codegen: failed to spawn lex: ${result.error.message}\n`, + ); + process.exit(1); +} +process.exit(result.status ?? 1); From 9ad6b008bb6010048da4d01bc054c522946f5abf Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Tue, 9 Jun 2026 15:17:00 +0000 Subject: [PATCH 08/17] =?UTF-8?q?docs:=20fix=20stale=20script-name=20refer?= =?UTF-8?q?ence=20(codegen-lexicon-files=20=E2=86=92=20run-lex-codegen)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/generate-exports.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/generate-exports.js b/scripts/generate-exports.js index f39eb58..20dc718 100644 --- a/scripts/generate-exports.js +++ b/scripts/generate-exports.js @@ -167,7 +167,7 @@ function generateIndex() { })) // Permission-set lexicons have no TS shape — lex gen-api cannot codegen them // — so they are excluded from `generated/` here, by the SAME content check - // (`main.type === "permission-set"`) that scripts/codegen-lexicon-files.js + // (`main.type === "permission-set"`) that scripts/run-lex-codegen.js // uses to exclude them from the gen-* CLI invocations. Excluding by content // (not by path) keeps the two in lock-step regardless of where set files live. .filter(({ doc }) => doc?.defs?.main?.type !== "permission-set"); From 240c05925e9db206c602117314fcffb69a9aec88 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Tue, 9 Jun 2026 15:26:48 +0000 Subject: [PATCH 09/17] =?UTF-8?q?docs(scripts):=20correct=20'no=20ARG=5FMA?= =?UTF-8?q?X'=20claim=20=E2=80=94=20argv=20is=20still=20execve-bounded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot is right: spawnSync still calls execve and is subject to the OS argument-length limit. The runner's real wins are avoiding shell pipeline exit-code masking and shell word-splitting, not removing ARG_MAX. Reworded the comments in run-lex-codegen.js and package.json accordingly (the limit is ~2 MB on Linux, far above this few-KB list). Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 2 +- scripts/run-lex-codegen.js | 19 ++++++++++++------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 0dc5622..e8e9bf7 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "check": "npm run gen-api && npm run lint && npm run typecheck && npm run build && npm run test", "build": "rollup -c && npm run build:types", "build:types": "tsc --project tsconfig.build.json", - "//permissions": "permission-set lexicons are published as-is but have no TS shape; lex gen-* cannot codegen them. scripts/run-lex-codegen.js runs a lex subcommand over all lexicons EXCEPT permission-sets (excluded by content — the same check generate-exports.js uses). It is a single Node process that spawns lex with the file list as argv and forwards lex's exit code, so a producer failure propagates without `set -o pipefail` (not portable across npm's sh) and there is no ARG_MAX limit.", + "//permissions": "permission-set lexicons are published as-is but have no TS shape; lex gen-* cannot codegen them. scripts/run-lex-codegen.js runs a lex subcommand over all lexicons EXCEPT permission-sets (excluded by content — the same check generate-exports.js uses). It is a single Node process that spawns lex with the file list as argv and forwards lex's exit code, so a producer failure propagates without `set -o pipefail` (not portable across npm's sh) and shell word-splitting is avoided. (argv is still bounded by the OS execve arg limit, ~2 MB on Linux — far above this list.)", "gen-api": "node ./scripts/run-lex-codegen.js gen-api --yes ./generated && npm run gen-index", "gen-md": "node ./scripts/run-lex-codegen.js gen-md --yes ./lexicons.md", "gen-schemas-md": "node ./scripts/generate-schemas.js", diff --git a/scripts/run-lex-codegen.js b/scripts/run-lex-codegen.js index 2a68b3a..fde5556 100644 --- a/scripts/run-lex-codegen.js +++ b/scripts/run-lex-codegen.js @@ -11,14 +11,17 @@ * `scripts/generate-exports.js` uses — so the two cannot drift as files move. * * Why a Node runner rather than a shell pipeline: - * - `lex … $(node …)` swallows a producer failure (command substitution discards - * its exit code) and can hit ARG_MAX. + * - `lex … $(node …)` swallows a producer failure: command substitution + * discards the inner exit code, so lex runs on whatever (partial/empty) + * output it produced and may report success. * - `node … | xargs -0 lex …` needs `set -o pipefail` to propagate a producer * failure, and `pipefail` is NOT portable across the `sh` npm runs scripts * with (rejected by the dash on GitHub's Ubuntu runners). - * This runner is a single process with one authoritative exit code, passes the - * file list to `lex` as argv (no shell, no ARG_MAX), and forwards lex's exit - * code. No shell builtins involved. + * This runner is a single process with one authoritative exit code: it forwards + * lex's exit code (and fails on an empty list or a spawn error). Passing the + * file list as argv also avoids shell word-splitting/quoting pitfalls. (It does + * not lift the OS `execve` argument-length limit — argv is still bounded — but + * that limit is ~2 MB on Linux, far above the few KB this list occupies.) * * Usage (in package.json): * node ./scripts/run-lex-codegen.js gen-api --yes ./generated @@ -73,8 +76,10 @@ if (files.length === 0) { } // `shell: false` (the default) — args are passed as argv, so there is no shell -// word-splitting, no ARG_MAX limit from a command line, and lex's exit code is -// ours. stdio inherited so `> file` redirection in the npm script still works. +// word-splitting/quoting to get wrong, and lex's exit code is ours. (argv is +// still subject to the OS `execve` arg-length limit, but that is ~2 MB on Linux +// vs. the few KB this list occupies.) stdio inherited so `> file` redirection in +// the npm script still works. const result = spawnSync("lex", [...lexArgs, ...files], { stdio: "inherit" }); if (result.error) { process.stderr.write( From b0e7ba7260928f1e680b76cf7b1b2772c9d5ea81 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 24 Jun 2026 13:18:42 +0000 Subject: [PATCH 10/17] docs(agents): document permission-set sync in the add-a-lexicon checklist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Adding / modifying a lexicon" steps covered codegen, ERD, README/SKILL, SCHEMAS.md, format, check, and changeset — but never the permission sets. Each authWrite.json enumerates every type:"record" collection in its namespace (the list is both the grant and its boundary; no wildcards allowed inside a set), so a new org.hypercerts.* / app.certified.* record lexicon is NOT grantable via the published set until added to the matching set — and must be removed when deleted. Add a step spelling this out, including the own-namespace-authority rule (each record goes only in its own namespace's set) and the non-record exemption (query/object/permission-set defs are not repo collections). Links to docs/design/permission-sets.md for the full rationale. Prettier also renumbered the list (fixing a pre-existing duplicate-"4" bug). Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 37 ++++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 07566ed..d01e789 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -234,11 +234,29 @@ scripts/ Build and codegen scripts - Generate types in `generated/` (including vendored external lexicons) - Auto-generate `generated/exports.ts` with all exports -3. Update `ERD.puml` as appropriate: +3. Update the relevant **permission set** if appropriate: + - The `permission-set` lexicons (`lexicons/org/hypercerts/authWrite.json` + and `lexicons/app/certified/authWrite.json`) each enumerate **every + `type: "record"` collection in their namespace** — the list is both + the grant and its boundary, with no wildcards allowed inside a set. + - When you **add** a new `org.hypercerts.*` or `app.certified.*` record + lexicon, add its NSID to the matching set's `permissions[].collection` + list, or it will not be grantable via the published set. Likewise + **remove** the NSID when you delete a record lexicon. + - A set may only reference collections under **its own namespace + authority**, so `org.hypercerts.*` records go in + `org.hypercerts.authWrite` and `app.certified.*` records in + `app.certified.authWrite` — never the other set. + - This does not apply to non-record defs (e.g. `query`, `object`, or + `permission-set` lexicons themselves), which are not repo collections. + - See [docs/design/permission-sets.md](docs/design/permission-sets.md) + for the full rationale. + +4. Update `ERD.puml` as appropriate: - **Include all fields except facet fields** (they're cosmetic and don't affect structure) -4. Update `README.md` and `SKILL.md` as appropriate: +5. Update `README.md` and `SKILL.md` as appropriate: - If `README.md` or `.agents/skills/building-with-hypercerts-lexicons/SKILL.md` already references that lexicon, **both files must be updated** @@ -263,17 +281,18 @@ scripts/ Build and codegen scripts valid export names, `$type` strings, and API call signatures. This will catch many (but not all) documentation errors. -5. Run `npm run gen-schemas-md` to regenerate `SCHEMAS.md` +6. Run `npm run gen-schemas-md` to regenerate `SCHEMAS.md` -6. Run `npm run format` to ensure everything is formatted correctly +7. Run `npm run format` to ensure everything is formatted correctly via Prettier -7. Run `npm run check` to validate, typecheck, and build +8. Run `npm run check` to validate, typecheck, and build -8. **REQUIRED: Create a changeset file** using the `writing-changesets` skill - - **This step is MANDATORY for ALL changes that affect users** - - See the `writing-changesets` skill for details on changeset requirements - - Use the skill - do NOT write changeset files manually +9. **REQUIRED: Create a changeset file** using the `writing-changesets` skill + +- **This step is MANDATORY for ALL changes that affect users** +- See the `writing-changesets` skill for details on changeset requirements +- Use the skill - do NOT write changeset files manually **No manual edits needed!** Everything is automatically regenerated. From 3dd882acbb4580cb819d415b204c571dd63c05c6 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 24 Jun 2026 14:20:49 +0000 Subject: [PATCH 11/17] feat(permissions): add org.hyperboards.authWrite; mark design implemented MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repo has a third first-class record namespace — org.hyperboards (board, displayProfile, described in AGENTS.md as the "Hyperboards visual layer") — that had no permission set, so a user's Hyperboards data was not grantable via any published set. Add org.hyperboards.authWrite granting create/update/delete over both org.hyperboards.* record collections, matching the other two namespaces. Verified it enumerates exactly the type:"record" defs on disk and expands to one combined repo: scope. Also update the surrounding docs that the new set and the now-shipped state make stale: - docs/design/permission-sets.md: Status "Proposed" -> "Implemented" (the sets are published lexicon files in this PR, not a proposal); "two sets" -> three throughout, with a new org.hyperboards.authWrite subsection and the three distinct authorities (hypercerts.org, hyperboards.org, certified.app) spelled out; corrected a stray claim that hypercerts/hyperboards "share a domain". - SKILL.md: third table row; "two sets, never one" -> "one set per namespace". - .changeset/permission-sets-crud.md: two -> three sets. - AGENTS.md: the add-a-lexicon permission-set step now lists all three namespaces. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../SKILL.md | 29 ++--- .changeset/permission-sets-crud.md | 4 +- AGENTS.md | 21 ++-- SCHEMAS.md | 24 ++-- docs/design/permission-sets.md | 119 ++++++++++++------ lexicons/org/hyperboards/authWrite.json | 23 ++++ 6 files changed, 144 insertions(+), 76 deletions(-) create mode 100644 lexicons/org/hyperboards/authWrite.json diff --git a/.agents/skills/building-with-hypercerts-lexicons/SKILL.md b/.agents/skills/building-with-hypercerts-lexicons/SKILL.md index 98f1e34..88ad1dd 100644 --- a/.agents/skills/building-with-hypercerts-lexicons/SKILL.md +++ b/.agents/skills/building-with-hypercerts-lexicons/SKILL.md @@ -192,16 +192,17 @@ if (result.success) { ## Permission Sets (OAuth scopes) -To **write** Hypercerts or Certified records to a user's repo, your app needs -the user's authorization. AT Protocol grants this via OAuth scopes: each -collection write needs a `repo:?action=…` scope. Rather than list -every collection by hand, request a **permission set** — a published bundle — -with a single `include:` scope: - -| Permission set NSID | Grants write (create/update/delete) on | -| -------------------------- | ----------------------------------------- | -| `org.hypercerts.authWrite` | all `org.hypercerts.*` record collections | -| `app.certified.authWrite` | all `app.certified.*` record collections | +To **write** Hypercerts, Hyperboards, or Certified records to a user's repo, +your app needs the user's authorization. AT Protocol grants this via OAuth +scopes: each collection write needs a `repo:?action=…` scope. Rather +than list every collection by hand, request a **permission set** — a published +bundle — with a single `include:` scope: + +| Permission set NSID | Grants write (create/update/delete) on | +| --------------------------- | ------------------------------------------ | +| `org.hypercerts.authWrite` | all `org.hypercerts.*` record collections | +| `org.hyperboards.authWrite` | all `org.hyperboards.*` record collections | +| `app.certified.authWrite` | all `app.certified.*` record collections | In your OAuth client's authorization request, ask for the set(s) you need: @@ -213,10 +214,10 @@ The user's PDS resolves the published set and expands it into the underlying `repo:` scopes on the consent screen, where the user sees the set's plain-language description (e.g. "Manage your Hypercerts data"). Notes: -- **Two sets, never one.** A permission set may only reference its own namespace - authority (atproto permission spec), so `org.hypercerts.*` and - `app.certified.*` cannot share a set. An app needing both requests **both** - `include:` scopes. +- **One set per namespace, never combined.** A permission set may only reference + its own namespace authority (atproto permission spec), so `org.hypercerts.*`, + `org.hyperboards.*`, and `app.certified.*` each need their own set. An app + needing more than one requests **each** `include:` scope. - **Writes only.** These cover create/update/delete. **Reading** records needs no scope at all — atproto repo records are public. - **`repo:` scopes carry no `aud`** — request the `include:` without an `?aud=`. diff --git a/.changeset/permission-sets-crud.md b/.changeset/permission-sets-crud.md index dcce2b0..c7c6cef 100644 --- a/.changeset/permission-sets-crud.md +++ b/.changeset/permission-sets-crud.md @@ -2,11 +2,11 @@ "@hypercerts-org/lexicon": minor --- -Add two permission-set lexicons — `org.hypercerts.authWrite` and `app.certified.authWrite` — each granting create/update/delete over every record collection in its namespace. +Add three permission-set lexicons — `org.hypercerts.authWrite`, `org.hyperboards.authWrite`, and `app.certified.authWrite` — each granting create/update/delete over every record collection in its namespace. 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:` 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. -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. +There are **three** 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`, `org.hyperboards`, and `app.certified` are separate namespace authorities, so they cannot be combined in a single set — an app needing more than one requests each `include:` scope. 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. diff --git a/AGENTS.md b/AGENTS.md index d01e789..f08d6e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -235,18 +235,21 @@ scripts/ Build and codegen scripts - Auto-generate `generated/exports.ts` with all exports 3. Update the relevant **permission set** if appropriate: - - The `permission-set` lexicons (`lexicons/org/hypercerts/authWrite.json` - and `lexicons/app/certified/authWrite.json`) each enumerate **every + - The `permission-set` lexicons (`lexicons/org/hypercerts/authWrite.json`, + `lexicons/org/hyperboards/authWrite.json`, and + `lexicons/app/certified/authWrite.json`) each enumerate **every `type: "record"` collection in their namespace** — the list is both the grant and its boundary, with no wildcards allowed inside a set. - - When you **add** a new `org.hypercerts.*` or `app.certified.*` record - lexicon, add its NSID to the matching set's `permissions[].collection` - list, or it will not be grantable via the published set. Likewise - **remove** the NSID when you delete a record lexicon. + - When you **add** a new `org.hypercerts.*`, `org.hyperboards.*`, or + `app.certified.*` record lexicon, add its NSID to the matching set's + `permissions[].collection` list, or it will not be grantable via the + published set. Likewise **remove** the NSID when you delete a record + lexicon. - A set may only reference collections under **its own namespace - authority**, so `org.hypercerts.*` records go in - `org.hypercerts.authWrite` and `app.certified.*` records in - `app.certified.authWrite` — never the other set. + authority**, so each namespace's records go in its own set + (`org.hypercerts.*` → `org.hypercerts.authWrite`, `org.hyperboards.*` → + `org.hyperboards.authWrite`, `app.certified.*` → + `app.certified.authWrite`) — never another set. - This does not apply to non-record defs (e.g. `query`, `object`, or `permission-set` lexicons themselves), which are not repo collections. - See [docs/design/permission-sets.md](docs/design/permission-sets.md) diff --git a/SCHEMAS.md b/SCHEMAS.md index 08ea5e4..4f96182 100644 --- a/SCHEMAS.md +++ b/SCHEMAS.md @@ -7,6 +7,12 @@ Hypercerts-specific lexicons for tracking impact work and claims. +### `org.hypercerts.authWrite` + +**Description:** Permission set granting create, update, and delete on every Hypercerts (org.hypercerts) record collection. + +--- + ### `org.hypercerts.claim.activity` **Description:** A hypercert record tracking impact work. @@ -299,12 +305,6 @@ A free-text string value (e.g. a display name, wallet address, or other identifi --- -### `org.hypercerts.permissions.crud` - -**Description:** Permission set granting create, update, and delete on every Hypercerts (org.hypercerts) record collection. - ---- - ### `org.hypercerts.workscope.cel` **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. @@ -490,6 +490,12 @@ A labeled URL reference. --- +### `app.certified.authWrite` + +**Description:** Permission set granting create, update, and delete on every Certified (app.certified) record collection. + +--- + ### `app.certified.graph.follow` **Description:** Record declaring a social 'follow' relationship of another account. Duplicate follows will be ignored by the AppView. @@ -547,12 +553,6 @@ The EIP-712 typed data message that was signed by the wallet. Contains the field --- -### `app.certified.permissions.crud` - -**Description:** Permission set granting create, update, and delete on every Certified (app.certified) record collection. - ---- - ### `app.certified.signature.defs` **Description:** Common type definitions for cryptographic signatures attached to records, per the ATProtocol Attestation Specification. diff --git a/docs/design/permission-sets.md b/docs/design/permission-sets.md index 566e838..73103d0 100644 --- a/docs/design/permission-sets.md +++ b/docs/design/permission-sets.md @@ -1,12 +1,16 @@ -# Design: Permission Sets for Hypercerts & Certified data +# Design: Permission Sets for Hypercerts, Hyperboards & Certified data -Status: **Proposed** +Status: **Implemented** — the sets described here are published as lexicon +files in this repository (`lexicons/org/hypercerts/authWrite.json`, +`lexicons/org/hyperboards/authWrite.json`, +`lexicons/app/certified/authWrite.json`). This document records the design and +the rationale behind it. -This document designs **permission sets** for the `org.hypercerts.*` and -`app.certified.*` record collections defined in this repository — published, -reusable bundles of AT Protocol OAuth scopes that any application can request -with a single `include:` scope, instead of enumerating every underlying -`repo:` scope by hand. +This document designs **permission sets** for the `org.hypercerts.*`, +`org.hyperboards.*`, and `app.certified.*` record collections defined in this +repository — published, reusable bundles of AT Protocol OAuth scopes that any +application can request with a single `include:` scope, instead of +enumerating every underlying `repo:` scope by hand. ## Background: what a permission set is @@ -35,11 +39,12 @@ set replaces that with one curated, versioned name. These are general AT Protocol artifacts, not specific to any one service. The expected consumers, in rough order of generality: -1. **Any atproto app working with Hypercerts / Certified data directly.** An - OAuth client requests `include:org.hypercerts.authWrite` (and/or the - `app.certified.*` set) in its authorization request; the user's PDS expands it - and the app reads/writes those collections in the user's own repo. No - intermediary is involved. This is the broadest case. +1. **Any atproto app working with Hypercerts / Hyperboards / Certified data + directly.** An OAuth client requests `include:org.hypercerts.authWrite` + (and/or the `org.hyperboards.*` or `app.certified.*` set) in its + authorization request; the user's PDS expands it and the app reads/writes + those collections in the user's own repo. No intermediary is involved. This + is the broadest case. 2. **The Certified group service (CGS), via OAuth.** CGS is used as a standard OAuth resource (reached through AT Protocol service proxying). A client calling CGS on a user's behalf requests the same sets; the user's PDS expands @@ -56,17 +61,18 @@ key-creation — reads the same published definition. ## The sets -We define two **record-collection write** sets — one per data namespace in this -repo. Each grants the three `repo:` write actions — create, update, delete — +We define three **record-collection write** sets — one per data namespace in +this repo. Each grants the three `repo:` write actions — create, update, delete — over all of that namespace's record collections. -| Set NSID | Grants write (create/update/delete) on | -| -------------------------- | ----------------------------------------- | -| `org.hypercerts.authWrite` | all `org.hypercerts.*` record collections | -| `app.certified.authWrite` | all `app.certified.*` record collections | +| Set NSID | Grants write (create/update/delete) on | +| --------------------------- | ------------------------------------------ | +| `org.hypercerts.authWrite` | all `org.hypercerts.*` record collections | +| `org.hyperboards.authWrite` | all `org.hyperboards.*` record collections | +| `app.certified.authWrite` | all `app.certified.*` record collections | -Both are authored and published from **this repository**, which is the namespace -authority for both — see _Namespace authority_ below. +All three are authored and published from **this repository**, which is the +namespace authority for all of them — see _Namespace authority_ below. > **Write only — no read action.** The leaf is `authWrite` because a `repo:` > permission has only three actions — `create`, `update`, `delete` (there is no @@ -134,6 +140,36 @@ Enumerates every `type: "record"` collection under `org.hypercerts.*`: } ``` +### `org.hyperboards.authWrite` + +Enumerates every `type: "record"` collection under `org.hyperboards.*`: + +```jsonc +{ + "lexicon": 1, + "id": "org.hyperboards.authWrite", + "defs": { + "main": { + "type": "permission-set", + "description": "Permission set granting create, update, and delete on every Hyperboards (org.hyperboards) record collection.", + "title": "Manage your Hyperboards data", + "detail": "Create, edit, and delete your Hyperboards records (board configurations and display profiles).", + "permissions": [ + { + "type": "permission", + "resource": "repo", + "collection": [ + "org.hyperboards.board", + "org.hyperboards.displayProfile", + ], + "action": ["create", "update", "delete"], + }, + ], + }, + }, +} +``` + ### `app.certified.authWrite` Enumerates every `type: "record"` collection under `app.certified.*`: @@ -170,9 +206,10 @@ Enumerates every `type: "record"` collection under `app.certified.*`: } ``` -> The collection lists are derived from this repo's `lexicons/org/hypercerts/` -> and `lexicons/app/certified/` (`type: "record"` defs at time of writing). They -> must be **kept in sync** as record types are added — see _Maintenance_. +> The collection lists are derived from this repo's `lexicons/org/hypercerts/`, +> `lexicons/org/hyperboards/`, and `lexicons/app/certified/` (`type: "record"` +> defs at time of writing). They must be **kept in sync** as record types are +> added — see _Maintenance_. ## Design rules these sets follow @@ -188,12 +225,14 @@ and `rpc:` permissions (by lxm NSID). Consequences here: - A set under `org.hypercerts.*` may grant `repo:org.hypercerts.*` collections, - and **only** those — it cannot reference `app.certified.*` collections, or any - third party's, or any service's `rpc:` methods. Likewise the `app.certified.*` - set is confined to `app.certified.*`. -- This is why there are **two** sets rather than one combined set: the two - namespaces are distinct authorities and cannot legally be mixed in a single - set. An app that needs both requests both `include:` scopes. + and **only** those — it cannot reference `org.hyperboards.*` or + `app.certified.*` collections, or any third party's, or any service's `rpc:` + methods. Likewise each of the `org.hyperboards.*` and `app.certified.*` sets + is confined to its own namespace. +- This is why there are **three** sets rather than one combined set: the three + namespaces have distinct authorities (`hypercerts.org`, `hyperboards.org`, + `certified.app`) and cannot legally be mixed in a single set. An app that + needs more than one requests each `include:` scope. - The authority confinement is also what makes a published set **safe for any client to resolve**: an `include:` can only ever widen access to the set's own namespace, never smuggle in foreign permissions. @@ -211,9 +250,10 @@ not what these sets are for.) ### `repo:` permissions carry no `aud` -A `repo:` scope targets the **user's own repo**, so it has no audience. Both sets -here are `repo:`-only, so an `include:` for them is requested **without** any -`?aud=` parameter. Each set expands to a **single combined `repo:` scope** that +A `repo:` scope targets the **user's own repo**, so it has no audience. All +three sets here are `repo:`-only, so an `include:` for them is requested +**without** any `?aud=` parameter. Each set expands to a **single combined +`repo:` scope** that lists every collection — `IncludeScope.toScopes` coalesces the permission's collection array into one scope string (`repo:?collection=&collection=&…&action=create&action=update&action=delete`), @@ -227,7 +267,7 @@ would.) ## Publication and resolution -Both sets are published like any other Lexicon schema in this repo (see +All three sets are published like any other Lexicon schema in this repo (see [`PUBLISHING.md`](../PUBLISHING.md)) and resolved at runtime via the standard **Lexicon resolution system**, to which the permission spec defers: _"Permission sets are Lexicon schemas and are published and fetched using the @@ -269,16 +309,17 @@ full detail lives in the CGS design doc The point for _this_ repo: the sets must be **self-contained and deployment-agnostic** — no CGS-specific assumptions baked into the definitions. -The two CRUD sets satisfy this trivially (pure `repo:` collection grants). +All three CRUD sets satisfy this trivially (pure `repo:` collection grants). ## Maintenance The enumerated `collection` lists are the one ongoing obligation. Whenever a -`type: "record"` lexicon is added to or removed from `org.hypercerts.*` or -`app.certified.*`, the corresponding set must be updated and re-published, or the -new collection will be silently uncovered by the set. Worth a check in the -release process (and ideally an automated test asserting each set lists exactly -the `type: "record"` defs in its namespace). +`type: "record"` lexicon is added to or removed from `org.hypercerts.*`, +`org.hyperboards.*`, or `app.certified.*`, the corresponding set must be updated +and re-published, or the new collection will be silently uncovered by the set. +The `AGENTS.md` "Adding / modifying a lexicon" checklist calls this out. Worth a +check in the release process too (and ideally an automated test asserting each +set lists exactly the `type: "record"` defs in its namespace). > **Known pending update.** PR > [#219](https://github.com/hypercerts-org/hypercerts-lexicon/pull/219) diff --git a/lexicons/org/hyperboards/authWrite.json b/lexicons/org/hyperboards/authWrite.json new file mode 100644 index 0000000..c7f13ee --- /dev/null +++ b/lexicons/org/hyperboards/authWrite.json @@ -0,0 +1,23 @@ +{ + "lexicon": 1, + "id": "org.hyperboards.authWrite", + "defs": { + "main": { + "type": "permission-set", + "description": "Permission set granting create, update, and delete on every Hyperboards (org.hyperboards) record collection.", + "title": "Manage your Hyperboards data", + "detail": "Create, edit, and delete your Hyperboards records (board configurations and display profiles).", + "permissions": [ + { + "type": "permission", + "resource": "repo", + "collection": [ + "org.hyperboards.board", + "org.hyperboards.displayProfile" + ], + "action": ["create", "update", "delete"] + } + ] + } + } +} From bfaea59c75d871fcfdc084b199f8909fcaed054e Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 24 Jun 2026 16:19:01 +0000 Subject: [PATCH 12/17] feat(permissions): add app.certified.signature.proof to the Certified set PR #219 (signature support, HYPER-181) has now merged to main, adding the app.certified.signature.proof record lexicon. This was the documented follow-up: app.certified.authWrite must enumerate every app.certified.* record collection, so the newly-landed collection has to be added or it is not grantable via the published set. Add it (the set now lists all 9 app.certified.* records, verified identical to the type:"record" defs on disk), sync the JSONC example in the design doc, and drop the now-resolved "Known pending update (#219)" note from the Maintenance section. SCHEMAS.md regenerated. npm run check passes (182 tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- SCHEMAS.md | 6 ++++++ docs/design/permission-sets.md | 10 +--------- lexicons/app/certified/authWrite.json | 3 ++- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/SCHEMAS.md b/SCHEMAS.md index 4f96182..15f25ef 100644 --- a/SCHEMAS.md +++ b/SCHEMAS.md @@ -734,6 +734,12 @@ Specifies the sub-string range a facet feature applies to. Start index is inclus --- +### `org.hyperboards.authWrite` + +**Description:** Permission set granting create, update, and delete on every Hyperboards (org.hyperboards) record collection. + +--- + ### `org.hyperboards.board` **Description:** Configuration record for a hyperboard, wrapping an underlying activity or collection with visual presentation settings. Stored in the creator's PDS. diff --git a/docs/design/permission-sets.md b/docs/design/permission-sets.md index 73103d0..98d4961 100644 --- a/docs/design/permission-sets.md +++ b/docs/design/permission-sets.md @@ -197,6 +197,7 @@ Enumerates every `type: "record"` collection under `app.certified.*`: "app.certified.graph.follow", "app.certified.link.evm", "app.certified.location", + "app.certified.signature.proof", ], "action": ["create", "update", "delete"], }, @@ -321,15 +322,6 @@ The `AGENTS.md` "Adding / modifying a lexicon" checklist calls this out. Worth a check in the release process too (and ideally an automated test asserting each set lists exactly the `type: "record"` defs in its namespace). -> **Known pending update.** PR -> [#219](https://github.com/hypercerts-org/hypercerts-lexicon/pull/219) -> (`feat/signature-support-v2`, HYPER-181) adds the `app.certified.signature.proof` -> record lexicon, which does **not** exist on `main` yet and so is **not** in -> `app.certified.authWrite` here. When #219 merges, -> `app.certified.signature.proof` must be added to the set's `collection` list -> (and re-published). The two PRs touch different files, so neither blocks the -> other — this is just a follow-up to land after both. - ## References - AT Protocol permission spec — permission sets: diff --git a/lexicons/app/certified/authWrite.json b/lexicons/app/certified/authWrite.json index 08aaa24..8e3e458 100644 --- a/lexicons/app/certified/authWrite.json +++ b/lexicons/app/certified/authWrite.json @@ -19,7 +19,8 @@ "app.certified.badge.response", "app.certified.graph.follow", "app.certified.link.evm", - "app.certified.location" + "app.certified.location", + "app.certified.signature.proof" ], "action": ["create", "update", "delete"] } From 1dd0dff6985f9f667e2e5823748a3adc5d24d8f0 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 1 Jul 2026 13:47:57 +0000 Subject: [PATCH 13/17] docs(agents): nest changeset step 9 sub-bullets under the list item Copilot flagged the bullets under step 9 as unindented, so Markdown renderers treated them as a separate top-level list and the blank line broke the ordered-list numbering. Indent them 3 spaces to match the sub-bullet convention used by the other steps. Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f08d6e5..6cc7a51 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -292,10 +292,9 @@ scripts/ Build and codegen scripts 8. Run `npm run check` to validate, typecheck, and build 9. **REQUIRED: Create a changeset file** using the `writing-changesets` skill - -- **This step is MANDATORY for ALL changes that affect users** -- See the `writing-changesets` skill for details on changeset requirements -- Use the skill - do NOT write changeset files manually + - **This step is MANDATORY for ALL changes that affect users** + - See the `writing-changesets` skill for details on changeset requirements + - Use the skill - do NOT write changeset files manually **No manual edits needed!** Everything is automatically regenerated. From 10cbeff6d80841ef04602d66000e5b14f9ed1009 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Thu, 2 Jul 2026 11:27:59 +0000 Subject: [PATCH 14/17] fix(permissions): drop unsupported description field; document set-growth semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review points from Ashex on PR #222: - `description` is not a valid field on a `permission-set` def — the spec defines only `title`/`detail` (+ `:lang`) as user-facing consent copy, and it is not shown in the OAuth dialog. It was an AI hallucination in the original set files. Removed from all three sets, their JSONC examples in the design doc, and the generated SCHEMAS.md. All 182 tests still pass, confirming the field was never load-bearing. - Document what happens when a published set later grows: OAuth grants persist the raw `include:` and re-expand it on token refresh, so existing sessions gain newly-added (same-authority) collections with no re-auth and no `.vN` NSID; CGS API keys stay a frozen snapshot until re-issued. Grounded in the permission spec and the reference PDS `rotateToken`/`buildTokenScope` path, with a caveat that non-reference Auth Servers should be verified. Co-Authored-By: Claude Opus 4.8 --- SCHEMAS.md | 6 ---- docs/design/permission-sets.md | 47 +++++++++++++++++++++++-- lexicons/app/certified/authWrite.json | 1 - lexicons/org/hyperboards/authWrite.json | 1 - lexicons/org/hypercerts/authWrite.json | 1 - 5 files changed, 44 insertions(+), 12 deletions(-) diff --git a/SCHEMAS.md b/SCHEMAS.md index 15f25ef..0b98880 100644 --- a/SCHEMAS.md +++ b/SCHEMAS.md @@ -9,8 +9,6 @@ Hypercerts-specific lexicons for tracking impact work and claims. ### `org.hypercerts.authWrite` -**Description:** Permission set granting create, update, and delete on every Hypercerts (org.hypercerts) record collection. - --- ### `org.hypercerts.claim.activity` @@ -492,8 +490,6 @@ A labeled URL reference. ### `app.certified.authWrite` -**Description:** Permission set granting create, update, and delete on every Certified (app.certified) record collection. - --- ### `app.certified.graph.follow` @@ -736,8 +732,6 @@ Specifies the sub-string range a facet feature applies to. Start index is inclus ### `org.hyperboards.authWrite` -**Description:** Permission set granting create, update, and delete on every Hyperboards (org.hyperboards) record collection. - --- ### `org.hyperboards.board` diff --git a/docs/design/permission-sets.md b/docs/design/permission-sets.md index 98d4961..84b9f4c 100644 --- a/docs/design/permission-sets.md +++ b/docs/design/permission-sets.md @@ -112,7 +112,6 @@ Enumerates every `type: "record"` collection under `org.hypercerts.*`: "defs": { "main": { "type": "permission-set", - "description": "Permission set granting create, update, and delete on every Hypercerts (org.hypercerts) record collection.", "title": "Manage your Hypercerts data", "detail": "Create, edit, and delete your Hypercerts records (impact claims, evaluations, funding, and related data).", "permissions": [ @@ -151,7 +150,6 @@ Enumerates every `type: "record"` collection under `org.hyperboards.*`: "defs": { "main": { "type": "permission-set", - "description": "Permission set granting create, update, and delete on every Hyperboards (org.hyperboards) record collection.", "title": "Manage your Hyperboards data", "detail": "Create, edit, and delete your Hyperboards records (board configurations and display profiles).", "permissions": [ @@ -181,7 +179,6 @@ Enumerates every `type: "record"` collection under `app.certified.*`: "defs": { "main": { "type": "permission-set", - "description": "Permission set granting create, update, and delete on every Certified (app.certified) record collection.", "title": "Manage your Certified data", "detail": "Create, edit, and delete your Certified records (profile, badges, follows, wallet links, and related data).", "permissions": [ @@ -322,6 +319,50 @@ The `AGENTS.md` "Adding / modifying a lexicon" checklist calls this out. Worth a check in the release process too (and ideally an automated test asserting each set lists exactly the `type: "record"` defs in its namespace). +### Growing a set is safe: existing grants pick up new collections + +Because we will keep adding lexicons, the key question is what happens to a grant +a user already accepted when the set is later re-published with **more** +collections. The answer differs for the two consumption paths: + +- **OAuth grants are dynamic — no re-auth, no `.vN` NSID.** The user's + Authorization Server (their PDS) persists the _raw_ `include:` scope in + the grant, not a frozen expansion of it, and **re-resolves the published set on + each token refresh**. So adding a collection to a published set widens every + existing session the next time it refreshes, without the user re-authorizing. + The permission spec makes this explicit: _"The permissions associated with an + Access Token should remain fixed, but when a client refreshes their tokens + (obtaining a new access token), the computed permissions for the session may be + updated to reflect changes to sets"_, so _"client software can then be updated + to take advantage of those new lexicons, without requiring users to + re-authenticate their sessions."_ In the reference PDS + ([`@atproto/oauth-provider`](https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-provider)) + this is the `rotateToken` path re-expanding `parameters.scope` via + `buildTokenScope`. Propagation is bounded by the Auth Server's resolved-set + cache (spec-recommended stale lifetime and firm upper bound: **24 h**) plus the + access-token lifetime (~15–30 min); bumping the client-metadata + `software_version` is the spec's cache-purge lever if a faster rollout is + needed. This holds only because every collection we add stays under the set's + **own namespace authority** (the [authority rule](#namespace-authority) both + permits it and is why no cross-namespace addition can ever go into one of these + sets — that still needs a separate `include:` and an explicit scope upgrade). + +- **CGS API keys are a frozen snapshot** (see [Consumption by CGS](#consumption-by-cgs)): + expansion happens once at key-creation time, so a re-published set does **not** + retroactively widen already-issued keys. Growing access there means re-issuing + the key — which is the intended workflow, since an API key's scope should be + auditable and stable for its lifetime. + +A new NSID (`*.authWrite2` / `.v2`) is therefore **not** needed to _add_ +collections. It would only be warranted to _remove or attenuate_ existing +permissions, which the spec advises doing "very sparingly" precisely because it +silently narrows live sessions. + +> Caveat: the above is the behaviour of the reference PDS and any +> spec-conformant Authorization Server. A non-reference or forked +> implementation should be checked against this before relying on the dynamic +> re-expansion. + ## References - AT Protocol permission spec — permission sets: diff --git a/lexicons/app/certified/authWrite.json b/lexicons/app/certified/authWrite.json index 8e3e458..7db7150 100644 --- a/lexicons/app/certified/authWrite.json +++ b/lexicons/app/certified/authWrite.json @@ -4,7 +4,6 @@ "defs": { "main": { "type": "permission-set", - "description": "Permission set granting create, update, and delete on every Certified (app.certified) record collection.", "title": "Manage your Certified data", "detail": "Create, edit, and delete your Certified records (profile, badges, follows, wallet links, and related data).", "permissions": [ diff --git a/lexicons/org/hyperboards/authWrite.json b/lexicons/org/hyperboards/authWrite.json index c7f13ee..6f7a301 100644 --- a/lexicons/org/hyperboards/authWrite.json +++ b/lexicons/org/hyperboards/authWrite.json @@ -4,7 +4,6 @@ "defs": { "main": { "type": "permission-set", - "description": "Permission set granting create, update, and delete on every Hyperboards (org.hyperboards) record collection.", "title": "Manage your Hyperboards data", "detail": "Create, edit, and delete your Hyperboards records (board configurations and display profiles).", "permissions": [ diff --git a/lexicons/org/hypercerts/authWrite.json b/lexicons/org/hypercerts/authWrite.json index 9fd03dd..2d5806a 100644 --- a/lexicons/org/hypercerts/authWrite.json +++ b/lexicons/org/hypercerts/authWrite.json @@ -4,7 +4,6 @@ "defs": { "main": { "type": "permission-set", - "description": "Permission set granting create, update, and delete on every Hypercerts (org.hypercerts) record collection.", "title": "Manage your Hypercerts data", "detail": "Create, edit, and delete your Hypercerts records (impact claims, evaluations, funding, and related data).", "permissions": [ From 194721b73f40847ad5e14e03e2a7e0a2e5a28eed Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Mon, 6 Jul 2026 10:17:25 +0000 Subject: [PATCH 15/17] docs(design): frame set-growth as a silent consent widening, confirm ePDS parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reframe the 'growing a set' subsection around the real concern: on the OAuth path, adding collections to a published set silently widens every existing grant on next token refresh, with no new consent prompt (verified: rotateToken re-expands and re-issues without a consent re-check). State that plainly, and note the two bounds that make it tolerable — additions are same-namespace by the authority rule (highly correlated with what the user already granted; a set can never silently pull in a cross-namespace permission), and growth only ever adds, never narrows. Guidance: publish under a new set NSID when re-consent is the point. Also record that ePDS inherits this behaviour: it wraps stock @atproto/pds / @atproto/oauth-provider (0.15.10) with a Better Auth login layer and does not fork or override token issuance/refresh, so it persists the raw include: and re-expands via the stock rotateToken exactly like the reference PDS. Co-Authored-By: Claude Opus 4.8 --- docs/design/permission-sets.md | 58 +++++++++++++++++++++++++++------- 1 file changed, 47 insertions(+), 11 deletions(-) diff --git a/docs/design/permission-sets.md b/docs/design/permission-sets.md index 84b9f4c..063fce2 100644 --- a/docs/design/permission-sets.md +++ b/docs/design/permission-sets.md @@ -319,11 +319,38 @@ The `AGENTS.md` "Adding / modifying a lexicon" checklist calls this out. Worth a check in the release process too (and ideally an automated test asserting each set lists exactly the `type: "record"` defs in its namespace). -### Growing a set is safe: existing grants pick up new collections +### Growing a set silently widens existing OAuth grants Because we will keep adding lexicons, the key question is what happens to a grant a user already accepted when the set is later re-published with **more** -collections. The answer differs for the two consumption paths: +collections. + +**The consent caveat, stated plainly:** on the OAuth path this widening is +**silent** — a user who granted `include:org.hypercerts.authWrite` when it +covered 11 collections gets write access to the 12th on their next token refresh, +with **no new consent prompt**. This is by design in AT Protocol: the user +consents to _the set_, an intentionally-mutable named bundle, not to a frozen +snapshot of its members. The refresh path (`rotateToken`) re-expands and re-issues +the widened scope without gating on consent (verified — there is no consent or +`authorizedScopes` re-check in that path). If that is not acceptable for a given +capability, do **not** add it to an existing set: publish it under a **new** set +NSID so clients must request it explicitly and users must re-consent. + +Two things bound how alarming this is in practice: + +1. **Newly-added permissions are highly correlated with what the user already + granted.** The [authority rule](#namespace-authority) forces every collection + in a set to live under the set's own namespace — so anything we can add to + `org.hypercerts.authWrite` is _another `org.hypercerts.*` write_, in the same + trust domain the user already opted into. The set is, in effect, a standing + consent to "write my Hypercerts data", and new Hypercerts record types are the + natural extension of that. A set can **never** silently pull in a _cross_- + namespace permission (e.g. `app.certified.*`) — that is structurally + impossible and always needs a separate `include:` and explicit re-consent. +2. It only ever _adds_; it can never silently _narrow_ or redirect an existing + grant. + +With that caveat understood, the mechanics differ for the two consumption paths: - **OAuth grants are dynamic — no re-auth, no `.vN` NSID.** The user's Authorization Server (their PDS) persists the _raw_ `include:` scope in @@ -353,15 +380,24 @@ collections. The answer differs for the two consumption paths: the key — which is the intended workflow, since an API key's scope should be auditable and stable for its lifetime. -A new NSID (`*.authWrite2` / `.v2`) is therefore **not** needed to _add_ -collections. It would only be warranted to _remove or attenuate_ existing -permissions, which the spec advises doing "very sparingly" precisely because it -silently narrows live sessions. - -> Caveat: the above is the behaviour of the reference PDS and any -> spec-conformant Authorization Server. A non-reference or forked -> implementation should be checked against this before relying on the dynamic -> re-expansion. +So a new NSID (`*.authWrite2` / `.v2`) is **not** needed to _add_ collections as +a mechanical matter — the platform is built to expand sets in place. Reach for a +new set NSID (or an explicit separate `include:`) only when the addition should +**not** be granted silently — i.e. when re-consent is the point — or to _remove +or attenuate_ existing permissions, which the spec advises doing "very sparingly" +because it silently narrows live sessions. For our sets, whose members are all +same-namespace CRUD, in-place growth is the expected and appropriate path. + +This holds for **ePDS**, our own Authorization Server, too. ePDS wraps a stock +[`@atproto/pds`](https://www.npmjs.com/package/@atproto/pds) / +[`@atproto/oauth-provider`](https://www.npmjs.com/package/@atproto/oauth-provider) +with a Better Auth login layer; it customises the authentication and consent +flow but does **not** fork or override token issuance or refresh. It persists the +raw `include:` scope (unexpanded) and delegates refresh to the provider's +stock `rotateToken`, which re-runs `buildTokenScope` on every rotation — so ePDS +re-expands the current published set exactly like the reference PDS. Any other +non-reference or forked Authorization Server should still be checked against this +before relying on the dynamic re-expansion. ## References From cd08e554099a9d755497188557eff0e0d242293e Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Mon, 6 Jul 2026 14:09:24 +0000 Subject: [PATCH 16/17] fix(scripts): render permission-set fields in SCHEMAS.md, add parse context Two Copilot review fixes on the permission-sets PR: - generate-schemas.js: permission-set defs (title/detail/permissions) now render in SCHEMAS.md instead of producing empty header-only sections. - generate-exports.js: extract a readLexicon() helper that rethrows JSON.parse failures with the offending file path, mirroring generate-schemas.js, so codegen errors are traceable. Co-Authored-By: Claude Opus 4.8 --- .changeset/permission-sets-crud.md | 2 ++ SCHEMAS.md | 30 ++++++++++++++++++++++++++++++ scripts/generate-exports.js | 18 +++++++++++++++++- scripts/generate-schemas.js | 27 +++++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 1 deletion(-) diff --git a/.changeset/permission-sets-crud.md b/.changeset/permission-sets-crud.md index c7c6cef..82c8996 100644 --- a/.changeset/permission-sets-crud.md +++ b/.changeset/permission-sets-crud.md @@ -11,3 +11,5 @@ There are **three** sets rather than one because the spec requires it: a permiss 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. 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`. + +The generated `SCHEMAS.md` reference now renders permission-set entries (title, detail, and the resource/collections/actions each set grants) instead of leaving them as empty sections. diff --git a/SCHEMAS.md b/SCHEMAS.md index 0b98880..3249f53 100644 --- a/SCHEMAS.md +++ b/SCHEMAS.md @@ -9,6 +9,16 @@ Hypercerts-specific lexicons for tracking impact work and claims. ### `org.hypercerts.authWrite` +**Title:** Manage your Hypercerts data + +**Detail:** Create, edit, and delete your Hypercerts records (impact claims, evaluations, funding, and related data). + +**Resource:** `repo` + +**Collections:** `org.hypercerts.claim.activity`, `org.hypercerts.claim.contribution`, `org.hypercerts.claim.contributorInformation`, `org.hypercerts.claim.rights`, `org.hypercerts.collection`, `org.hypercerts.context.acknowledgement`, `org.hypercerts.context.attachment`, `org.hypercerts.context.evaluation`, `org.hypercerts.context.measurement`, `org.hypercerts.funding.receipt`, `org.hypercerts.workscope.tag` + +**Actions:** `create`, `update`, `delete` + --- ### `org.hypercerts.claim.activity` @@ -490,6 +500,16 @@ A labeled URL reference. ### `app.certified.authWrite` +**Title:** Manage your Certified data + +**Detail:** Create, edit, and delete your Certified records (profile, badges, follows, wallet links, and related data). + +**Resource:** `repo` + +**Collections:** `app.certified.actor.organization`, `app.certified.actor.profile`, `app.certified.badge.award`, `app.certified.badge.definition`, `app.certified.badge.response`, `app.certified.graph.follow`, `app.certified.link.evm`, `app.certified.location`, `app.certified.signature.proof` + +**Actions:** `create`, `update`, `delete` + --- ### `app.certified.graph.follow` @@ -732,6 +752,16 @@ Specifies the sub-string range a facet feature applies to. Start index is inclus ### `org.hyperboards.authWrite` +**Title:** Manage your Hyperboards data + +**Detail:** Create, edit, and delete your Hyperboards records (board configurations and display profiles). + +**Resource:** `repo` + +**Collections:** `org.hyperboards.board`, `org.hyperboards.displayProfile` + +**Actions:** `create`, `update`, `delete` + --- ### `org.hyperboards.board` diff --git a/scripts/generate-exports.js b/scripts/generate-exports.js index 20dc718..8ebb4aa 100644 --- a/scripts/generate-exports.js +++ b/scripts/generate-exports.js @@ -22,6 +22,22 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const projectRoot = join(__dirname, ".."); const lexiconsDir = join(projectRoot, "lexicons"); +/** + * Read and parse a lexicon JSON file, rethrowing parse failures with the + * offending file path so codegen errors are traceable (mirrors + * scripts/generate-schemas.js). + */ +function readLexicon(filePath) { + const content = readFileSync(join(lexiconsDir, filePath), "utf-8"); + + try { + return JSON.parse(content); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to parse lexicon ${filePath}: ${message}`); + } +} + /** * Recursively find all JSON files in a directory */ @@ -163,7 +179,7 @@ function generateIndex() { .sort() .map((filePath) => ({ filePath, - doc: JSON.parse(readFileSync(join(lexiconsDir, filePath), "utf-8")), + doc: readLexicon(filePath), })) // Permission-set lexicons have no TS shape — lex gen-api cannot codegen them // — so they are excluded from `generated/` here, by the SAME content check diff --git a/scripts/generate-schemas.js b/scripts/generate-schemas.js index db292fd..7ef7ec7 100644 --- a/scripts/generate-schemas.js +++ b/scripts/generate-schemas.js @@ -272,11 +272,38 @@ function generateDescription(description) { return description ? [`**Description:** ${description}`, ""] : []; } +function generatePermissionSetSection(mainDef) { + const output = []; + + if (mainDef.title) output.push(`**Title:** ${mainDef.title}`, ""); + if (mainDef.detail) output.push(`**Detail:** ${mainDef.detail}`, ""); + + const permissions = mainDef.permissions || []; + for (const permission of permissions) { + const collections = (permission.collection || []) + .map((c) => `\`${c}\``) + .join(", "); + const actions = (permission.action || []).map((a) => `\`${a}\``).join(", "); + output.push(`**Resource:** \`${permission.resource}\``, ""); + if (collections) output.push(`**Collections:** ${collections}`, ""); + if (actions) output.push(`**Actions:** ${actions}`, ""); + } + + return output; +} + function generateMainSection(mainDef, lexicon) { const output = []; if (!mainDef) return output; + // Permission sets have no properties — render their title/detail/permissions + // so the section isn't blank in the reference. + if (mainDef.type === "permission-set") { + const permOutput = generatePermissionSetSection(mainDef); + return { output: permOutput, hasProperties: false, hasPropertyRows: false }; + } + output.push(...generateDescription(mainDef.description)); // Object types (e.g., celExpression) have no record key From 733f5557aafcc23fcd1b0bcb432e5512427dee82 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Sat, 11 Jul 2026 20:41:24 +0100 Subject: [PATCH 17/17] fix(scripts,docs): address PR #222 review feedback - generate-exports.js: normalize findJsonFiles output to POSIX separators so downstream split("/") helpers work on Windows (Copilot) - generate-schemas.js: return consistent {output,...} shape from generateMainSection's !mainDef early-return branch (Copilot); render rpc-permission fields (lxm/aud/inheritAud) in generatePermissionSetSection so rpc permissions aren't reduced to a bare "Resource: rpc" (CodeRabbit) - permission-sets.md: prefix blockquote separator with > to fix MD028 (CodeRabbit) Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/design/permission-sets.md | 2 +- scripts/generate-exports.js | 7 +++++-- scripts/generate-schemas.js | 15 +++++++++++++-- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/docs/design/permission-sets.md b/docs/design/permission-sets.md index 063fce2..71cc242 100644 --- a/docs/design/permission-sets.md +++ b/docs/design/permission-sets.md @@ -83,7 +83,7 @@ namespace authority for all of them — see _Namespace authority_ below. > "read" is implicitly available to everyone. The user-facing `title` / `detail` > (the authorization-dialog consent copy) say "Manage your … data" / "Create, > edit, and delete …" accordingly. - +> > **Naming — and why the NSID is a single segment under the namespace root.** > Two constraints, one of them hard: > diff --git a/scripts/generate-exports.js b/scripts/generate-exports.js index 8ebb4aa..332c0fd 100644 --- a/scripts/generate-exports.js +++ b/scripts/generate-exports.js @@ -14,7 +14,7 @@ */ import { readdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join, dirname, relative, parse } from "node:path"; +import { join, dirname, relative, parse, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { execSync } from "node:child_process"; @@ -50,7 +50,10 @@ function findJsonFiles(dir, baseDir = dir) { if (entry.isDirectory()) { results.push(...findJsonFiles(fullPath, baseDir)); } else if (entry.isFile() && entry.name.endsWith(".json")) { - const relativePath = relative(baseDir, fullPath); + // Normalize to POSIX separators: downstream helpers (pathToImportName, + // pathToNamespace, etc.) treat lexicon paths as POSIX and split on "/". + // On Windows, path.relative yields "\"-separated paths that would break them. + const relativePath = relative(baseDir, fullPath).split(sep).join("/"); results.push(relativePath); } } diff --git a/scripts/generate-schemas.js b/scripts/generate-schemas.js index 7ef7ec7..0addb7e 100644 --- a/scripts/generate-schemas.js +++ b/scripts/generate-schemas.js @@ -280,13 +280,24 @@ function generatePermissionSetSection(mainDef) { const permissions = mainDef.permissions || []; for (const permission of permissions) { + output.push(`**Resource:** \`${permission.resource}\``, ""); + + // repo permissions carry collection/action; rpc permissions carry + // lxm/aud/inheritAud. Render whichever fields are present so an rpc + // permission isn't reduced to a bare "**Resource:** `rpc`". const collections = (permission.collection || []) .map((c) => `\`${c}\``) .join(", "); const actions = (permission.action || []).map((a) => `\`${a}\``).join(", "); - output.push(`**Resource:** \`${permission.resource}\``, ""); if (collections) output.push(`**Collections:** ${collections}`, ""); if (actions) output.push(`**Actions:** ${actions}`, ""); + + const lxm = (permission.lxm || []).map((l) => `\`${l}\``).join(", "); + if (lxm) output.push(`**LXM:** ${lxm}`, ""); + if (permission.aud) output.push(`**Audience:** \`${permission.aud}\``, ""); + if (permission.inheritAud !== undefined) { + output.push(`**Inherit audience:** \`${permission.inheritAud}\``, ""); + } } return output; @@ -295,7 +306,7 @@ function generatePermissionSetSection(mainDef) { function generateMainSection(mainDef, lexicon) { const output = []; - if (!mainDef) return output; + if (!mainDef) return { output, hasProperties: false, hasPropertyRows: false }; // Permission sets have no properties — render their title/detail/permissions // so the section isn't blank in the reference.