• 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

98.47
/src/api/util.ts
1
import type { Server, MethodHandler, RouteOptions } from '@atproto/xrpc-server'
1✔
2
import type { Response as ExpressResponse } from 'express'
3
import type { Kysely } from 'kysely'
4
import type { AppContext } from '../context.js'
5
import type { GroupAuthResult, ServiceAuthResult, AdminAuthResult } from '../auth/verifier.js'
6
import type { AuditEventDetail } from '../audit.js'
7
import type { Operation, Role } from '../rbac/permissions.js'
8
import type { GroupDatabase } from '../db/schema.js'
9
import { XRPCError as ClientXRPCError } from '@atproto/xrpc'
10
import { XRPCError, UpstreamFailureError, ForbiddenError } from '@atproto/xrpc-server'
11
import type { PdsAgentPool } from '../pds/agent.js'
12
import {
13
  scopesCoverOperation,
14
  repoActionForOperation,
15
  repoScopesCover,
16
  blobScopesCover,
17
} from '../auth/scopes.js'
18

19
/**
20
 * The auth-mode-dependent slice of the credential the gate needs: for an
21
 * `apiKey` principal it applies a scope check on top of the role check and
22
 * attributes the audit entry to the specific key.
23
 */
24
export interface GatePrincipal {
25
  authKind: 'jwt' | 'apiKey'
26
  scopes?: string[]
27
  apiKeyRef?: string
28
}
29

30
export interface AuthedMethodConfig {
31
  opts?: RouteOptions
32
  handler: MethodHandler<GroupAuthResult>
33
}
34

35
export interface ServiceAuthMethodConfig {
36
  opts?: RouteOptions
37
  handler: MethodHandler<ServiceAuthResult>
38
}
39

40
export interface AdminMethodConfig {
41
  opts?: RouteOptions
42
  handler: MethodHandler<AdminAuthResult>
43
}
44

45
export function jsonResponse<T>(body: T) {
1✔
46
  return { encoding: 'application/json' as const, body }
111✔
47
}
111✔
48

49
/**
50
 * Resolve the target group for an authed request.
51
 *
52
 * JWT (and legacy) callers: queries set `groupDid` on the credential at the
53
 * verifier (from the querystring `repo` or the legacy `aud` overload); body-input
54
 * procedures leave it undefined and pass the group as `repo` in the body (the
55
 * verifier can't read the body), which this resolves. Precedence mirrors the
56
 * verifier: an explicit body `repo` wins; otherwise the credential's `groupDid`.
57
 *
58
 * **API-key callers are different and stricter.** `verifyApiKey` already resolved
59
 * the group from the **querystring** `repo` and authenticated the key against
60
 * *that* group's DB, so the credential's `groupDid` is authoritative. A body
61
 * `repo` cannot redirect the action to a different group — that would be a
62
 * confused deputy (auth bound to group A, action on group B). So for an apiKey
63
 * credential we use the credential's `groupDid` and reject a body `repo` that
64
 * resolves to anything else.
65
 */
66
export async function resolveGroupDid(
92✔
67
  ctx: AppContext,
92✔
68
  credentials: { groupDid?: string; authKind?: 'jwt' | 'apiKey' },
92✔
69
  bodyRepo: string | undefined,
92✔
70
): Promise<string> {
92✔
71
  if (credentials.authKind === 'apiKey') {
92✔
72
    // The key was verified against credentials.groupDid (querystring repo).
73
    if (!credentials.groupDid) {
13✔
74
      throw new XRPCError(400, 'Missing repo', 'InvalidRequest')
1✔
75
    }
1✔
76
    if (bodyRepo !== undefined && bodyRepo.length > 0) {
13✔
77
      const bodyGroup = await ctx.authVerifier.resolveRepoToGroup(bodyRepo)
8✔
78
      if (bodyGroup !== credentials.groupDid) {
8✔
79
        throw new XRPCError(
1✔
80
          400,
1✔
81
          'API-key request: body `repo` must match the querystring `repo` the key was authenticated against',
1✔
82
          'InvalidRequest',
1✔
83
        )
1✔
84
      }
1✔
85
    }
8✔
86
    return credentials.groupDid
11✔
87
  }
11✔
88

89
  if (bodyRepo !== undefined && bodyRepo.length > 0) {
92✔
90
    return ctx.authVerifier.resolveRepoToGroup(bodyRepo)
24✔
91
  }
24✔
92
  if (credentials.groupDid) return credentials.groupDid
63✔
93
  throw new XRPCError(400, 'Missing repo', 'InvalidRequest')
2✔
94
}
2✔
95

