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

nats-io / jwt / 19439588169

17 Nov 2025 06:04PM UTC coverage: 81.015% (-0.06%) from 81.072%
19439588169

Pull #241

github

aricart
Add test for self-mapping validation in account claims

Signed-off-by: Alberto Ricart <alberto@synadia.com>
Pull Request #241: Handle zero weights in cluster and non-cluster mappings, add new tests

16 of 19 new or added lines in 1 file covered. (84.21%)

2 existing lines in 1 file now uncovered.

2778 of 3429 relevant lines covered (81.01%)

26.49 hits per line

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

83.64
/v2/account_claims.go
1
/*
2
 * Copyright 2018-2024 The NATS Authors
3
 * Licensed under the Apache License, Version 2.0 (the "License");
4
 * you may not use this file except in compliance with the License.
5
 * You may obtain a copy of the License at
6
 *
7
 * http://www.apache.org/licenses/LICENSE-2.0
8
 *
9
 * Unless required by applicable law or agreed to in writing, software
10
 * distributed under the License is distributed on an "AS IS" BASIS,
11
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
 * See the License for the specific language governing permissions and
13
 * limitations under the License.
14
 */
15

16
package jwt
17

18
import (
19
        "encoding/json"
20
        "errors"
21
        "fmt"
22
        "sort"
23
        "time"
24

25
        "github.com/nats-io/nkeys"
26
)
27

28
// NoLimit is used to indicate a limit field is unlimited in value.
29
const (
30
        NoLimit    = -1
31
        AnyAccount = "*"
32
)
33

34
type AccountLimits struct {
35
        Imports         int64 `json:"imports,omitempty"`         // Max number of imports
36
        Exports         int64 `json:"exports,omitempty"`         // Max number of exports
37
        WildcardExports bool  `json:"wildcards,omitempty"`       // Are wildcards allowed in exports
38
        DisallowBearer  bool  `json:"disallow_bearer,omitempty"` // User JWT can't be bearer token
39
        Conn            int64 `json:"conn,omitempty"`            // Max number of active connections
40
        LeafNodeConn    int64 `json:"leaf,omitempty"`            // Max number of active leaf node connections
41
}
42

43
// IsUnlimited returns true if all limits are unlimited
44
func (a *AccountLimits) IsUnlimited() bool {
1✔
45
        return *a == AccountLimits{NoLimit, NoLimit, true, false, NoLimit, NoLimit}
1✔
46
}
1✔
47

48
type NatsLimits struct {
49
        Subs    int64 `json:"subs,omitempty"`    // Max number of subscriptions
50
        Data    int64 `json:"data,omitempty"`    // Max number of bytes
51
        Payload int64 `json:"payload,omitempty"` // Max message payload
52
}
53

54
// IsUnlimited returns true if all limits are unlimited
55
func (n *NatsLimits) IsUnlimited() bool {
3✔
56
        return *n == NatsLimits{NoLimit, NoLimit, NoLimit}
3✔
57
}
3✔
58

59
type JetStreamLimits struct {
60
        MemoryStorage        int64 `json:"mem_storage,omitempty"`           // Max number of bytes stored in memory across all streams. (0 means disabled)
61
        DiskStorage          int64 `json:"disk_storage,omitempty"`          // Max number of bytes stored on disk across all streams. (0 means disabled)
62
        Streams              int64 `json:"streams,omitempty"`               // Max number of streams
63
        Consumer             int64 `json:"consumer,omitempty"`              // Max number of consumers
64
        MaxAckPending        int64 `json:"max_ack_pending,omitempty"`       // Max ack pending of a Stream
65
        MemoryMaxStreamBytes int64 `json:"mem_max_stream_bytes,omitempty"`  // Max bytes a memory backed stream can have. (0 means disabled/unlimited)
66
        DiskMaxStreamBytes   int64 `json:"disk_max_stream_bytes,omitempty"` // Max bytes a disk backed stream can have. (0 means disabled/unlimited)
67
        MaxBytesRequired     bool  `json:"max_bytes_required,omitempty"`    // Max bytes required by all Streams
68
}
69

