• 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

92.39
/src/api/keys/create.ts
1
import type { Server } from '@atproto/xrpc-server'
1✔
2
import { XRPCError } from '@atproto/xrpc-server'
1✔
3
import { ForbiddenError } from '../../errors.js'
1✔
4
import type { AppContext } from '../../context.js'
5
import {
6
  registerAuthedMethod,
7
  jsonResponse,
8
  resolveGroupDid,
9
  assertCanWithAudit,
10
  sqliteToIso,
11
} from '../util.js'
1✔
12
import { generateApiKey } from '../../auth/api-key.js'
1✔
13
import {
14
  canonicalizeScopes,
15
  expandIncludes,
16
  validateScopesAllowedForRole,
17
} from '../../auth/scopes.js'
1✔
18

19
export default function (server: Server, ctx: AppContext) {
16✔
20
  registerAuthedMethod(server, 'app.certified.group.keys.create', ctx, {
16✔
21
    handler: async ({ auth, input }) => {
16✔
22
      const { callerDid, authKind, scopes: callerScopes, apiKeyRef } = auth.credentials
11✔
23
      // Default to {} so the destructure can't 500 on an absent body (the
24
      // framework already rejects missing required input with a 400).
25
      const { repo, name, scopes } = (input?.body ?? {}) as {
11!
26
        repo?: string
27
        name?: string
28
        scopes?: string[]
29
      }
30

31
      if (typeof name !== 'string' || name.length === 0) {
11!
NEW
32
        throw new XRPCError(400, 'name is required', 'InvalidRequest')
×
NEW
33
      }
×
34
      if (!Array.isArray(scopes) || scopes.length === 0) {
11!
NEW
35
        throw new XRPCError(400, 'At least one scope is required', 'InvalidRequest')
×
NEW
36
      }
×
37

38
      const groupDid = await resolveGroupDid(ctx, auth.credentials, repo)
11✔
39
      const groupDb = ctx.groupDbs.get(groupDid)
11✔
40

41
      // Any group member may mint their own API keys. Passing the principal
42
      // means an apiKey caller is still rejected here: keys.create has no scope
43
      // mapping, so a long-lived key cannot mint more keys.
44
      const callerRole = await assertCanWithAudit(
11✔
45
        ctx,
11✔
46
        groupDb,
11✔
47
        callerDid,
11✔
48
        'keys.create',
11✔
49
        undefined,
11✔
50
        {
11✔
51
          authKind,
11✔
52
          scopes: callerScopes,
11✔
53
          apiKeyRef,
11✔
54
        },
11✔
55
      )
11✔
56

57
      // Expand any `include:<nsid>` permission-set scopes to the concrete
58
      // `rpc:`/`repo:` scopes they bundle (resolved via Lexicon resolution), then
59
      // canonicalize. This intentionally happens after authorization so API-key
60
      // callers and non-members are rejected before any external DNS/PDS work.
61
      // A key stores only concrete scopes — the `include:` is a create-time
62
      // convenience and is never persisted. Non-`include:` scopes pass through
63
      // untouched.
64
      const expanded = await expandIncludes(scopes, ctx.config.serviceDid, ctx.permissionSets)
9✔
65
      if (!expanded.ok) {
11✔
66
        throw new XRPCError(
1✔
67
          400,
1✔
68
          `Invalid scope: ${expanded.scope} (${expanded.reason})`,
1✔
69
          'InvalidScope',
1✔
70
        )
1✔
71
      }
1✔
72

73
      // Canonicalize to this service's stored form: a key only ever calls the
74
      // CGS it was minted on, so we append our own scope `aud`. A friendly
75
      // `rpc:<lxm>` is expanded; an already-canonical scope is accepted only if
76
      // its `aud` is ours — a foreign service DID or wrong service fragment is
77
      // rejected rather than stored as a dead grant.
78
      const canon = canonicalizeScopes(expanded.scopes, ctx.config.serviceDid)
8✔
79
      if (!canon.ok) {
11✔
80
        throw new XRPCError(400, `Invalid scope: ${canon.scope} (${canon.reason})`, 'InvalidScope')
2✔
81
      }
2✔
82
      const storedScopes = canon.scopes
6✔
83

84
      const roleValidation = validateScopesAllowedForRole(storedScopes, callerRole)
6✔
85
      if (!roleValidation.ok) {
11✔
86
        await ctx.audit.log(groupDb, callerDid, 'keys.create', 'denied', {
1✔
87
          name,
1✔
88
          scopes: storedScopes,
1✔
89
          rejectedScope: roleValidation.scope,
1✔
90
          reason: roleValidation.reason,
1✔
91
        })
1✔
92
        throw new ForbiddenError(roleValidation.reason)
1✔
93
      }
1✔
94

95
      const key = generateApiKey()
5✔
96

97
      // Insert and read created_at back: datetime('now') is only resolved at
98
      // step time, so we cannot know it without selecting it.
99
      const inserted = await groupDb
5✔
100
        .insertInto('group_api_keys')
5✔
101
        .values({
5✔
102
          key_ref: key.keyRef,
5✔
103
          key_hash: key.hash,
5✔
104
          name,
5✔
105
          scopes: JSON.stringify(storedScopes),
5✔
106
          created_by: callerDid,
5✔
107
        })
5✔
108
        .returning('created_at')
5✔
109
        .executeTakeFirstOrThrow()
5✔
110

111
      // `createdKeyRef` (the key this action created), distinct from `apiKeyRef`
112
      // which attributes an action performed *by* a key (see assertCanWithAudit).
113
      await ctx.audit.log(groupDb, callerDid, 'keys.create', 'permitted', {
5✔
114
        createdKeyRef: key.keyRef,
5✔
115
        name,
5✔
116
        scopes: storedScopes,
5✔
117
      })
5✔
118

119
      return jsonResponse({
5✔
120
        keyRef: key.keyRef,
5✔
121
        key: key.plaintext, // returned exactly once
5✔
122
        scopes: storedScopes,
5✔
123
        createdAt: sqliteToIso(inserted.created_at),
5✔
124
      })
5✔
125
    },
11✔
126
  })
16✔
127
}
16✔
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