96
/** Convert a SQLite DATETIME string (no timezone) to ISO 8601. */
97
export function sqliteToIso(timestamp: string): string {
1✔
98
  return new Date(timestamp + 'Z').toISOString()
80✔
99
}
80✔
100

101
export function encodeCursor(payload: string): string {
1✔
102
  return Buffer.from(payload).toString('base64')
8✔
103
}
8✔
104

105
export function decodeCursor(cursor: string): string {
1✔
106
  return Buffer.from(cursor, 'base64').toString('utf8')
12✔
107
}
12✔
108

109
/**
110
 * Authorize an operation and audit a denial.
111
 *
112
 * For an `apiKey` principal two checks must BOTH pass (design: scopes ∩
113
 * role-perms): first the scope check (does the key's granted scope set cover
114
 * this operation, delegated to `@atproto/oauth-scopes`), then the existing role
115
 * check (the key acts as its issuing member, so the role check naturally caps the
116
 * key at its issuer's role). A JWT principal is scope-unlimited — only the role
117
 * check applies. The specific key (`apiKeyRef`) is attached to the audit detail
118
 * so key-driven actions are attributable beyond the issuing member DID.
119
 */
120
export async function assertCanWithAudit(
116✔
121
  ctx: AppContext,
116✔
122
  groupDb: Kysely<GroupDatabase>,
116✔
123
  callerDid: string,
116✔
124
  operation: Operation,
116✔
125
  detail?: Omit<AuditEventDetail, 'reason'>,
116✔
126
  principal?: GatePrincipal,
116✔
127
): Promise<Role> {
116✔
128
  const auditDetail: Omit<AuditEventDetail, 'reason'> | undefined =
116✔
129
    principal?.authKind === 'apiKey' && principal.apiKeyRef
116✔
130
      ? { ...detail, apiKeyRef: principal.apiKeyRef }
31✔
131
      : detail
85✔
132

133
  const denied = async (reason: string): Promise<never> => {
116✔
134
    await ctx.audit.log(groupDb, callerDid, operation, 'denied', { ...auditDetail, reason })
17✔
135
    throw new ForbiddenError(reason)
17✔
136
  }
17✔
137

138
  if (principal?.authKind === 'apiKey') {
116✔
139
    const scopes = principal.scopes ?? []
31!
140
    const repoAction = repoActionForOperation(operation)
31✔
141
    let covered: boolean
31✔
142
    if (operation === 'uploadBlob') {
31✔
143
      // Blob upload: gated by a `blob:<mime>` scope against the upload's
144
      // Content-Type (carried in the audit detail). No mime -> deny.
145
      const mime = typeof detail?.mime === 'string' ? detail.mime : undefined
3!
146
      covered = mime !== undefined && blobScopesCover(scopes, mime)
3✔
147
    } else if (repoAction !== undefined) {
31✔
148
      // PDS-repo write op: gated by a `repo:<collection>?action=…` scope. The
149
      // collection comes from the request (carried in the audit detail by the
150
      // handler). With no collection we cannot match a scope, so deny.
151
      const collection = typeof detail?.collection === 'string' ? detail.collection : undefined
13✔
152
      covered = collection !== undefined && repoScopesCover(scopes, operation, collection)
13✔
153
    } else {
21✔
154
      // Service method: gated by an `rpc:` scope.
155
      covered = scopesCoverOperation(scopes, operation, ctx.config.serviceDid)
15✔
156
    }
15✔
157
    if (!covered) {
31✔
158
      await denied(`API key scopes do not permit '${operation}'`)
17!
NEW
159
    }
×
160
  }
31✔
161

162
  try {
99✔
163
    return await ctx.rbac.assertCan(groupDb, callerDid, operation)
99✔
164
  } catch (err) {
116✔
165
    await ctx.audit.log(groupDb, callerDid, operation, 'denied', {
16✔
166
      ...auditDetail,
16✔
167
      reason: (err as Error).message,
16✔
168
    })
16✔
169
    throw err
16✔
170
  }
16✔
171
}
116✔
172

