• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

hypercerts-org / certified-group-service / 28203195314

25 Jun 2026 10:00PM UTC coverage: 94.026% (+0.1%) from 93.908%
28203195314

Pull #61

github

web-flow
Merge 063d2a772 into 42a192c26
Pull Request #61: feat(admin): add app.certified.group.admin.setOwner XRPC procedure

696 of 760 branches covered (91.58%)

Branch coverage included in aggregate %.

169 of 175 new or added lines in 6 files covered. (96.57%)

1 existing line in 1 file now uncovered.

2562 of 2705 relevant lines covered (94.71%)

50.54 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

92.38
/src/api/admin/setOwner.ts
1
import type { Server } from '@atproto/xrpc-server'
1✔
2
import { XRPCError, AuthRequiredError } from '@atproto/xrpc-server'
1✔
3
import { ensureValidDid } from '@atproto/syntax'
1✔
4
import type { AppContext } from '../../context.js'
5
import { registerAdminMethod, jsonResponse } from '../util.js'
1✔
6

7
/**
8
 * app.certified.group.admin.setOwner — operator-only ownership reassignment.
9
 *
10
 * Authenticated by HTTP Basic auth against ADMIN_PASSWORD (see
11
 * registerAdminMethod), NOT group membership. This is the in-process equivalent
12
 * of the former direct-DB script: because it writes through the same
13
 * GroupDbPool connection the read paths use, the change is visible immediately —
14
 * no service restart required.
15
 *
16
 * The previous owner (if any) is demoted to admin; the new owner is promoted to
17
 * owner. The new owner must already be a member — unlike a self-service role
18
 * change, but creating membership as a side effect of an ownership transfer
19
 * would be surprising for an admin tool, so we require it explicitly.
20
 */
21
export default function (server: Server, ctx: AppContext) {
13✔
22
  registerAdminMethod(server, 'app.certified.group.admin.setOwner', ctx, {
13✔
23
    handler: async ({ input }) => {
13✔
24
      const { repo, newOwner } = input?.body as { repo: string; newOwner: string }
7✔
25

26
      // Resolve the group (validates it is a managed group) and the new owner's
27
      // DID (handle → DID via the resolver) in parallel.
28
      const [groupDid, newOwnerDid] = await Promise.all([
7✔
29
        resolveGroup(ctx, repo),
7✔
30
        resolveNewOwner(ctx, newOwner),
7✔
31
      ])
7✔
32

33
      const groupDb = ctx.groupDbs.get(groupDid)
5✔
34

35
      const [currentOwner, target] = await Promise.all([
5✔
36
        groupDb
5✔
37
          .selectFrom('group_members')
5✔
38
          .select('member_did')
5✔
39
          .where('role', '=', 'owner')
5✔
40
          .executeTakeFirst(),
5✔
41
        groupDb
5✔
42
          .selectFrom('group_members')
5✔
43
          .select('role')
5✔
44
          .where('member_did', '=', newOwnerDid)
5✔
45
          .executeTakeFirst(),
5✔
46
      ])
5✔
47

48
      if (!target) {
7✔
49
        throw new XRPCError(404, `${newOwnerDid} is not a member of the group`, 'MemberNotFound')
1✔
50
      }
1✔
51

52
      // Already the owner — nothing to do. Report it rather than churn the DB.
53
      if (currentOwner?.member_did === newOwnerDid) {
7✔
54
        await ctx.audit.log(groupDb, 'admin', 'admin.setOwner', 'permitted', {
1✔
55
          newOwner: newOwnerDid,
1✔
56
          previousOwner: newOwnerDid,
1✔
57
          noop: true,
1✔
58
        })
1✔
59
        return jsonResponse({
1✔
60
          groupDid,
1✔
61
          owner: newOwnerDid,
1✔
62
          noop: true,
1✔
63
          updatedAt: new Date().toISOString(),
1✔
64
        })
1✔
65
      }
1✔
66

67
      const previousOwner = currentOwner?.member_did ?? null
7!
68
      ctx.memberIndex.transferOwner(
7✔
69
        ctx.groupDbs.getRaw(groupDid),
7✔
70
        groupDid,
7✔
71
        newOwnerDid,
7✔
72
        previousOwner,
7✔
73
      )
7✔
74

75
      await ctx.audit.log(groupDb, 'admin', 'admin.setOwner', 'permitted', {
7✔
76
        newOwner: newOwnerDid,
7✔
77
        previousOwner,
7✔
78
      })
7✔
79

80
      // updatedAt is the time of this operation, consistent with the no-op
81
      // branch — not the new owner's (older) original join time.
82
      return jsonResponse({
3✔
83
        groupDid,
3✔
84
        owner: newOwnerDid,
3✔
85
        ...(previousOwner ? { previousOwner } : {}),
7!
86
        noop: false,
7✔
87
        updatedAt: new Date().toISOString(),
7✔
88
      })
7✔
89
    },
7✔
90
  })
13✔
91
}
13✔
92

93
/** Resolve the `repo` at-identifier to a known group DID. */
94
async function resolveGroup(ctx: AppContext, repo: string): Promise<string> {
7✔
95
  try {
7✔
96
    return await ctx.authVerifier.resolveRepoToGroup(repo)
7✔
97
  } catch (err) {
7✔
98
    // resolveRepoToGroup throws AuthRequiredError specifically when the repo
99
    // does not resolve to a managed group. Map only that to UnknownGroup; let
100
    // unexpected failures (e.g. a transient resolver error) surface as-is so
101
    // they aren't misdiagnosed as a bad group DID.
102
    if (err instanceof AuthRequiredError) {
1✔
103
      throw new XRPCError(404, `Unknown group: ${repo}`, 'UnknownGroup')
1✔
104
    }
1!
NEW
105
    throw err
×
NEW
106
  }
×
107
}
7✔
108

109
/** Resolve the `newOwner` at-identifier (handle or DID) to a DID. */
110
async function resolveNewOwner(ctx: AppContext, newOwner: string): Promise<string> {
7✔
111
  if (newOwner.startsWith('did:')) {
7✔
112
    try {
5✔
113
      ensureValidDid(newOwner)
5✔
114
    } catch {
5!
NEW
115
      throw new XRPCError(400, `Invalid newOwner DID: ${newOwner}`, 'InvalidRequest')
×
NEW
116
    }
×
117
    return newOwner
5✔
118
  }
5✔
119
  const did = await ctx.idResolver.handle.resolve(newOwner)
2✔
120
  if (!did) {
7✔
121
    throw new XRPCError(400, `Could not resolve newOwner handle: ${newOwner}`, 'InvalidRequest')
1✔
122
  }
1✔
123
  return did
1✔
124
}
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc