Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/group-import-existing-account.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'group-service': minor
---

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

- Call it directly (like `register`, not via the proxy), with a service-auth JWT (`aud` = the group service DID, `lxm` = `app.certified.group.import`).
- Request body: `{ groupDid, appPassword, ownerDid }`. `groupDid` is the existing account's DID; `appPassword` is an app password for that account so the service can act on its behalf; `ownerDid` must match the JWT `iss` and is seeded as owner. The service resolves the account's PDS and handle from its DID document — no `handle` input.
- Response: `{ groupDid, handle }` (handle resolved from the account).
- Errors: `InvalidRequest` (missing/invalid fields or unresolvable DID), `InvalidAppPassword` (`401` — the app password is wrong/revoked or the account is not on the resolved PDS), `GroupAlreadyRegistered` (`409`).
- Unlike registered groups, the service holds **no recovery key** for an imported account, and `import` does **not** modify the account's DID document. See `docs/integration-guide.md` (Step 1b) and `docs/design/group-import.md`.

**Operators:** `import` is served as a standard XRPC method on `/xrpc/app.certified.group.import` (service-auth, like `register`). No new environment variables. Imported groups are stored in the `groups` table with `encrypted_recovery_key` left `NULL` (the column is already nullable), so they are distinguishable from registered groups, and `PdsAgentPool` drives them via the per-group `pds_url` resolved at import time — which may differ from `GROUP_PDS_URL`.
1 change: 1 addition & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,7 @@ Every audited operation produces one of the following `action` strings. Denied o
| Action | Trigger | `detail` fields |
| ------------------- | ----------------------------------------------------------------- | -------------------------------------- |
| `group.register` | Group created via `app.certified.group.register` | `{ handle }` |
| `group.import` | Existing account imported via `app.certified.group.import` | `{ handle }` |
| `member.add` | Member added via `member.add` | `{ memberDid, role }` |
| `member.remove` | Member removed via `member.remove` | `{ memberDid }` |
| `role.set` | Role changed via `role.set` | `{ memberDid, previousRole, newRole }` |
Expand Down
395 changes: 395 additions & 0 deletions docs/design/group-import.md

Large diffs are not rendered by default.