70
// IsUnlimited returns true if all limits are unlimited
71
func (j *JetStreamLimits) IsUnlimited() bool {
×
72
        lim := *j
×
73
        // workaround in case NoLimit was used instead of 0
×
74
        if lim.MemoryMaxStreamBytes < 0 {
×
75
                lim.MemoryMaxStreamBytes = 0
×
76
        }
×
77
        if lim.DiskMaxStreamBytes < 0 {
×
78
                lim.DiskMaxStreamBytes = 0
×
79
        }
×
80
        if lim.MaxAckPending < 0 {
×
81
                lim.MaxAckPending = 0
×
82
        }
×
83
        return lim == JetStreamLimits{NoLimit, NoLimit, NoLimit, NoLimit, 0, 0, 0, false}
×
84
}
85

86
type JetStreamTieredLimits map[string]JetStreamLimits
87

88
// OperatorLimits are used to limit access by an account
89
type OperatorLimits struct {
90
        NatsLimits
91
        AccountLimits
92
        JetStreamLimits
93
        JetStreamTieredLimits `json:"tiered_limits,omitempty"`
94
}
95

96
// IsJSEnabled returns if this account claim has JS enabled either through a tier or the non tiered limits.
97
func (o *OperatorLimits) IsJSEnabled() bool {
2✔
98
        if len(o.JetStreamTieredLimits) > 0 {
2✔
99
                for _, l := range o.JetStreamTieredLimits {
×
100
                        if l.MemoryStorage != 0 || l.DiskStorage != 0 {
×
101
                                return true
×
102
                        }
×
103
                }
104
                return false
×
105
        }
106
        l := o.JetStreamLimits
2✔
107
        return l.MemoryStorage != 0 || l.DiskStorage != 0
2✔
108
}
109

110
// IsEmpty returns true if all limits are 0/false/empty.
111
func (o *OperatorLimits) IsEmpty() bool {
72✔
112
        return o.NatsLimits == NatsLimits{} &&
72✔
113
                o.AccountLimits == AccountLimits{} &&
72✔
114
                o.JetStreamLimits == JetStreamLimits{} &&
72✔
115
                len(o.JetStreamTieredLimits) == 0
72✔
116
}
72✔
117

118
// IsUnlimited returns true if all limits are unlimited
119
func (o *OperatorLimits) IsUnlimited() bool {
×
120
        return o.AccountLimits.IsUnlimited() && o.NatsLimits.IsUnlimited() &&
×
121
                o.JetStreamLimits.IsUnlimited() && len(o.JetStreamTieredLimits) == 0
×
122
}
×
123

124
// Validate checks that the operator limits contain valid values
125
func (o *OperatorLimits) Validate(vr *ValidationResults) {
70✔
126
        // negative values mean unlimited, so all numbers are valid
70✔
127
        if len(o.JetStreamTieredLimits) > 0 {
72✔
128
                if (o.JetStreamLimits != JetStreamLimits{}) {
3✔
129
                        vr.AddError("JetStream Limits and tiered JetStream Limits are mutually exclusive")
1✔
130
                }
1✔
131
                if _, ok := o.JetStreamTieredLimits[""]; ok {
2✔
132
                        vr.AddError(`Tiered JetStream Limits can not contain a blank "" tier name`)
×
133
                }
×
134
        }
135
}
136

137
// WeightedMapping for publishes
138
type WeightedMapping struct {
139
        Subject Subject `json:"subject"`
140
        Weight  uint8   `json:"weight"`
141
        Cluster string  `json:"cluster,omitempty"`
142
}
143

144
// UnmarshalJSON implements custom JSON unmarshalling for backward compatibility.
145
// If weight field is missing (old JWTs), it defaults to 100 so existing JWTs that
146
// omit the weight will continue to work properly
147
func (m *WeightedMapping) UnmarshalJSON(data []byte) error {
3✔
148
        temp := &struct {
3✔
149
                Subject Subject `json:"subject"`
3✔
150
                Weight  *uint8  `json:"weight"` // pointer to detect if field is present
3✔
151
                Cluster string  `json:"cluster,omitempty"`
3✔
152
        }{}
3✔
153
        if err := json.Unmarshal(data, temp); err != nil {
3✔
NEW
154
                return err
×
NEW
155
        }
×
156
        m.Subject = temp.Subject
3✔
157
        m.Cluster = temp.Cluster
3✔
158
        // if weight field is not present in JSON (old JWT), default to 100
3✔
159
        if temp.Weight == nil {
5✔
160
                m.Weight = 100
2✔
161
        } else {
3✔
162
                m.Weight = *temp.Weight
1✔
163
        }
1✔
164
        return nil
3✔
165
}
166

167
// GetWeight returns the weight value.
168
// Deprecated: use Weight field directly.
NEW
169
func (m *WeightedMapping) GetWeight() uint8 {
×
UNCOV
170
        return m.Weight
×
UNCOV
171
}
×
172

173
type Mapping map[Subject][]WeightedMapping
174

175
func (m *Mapping) Validate(vr *ValidationResults) {
70✔
176
        for ubFrom, wm := range (map[Subject][]WeightedMapping)(*m) {
85✔
177
                ubFrom.Validate(vr)
15✔
178
                perCluster := make(map[string]uint8)
15✔
179
                total := uint8(0)
15✔
180
                for _, e := range wm {
45✔
181
                        e.Subject.Validate(vr)
30✔
182
                        if e.Cluster != "" {
40✔
183
                                t := perCluster[e.Cluster]
10✔
184
                                t += e.Weight
10✔
185
                                perCluster[e.Cluster] = t
10✔
186
                                if t > 100 {
11✔
187
                                        vr.AddError("Mapping %q in cluster %q exceeds 100%% among all of it's weighted to mappings", ubFrom, e.Cluster)
1✔
188
                                }
1✔
189
                        } else {
20✔
190
                                total += e.Weight
20✔
191
                        }
20✔
192
                }
193
                if total > 100 {
17✔
194
                        vr.AddError("Mapping %q exceeds 100%% among all of it's weighted to mappings", ubFrom)
2✔
195
                }
2✔
196
        }
197
}
198

199
func (a *Account) AddMapping(sub Subject, to ...WeightedMapping) {
11✔
200
        a.Mappings[sub] = to
11✔
201
}
11✔
202

203
// ExternalAuthorization enables external authorization for account users.
204
// AuthUsers are those users specified to bypass the authorization callout and should be used for the authorization service itself.
205
// AllowedAccounts specifies which accounts, if any, that the authorization service can bind an authorized user to.
206
// The authorization response, a user JWT, will still need to be signed by the correct account.
207
// If optional XKey is specified, that is the public xkey (x25519) and the server will encrypt the request such that only the
208
// holder of the private key can decrypt. The auth service can also optionally encrypt the response back to the server using it's
209
// public xkey which will be in the authorization request.
210
type ExternalAuthorization struct {
211
        AuthUsers       StringList `json:"auth_users,omitempty"`
212
        AllowedAccounts StringList `json:"allowed_accounts,omitempty"`
213
        XKey            string     `json:"xkey,omitempty"`
214
}
215

216
func (ac *ExternalAuthorization) IsEnabled() bool {
2✔
217
        return len(ac.AuthUsers) > 0
2✔
218
}
2✔
219

220
// HasExternalAuthorization helper function to determine if external authorization is enabled.
221
func (a *Account) HasExternalAuthorization() bool {
1✔
222
        return a.Authorization.IsEnabled()
1✔
223
}
1✔
224

225
// EnableExternalAuthorization helper function to setup external authorization.
226
func (a *Account) EnableExternalAuthorization(users ...string) {
1✔
227
        a.Authorization.AuthUsers.Add(users...)
1✔
228
}
1✔
229

230
func (ac *ExternalAuthorization) Validate(vr *ValidationResults) {
70✔
231
        if len(ac.AllowedAccounts) > 0 && len(ac.AuthUsers) == 0 {
72✔
232
                vr.AddError("External authorization cannot have accounts without users specified")
2✔
233
        }
2✔
234
        // Make sure users are all valid user nkeys.
235
        // Make sure allowed accounts are all valid account nkeys.
236
        for _, u := range ac.AuthUsers {
75✔
237
                if !nkeys.IsValidPublicUserKey(u) {
6✔
238
                        vr.AddError("AuthUser %q is not a valid user public key", u)
1✔
239
                }
1✔
240
        }
241
        for _, a := range ac.AllowedAccounts {
76✔
242
                if a == AnyAccount && len(ac.AllowedAccounts) > 1 {
7✔
243
                        vr.AddError("AllowedAccounts can only be a list of accounts or %q", AnyAccount)
1✔
244
                        continue
1✔
245
                } else if a == AnyAccount {
6✔
246
                        continue
1✔
247
                } else if !nkeys.IsValidPublicAccountKey(a) {
5✔
248
                        vr.AddError("Account %q is not a valid account public key", a)
1✔
249
                }
1✔
250
        }
251
        if ac.XKey != "" && !nkeys.IsValidPublicCurveKey(ac.XKey) {
70✔
252
                vr.AddError("XKey %q is not a valid public xkey", ac.XKey)
×
253
        }
×
254
}
255

256
const (
257
        ClusterTrafficSystem = "system"
258
        ClusterTrafficOwner  = "owner"
259
)
260

261
type ClusterTraffic string
262

263
func (ct ClusterTraffic) Valid() error {
77✔
264
        if ct == "" || ct == ClusterTrafficSystem || ct == ClusterTrafficOwner {
150✔
265
                return nil
73✔
266
        }
73✔
267
        return fmt.Errorf("unknown cluster traffic option: %q", ct)
4✔
268
}
269

270
// Account holds account specific claims data
271
type Account struct {
272
        Imports            Imports               `json:"imports,omitempty"`
273
        Exports            Exports               `json:"exports,omitempty"`
274
        Limits             OperatorLimits        `json:"limits,omitempty"`
275
        SigningKeys        SigningKeys           `json:"signing_keys,omitempty"`
276
        Revocations        RevocationList        `json:"revocations,omitempty"`
277
        DefaultPermissions Permissions           `json:"default_permissions,omitempty"`
278
        Mappings           Mapping               `json:"mappings,omitempty"`
279
        Authorization      ExternalAuthorization `json:"authorization,omitempty"`
280
        Trace              *MsgTrace             `json:"trace,omitempty"`
281
        ClusterTraffic     ClusterTraffic        `json:"cluster_traffic,omitempty"`
282
        Info
283
        GenericFields
284
}
285

286
// MsgTrace holds distributed message tracing configuration
287
type MsgTrace struct {
288
        // Destination is the subject the server will send message traces to
289
        // if the inbound message contains the "traceparent" header and has
290
        // its sampled field indicating that the trace should be triggered.
291
        Destination Subject `json:"dest,omitempty"`
292
        // Sampling is used to set the probability sampling, that is, the
293
        // server will get a random number between 1 and 100 and trigger
294
        // the trace if the number is lower than this Sampling value.
295
        // The valid range is [1..100]. If the value is not set Validate()
296
        // will set the value to 100.
297
        Sampling int `json:"sampling,omitempty"`
298
}
299

300
// Validate checks if the account is valid, based on the wrapper
301
func (a *Account) Validate(acct *AccountClaims, vr *ValidationResults) {
70✔
302
        a.Imports.Validate(acct.Subject, vr)
70✔
303
        a.Exports.Validate(vr)
70✔
304
        a.Limits.Validate(vr)
70✔
305
        a.DefaultPermissions.Validate(vr)
70✔
306
        a.Mappings.Validate(vr)
70✔
307
        a.Authorization.Validate(vr)
70✔
308
        if a.Trace != nil {
81✔
309
                tvr := CreateValidationResults()
11✔
310
                a.Trace.Destination.Validate(tvr)
11✔
311
                if !tvr.IsEmpty() {
17✔
312
                        vr.AddError(fmt.Sprintf("the account Trace.Destination %s", tvr.Issues[0].Description))
6✔
313
                }
6✔
314
                if a.Trace.Destination.HasWildCards() {
12✔
315
                        vr.AddError("the account Trace.Destination subject %q is not a valid publish subject", a.Trace.Destination)
1✔
316
                }
1✔
317
                if a.Trace.Sampling < 0 || a.Trace.Sampling > 100 {
13✔
318
                        vr.AddError("the account Trace.Sampling value '%d' is not valid, should be in the range [1..100]", a.Trace.Sampling)
2✔
319
                } else if a.Trace.Sampling == 0 {
18✔
320
                        a.Trace.Sampling = 100
7✔
321
                }
7✔
322
        }
323

324
        if !a.Limits.IsEmpty() && a.Limits.Imports >= 0 && int64(len(a.Imports)) > a.Limits.Imports {
70✔
325
                vr.AddError("the account contains more imports than allowed by the operator")
×
326
        }
×
327

328
        // Check Imports and Exports for limit violations.
329
        if a.Limits.Imports != NoLimit {
74✔
330
                if int64(len(a.Imports)) > a.Limits.Imports {
4✔
331
                        vr.AddError("the account contains more imports than allowed by the operator")
×
332
                }
×
333
        }
334
        if a.Limits.Exports != NoLimit {
74✔
335
                if int64(len(a.Exports)) > a.Limits.Exports {
5✔
336
                        vr.AddError("the account contains more exports than allowed by the operator")
1✔
337
                }
1✔
338
                // Check for wildcard restrictions
339
                if !a.Limits.WildcardExports {
5✔
340
                        for _, ex := range a.Exports {
3✔
341
                                if ex.Subject.HasWildCards() {
3✔
342
                                        vr.AddError("the account contains wildcard exports that are not allowed by the operator")
1✔
343
                                }
1✔
344
                        }
345
                }
346
        }
347
        a.SigningKeys.Validate(vr)
70✔
348
        a.Info.Validate(vr)
70✔
349

70✔
350
        if err := a.ClusterTraffic.Valid(); err != nil {
70✔
351
                vr.AddError(err.Error())
×
352
        }
×
353
}
354

