Skip to content

feat(group): add app.certified.group.import to promote existing accounts#30

Draft
aspiers wants to merge 3 commits into
mainfrom
adam/hyper-469-add-appcertifiedgroupimport-xrpc-to-promote-an-existing-pds
Draft

feat(group): add app.certified.group.import to promote existing accounts#30
aspiers wants to merge 3 commits into
mainfrom
adam/hyper-469-add-appcertifiedgroupimport-xrpc-to-promote-an-existing-pds

Conversation

@aspiers
Copy link
Copy Markdown
Contributor

@aspiers aspiers commented Jun 4, 2026

Summary

Adds app.certified.group.import (HYPER-469) — the sibling of app.certified.group.register that promotes an existing PDS account into a group instead of creating a new one. The caller supplies an app password so the service can act on the account's behalf; the account's PDS and handle are resolved from its DID document.

Also refactors register and import from raw Express routes into proper XRPC methods, removing the last raw routes from the service.

Design doc: docs/design/group-import.md.

What's in here

Feature — group.import (5e5dc1b's parent):

  • Lexicon lexicons/app/certified/group/import.json{ groupDid, appPassword, ownerDid }{ groupDid, handle }.
  • Handler src/api/group/import.ts: verify owner → resolve #atproto_pds + handle from the DID doc → log in with the app password → persist + seed owner + audit.
  • Shared register/import tail extracted into src/api/group/finalize.ts (encrypt-store-migrate-seed-audit) — no copy-paste.
  • IdResolver exposed on AppContext for DID resolution.

Refactor — raw routes → XRPC methods (5e5dc1b):

  • New registerServiceAuthMethod helper (service-auth analogue of registerAuthedMethod), binding the existing xrpcServiceAuth() verifier.
  • register and import are now XRPC methods (same NSIDs), so their request bodies are validated against the lexicons.
  • Deleted registerRawRoutes, the manual express.json() mounts, and the now-dead verifyRegistration/verifyImport/verifyFixedNsidServiceAuth. Net simplification.
  • No client-facing change: same NSIDs, URLs, and service-auth JWT contract (aud = service DID, lxm = the method).

Key design decisions (see docs/design/group-import.md)

  • No DID-document mutation. import neither adds nor requires the #certified_group service entry. Service proxying is not currently relied upon (clients call CGS directly), and an app password cannot perform PLC operations anyway (that needs the ACCESS_FULL scope; app-password logins only ever get AppPass/AppPassPrivileged). register can add the entry only because it holds a genesis rotation key, which import has no equivalent for.
  • No recovery key. encrypted_recovery_key is left NULL (column already nullable). The owner's own pre-existing credentials are their credible exit; the CGS is not a key custodian for imported groups.
  • Cross-PDS. An imported account may live on a PDS other than GROUP_PDS_URL; its #atproto_pds is resolved from the DID doc and stored as the per-group pds_url.
  • No schema change required.

Security: why both an ownership check and the app-password test

import performs two independent checks, in this order:

  1. Owner authorizationverifyServiceAuth proves the caller controls ownerDid (a service-auth JWT signed by that DID's key), and the handler requires callerDid === ownerDid.
  2. App-password functionality — log in to the account's PDS with the supplied app password; failure → InvalidAppPassword.

Both are necessary; neither subsumes the other. Holding a working app password is evidence of involvement, but app passwords are deliberately lower-trust, more widely distributed, and revocable. Crucially, import creates a durable new control path (the CGS stores the app password and acts as the account indefinitely, and seeds an owner with RBAC powers) — so without the ownership check, a leaked app password would be a privilege-escalation / account-enrollment primitive: an attacker could bind someone else's account to the CGS and install themselves as owner. The ownership test requires the account's signing key (which an app password does not grant), closing that gap. Owner-auth runs first so we never attempt a PDS login on behalf of an unauthenticated/unauthorized caller.

Testing

  • tests/import.test.ts: success, cross-PDS pds_url, InvalidAppPassword, GroupAlreadyRegistered, owner seeding, input validation, auth-issuer mismatch, unauthenticated.
  • Full suite green (226 tests), typecheck + ESLint clean.
  • Live end-to-end import not yet run — the unit tests mock the PDS login. Intend to verify against the Railway PR preview with a real test account before merge.

Open questions (not blocking, tracked for follow-up)

Closes HYPER-469 once the live test passes.

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Added group import capability enabling existing account holders to convert their accounts into certified groups
    • Provides an alternative to registration for users with existing accounts
    • Import process validates ownership and securely manages account credentials
  • Documentation

    • Enhanced API reference and integration guides with group import workflow examples

aspiers and others added 3 commits June 4, 2026 00:58
Design for app.certified.group.import (HYPER-469), the sibling to
group.register that promotes an existing PDS account into a group rather
than creating a new account.

The #certified_group DID-document entry is addressed two ways, and import
gates on neither:

- Not currently relied upon: service proxying is not in use; clients call
  CGS directly (the shipped HYPER-453 workaround), which has no dependence
  on the group's #certified_group entry resolving. A group is fully
  functional without it.
- Not possible via app password anyway: DID-doc updates require the
  ACCESS_FULL scope; app-password logins only ever receive
  AppPass/AppPassPrivileged. register sidesteps this by holding a genesis
  rotation key, which import has no equivalent for.

So import neither adds nor requires the entry (no MissingServiceEntry
error). If proxying ever becomes a requirement, the owner adds the entry
out-of-band with a full-access credential.

Other open questions resolved:

- Credible exit: encrypted_recovery_key is NULL; the owner's own
  pre-existing credentials are the exit (CGS is not a key custodian).
- App password: stored as supplied, owner-managed/revocable, not
  re-minted.

No schema change needed (encrypted_recovery_key already nullable, pds_url
already per-group). Notes that imported accounts may live on a different
PDS, so pds_url is resolved from the DID doc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add app.certified.group.import (HYPER-469), the sibling of group.register
that imports an existing PDS account as a group instead of creating a new
one. The caller supplies an app password so the service can act on the
account's behalf; the account's PDS and handle are resolved from its DID
document, so an imported account may live on a PDS other than
GROUP_PDS_URL.

Key behaviours (per docs/design/group-import.md):

- Auth via a new AuthVerifier.verifyImport (aud = service DID, lxm =
  app.certified.group.import). Refactored the shared body out of
  verifyRegistration rather than duplicating it; register hardcodes its
  own NSID, so import could not reuse verifyRegistration directly.
- No DID-document mutation. Service proxying is not currently relied upon
  (clients call CGS directly), and an app password cannot perform PLC
  operations anyway (needs ACCESS_FULL; app passwords only ever get
  AppPass/AppPassPrivileged). So import neither adds nor requires the
  #certified_group entry.
- No recovery key: encrypted_recovery_key is left NULL (column already
  nullable). The owner's own pre-existing credentials are their credible
  exit; CGS is not a key custodian for imported groups.
- App password stored as supplied, owner-managed and revocable.

Extracted the shared register/import tail (encrypt + store credentials,
migrate per-group DB, seed owner, audit-log) into finalizeGroup() so the
two handlers share it instead of copy-pasting. Exposed IdResolver on
AppContext for DID resolution. Mounted as a raw route with JSON parsing,
alongside register.

Tests: tests/import.test.ts (success, cross-PDS pds_url, InvalidAppPassword
on login failure, GroupAlreadyRegistered, owner seeding, validation, auth
mismatch, unauthenticated). Full suite green (226 tests). Docs and a
changeset added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…outes

register and import were plain Express routes mounted outside the XRPC
server ("raw routes"), so they bypassed lexicon validation and did their
own JWT verification. They only existed that way because the XRPC
server's default auth (xrpcAuth -> verify) treats the JWT `aud` as a
group DID and looks it up in the `groups` table — which fails for a
group that doesn't exist yet. That is the group-bootstrapping case.

Both endpoints already use `aud` correctly (= the service DID), so the
fix is simply to register them as XRPC methods bound to the existing
service-auth verifier rather than the group-scoped one:

- Add registerServiceAuthMethod(server, nsid, ctx, { handler }) — the
  service-auth analogue of registerAuthedMethod, binding
  xrpcServiceAuth() (aud = service DID, lxm derived from the request
  path) instead of xrpcAuth().
- Convert register and import handlers to (server, ctx) XRPC methods.
  Their request bodies are now validated against register.json /
  import.json by the XRPC server; the handlers read input.body and
  auth.credentials.callerDid, and keep the callerDid === ownerDid check.
- Delete registerRawRoutes, the two express.json() mounts, and the now
  dead verifyRegistration / verifyImport / verifyFixedNsidServiceAuth
  (xrpcServiceAuth / verifyServiceAuth supersede them).

No client-facing change: same NSIDs, same URLs, same service-auth JWT
contract (aud = service DID, lxm = the method). Purely internal — net
-82 lines. Tests updated to drive both endpoints through a real XRPC
server (createTestApp); two verifier tests repointed from
verifyRegistration to verifyServiceAuth. Full suite green (226 tests),
typecheck and eslint clean.

This removes the last raw routes; every /xrpc endpoint now goes through
the XRPC server. Relates to the `aud` cleanup tracked in
#27.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 4, 2026 13:23
Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.

Tip: disable this comment in your organization's Code Review settings.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 4, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds a new app.certified.group.import XRPC endpoint that imports existing PDS accounts as certified groups. It extracts shared finalization logic used by both import and register, refactors registration to use that logic, and unifies endpoint routing through the XRPC server infrastructure with service-auth verification.

Changes

Group Import Feature

Layer / File(s) Summary
Design & Lexicon Definition
lexicons/app/certified/group/import.json, docs/design/group-import.md, .changeset/group-import-existing-account.md, docs/api-reference.md, docs/integration-guide.md
Lexicon schema for the new procedure defines input (groupDid, appPassword, ownerDid) and output (groupDid, handle); design doc establishes why the endpoint must not require DID-document service proxying, specifies the handler flow (validate → verify caller → resolve PDS → login → finalize), and clarifies app-password storage and credible exit via password revocation with no recovery key.
Shared Finalization Infrastructure
src/context.ts, src/api/group/finalize.ts, src/api/util.ts, src/auth/verifier.ts
New finalizeGroup workflow encrypts and persists group credentials, migrates per-group database, seeds owner into member_index, and audits the operation; app context adds idResolver for account PDS resolution; service-auth method registration helper isolates service-auth verification; registration-specific verifyRegistration method is removed in favor of unified verifyServiceAuth flow.
Import & Register Handlers
src/api/group/import.ts, src/api/group/register.ts
New import handler validates input DIDs, enforces caller identity matches ownerDid, resolves account PDS/handle via ID resolver, authenticates to PDS with app password (mapping login failures to InvalidAppPassword), then calls finalizeGroup with action: 'group.import' and no recovery key; register handler refactored to delegate credential initialization and indexing to finalizeGroup.
XRPC Routing & Application Wiring
src/api/index.ts, src/index.ts
Both import and register handlers are now registered via registerXrpcMethods using the new registerServiceAuthMethod helper; raw-route registration removed; application startup explicitly creates XRPC server, registers methods, and mounts router before fallback error handling.
Test Infrastructure & Coverage
tests/helpers/mock-server.ts, tests/import.test.ts, tests/register.test.ts, tests/verifier.test.ts
Mock-server helpers add ID resolver stub for PDS resolution in tests; comprehensive import test suite validates successful import, correct PDS resolution, database persistence with null recovery key, owner seeding, and auth/validation error paths; register and auth-verifier tests refactored to use unified service-auth helpers.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • hypercerts-org/certified-group-service#2: Both PRs integrate with the member_index infrastructure—this PR's finalizeGroup seeds the owner, while the related PR adds the app.certified.groups.membership.list endpoint and accompanying member_index synchronization tests.

Poem

🐰 A group import flows free,
No new account needed to be,
Just a password, a DID, a PDS to call,
And the owner's confirmed through it all!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(group): add app.certified.group.import to promote existing accounts' clearly and specifically summarizes the main feature addition in the changeset, which is a new XRPC method that converts existing PDS accounts into certified groups.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch adam/hyper-469-add-appcertifiedgroupimport-xrpc-to-promote-an-existing-pds

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@railway-app
Copy link
Copy Markdown
Contributor

railway-app Bot commented Jun 4, 2026

🚅 Deployed to the certified-group-service-pr-30 environment in CGS

Service Status Web Updated (UTC)
certified-group-service ✅ Success (View Logs) Web Jun 4, 2026 at 1:51 pm

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new service-auth XRPC procedure app.certified.group.import to promote an existing atproto/PDS account into a managed “group”, and completes the refactor of the last remaining raw Express routes into lexicon-validated XRPC methods.

Changes:

  • Introduces app.certified.group.import (lexicon + handler) that resolves #atproto_pds + handle from the DID doc, validates ownership via service-auth, verifies the app password via PDS login, then persists/seeds/audits.
  • Refactors group.register to be an XRPC method (service-auth) and extracts the shared “persist + migrate + seed + audit” tail into finalizeGroup().
  • Updates docs, changeset, and test infrastructure to support service-auth methods and DID resolution in context.

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/verifier.test.ts Renames/updates test cases to cover verifyServiceAuth behavior.
tests/register.test.ts Updates register tests to use XRPC test app + new mockAuth helper shape.
tests/import.test.ts Adds coverage for import success, cross-PDS behavior, auth failures, and invalid app password.
tests/helpers/mock-server.ts Adds idResolver to test context; refactors test app creation; updates mockAuth for service-auth.
src/index.ts Removes raw route mounting; routes all /xrpc/* via XRPC server; wires idResolver into context.
src/context.ts Exposes IdResolver on AppContext for handlers to resolve atproto data.
src/auth/verifier.ts Removes verifyRegistration; relies on generalized verifyServiceAuth for service-auth methods.
src/api/util.ts Adds registerServiceAuthMethod() helper to register service-auth XRPC procedures.
src/api/index.ts Registers group.register + new group.import as XRPC methods; removes raw route registrar.
src/api/group/register.ts Converts register to XRPC handler; reuses finalizeGroup() for shared persistence/seeding/auditing.
src/api/group/import.ts New import XRPC handler: resolves PDS/handle, app-password login verification, then finalizeGroup().
src/api/group/finalize.ts New shared helper for encrypt/store + DB init + owner seeding + audit logging.
lexicons/app/certified/group/import.json New lexicon definition for app.certified.group.import.
docs/integration-guide.md Documents import flow and adds method to endpoint table.
docs/design/group-import.md Design doc describing import semantics, security rationale, and non-goals.
docs/api-reference.md Adds group.import to audit action strings.
.changeset/group-import-existing-account.md Release note describing the new procedure and operational notes.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/api/group/import.ts
Comment on lines +63 to +69
const pdsUrl = atprotoData.pds
const handle = atprotoData.handle

// Authenticate to the account's PDS with the supplied app password. This
// is a PDS-local createSession against the host PDS itself (no entryway),
// and both proves the credential works and confirms the account is there.
const agent = new AtpAgent({ service: pdsUrl })

**Affects:** Client app developers, Operators

**Client app developers:** a new procedure `app.certified.group.import` is the sibling of `app.certified.group.register`. Where `register` creates a new account on the group PDS, `import` reuses an account that already exists.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/integration-guide.md (1)

337-343: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix putRecord role table for non-authored updates.

Line 341 incorrectly states member can update another member’s record; this should be admin to match runtime authorization.

Proposed doc fix
 | Scenario                                                         | Required role |
 | ---------------------------------------------------------------- | ------------- |
 | Creating new record (no existing author)                         | member        |
 | Updating a record you authored                                   | member        |
-| Updating another member's record                                 | member        |
+| Updating another member's record                                 | admin         |
 | Editing the group profile (`app.bsky.actor.profile` rkey `self`) | admin         |

As per coding guidelines: "Only admins can putAnyRecord or deleteAnyRecord; members can only edit/delete records they authored."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/integration-guide.md` around lines 337 - 343, The docs table for
putRecord is wrong: the row "Updating another member's record" should list role
admin, not member; update the integration-guide.md table entry so that the
Scenario "Updating another member's record" has Required role set to admin, and
optionally add a short note referencing the runtime auth rules (putRecord vs
putAnyRecord/deleteAnyRecord) to make it clear that only admins can perform
non-authored putAnyRecord/deleteAnyRecord operations.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@docs/integration-guide.md`:
- Around line 337-343: The docs table for putRecord is wrong: the row "Updating
another member's record" should list role admin, not member; update the
integration-guide.md table entry so that the Scenario "Updating another member's
record" has Required role set to admin, and optionally add a short note
referencing the runtime auth rules (putRecord vs putAnyRecord/deleteAnyRecord)
to make it clear that only admins can perform non-authored
putAnyRecord/deleteAnyRecord operations.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0c5a833b-9b9a-4141-966d-0e2d7c1a745f

📥 Commits

Reviewing files that changed from the base of the PR and between d1c8791 and 5e5dc1b.

📒 Files selected for processing (17)
  • .changeset/group-import-existing-account.md
  • docs/api-reference.md
  • docs/design/group-import.md
  • docs/integration-guide.md
  • lexicons/app/certified/group/import.json
  • src/api/group/finalize.ts
  • src/api/group/import.ts
  • src/api/group/register.ts
  • src/api/index.ts
  • src/api/util.ts
  • src/auth/verifier.ts
  • src/context.ts
  • src/index.ts
  • tests/helpers/mock-server.ts
  • tests/import.test.ts
  • tests/register.test.ts
  • tests/verifier.test.ts
💤 Files with no reviewable changes (1)
  • src/auth/verifier.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants