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

hypercerts-org / certified-group-service / 28298818628

27 Jun 2026 07:03PM UTC coverage: 94.158% (+1.9%) from 92.257%
28298818628

Pull #68

github

web-flow
Merge 60eb5fa9d into 67fd795de
Pull Request #68: Promote production to v0.5.0

699 of 762 branches covered (91.73%)

Branch coverage included in aggregate %.

1041 of 1065 new or added lines in 21 files covered. (97.75%)

1 existing line in 1 file now uncovered.

2589 of 2730 relevant lines covered (94.84%)

50.68 hits per line

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

95.15
/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 CGS_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) {
16✔
22
  registerAdminMethod(server, 'app.certified.group.admin.setOwner', ctx, {
16✔
23
    handler: async ({ input }) => {
16✔
24
      const { repo, newOwner } = input?.body as { repo: string; newOwner: string }
9✔
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([
9✔
29
        resolveGroup(ctx, repo),
9✔
30
        resolveNewOwner(ctx, newOwner),
9✔
31
      ])
9✔
32

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

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

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

63
      // The new owner need NOT already be a member: this is an operator
64
      // break-glass endpoint, used precisely when the incumbent owner/admin is
65
      // unavailable (lost keys, incapacitated) and a fresh owner must be
66
      // installed. If they aren't a member, add them as owner; otherwise promote
67
      // in place. Either way the previous owner (if any) is demoted to admin.
68
      const addedAsMember = !target
5✔
69
      const previousOwner = currentOwner?.member_did ?? null
9!
70
      ctx.memberIndex.transferOwner(
9✔
71
        ctx.groupDbs.getRaw(groupDid),
9✔
72
        groupDid,
9✔
73
        newOwnerDid,
9✔
74
        previousOwner,
9✔
75
      )
9✔
76

77
      await ctx.audit.log(groupDb, 'admin', 'admin.setOwner', 'permitted', {
9✔
78
        newOwner: newOwnerDid,
9✔
79
        previousOwner,
9✔
80
        addedAsMember,
9✔
81
      })
9✔
82

83
      // updatedAt is the time of this operation, consistent with the no-op
84
      // branch — not the new owner's (older) original join time.
85
      return jsonResponse({
5✔
86
        groupDid,
5✔
87
        owner: newOwnerDid,
5✔
88
        ...(previousOwner ? { previousOwner } : {}),
9!
89
        addedAsMember,
9✔
90
        noop: false,
9✔
91
        updatedAt: new Date().toISOString(),
9✔
92
      })
9✔
93
    },
9✔
94
  })
16✔
95
}
16✔
96

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

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