Skip to content

Commit c0f1638

Browse files
authored
Merge pull request #118 from hypercerts-org/adam/hyper-282-document-various-certified-infrastructure-services
docs: document Certified infrastructure — CGS, PDSs (HYPER-282)
2 parents c1115c2 + c4467d2 commit c0f1638

31 files changed

Lines changed: 753 additions & 156 deletions

components/LastUpdated.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,15 @@ export function LastUpdated() {
88
const date = lastUpdated[currentPath];
99

1010
useEffect(() => {
11-
if (!date) return;
12-
13-
// Remove any existing last-updated element
11+
// Always remove any stale last-updated element from a previous route,
12+
// even if the current route has no date — otherwise the imperatively
13+
// appended element survives React reconciliation and ends up stranded
14+
// on the new page.
1415
const existing = document.querySelector('.last-updated');
1516
if (existing) existing.remove();
1617

18+
if (!date) return;
19+
1720
const article = document.querySelector('.layout-content article');
1821
if (!article) return;
1922

lib/lastUpdated.json

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,22 @@
11
{
2-
"/architecture/account-and-identity": "2026-04-01T13:47:13+06:00",
2+
"/architecture/account-and-identity": "2026-04-09T21:22:15-10:00",
3+
"/architecture/certified-group-service": "2026-04-09T21:03:20-10:00",
34
"/architecture/data-flow-and-lifecycle": "2026-04-01T19:28:36+06:00",
4-
"/architecture/epds": "2026-04-01T13:47:13+06:00",
5+
"/architecture/epds": "2026-04-09T21:22:15-10:00",
56
"/architecture/indexers-and-discovery": "2026-03-05T04:01:13+08:00",
6-
"/architecture/overview": "2026-03-05T20:46:24+08:00",
7+
"/architecture/overview": "2026-04-09T21:22:15-10:00",
78
"/architecture/portability-and-scaling": "2026-03-05T13:01:42+08:00",
89
"/core-concepts/cel-work-scopes": "2026-04-01T19:28:36+06:00",
9-
"/core-concepts/certified-identity": "2026-04-01T19:28:36+06:00",
10+
"/core-concepts/certified-identity": "2026-04-09T21:27:54-10:00",
1011
"/core-concepts/common-use-cases": "2026-03-05T12:52:26+08:00",
1112
"/core-concepts/funding-and-value-flow": "2026-04-01T19:28:36+06:00",
1213
"/core-concepts/hypercerts-core-data-model": "2026-04-01T19:28:36+06:00",
1314
"/core-concepts/what-is-hypercerts": "2026-03-05T03:40:14+08:00",
1415
"/core-concepts/why-at-protocol": "2026-03-05T21:06:04+08:00",
1516
"/ecosystem/why-we-need-hypercerts": "2026-02-20T12:49:43+01:00",
1617
"/getting-started/building-on-hypercerts": "2026-03-06T22:56:46+08:00",
17-
"/getting-started/quickstart": "2026-03-14T20:43:33-07:00",
18-
"/getting-started/testing-and-deployment": "2026-03-05T14:36:55+06:00",
18+
"/getting-started/quickstart": "2026-04-09T21:22:15-10:00",
19+
"/getting-started/testing-and-deployment": "2026-04-09T21:22:15-10:00",
1920
"/getting-started/working-with-evaluations": "2026-04-01T19:28:36+06:00",
2021
"/": "2026-03-24T12:56:03-07:00",
2122
"/lexicons/certified-lexicons/badge-award": "2026-03-24T12:56:03-07:00",
@@ -36,12 +37,13 @@
3637
"/lexicons/hypercerts-lexicons/measurement": "2026-03-24T12:56:03-07:00",
3738
"/lexicons/hypercerts-lexicons/rights": "2026-03-24T12:56:03-07:00",
3839
"/lexicons/introduction-to-lexicons": "2026-03-24T12:56:03-07:00",
40+
"/reference/certified-services": "2026-04-09T21:22:15-10:00",
3941
"/reference/faq": "2026-04-01T19:28:36+06:00",
40-
"/reference/glossary": "2026-03-14T20:43:33-07:00",
42+
"/reference/glossary": "2026-04-09T21:22:15-10:00",
4143
"/reference/release-notes-v0-11-0": "2026-04-08T16:50:34+02:00",
42-
"/roadmap": "2026-04-01T19:28:36+06:00",
43-
"/tools/hyperboards": "2026-04-01T19:28:36+06:00",
44-
"/tools/hypercerts-cli": "2026-03-05T12:30:00+06:00",
44+
"/roadmap": "2026-04-09T21:09:38Z",
45+
"/tools/hyperboards": "2026-04-09T21:22:15-10:00",
46+
"/tools/hypercerts-cli": "2026-04-09T21:22:15-10:00",
4547
"/tools/hyperindex": "2026-04-03T00:18:36+06:00",
46-
"/tools/scaffold": "2026-04-01T19:28:36+06:00"
48+
"/tools/scaffold": "2026-04-09T21:22:15-10:00"
4749
}

lib/navigation.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ export const navigation = [
6969
title: "ePDS (extended PDS)",
7070
path: "/architecture/epds",
7171
},
72+
{
73+
title: "Certified Group Service (CGS)",
74+
path: "/architecture/certified-group-service",
75+
},
7276
{
7377
title: "Data Flow & Lifecycle",
7478
path: "/architecture/data-flow-and-lifecycle",
@@ -165,6 +169,7 @@ export const navigation = [
165169
title: "Lexicon v0.11.0 Release Notes",
166170
path: "/reference/release-notes-v0-11-0",
167171
},
172+
{ title: "Certified PDSs", path: "/reference/certified-services" },
168173
{ title: "Glossary", path: "/reference/glossary" },
169174
{ title: "FAQ", path: "/reference/faq" },
170175
{ title: "Roadmap", path: "/roadmap" },

pages/architecture/account-and-identity.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,14 @@ Every record you create carries your DID as the author. If you change PDS provid
4141

4242
## Handles (your public username) and domain verification
4343

44-
Handles are not needed to log in to the Hypercerts ecosystem, but every user has one. They serve as human-readable names for publicly addressing others and for interacting with other applications in the AT Protocol ecosystem that haven't implemented email-based login with Certified. Your handle is a human-readable name like `alice.certified.app`. Unlike your DID, your handle can change — it's a pointer to your DID, not your identity itself.
44+
Handles are not needed to log in to the Hypercerts ecosystem, but every user has one. They serve as human-readable names for publicly addressing others and for interacting with other applications in the AT Protocol ecosystem that haven't implemented email-based login with Certified. Your handle is a human-readable name like `alice.certified.one`. Unlike your DID, your handle can change — it's a pointer to your DID, not your identity itself.
4545

4646
**Organizations should use custom domain handles.** A handle like `numpy.org` proves organizational identity — anyone can verify that the DID behind `numpy.org` is controlled by whoever controls the domain.
4747

4848
To set up a custom handle, add a DNS TXT record or host a file at `https://your-domain.com/.well-known/atproto-did`. See the [AT Protocol handle documentation](https://atproto.com/specs/handle) for details.
4949

5050
{% callout type="note" %}
51-
If you sign up using your email on certified.app you will initially be given a random handle like `1lasdk.certified.app`. You can change your handle by going to your profile settings and clicking on "Change handle" on [certified.app](https://certified.app).
51+
If you sign up using your email on certified.app you will initially be given a random handle like `1lasdk.certified.one`. You can change your handle by going to your profile settings and clicking on "Change handle" on [certified.app](https://certified.app).
5252
{% /callout %}
5353
---
5454

@@ -60,6 +60,14 @@ For teams with multiple contributors, create a dedicated organizational account
6060
To set up an organizational account, create an account at [certified.app](https://certified.app) with the organization's email. Use a [custom domain handle](#handles-your-public-username-and-domain-verification) (e.g., `numpy.org`) to prove organizational identity.
6161
{% /callout %}
6262

63+
### Role-based governance with CGS
64+
65+
Sharing a single app password across an organisation is the simplest path but has real limits: every team member ends up with the same level of access, there's no audit trail, and revoking one person's access means rotating the password for everyone.
66+
67+
The [Certified Group Service (CGS)](/architecture/certified-group-service) is the more principled answer. CGS sits in front of a PDS and adds **role-based access control** on top of a shared repository — multiple identities can co-manage the same ATProto repo with distinct member, admin, and owner roles, and every action is written to a per-group audit log. Members authenticate as themselves (not as the organisation), so access can be granted or revoked per-person without disturbing anyone else.
68+
69+
Certified operates a hosted CGS instance (used by "create a group" flows on [certified.app](https://certified.app)), and CGS is also self-hostable if you want to run your own. Note: groups created via the hosted flow currently land on a test PDS — see [Certified PDSs](/reference/certified-services) for environment caveats. See the [CGS architecture page](/architecture/certified-group-service) for the full model.
70+
6371
---
6472

6573
## Authentication
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
---
2+
title: Certified Group Service (CGS)
3+
description: How CGS adds role-based access control to group-governed AT Protocol repositories without modifying the underlying PDS.
4+
---
5+
6+
# Certified Group Service (CGS)
7+
8+
In standard AT Protocol, each repository is controlled by a single identity (DID) — there's no built-in way for multiple users to collaboratively manage the same repo with different permission levels.
9+
10+
The [Certified Group Service](https://github.com/hypercerts-org/certified-group-service) (CGS) fills that gap. It's an AT Protocol service that sits between clients and a group's backing PDS, enforcing role-based access control, tracking record authorship, and keeping a full audit log. From the client's perspective, a group looks like any other AT Protocol repository — it just happens to be co-governed.
11+
12+
Certified operates a hosted CGS instance, but CGS is also designed to be **self-hostable per operator** — anyone can run their own instance and point it at whichever PDS backs the group (including, but not limited to, the [Certified-operated PDSs](/reference/certified-services)).
13+
14+
Today, a given CGS deployment points at a single backing PDS (configured via the `GROUP_PDS_URL` environment variable), and every group it registers lives on that PDS. Operators who want to host groups across multiple PDSs currently run multiple CGS instances. This is a current architectural constraint rather than a fundamental one, and may evolve in the future.
15+
16+
## System overview
17+
18+
```text
19+
Any AT Protocol client
20+
21+
│ atproto-proxy: did:plc:GROUP#certified_group
22+
23+
User's PDS
24+
25+
│ Authorization: Bearer <service-auth-jwt>
26+
│ (signed with user's key, iss=user, aud=group)
27+
28+
┌──────────────────────────────────┐
29+
│ Certified Group Service │
30+
│ │
31+
│ 1. AuthVerifier (JWT → DID) │
32+
│ 2. RbacChecker (DID → role) │
33+
│ 3. PDS proxy (forward) │
34+
│ 4. AuditLogger (record all) │
35+
└──────────────────────────────────┘
36+
37+
38+
Group's backing PDS
39+
```
40+
41+
Clients never call CGS directly. Instead they send a normal XRPC request to their own PDS with an `atproto-proxy` header pointing at the group DID and service fragment (`did:plc:GROUP#certified_group`). The user's PDS resolves the group's DID document, finds the `#certified_group` service endpoint, mints a service auth JWT signed with the user's key, and forwards the request to CGS. This is the same proxying mechanism used by Ozone labeling services.
42+
43+
## Integrating from an app
44+
45+
Apps don't call CGS directly. Your backend uses an authenticated `AtpAgent` for the user and sends XRPC requests *to the user's PDS* with an `atproto-proxy` header set to `did:plc:<groupDid>#certified_group`. The PDS handles service auth and forwards the request to CGS on your behalf. See the upstream [integration guide](https://github.com/hypercerts-org/certified-group-service/blob/main/docs/integration-guide.md) for a worked example.
46+
47+
One important gotcha: CGS uses **custom NSIDs** for record operations — `app.certified.group.repo.createRecord`, `putRecord`, `deleteRecord`, `uploadBlob` — instead of the standard `com.atproto.repo.*`. This is deliberate: if your app called `com.atproto.repo.createRecord`, the user's PDS would handle it itself and write to the user's own repo, not proxy it to CGS. The custom NSIDs are unrecognized by the PDS, so it looks them up in the group's DID document and routes them to CGS. Use the custom NSIDs in your proxied calls.
48+
49+
## Authentication
50+
51+
Every request to CGS arrives with `Authorization: Bearer <JWT>`. The `AuthVerifier` runs the following checks:
52+
53+
1. **Signature** — verified against the issuer's DID document via `@atproto/xrpc-server`'s `verifyJwt()`.
54+
2. **Audience** — the JWT's `aud` must match a group DID registered with this CGS instance.
55+
3. **Lexicon method** — the JWT's `lxm` must match the requested XRPC method (from an allowlist of record and group-management operations).
56+
4. **Token lifetime**`exp - iat` must not exceed the nonce TTL (120 seconds), so that tokens can't outlive the replay-prevention window.
57+
5. **Nonce (replay prevention)** — the JWT's `jti` is checked against a short-lived nonce cache. If it's been seen before, the request is rejected.
58+
59+
If all checks pass, the handler receives `{ iss: callerDid, aud: groupDid }` and proceeds to authorization.
60+
61+
## Authorization (RBAC)
62+
63+
Roles are strictly hierarchical and compared numerically. A higher level grants every permission of the lower levels.
64+
65+
```text
66+
member (0) < admin (1) < owner (2)
67+
```
68+
69+
### Permission matrix
70+
71+
| Operation | Minimum role |
72+
|---|---|
73+
| Create records, upload blobs, edit any record, list members | member |
74+
| Delete records you authored | member |
75+
| Delete any member's record | admin |
76+
| Edit the group's profile | admin |
77+
| Add / remove members | admin |
78+
| Query the audit log | admin |
79+
| Change a member's role | owner |
80+
81+
### Special rules
82+
83+
- **Cannot modify equal or higher roles.** An admin cannot remove another admin; only an owner can.
84+
- **`member.add` cannot assign `owner`.** New owners must be promoted by an existing owner via `role.set`.
85+
- **Self-removal always succeeds.** Any member can remove themselves, regardless of role.
86+
- **Last-owner protection.** The system atomically prevents demoting or removing the only remaining owner.
87+
- **Authorship is tracked per record.** CGS maintains a `group_record_authors` table so `deleteOwnRecord` (member) can be distinguished from `deleteAnyRecord` (admin).
88+
89+
## PDS proxying and credentials
90+
91+
Once a request is authorized, CGS forwards it to the group's backing PDS using stored credentials:
92+
93+
- **Credential storage.** The group's PDS app password (and, where applicable, the recovery keypair used for PLC operations) is stored encrypted with AES-256-GCM, using a 32-byte master key from the service's `ENCRYPTION_KEY` environment variable.
94+
- **Agent pool.** An authenticated `AtpAgent` per group is cached in memory; stale sessions are refreshed automatically on `AuthenticationRequired` / `ExpiredToken` errors.
95+
- **Blob uploads.** `uploadBlob` requests are streamed to the PDS with an enforced `MAX_BLOB_SIZE` limit (checked both upfront via `Content-Length` and incrementally during the stream).
96+
97+
## Audit logging
98+
99+
Every meaningful action — permitted or denied — is written to the per-group `group_audit_log` table. Each entry captures:
100+
101+
- **Who** — the caller's DID (`actor_did`)
102+
- **What** — the operation name (e.g. `createRecord`, `member.add`)
103+
- **Where** — collection and rkey, for record-level operations
104+
- **Result**`permitted` or `denied` (plus a reason for denials)
105+
- **Tracing** — the JWT's `jti`, for correlation with auth logs
106+
- **When** — ISO timestamp
107+
108+
Admins can query the audit log via `app.certified.group.audit.query`.
109+
110+
## Group lifecycle
111+
112+
Groups are created via `app.certified.group.register`, which requires a service auth JWT proving the caller controls the prospective owner DID. During registration, CGS:
113+
114+
1. Creates a new PDS account on the instance's configured backing PDS and receives a new group DID.
115+
2. Generates a recovery keypair and registers a `#certified_group` service entry in the group's DID document via a PLC operation.
116+
3. Stores the encrypted app password and recovery key in its own database.
117+
4. Seeds the caller as the group's first owner.
118+
119+
From then on, the group's DID is co-governed through CGS: owners promote admins, admins manage members, and members interact with the repository subject to the permission matrix above.
120+
121+
## Storage
122+
123+
CGS uses SQLite for all persistence:
124+
125+
- A **global database** holds the group registry (`groups` table) and the nonce cache.
126+
- Each group gets its **own per-group database**, named by the SHA-256 hash of the group DID. This isolates group data and keeps audit logs per-group.
127+
- All databases use WAL mode for concurrent read performance.
128+
129+
## Future directions
130+
131+
The current RBAC model — three fixed roles (`member`, `admin`, `owner`) with a hard-coded permission matrix — is intentionally simple. It covers the common case of a small group co-managing a repository, but it is a starting point rather than an endpoint. Directions being explored as groups' governance needs mature:
132+
133+
- **Customizable roles.** Let each group define its own roles and permissions instead of relying on a fixed three-tier hierarchy.
134+
- **Finer-grained permissions.** Scope permissions per collection or record type — for example, a role that can only create records in one lexicon, or only edit the group profile.
135+
- **Group-level governance.** Move beyond unilateral admin/owner actions toward proposals, voting, or quorum-based decisions for sensitive operations.
136+
- **Time-bound and delegated roles.** Temporary elevations — e.g. an admin grants another member `admin` for 24 hours, after which the role automatically reverts.
137+
- **Credential-based membership.** Derive membership and roles from external signals (verifiable credentials, badges, tokens) rather than only manual `member.add` calls.
138+
- **Multiple backing PDSs per instance.** Today, one CGS deployment is bound to a single backing PDS. Supporting multiple PDSs per instance (or per group) would let a single deployment host groups across different PDS providers.
139+
140+
None of the above are committed features; they're possibilities being shaped by user and developer needs and feedback.
141+
142+
## Further reading
143+
144+
- [CGS repository](https://github.com/hypercerts-org/certified-group-service)
145+
- [Architecture doc](https://github.com/hypercerts-org/certified-group-service/blob/main/docs/architecture.md) — full data model, startup sequence, and implementation details
146+
- [Integration guide](https://github.com/hypercerts-org/certified-group-service/blob/main/docs/integration-guide.md)
147+
- [API reference](https://github.com/hypercerts-org/certified-group-service/blob/main/docs/api-reference.md)
148+
- [Deployment guide](https://github.com/hypercerts-org/certified-group-service/blob/main/docs/deployment.md) — for running your own CGS instance

pages/architecture/epds.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ description: How the ePDS adds email/OTP login on top of AT Protocol without cha
77

88
The ePDS adds email-based, passwordless sign-in on top of a standard AT Protocol PDS. Users enter their email, receive a one-time code, and end up with a normal AT Protocol session tied to a DID.
99

10-
Certified hosts an ePDS instance at [certified.one](https://certified.one).
10+
Certified operates production, staging, and test ePDS instances. See [Certified services](/reference/certified-services) for the current hostnames and guidance on which to use in which scenario.
1111

1212
For applications, the important part is that ePDS still finishes by issuing a standard AT Protocol authorization code. In practice, this means you can integrate it with [`@atproto/oauth-client-node`](https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client-node).
1313

@@ -62,7 +62,7 @@ const oauthClient = new NodeOAuthClient({
6262
sessionStore,
6363
})
6464

65-
const url = await oauthClient.authorize('alice.certified.app', {
65+
const url = await oauthClient.authorize('alice.certified.one', {
6666
scope: 'atproto transition:generic',
6767
})
6868

@@ -103,7 +103,7 @@ const oauthClient = new NodeOAuthClient({
103103
sessionStore,
104104
})
105105

106-
const url = await oauthClient.authorize('alice.certified.app', {
106+
const url = await oauthClient.authorize('alice.certified.one', {
107107
scope: 'atproto transition:generic',
108108
})
109109

@@ -191,6 +191,8 @@ The extra branding fields customize the hosted login and email experience. `epds
191191
## Further reading
192192

193193
- [Account & Identity Setup](/architecture/account-and-identity)
194+
- [Certified PDSs](/reference/certified-services) — the production, staging, and test ePDS instances Certified operates
195+
- [Certified Group Service (CGS)](/architecture/certified-group-service) — a governance layer that sits in front of a PDS to support multi-identity, role-based repo management
194196
- [Scaffold Starter App](/tools/scaffold)
195197
- [ePDS repository](https://github.com/hypercerts-org/ePDS)
196198
- Install the ePDS agent skill with `npx skills add hypercerts-org/ePDS --skill epds-login`

0 commit comments

Comments
 (0)