group-service
0.6.0#
Who should read this release#
- End users:
- Client app developers:
Minor Changes#
#72
cfe0d40Thanks @aspiers! - A group's owner can now hand ownership to another member, who must accept before it takes effect.Affects: End users, Client app developers
End users: once the app you use adds support for it, owning a group lets you transfer ownership to another member yourself instead of asking an operator. You propose them, and ownership only moves once they accept by signing in themselves — so it can't be handed to someone who has lost access to their account. Either of you can cancel before then, and an un-accepted transfer expires after 7 days. Only the two of you can see a transfer in progress.
Client app developers:
- Four new methods under
app.certified.group.ownershipTransfer.*:propose,accept,cancel(procedures) andstatus(query). See Ownership transfer for the contract. proposeis owner-only;acceptis callable only by the proposed member;cancelandstatusby either party.statusis deliberately not exposed onmember.list.- A non-party cannot detect that a transfer exists. Whether or not one is pending, a member who is not a party gets the same response:
{ "pending": false }fromstatus, and404 NoPendingTransferfromacceptandcancel. There are noNotPartyToTransferorNotProposedOwnererrors — a distinct code would itself be the disclosure. Do not branch on the difference between "no transfer" and "not yours" in client code; it is not observable. When a transfer is pending, the group's audit log records the true reason a non-party was refused; a request that finds nothing pending is not logged. propose,cancelandstatusaccept a service-auth JWT or an API key with the matchingrpc:scope, subject to the caller's role (so a key can onlyproposeif issued by the owner).acceptis JWT-only. An API-key request is refused with403 ApiKeyNotPermitted, and there is norpc:scope for it —app.certified.group.ownershipTransfer.acceptcannot be granted to a key, and a wildcardrpc:*scope does not cover it. Acceptance is what proves the incoming owner still controls their account, and an API key keeps working after its creator can no longer authenticate as that DID, so a key could otherwise park ownership on an unrecoverable account. Apps that automate group admin with a key must route this one step through the user's own authenticated session.- A pending proposal is cleared automatically when ownership or a party's membership changes by another route (
admin.setOwner,member.remove,role.set), so a stale proposal can never be accepted to revert those changes.
- Four new methods under
#72
46a3aa4Thanks @aspiers! - An API key requesting "everything I'm allowed to do" is no longer refused for members and admins.Affects: Client app developers
Client app developers:
keys.createno longer rejects a wildcardrpc:*scope on the basis of the creator's role. Previously the wildcard was expanded to every key-accessible operation and each was role-checked, so the whole request failed if any single one outranked the caller — in practicerpc:*was refused for members (blocked by the admin-onlyaudit.query) and, once ownership transfer added the owner-onlyownershipTransfer.propose, for admins too.- A wildcard now always passes creation and grants whatever the issuing member's role permits at request time. A member's
rpc:*key can callmember.listbut is still refusedaudit.querywith a403; the cap follows the issuer's current role, so promotion or demotion widens or narrows an existing key with no re-issue. - Enumerated scopes are unchanged: naming an operation your role cannot use is still rejected at creation with
role '<role>' cannot use scope for '<operation>'. The fail-fast behaviour applies where you made a specific claim, not where you asked for whatever is available. - No key gains access it did not have before. Request-time RBAC was always the enforcement point; this only stops refusing to mint keys that would have been correctly capped anyway.
0.5.0#
Who should read this release#
Minor Changes#
#61
4a73f38Thanks @aspiers! - Operators can reassign a group's owner through a new admin API.Affects: Operators
This adds an operator-only admin endpoint. It is deliberately not part of the member-facing API — it is authenticated with the service admin password, not a member's JWT or API key — so client app developers building on the group service have nothing to call or adapt; only operators running an instance are affected.
Operators: a new optional env var
CGS_ADMIN_PASSWORDenables operator-only admin endpoints (app.certified.group.admin.*), authenticated with HTTP Basic auth rather than group membership — the same mechanism the upstream AT Protocol reference PDS uses forcom.atproto.admin.*. When unset, all admin endpoints are disabled. The trust model is the one a PDS operator already operates under, applied to the groups CGS hosts; admin actions are audit-logged. The first such endpoint,app.certified.group.admin.setOwner, reassigns a group's owner (including installing an owner who is not yet a member, for operator recovery when the incumbent is unavailable). Seedocs/deployment.md#admin-endpointsand the API reference for configuration and the endpoint contract.
0.4.0#
Who should read this release#
- Client app developers:
Minor Changes#
#58
fb0e253Thanks @aspiers! - Allow all group members to create, list, and revoke their own API keys. Owners retain group-wide visibility and revocation, while API-key auth itself still cannot manage keys.Affects: Client app developers
0.3.0#
Who should read this release#
- Client app developers:
- Operators:
Minor Changes#
#34
e6c8f87Thanks @aspiers! - Backend services can now use revocable API keys for group-owned records.Affects: Client app developers, Operators
Client app developers: group owners can create, list, and revoke keys with
app.certified.group.keys.create,app.certified.group.keys.list, andapp.certified.group.keys.delete. Key auth usesX-API-Keyand supports explicit scopes plus reusable permission-set scopes such asinclude:org.hypercerts.authWrite, so apps no longer need to list every record permission by hand. Seedocs/design/api-keys.md,docs/design/api-key-permission-sets.md, anddocs/integration-guide.mdfor request shapes, supported permissions, and examples.Operators: no new environment variables or manual migration are required. Leaked keys are recognizable by the
cgsk_prefix and can be revoked by the group owner.
Patch Changes#
#43
a059e0fThanks @aspiers! - Authentication failures are now logged server-side, so an operator can diagnose a rejected request from the logs instead of having to reproduce it.Affects: Operators
Operators: every auth rejection in the verifier now emits a
warn-level log line"Auth verification failed"before the request is refused. Previously the fallback error handler returned the error to the client but logged nothing, so a production401(e.g.Invalid audience) left no server-side trace of who called or which group they targeted.- JWT (service-auth) failures mostly log
{ reason, nsid, jwt: { header, payload } }. The JWT is decoded for logging without verifying its signature, and the signature segment is dropped — it is a bearer credential and is never written to the logs (jwtisnullfor a token that is not a well-formed three-part base64url JWT). The exception isMissing auth token, which fires before any token exists and logs only{ reason, path }.reasonis one of:Missing auth token,verifyJwt threw,Token lifetime check failed,jwt audience does not match service did,repo did not resolve to a known group,Invalid audience,Missing jti,Replayed token. - API-key failures log
{ reason, authKind: 'apiKey', keyRef, groupDid }— only the non-secret key reference, never the rawX-API-Keyvalue.reasonis one of:Malformed API key,Missing repo for API-key request,repo did not resolve to a known group,Invalid API key,Corrupt API-key scopes. - No request that previously succeeded is affected, and the HTTP responses returned to clients are unchanged — this is purely additional logging at the existing
logLevel.
- JWT (service-auth) failures mostly log
0.2.1#
Patch Changes#
Republish the container image. The v0.2.0 image build failed (its version-stamping step had no
.cgs-versionto read), so noghcr.io/hypercerts-org/group-servicetags were published for v0.2.0. The publish workflow now stamps the version before building; v0.2.1 is the first release to produce a working image. No functional change to the service versus what v0.2.0 would have shipped.Affects: Operators
0.2.0#
Who should read this release#
- End users:
- Client app developers:
- Apps now name the target group with an explicit
repofield instead of overloading the service-auth token's audience. - A group's owner can now remove the group from the service.
- You can now turn an existing account into a group, instead of always creating a brand-new one.
- The health check now reports the running service version, and the same check is also reachable at
/xrpc/_health.
- Apps now name the target group with an explicit
- Operators:
- Apps now name the target group with an explicit
repofield instead of overloading the service-auth token's audience. - A group's owner can now remove the group from the service.
- You can now turn an existing account into a group, instead of always creating a brand-new one.
- The health check now reports the running service version, and the same check is also reachable at
/xrpc/_health.
- Apps now name the target group with an explicit
Minor Changes#
#33
677298eThanks @aspiers! - Apps now name the target group with an explicitrepofield instead of overloading the service-auth token's audience.Affects: Client app developers, Operators
Client app developers: group-scoped methods take a
repofield (a handle or DID) naming the target group, with the JWTaudset to the service DID — the shape a stock@atproto/apiclient already emits. The old form (group inaud, norepo) still works but is deprecated.Legacy (deprecated) New (supported) Group named by JWT audexplicit repoJWT audthe group DID the service DID repofieldabsent present Deprecation header Deprecation: true+Linknone - Behaviour change to adapt to now:
repois the group selector, not a cross-check — the old403"repo field must match the group DID" is gone; areponaming no registered group returns401 Unknown group. RBAC is unchanged: you can only target groups you have a role in. - How to migrate (per-method
repoplacement, the coupledrepo+audswitch, non-proxied vs proxied calls, detecting un-migrated calls): seedocs/aud-migration.md. Design rationale and security analysis:docs/design/aud-deprecation.md.
Operators: no new environment variables and no migration;
SERVICE_DIDis unchanged. The service now serves itsdid:webdocument atGET /.well-known/did.json(a public, unauthenticated route, sibling to/health); it must be publicly reachable, since that is how the service DID resolves and how service proxying targets the service. A rate-limitedwarnlog flags any client still on the legacy form — one line per caller per 15 minutes.- Behaviour change to adapt to now:
#30
e4a0aacThanks @aspiers! - A group's owner can now remove the group from the service.Affects: End users, Client app developers, Operators
End users: if you are a group's owner, you can now delete the group from the service. This removes the group and its membership from the service only — the underlying account and its data are left untouched, so the account continues to exist and could be added back later. Only the owner can do this; admins and members cannot.
Client app developers: a new procedure
app.certified.group.destroyremoves a group from the service, gated on theownerrole (new RBAC operationgroup.destroy). It removes only the service's record of the group; the underlying PDS account is left intact, so the account can be re-imported afterwards viaapp.certified.group.import. Call it group-scoped, the same auth style as the other per-group methods. Full request/response and errors are indocs/api-reference.mdanddocs/integration-guide.md.Heads-up: like the other per-group methods,
destroycurrently names the target group via the JWTaud. That overload is being deprecated (issue #27) in favour of an explicit request-level group field, withaudreverting to the group service's own DID. Theaud= group DID form will be supported during a transition window and then removed — build against the explicit-group form once it ships.Operators:
destroyis served on/xrpc/app.certified.group.destroy; no new environment variables. It deletes the group'sgroupsrow andmember_indexentries (in one transaction) and then the per-group SQLite file underdata/groups/. The destroy is recorded in the service log, not in the (deleted) per-group audit log.#30
f265075Thanks @aspiers! - You can now turn an existing account into a group, instead of always creating a brand-new one.Affects: Client app developers, Operators
Client app developers: a new procedure
app.certified.group.importis the sibling ofapp.certified.group.register— it reuses an existing account instead of creating one. You supply the account's app password so the service can act on its behalf, and the JWT must be signed by the account being imported (iss= the account's DID), not by the prospective owner. Two consequences worth knowing: the service holds no recovery key for an imported account (the owner's own credentials are their credible exit), andimportdoes not modify the account's DID document. Full request/response, auth model, and errors are indocs/api-reference.md(Group lifecycle),docs/integration-guide.md(Step 1b), anddocs/design/group-import.md.Operators:
importis served on/xrpc/app.certified.group.import(service-auth, likeregister); no new environment variables. Imported groups are stored in thegroupstable withencrypted_recovery_keyleftNULL, distinguishing them from registered groups, and are driven via the per-grouppds_urlresolved at import time — which may differ fromGROUP_PDS_URL.#35
a906bd8Thanks @aspiers! - The health check now reports the running service version, and the same check is also reachable at/xrpc/_health.Affects: Client app developers, Operators
Client app developers:
GET /healthresponse gains two fields:service(always"group-service") andversion(e.g."0.1.0+90d10b96"). The existingstatus: "ok"/503 { status: "error", message: "database unreachable" }behaviour is unchanged, so existing health checks keep working.- A new
GET /xrpc/_healthroute returns the identical body to/health(including the same 503-on-DB-failure semantics). This mirrors the upstream PDS convention of exposing/xrpc/_health; the group service has no upstream PDS, so it serves the route itself. Note it returns the full{ status, service, version }object, not the bare{ version }some atproto services return.
Operators:
- The reported version resolves in this order: the
CGS_VERSIONenv var, then a.cgs-versionfile written at image-build time, then theversionfield inpackage.json. SetCGS_VERSIONto override the stamp (e.g.CGS_VERSION=0.1.0+abcdef01). - On Railway, the Docker build stamps
<package.json version>+<short commit sha>automatically fromRAILWAY_GIT_COMMIT_SHA— no action needed. - For local
docker build, run./scripts/stamp-version.shfirst to write.cgs-version; the build fails withERROR: .cgs-version not foundotherwise..cgs-versionis gitignored.
0.1.0#
Initial release — the baseline that was already deployed before changeset-based release notes were adopted, recorded here so the changelog has a starting point rather than retroactively itemising shipped features as if they were new.
The 0.1.0 feature set (group registration, role-based access control, record proxying to each group's PDS with author-scoped edit/delete gating, blob upload, the per-group audit log, atproto service-auth with replay protection, and cross-group membership discovery) is documented in README.md and docs/. Subsequent releases document only what changes after this point, via changesets — see docs/PUBLISHING.md.