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

hypercerts-org / certified-group-service / 28202094419

25 Jun 2026 09:37PM UTC coverage: 94.014% (+0.1%) from 93.908%
28202094419

Pull #61

github

web-flow
Merge 4a73f385a 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 %.

162 of 168 new or added lines in 6 files covered. (96.43%)

1 existing line in 1 file now uncovered.

2555 of 2698 relevant lines covered (94.7%)

50.64 hits per line

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

95.28
/src/api/admin/setOwner.ts
1
import type { Server } from '@atproto/xrpc-server'
1✔
2
import { XRPCError } from '@atproto/xrpc-server'
1✔
3
import { ensureValidDid } from '@atproto/syntax'
1✔
4
import type { AppContext } from '../../context.js'
5
import { registerAdminMethod, jsonResponse, sqliteToIso } 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
      const updated = await groupDb
7✔
76
        .selectFrom('group_members')
7✔
77
        .select('added_at')
7✔
78
        .where('member_did', '=', newOwnerDid)
7✔
79
        .executeTakeFirstOrThrow()
7✔
80

81
      await ctx.audit.log(groupDb, 'admin', 'admin.setOwner', 'permitted', {
3✔
82
        newOwner: newOwnerDid,
3✔
83
        previousOwner,
3✔
84
      })
3✔
85

86
      return jsonResponse({
3✔
87
        groupDid,
3✔
88
        owner: newOwnerDid,
3✔
89
        ...(previousOwner ? { previousOwner } : {}),
7!
90
        noop: false,
7✔
91
        updatedAt: sqliteToIso(updated.added_at),
7✔
92
      })
7✔
93
    },
7✔
94
  })
13✔
95
}
13✔
96

97
/** Resolve the `repo` at-identifier to a known group DID. */
98
async function resolveGroup(ctx: AppContext, repo: string): Promise<string> {
7✔
99
  try {
7✔
100
    return await ctx.authVerifier.resolveRepoToGroup(repo)
7✔
101
  } catch {
7✔
102
    throw new XRPCError(404, `Unknown group: ${repo}`, 'UnknownGroup')
1✔
103
  }
1✔
104
}
7✔
105

106
/** Resolve the `newOwner` at-identifier (handle or DID) to a DID. */
107
async function resolveNewOwner(ctx: AppContext, newOwner: string): Promise<string> {
7✔
108
  if (newOwner.startsWith('did:')) {
7✔
109
    try {
5✔
110
      ensureValidDid(newOwner)
5✔
111
    } catch {
5!
NEW
112
      throw new XRPCError(400, `Invalid newOwner DID: ${newOwner}`, 'InvalidRequest')
×
NEW
113
    }
×
114
    return newOwner
5✔
115
  }
5✔
116
  const did = await ctx.idResolver.handle.resolve(newOwner)
2✔
117
  if (!did) {
7✔
118
    throw new XRPCError(400, `Could not resolve newOwner handle: ${newOwner}`, 'InvalidRequest')
1✔
119
  }
1✔
120
  return did
1✔
121
}
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