355
// AccountClaims defines the body of an account JWT
356
type AccountClaims struct {
357
        ClaimsData
358
        Account `json:"nats,omitempty"`
359
}
360

361
// NewAccountClaims creates a new account JWT
362
func NewAccountClaims(subject string) *AccountClaims {
73✔
363
        if subject == "" {
74✔
364
                return nil
1✔
365
        }
1✔
366
        c := &AccountClaims{}
72✔
367
        c.SigningKeys = make(SigningKeys)
72✔
368
        // Set to unlimited to start. We do it this way so we get compiler
72✔
369
        // errors if we add to the OperatorLimits.
72✔
370
        c.Limits = OperatorLimits{
72✔
371
                NatsLimits{NoLimit, NoLimit, NoLimit},
72✔
372
                AccountLimits{NoLimit, NoLimit, true, false, NoLimit, NoLimit},
72✔
373
                JetStreamLimits{0, 0, 0, 0, 0, 0, 0, false},
72✔
374
                JetStreamTieredLimits{},
72✔
375
        }
72✔
376
        c.Subject = subject
72✔
377
        c.Mappings = Mapping{}
72✔
378
        return c
72✔
379
}
380

381
// Encode converts account claims into a JWT string
382
func (a *AccountClaims) Encode(pair nkeys.KeyPair) (string, error) {
52✔
383
        return a.EncodeWithSigner(pair, nil)
52✔
384
}
52✔
385

386
func (a *AccountClaims) EncodeWithSigner(pair nkeys.KeyPair, fn SignFn) (string, error) {
53✔
387
        if !nkeys.IsValidPublicAccountKey(a.Subject) {
57✔
388
                return "", errors.New("expected subject to be account public key")
4✔
389
        }
4✔
390
        sort.Sort(a.Exports)
49✔
391
        sort.Sort(a.Imports)
49✔
392
        a.Type = AccountClaim
49✔
393
        return a.ClaimsData.encode(pair, a, fn)
49✔
394
}
395

396
// DecodeAccountClaims decodes account claims from a JWT string
397
func DecodeAccountClaims(token string) (*AccountClaims, error) {
53✔
398
        claims, err := Decode(token)
53✔
399
        if err != nil {
56✔
400
                return nil, err
3✔
401
        }
3✔
402
        ac, ok := claims.(*AccountClaims)
50✔
403
        if !ok {
50✔
404
                return nil, errors.New("not account claim")
×
405
        }
×
406
        return ac, nil
50✔
407
}
408

409
func (a *AccountClaims) String() string {
44✔
410
        return a.ClaimsData.String(a)
44✔
411
}
44✔
412

413
// Payload pulls the accounts specific payload out of the claims
414
func (a *AccountClaims) Payload() interface{} {
1✔
415
        return &a.Account
1✔
416
}
1✔
417