173
/**
174
 * Proxy a call to the group's PDS.
175
 *
176
 * 4xx errors from the PDS are forwarded to the client so they can
177
 * distinguish e.g. "duplicate rkey" (400) from a server problem.
178
 * 401s are already handled by PdsAgentPool.withAgent (auto-retry),
179
 * so any 401 that reaches here is a genuine auth failure on our side
180
 * and gets wrapped as 502 along with 5xx and network errors.
181
 */
182
export async function proxyToPds<T>(
36✔
183
  pdsAgents: PdsAgentPool,
36✔
184
  groupDid: string,
36✔
185
  fn: (agent: import('@atproto/api').Agent) => Promise<T>,
36✔
186
): Promise<T> {
36✔
187
  try {
36✔
188
    return await pdsAgents.withAgent(groupDid, fn)
36✔
189
  } catch (err) {
36✔
190
    if (err instanceof UpstreamFailureError) throw err
11✔
191
    if (err instanceof ClientXRPCError) {
11✔
192
      // err.status is the ResponseType enum; coerce to its numeric HTTP
193
      // status code so we can range-check it.
194
      const status = Number(err.status)
8✔
195
      if (status >= 400 && status < 500 && status !== 401) {
8✔
196
        throw new XRPCError(status, err.message, err.error)
3✔
197
      }
3✔
198
    }
8✔
199
    const msg = err instanceof Error ? err.message : String(err)
11✔
200
    throw new UpstreamFailureError(`Upstream PDS error: ${msg}`)
11✔
201
  }
11✔
202
}
36✔
203

204
/**
205
 * Link to the deprecation explanation, surfaced in the RFC 8594 `Link` header
206
 * on legacy-`aud` responses.
207
 */
208
const DEPRECATION_INFO_URL = 'https://github.com/hypercerts-org/certified-group-service/issues/27'
1✔
209

210
/** One warn per caller-DID per this window, to keep legacy traffic from flooding logs. */
211
const LEGACY_WARN_WINDOW_MS = 15 * 60 * 1000
1✔
212
/** Cap on distinct callers tracked; above this we sweep expired entries first. */
213
const LEGACY_WARN_MAX_ENTRIES = 10_000
1✔
214
const lastLegacyWarn = new Map<string, number>()
1✔
215

216
/**
217
 * Per-key rate limiter backed by a bounded `Map<key, lastSeenMs>`. Returns true
218
 * (and records `now`) when `key` has not been seen within `windowMs`; false
219
 * otherwise.
220
 *
221
 * Memory is hard-bounded to `maxEntries`. Before inserting a new key at the cap
222
 * it first sweeps entries older than the window (cheap, and they'd fire again
223
 * anyway); if every entry is still fresh (a high-cardinality burst), it evicts
224
 * the oldest by insertion order (`Map` preserves it) so the cap is never
225
 * exceeded. Evicting a fresh entry only costs that key one extra warn later.
226
 */
227
export function rateLimitAllow(
1✔
228
  map: Map<string, number>,
125✔
229
  key: string,
125✔
230
  now: number,
125✔
231
  windowMs: number,
125✔
232
  maxEntries: number,
125✔
233
): boolean {
125✔
234
  const previous = map.get(key)
125✔
235
  if (previous !== undefined && now - previous < windowMs) return false
125✔
236
  if (map.size >= maxEntries && !map.has(key)) {
125✔
237
    for (const [k, ts] of map) {
2✔
238
      if (now - ts >= windowMs) map.delete(k)
6✔
239
    }
6✔
240
    // Still full of fresh entries: evict the oldest to keep a hard cap.
241
    if (map.size >= maxEntries) {
2✔
242
      const oldest: string | undefined = map.keys().next().value
1✔
243
      if (oldest !== undefined) map.delete(oldest)
1✔
244
    }
1✔
245
  }
2✔
246
  map.set(key, now)
26✔
247
  return true
26✔
248
}
26✔
249