47 changes: 46 additions & 1 deletion docs/integration-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,51 @@ async function registerGroup(agent: AtpAgent, handle: string, ownerDid: string,
- `ownerDid` — the DID of the user who will own this group. Must match the JWT's `iss` claim. They're immediately seeded as the owner.
- `email` — optional recovery email for the group account. If omitted, a placeholder is generated. Providing a real email enables the forgot-password flow for credible exit.

Registration is the **only** endpoint called directly (not via proxy). All subsequent calls go through the proxy agent.
Registration (and import, below) are called **directly**, not via proxy. All subsequent calls go through the proxy agent.

## Step 1b (alternative): Import an existing account

If the account already exists — e.g. a Bluesky/atproto account you want to "promote" to a group rather than creating a fresh one — use `app.certified.group.import` instead of `register`. It reuses the existing DID, handle, and repo.

```typescript
async function importGroup(
agent: AtpAgent,
groupDid: string,
appPassword: string,
ownerDid: string,
) {
const {
data: { token },
} = await agent.com.atproto.server.getServiceAuth({
aud: GROUP_SERVICE_DID,
lxm: 'app.certified.group.import',
})

const res = await fetch(`${GROUP_SERVICE}/xrpc/app.certified.group.import`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ groupDid, appPassword, ownerDid }),
})

if (!res.ok) throw new Error(`Import failed: ${res.status}`)

// Response: { groupDid: "did:plc:abc123", handle: "existing.pds.example.com" }
return res.json()
}
```

- `groupDid` — the DID of the existing account to import. The group service resolves its PDS and handle from the DID document.
- `appPassword` — an [app password](https://bsky.app/settings/app-passwords) for that account, so the service can act on its behalf. Stored encrypted; **the owner manages its lifecycle and can revoke it at any time** to sever the service's access.
- `ownerDid` — as for `register`, must match the JWT's `iss` claim; seeded as owner.

**How import differs from register:**

- The account is **not** created — it already exists, and its DID/handle/repo are reused.
- The group service holds **no recovery key** for an imported account (unlike registered groups, where it generates one). The owner's own pre-existing account credentials are their credible exit; the service is not a custodian of the account's keys.
- Import does **not** modify the account's DID document. (Service proxying is not currently relied upon; and an app password cannot perform the PLC operation required to add a service entry. See `docs/design/group-import.md`.)

## Step 2: Create a proxy agent with custom lexicons

Expand Down Expand Up @@ -445,6 +489,7 @@ All error responses follow this shape:
| NSID | Type | Required role | Description |
| --------------------------------------- | --------- | ------------- | ----------------------------------------------- |
| `app.certified.group.register` | procedure | service auth | Register a new group (direct call, not proxied) |
| `app.certified.group.import` | procedure | service auth | Import an existing account as a group (direct) |
| `app.certified.group.repo.createRecord` | procedure | member | Create a record |
| `app.certified.group.repo.putRecord` | procedure | member/admin | Update or create a record |
| `app.certified.group.repo.deleteRecord` | procedure | member/admin | Delete a record |
Expand Down
48 changes: 48 additions & 0 deletions lexicons/app/certified/group/import.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
{
"lexicon": 1,
"id": "app.certified.group.import",
"defs": {
"main": {
"type": "procedure",
"description": "Import an existing PDS account as a group. Sibling to app.certified.group.register, but reuses an existing account rather than creating a new one. Stores the supplied app password so the service can act on the account's behalf, and seeds the caller as owner. Does not modify the account's DID document.",
"input": {
"encoding": "application/json",
"schema": {
"type": "object",
"required": ["groupDid", "appPassword", "ownerDid"],
"properties": {
"groupDid": {
"type": "string",
"format": "did",
"description": "DID of the existing account to import as a group."
},
"appPassword": {
"type": "string",
"description": "An app password for the account, so the service can act on its behalf. Stored encrypted, exactly as supplied; the owner manages its lifecycle and may revoke it."
},
"ownerDid": { "type": "string", "format": "did" }
}
}
},
"output": {
"encoding": "application/json",
"schema": {
"type": "object",
"required": ["groupDid", "handle"],
"properties": {
"groupDid": { "type": "string", "format": "did" },
"handle": {
"type": "string",
"description": "Handle resolved from the imported account."
}
}
}
},
"errors": [
{ "name": "InvalidRequest" },
{ "name": "InvalidAppPassword" },
{ "name": "GroupAlreadyRegistered" }
]
}
}
}
73 changes: 73 additions & 0 deletions src/api/group/finalize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { AppContext } from '../../context.js'
import { ConflictError } from '../../errors.js'
import { encrypt } from '../../pds/credentials.js'

export interface FinalizeGroupParams {
/** DID of the group account (created by register, or pre-existing for import). */
groupDid: string
/** PDS hosting the group account; stored verbatim and reused by PdsAgentPool. */
pdsUrl: string
/** App password the service uses to act on the account's behalf (plaintext). */
appPassword: string
/** DID seeded as the immutable owner. */
ownerDid: string
/**
* Recovery-key material to store, already base64url-encoded, or `null` when
* the service holds no recovery key for this group (the import case). The
* `groups.encrypted_recovery_key` column is nullable.
*/
recoveryKeyMaterial: string | null
/** Audit action: distinguishes how the group entered the service. */
action: 'group.register' | 'group.import'
/** Resolved full handle, recorded in the audit detail. */
handle: string
}

/**
* Shared tail of `group.register` and `group.import`: persist the group's
* credentials, initialise its per-group database, seed the owner, and audit-log
* the operation. Everything before this differs between the two (register
* creates an account and signs a PLC op; import logs in to an existing one),
* but from credential storage onward the two are identical save for the
* recovery key and the audit action.
*
* Throws `ConflictError('GroupAlreadyRegistered')` if the group DID is already
* present in the `groups` table.
*/
export async function finalizeGroup(ctx: AppContext, params: FinalizeGroupParams): Promise<void> {
const { groupDid, pdsUrl, appPassword, ownerDid, recoveryKeyMaterial, action, handle } = params

const encryptionKey = Buffer.from(ctx.config.encryptionKey, 'hex')
const encryptedAppPassword = encrypt(appPassword, encryptionKey)
const encryptedRecoveryKey =
recoveryKeyMaterial === null ? null : encrypt(recoveryKeyMaterial, encryptionKey)

try {
await ctx.globalDb
.insertInto('groups')
.values({
did: groupDid,
pds_url: pdsUrl,
encrypted_app_password: encryptedAppPassword,
encrypted_recovery_key: encryptedRecoveryKey,
})
.execute()
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err)
if (msg.includes('UNIQUE constraint failed') || msg.includes('PRIMARY KEY constraint failed')) {
throw new ConflictError('Group already registered', 'GroupAlreadyRegistered')
}
throw err
}

// Initialize per-group database and run migrations
await ctx.groupDbs.migrateGroup(groupDid)

// Seed owner (atomic write to both group DB and member_index)
const groupDb = ctx.groupDbs.get(groupDid)
const groupRaw = ctx.groupDbs.getRaw(groupDid)
ctx.memberIndex.add(groupRaw, groupDid, ownerDid, 'owner', ownerDid)

// Audit log the group creation/import
await ctx.audit.log(groupDb, ownerDid, action, 'permitted', { handle })
}
100 changes: 100 additions & 0 deletions src/api/group/import.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import type { Server } from '@atproto/xrpc-server'
import { AtpAgent } from '@atproto/api'
import { ensureValidDid } from '@atproto/syntax'
import { AuthRequiredError, InvalidRequestError } from '@atproto/xrpc-server'
import type { AppContext } from '../../context.js'
import { registerServiceAuthMethod, jsonResponse } from '../util.js'
import { finalizeGroup } from './finalize.js'

/**
* app.certified.group.import — promote an existing PDS account into a group.
*
* Sibling to group.register: where register creates a new account on the group
* PDS and signs a PLC op to advertise the certified_group service, import
* reuses an account that already exists. The caller supplies an app password so
* the service can act on the account's behalf.
*
* import deliberately does NOT touch the account's DID document: 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). See docs/design/group-import.md.
*
* Auth is service-level (aud = the service DID), because the group does not yet
* exist in the service. The handler additionally verifies the authenticated
* caller matches the ownerDid it is about to seed.
*/
export default function (server: Server, ctx: AppContext) {
registerServiceAuthMethod(server, 'app.certified.group.import', ctx, {
handler: async ({ auth, input }) => {
const { callerDid } = auth.credentials
const { groupDid, appPassword, ownerDid } = input?.body as {
groupDid: string
appPassword: string
ownerDid: string
}

// Validate inputs (the lexicon enforces presence + did format; we also
// guard explicitly so a malformed DID fails as a clean 400)
try {
ensureValidDid(groupDid)
} catch {
throw new InvalidRequestError('Invalid groupDid')
}
try {
ensureValidDid(ownerDid)
} catch {
throw new InvalidRequestError('Invalid ownerDid')
}

// The authenticated caller must be the owner they are seeding
if (callerDid !== ownerDid) {
throw new AuthRequiredError('Service auth token issuer does not match ownerDid')
}

// Resolve the account's PDS and handle from its DID document. An imported
// account may live on a PDS other than config.groupPdsUrl, so we use the
// account's own #atproto_pds endpoint rather than assuming a host.
let atprotoData
try {
atprotoData = await ctx.idResolver.did.resolveAtprotoData(groupDid)
} catch {
throw new InvalidRequestError(`Could not resolve DID document for ${groupDid}`)
}
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 })
Comment on lines +63 to +69
try {
await agent.login({ identifier: groupDid, password: appPassword })
} catch (err) {
const e = err as { status?: number; error?: string; message?: string }
// Bad/revoked app password, or the account is not on the resolved PDS.
if (e?.status === 401 || e?.status === 400) {
throw new AuthRequiredError(
'Could not authenticate to the account PDS with the supplied app password',
'InvalidAppPassword',
)
}
throw err
}

// Persist credentials, init per-group DB, seed owner, audit-log.
// No recovery key: the service never had genesis control of this account,
// and an app password cannot grant key control (see design doc).
await finalizeGroup(ctx, {
groupDid,
pdsUrl,
appPassword,
ownerDid,
recoveryKeyMaterial: null,
action: 'group.import',
handle,
})

return jsonResponse({ groupDid, handle })
},
})
}
Loading
Loading