418
// Validate checks the accounts contents
419
func (a *AccountClaims) Validate(vr *ValidationResults) {
70✔
420
        a.ClaimsData.Validate(vr)
70✔
421
        a.Account.Validate(a, vr)
70✔
422

70✔
423
        if nkeys.IsValidPublicAccountKey(a.ClaimsData.Issuer) {
72✔
424
                if !a.Limits.IsEmpty() {
4✔
425
                        vr.AddWarning("self-signed account JWTs shouldn't contain operator limits")
2✔
426
                }
2✔
427
        }
428
}
429

430
func (a *AccountClaims) ClaimType() ClaimType {
2✔
431
        return a.Type
2✔
432
}
2✔
433

434
func (a *AccountClaims) updateVersion() {
49✔
435
        a.GenericFields.Version = libVersion
49✔
436
}
49✔
437

438
// ExpectedPrefixes defines the types that can encode an account jwt, account and operator
439
func (a *AccountClaims) ExpectedPrefixes() []nkeys.PrefixByte {
103✔
440
        return []nkeys.PrefixByte{nkeys.PrefixByteAccount, nkeys.PrefixByteOperator}
103✔
441
}
103✔
442

443
// Claims returns the accounts claims data
444
func (a *AccountClaims) Claims() *ClaimsData {
112✔
445
        return &a.ClaimsData
112✔
446
}
112✔
447
func (a *AccountClaims) GetTags() TagList {
2✔
448
        return a.Account.Tags
2✔
449
}
2✔
450

451
// DidSign checks the claims against the account's public key and its signing keys
452
func (a *AccountClaims) DidSign(c Claims) bool {
10✔
453
        if c != nil {
20✔
454
                issuer := c.Claims().Issuer
10✔
455
                if issuer == a.Subject {
13✔
456
                        return true
3✔
457
                }
3✔
458
                uc, ok := c.(*UserClaims)
7✔
459
                if ok && uc.IssuerAccount == a.Subject {
12✔
460
                        return a.SigningKeys.Contains(issuer)
5✔
461
                }
5✔
462
                at, ok := c.(*ActivationClaims)
2✔
463
                if ok && at.IssuerAccount == a.Subject {
3✔
464
                        return a.SigningKeys.Contains(issuer)
1✔
465
                }
1✔
466
        }
467
        return false
1✔
468
}
469

470
// Revoke enters a revocation by public key using time.Now().
471
func (a *AccountClaims) Revoke(pubKey string) {
×
472
        a.RevokeAt(pubKey, time.Now())
×
473
}
×
474

475
// RevokeAt enters a revocation by public key and timestamp into this account
476
// This will revoke all jwt issued for pubKey, prior to timestamp
477
// If there is already a revocation for this public key that is newer, it is kept.
478
// The value is expected to be a public key or "*" (means all public keys)
479
func (a *AccountClaims) RevokeAt(pubKey string, timestamp time.Time) {
7✔
480
        if a.Revocations == nil {
10✔
481
                a.Revocations = RevocationList{}
3✔
482
        }
3✔
483
        a.Revocations.Revoke(pubKey, timestamp)
7✔
484
}
485

486
// ClearRevocation removes any revocation for the public key
487
func (a *AccountClaims) ClearRevocation(pubKey string) {
2✔
488
        a.Revocations.ClearRevocation(pubKey)
2✔
489
}
2✔
490

491
// isRevoked checks if the public key is in the revoked list with a timestamp later than the one passed in.
492
// Generally this method is called with the subject and issue time of the jwt to be tested.
493
// DO NOT pass time.Now(), it will not produce a stable/expected response.
494
func (a *AccountClaims) isRevoked(pubKey string, claimIssuedAt time.Time) bool {
9✔
495
        return a.Revocations.IsRevoked(pubKey, claimIssuedAt)
9✔
496
}
9✔
497

498
// IsClaimRevoked checks if the account revoked the claim passed in.
499
// Invalid claims (nil, no Subject or IssuedAt) will return true.
500
func (a *AccountClaims) IsClaimRevoked(claim *UserClaims) bool {
4✔
501
        if claim == nil || claim.IssuedAt == 0 || claim.Subject == "" {
4✔
502
                return true
×
503
        }
×
504
        return a.isRevoked(claim.Subject, time.Unix(claim.IssuedAt, 0))
4✔
505
}
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

© 2025 Coveralls, Inc