250
/**
251
 * Signal the deprecated `aud`-as-group path (issue #27) on a per-request basis:
252
 * attach RFC 8594 headers so clients can detect it programmatically, and emit a
253
 * rate-limited warn so operators can see lingering legacy traffic. No `Sunset`
254
 * header — a removal date is not yet set.
255
 */
256
function signalLegacyAud(ctx: AppContext, res: ExpressResponse, callerDid: string, nsid: string) {
118✔
257
  res.setHeader('Deprecation', 'true')
118✔
258
  res.setHeader('Link', `<${DEPRECATION_INFO_URL}>; rel="deprecation"`)
118✔
259

260
  if (
118✔
261
    rateLimitAllow(
118✔
262
      lastLegacyWarn,
118✔
263
      callerDid,
118✔
264
      Date.now(),
118✔
265
      LEGACY_WARN_WINDOW_MS,
118✔
266
      LEGACY_WARN_MAX_ENTRIES,
118✔
267
    )
118✔
268
  ) {
118✔
269
    ctx.logger.warn(
20✔
270
      { callerDid, nsid },
20✔
271
      'Deprecated auth: group taken from JWT aud. Pass an explicit `repo` and set aud to the service DID (issue #27).',
20✔
272
    )
20✔
273
  }
20✔
274
}
118✔
275

276
export function registerAuthedMethod(
1✔
277
  server: Server,
462✔
278
  nsid: string,
462✔
279
  ctx: AppContext,
462✔
280
  config: AuthedMethodConfig,
462✔
281
): void {
462✔
282
  const handler: MethodHandler<GroupAuthResult> = async (reqCtx) => {
462✔
283
    if (reqCtx.auth.credentials.legacyAud) {
143✔
284
      signalLegacyAud(ctx, reqCtx.res, reqCtx.auth.credentials.callerDid, nsid)
118✔
285
    }
118✔
286
    return config.handler(reqCtx)
143✔
287
  }
143✔
288
  server.method(nsid, {
462✔
289
    auth: ctx.authVerifier.xrpcAuth(),
462✔
290
    opts: config.opts,
462✔
291
    handler,
462✔
292
  })
462✔
293
}
462✔
294

295
/**
296
 * Register a group-bootstrapping XRPC method (register, import) — one whose
297
 * audience is the service's own DID rather than a group DID, and whose target
298
 * group does not yet exist in the service. Unlike registerAuthedMethod, the
299
 * auth verifier does not open a per-group DB or check group membership; it only
300
 * proves the caller controls the issuing DID. The handler is responsible for
301
 * any ownerDid / authorship checks.
302
 */
303
export function registerServiceAuthMethod(
1✔
304
  server: Server,
36✔
305
  nsid: string,
36✔
306
  ctx: AppContext,
36✔
307
  config: ServiceAuthMethodConfig,
36✔
308
): void {
36✔
309
  server.method(nsid, {
36✔
310
    auth: ctx.authVerifier.xrpcServiceAuth(),
36✔
311
    opts: config.opts,
36✔
312
    handler: config.handler,
36✔
313
  })
36✔
314
}
36✔
315

316
/**
317
 * Register an operator-authenticated admin XRPC method (the `*.admin.*`
318
 * namespace), gated by HTTP Basic auth against `CGS_ADMIN_PASSWORD` rather than any
319
 * group membership or DID — the same model as `com.atproto.admin.*` on a PDS.
320
 * The endpoint is disabled when no admin password is configured. The handler
321
 * receives no caller identity (the credential is just `{ type: 'admin' }`); it
322
 * is fully trusted and must validate its own inputs.
323
 */
324
export function registerAdminMethod(
1✔
325
  server: Server,
16✔
326
  nsid: string,
16✔
327
  ctx: AppContext,
16✔
328
  config: AdminMethodConfig,
16✔
329
): void {
16✔
330
  server.method(nsid, {
16✔
331
    auth: ctx.authVerifier.xrpcAdminAuth(),
16✔
332
    opts: config.opts,
16✔
333
    handler: config.handler,
16✔
334
  })
16✔
335
}
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