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

supabase / cli / 15239106693

25 May 2025 02:59PM UTC coverage: 60.201% (-0.02%) from 60.221%
15239106693

Pull #3609

github

web-flow
Merge 21c588ad3 into 09d487004
Pull Request #3609: Update mailpit label for supabase status in terminal and other reference to inbucket

22 of 26 new or added lines in 5 files covered. (84.62%)

9 existing lines in 4 files now uncovered.

9026 of 14993 relevant lines covered (60.2%)

509.14 hits per line

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

59.79
/pkg/config/config.go
1
package config
2

3
import (
4
        "bytes"
5
        "context"
6
        _ "embed"
7
        "encoding/base64"
8
        "encoding/json"
9
        "fmt"
10
        "io"
11
        "io/fs"
12
        "maps"
13
        "net"
14
        "net/http"
15
        "net/url"
16
        "os"
17
        "path"
18
        "path/filepath"
19
        "regexp"
20
        "sort"
21
        "strconv"
22
        "strings"
23
        "text/template"
24
        "time"
25

26
        "github.com/BurntSushi/toml"
27
        "github.com/docker/go-units"
28
        "github.com/go-errors/errors"
29
        "github.com/go-viper/mapstructure/v2"
30
        "github.com/golang-jwt/jwt/v5"
31
        "github.com/joho/godotenv"
32
        "github.com/spf13/viper"
33
        "github.com/supabase/cli/pkg/cast"
34
        "github.com/supabase/cli/pkg/fetcher"
35
        "golang.org/x/mod/semver"
36
)
37

38
// Type for turning human-friendly bytes string ("5MB", "32kB") into an int64 during toml decoding.
39
type sizeInBytes int64
40

41
func (s *sizeInBytes) UnmarshalText(text []byte) error {
69✔
42
        size, err := units.RAMInBytes(string(text))
69✔
43
        if err == nil {
137✔
44
                *s = sizeInBytes(size)
68✔
45
        }
68✔
46
        return err
69✔
47
}
48

49
func (s sizeInBytes) MarshalText() (text []byte, err error) {
10✔
50
        return []byte(units.BytesSize(float64(s))), nil
10✔
51
}
10✔
52

53
type LogflareBackend string
54

55
const (
56
        LogflarePostgres LogflareBackend = "postgres"
57
        LogflareBigQuery LogflareBackend = "bigquery"
58
)
59

60
func (b *LogflareBackend) UnmarshalText(text []byte) error {
59✔
61
        allowed := []LogflareBackend{LogflarePostgres, LogflareBigQuery}
59✔
62
        if *b = LogflareBackend(text); !sliceContains(allowed, *b) {
59✔
63
                return errors.Errorf("must be one of %v", allowed)
×
64
        }
×
65
        return nil
59✔
66
}
67

68
type AddressFamily string
69

70
const (
71
        AddressIPv6 AddressFamily = "IPv6"
72
        AddressIPv4 AddressFamily = "IPv4"
73
)
74

75
func (f *AddressFamily) UnmarshalText(text []byte) error {
5✔
76
        allowed := []AddressFamily{AddressIPv6, AddressIPv4}
5✔
77
        if *f = AddressFamily(text); !sliceContains(allowed, *f) {
5✔
78
                return errors.Errorf("must be one of %v", allowed)
×
79
        }
×
80
        return nil
5✔
81
}
82

83
type RequestPolicy string
84

85
const (
86
        PolicyPerWorker RequestPolicy = "per_worker"
87
        PolicyOneshot   RequestPolicy = "oneshot"
88
)
89

90
func (p *RequestPolicy) UnmarshalText(text []byte) error {
59✔
91
        allowed := []RequestPolicy{PolicyPerWorker, PolicyOneshot}
59✔
92
        if *p = RequestPolicy(text); !sliceContains(allowed, *p) {
59✔
93
                return errors.Errorf("must be one of %v", allowed)
×
94
        }
×
95
        return nil
59✔
96
}
97

98
type Glob []string
99

100
// Match the glob patterns in the given FS to get a deduplicated
101
// array of all migrations files to apply in the declared order.
102
func (g Glob) Files(fsys fs.FS) ([]string, error) {
22✔
103
        var result []string
22✔
104
        var allErrors []error
22✔
105
        set := make(map[string]struct{})
22✔
106
        for _, pattern := range g {
47✔
107
                // Glob expects / as path separator on windows
25✔
108
                matches, err := fs.Glob(fsys, filepath.ToSlash(pattern))
25✔
109
                if err != nil {
26✔
110
                        allErrors = append(allErrors, errors.Errorf("failed to glob files: %w", err))
1✔
111
                } else if len(matches) == 0 {
37✔
112
                        allErrors = append(allErrors, errors.Errorf("no files matched pattern: %s", pattern))
12✔
113
                }
12✔
114
                sort.Strings(matches)
25✔
115
                // Remove duplicates
25✔
116
                for _, item := range matches {
41✔
117
                        if _, exists := set[item]; !exists {
30✔
118
                                set[item] = struct{}{}
14✔
119
                                result = append(result, item)
14✔
120
                        }
14✔
121
                }
122
        }
123
        return result, errors.Join(allErrors...)
22✔
124
}
125

126
type CustomClaims struct {
127
        // Overrides Issuer to maintain json order when marshalling
128
        Issuer string `json:"iss,omitempty"`
129
        Ref    string `json:"ref,omitempty"`
130
        Role   string `json:"role"`
131
        jwt.RegisteredClaims
132
}
133

134
const (
135
        defaultJwtSecret = "super-secret-jwt-token-with-at-least-32-characters-long"
136
        defaultJwtExpiry = 1983812996
137
)
138

139
func (c CustomClaims) NewToken() *jwt.Token {
50✔
140
        if c.ExpiresAt == nil {
100✔
141
                c.ExpiresAt = jwt.NewNumericDate(time.Unix(defaultJwtExpiry, 0))
50✔
142
        }
50✔
143
        if len(c.Issuer) == 0 {
100✔
144
                c.Issuer = "supabase-demo"
50✔
145
        }
50✔
146
        return jwt.NewWithClaims(jwt.SigningMethodHS256, c)
50✔
147
}
148

149
// We follow these rules when adding new config:
150
//  1. Update init_config.toml (and init_config.test.toml) with the new key, default value, and comments to explain usage.
151
//  2. Update config struct with new field and toml tag (spelled in snake_case).
152
//  3. Add custom field validations to LoadConfigFS function for eg. integer range checks.
153
//
154
// If you are adding new user defined secrets, such as OAuth provider secret, the default value in
155
// init_config.toml should be an env var substitution. For example,
156
//
157
// > secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
158
//
159
// Default values for internal configs should be added to `var Config` initializer.
160
type (
161
        // Common config fields between our "base" config and any "remote" branch specific
162
        baseConfig struct {
163
                ProjectId    string         `toml:"project_id"`
164
                Hostname     string         `toml:"-"`
165
                Api          api            `toml:"api"`
166
                Db           db             `toml:"db"`
167
                Realtime     realtime       `toml:"realtime"`
168
                Studio       studio         `toml:"studio"`
169
                Mailpit      mailpit        `toml:"mailpit"`
170
                Storage      storage        `toml:"storage"`
171
                Auth         auth           `toml:"auth"`
172
                EdgeRuntime  edgeRuntime    `toml:"edge_runtime"`
173
                Functions    FunctionConfig `toml:"functions"`
174
                Analytics    analytics      `toml:"analytics"`
175
                Experimental experimental   `toml:"experimental"`
176
        }
177

178
        config struct {
179
                baseConfig
180
                Remotes map[string]baseConfig `toml:"remotes"`
181
        }
182

183
        realtime struct {
184
                Enabled         bool          `toml:"enabled"`
185
                Image           string        `toml:"-"`
186
                IpVersion       AddressFamily `toml:"ip_version"`
187
                MaxHeaderLength uint          `toml:"max_header_length"`
188
                TenantId        string        `toml:"-"`
189
                EncryptionKey   string        `toml:"-"`
190
                SecretKeyBase   string        `toml:"-"`
191
        }
192

193
        studio struct {
194
                Enabled      bool   `toml:"enabled"`
195
                Image        string `toml:"-"`
196
                Port         uint16 `toml:"port"`
197
                ApiUrl       string `toml:"api_url"`
198
                OpenaiApiKey Secret `toml:"openai_api_key"`
199
                PgmetaImage  string `toml:"-"`
200
        }
201

202
        mailpit struct {
203
                Enabled    bool   `toml:"enabled"`
204
                Image      string `toml:"-"`
205
                Port       uint16 `toml:"port"`
206
                SmtpPort   uint16 `toml:"smtp_port"`
207
                Pop3Port   uint16 `toml:"pop3_port"`
208
                AdminEmail string `toml:"admin_email"`
209
                SenderName string `toml:"sender_name"`
210
        }
211

212
        edgeRuntime struct {
213
                Enabled       bool          `toml:"enabled"`
214
                Image         string        `toml:"-"`
215
                Policy        RequestPolicy `toml:"policy"`
216
                InspectorPort uint16        `toml:"inspector_port"`
217
                Secrets       SecretsConfig `toml:"secrets"`
218
                DenoVersion   uint          `toml:"deno_version"`
219
        }
220

221
        SecretsConfig  map[string]Secret
222
        FunctionConfig map[string]function
223

224
        function struct {
225
                Enabled     bool   `toml:"enabled" json:"-"`
226
                VerifyJWT   bool   `toml:"verify_jwt" json:"verifyJWT"`
227
                ImportMap   string `toml:"import_map" json:"importMapPath,omitempty"`
228
                Entrypoint  string `toml:"entrypoint" json:"entrypointPath,omitempty"`
229
                StaticFiles Glob   `toml:"static_files" json:"staticFiles,omitempty"`
230
        }
231

232
        analytics struct {
233
                Enabled          bool            `toml:"enabled"`
234
                Image            string          `toml:"-"`
235
                VectorImage      string          `toml:"-"`
236
                Port             uint16          `toml:"port"`
237
                Backend          LogflareBackend `toml:"backend"`
238
                GcpProjectId     string          `toml:"gcp_project_id"`
239
                GcpProjectNumber string          `toml:"gcp_project_number"`
240
                GcpJwtPath       string          `toml:"gcp_jwt_path"`
241
                ApiKey           string          `toml:"-"`
242
                // Deprecated together with syslog
243
                VectorPort uint16 `toml:"vector_port"`
244
        }
245

246
        webhooks struct {
247
                Enabled bool `toml:"enabled"`
248
        }
249

250
        experimental struct {
251
                OrioleDBVersion string    `toml:"orioledb_version"`
252
                S3Host          string    `toml:"s3_host"`
253
                S3Region        string    `toml:"s3_region"`
254
                S3AccessKey     string    `toml:"s3_access_key"`
255
                S3SecretKey     string    `toml:"s3_secret_key"`
256
                Webhooks        *webhooks `toml:"webhooks"`
257
        }
258
)
259

260
func (a *auth) Clone() auth {
38✔
261
        copy := *a
38✔
262
        if copy.Captcha != nil {
42✔
263
                capt := *a.Captcha
4✔
264
                copy.Captcha = &capt
4✔
265
        }
4✔
266
        copy.External = maps.Clone(a.External)
38✔
267
        if a.Email.Smtp != nil {
43✔
268
                mailer := *a.Email.Smtp
5✔
269
                copy.Email.Smtp = &mailer
5✔
270
        }
5✔
271
        copy.Email.Template = maps.Clone(a.Email.Template)
38✔
272
        if a.Hook.MFAVerificationAttempt != nil {
42✔
273
                hook := *a.Hook.MFAVerificationAttempt
4✔
274
                copy.Hook.MFAVerificationAttempt = &hook
4✔
275
        }
4✔
276
        if a.Hook.PasswordVerificationAttempt != nil {
40✔
277
                hook := *a.Hook.PasswordVerificationAttempt
2✔
278
                copy.Hook.PasswordVerificationAttempt = &hook
2✔
279
        }
2✔
280
        if a.Hook.CustomAccessToken != nil {
42✔
281
                hook := *a.Hook.CustomAccessToken
4✔
282
                copy.Hook.CustomAccessToken = &hook
4✔
283
        }
4✔
284
        if a.Hook.SendSMS != nil {
42✔
285
                hook := *a.Hook.SendSMS
4✔
286
                copy.Hook.SendSMS = &hook
4✔
287
        }
4✔
288
        if a.Hook.SendEmail != nil {
42✔
289
                hook := *a.Hook.SendEmail
4✔
290
                copy.Hook.SendEmail = &hook
4✔
291
        }
4✔
292
        copy.Sms.TestOTP = maps.Clone(a.Sms.TestOTP)
38✔
293
        return copy
38✔
294
}
295

296
func (s *storage) Clone() storage {
7✔
297
        copy := *s
7✔
298
        copy.Buckets = maps.Clone(s.Buckets)
7✔
299
        if s.ImageTransformation != nil {
8✔
300
                img := *s.ImageTransformation
1✔
301
                copy.ImageTransformation = &img
1✔
302
        }
1✔
303
        return copy
7✔
304
}
305

306
func (c *baseConfig) Clone() baseConfig {
4✔
307
        copy := *c
4✔
308
        copy.Db.Vault = maps.Clone(c.Db.Vault)
4✔
309
        copy.Storage = c.Storage.Clone()
4✔
310
        copy.EdgeRuntime.Secrets = maps.Clone(c.EdgeRuntime.Secrets)
4✔
311
        copy.Functions = maps.Clone(c.Functions)
4✔
312
        copy.Auth = c.Auth.Clone()
4✔
313
        if c.Experimental.Webhooks != nil {
4✔
314
                webhooks := *c.Experimental.Webhooks
×
315
                copy.Experimental.Webhooks = &webhooks
×
316
        }
×
317
        return copy
4✔
318
}
319

320
type ConfigEditor func(*config)
321

322
func WithHostname(hostname string) ConfigEditor {
89✔
323
        return func(c *config) {
178✔
324
                c.Hostname = hostname
89✔
325
        }
89✔
326
}
327

328
func NewConfig(editors ...ConfigEditor) config {
149✔
329
        initial := config{baseConfig: baseConfig{
149✔
330
                Hostname: "127.0.0.1",
149✔
331
                Api: api{
149✔
332
                        Image:     Images.Postgrest,
149✔
333
                        KongImage: Images.Kong,
149✔
334
                },
149✔
335
                Db: db{
149✔
336
                        Image:    Images.Pg,
149✔
337
                        Password: "postgres",
149✔
338
                        RootKey: Secret{
149✔
339
                                Value: "d4dc5b6d4a1d6a10b2c1e76112c994d65db7cec380572cc1839624d4be3fa275",
149✔
340
                        },
149✔
341
                        Pooler: pooler{
149✔
342
                                Image:         Images.Supavisor,
149✔
343
                                TenantId:      "pooler-dev",
149✔
344
                                EncryptionKey: "12345678901234567890123456789032",
149✔
345
                                SecretKeyBase: "EAx3IQ/wRG1v47ZD4NE4/9RzBI8Jmil3x0yhcW4V2NHBP6c2iPIzwjofi2Ep4HIG",
149✔
346
                        },
149✔
347
                        Seed: seed{
149✔
348
                                Enabled:  true,
149✔
349
                                SqlPaths: []string{"seed.sql"},
149✔
350
                        },
149✔
351
                },
149✔
352
                Realtime: realtime{
149✔
353
                        Image:           Images.Realtime,
149✔
354
                        IpVersion:       AddressIPv4,
149✔
355
                        MaxHeaderLength: 4096,
149✔
356
                        TenantId:        "realtime-dev",
149✔
357
                        EncryptionKey:   "supabaserealtime",
149✔
358
                        SecretKeyBase:   "EAx3IQ/wRG1v47ZD4NE4/9RzBI8Jmil3x0yhcW4V2NHBP6c2iPIzwjofi2Ep4HIG",
149✔
359
                },
149✔
360
                Storage: storage{
149✔
361
                        Image:         Images.Storage,
149✔
362
                        ImgProxyImage: Images.ImgProxy,
149✔
363
                        S3Credentials: storageS3Credentials{
149✔
364
                                AccessKeyId:     "625729a08b95bf1b7ff351a663f3a23c",
149✔
365
                                SecretAccessKey: "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907",
149✔
366
                                Region:          "local",
149✔
367
                        },
149✔
368
                },
149✔
369
                Auth: auth{
149✔
370
                        Image: Images.Gotrue,
149✔
371
                        Email: email{
149✔
372
                                Template: map[string]emailTemplate{},
149✔
373
                        },
149✔
374
                        Sms: sms{
149✔
375
                                TestOTP: map[string]string{},
149✔
376
                        },
149✔
377
                        External: map[string]provider{},
149✔
378
                        JwtSecret: Secret{
149✔
379
                                Value: defaultJwtSecret,
149✔
380
                        },
149✔
381
                },
149✔
382
                Mailpit: mailpit{
149✔
383
                        Image:      Images.Mailpit,
149✔
384
                        AdminEmail: "admin@email.com",
149✔
385
                        SenderName: "Admin",
149✔
386
                },
149✔
387
                Studio: studio{
149✔
388
                        Image:       Images.Studio,
149✔
389
                        PgmetaImage: Images.Pgmeta,
149✔
390
                },
149✔
391
                Analytics: analytics{
149✔
392
                        Image:       Images.Logflare,
149✔
393
                        VectorImage: Images.Vector,
149✔
394
                        ApiKey:      "api-key",
149✔
395
                        // Defaults to bigquery for backwards compatibility with existing config.toml
149✔
396
                        Backend: LogflareBigQuery,
149✔
397
                },
149✔
398
                EdgeRuntime: edgeRuntime{
149✔
399
                        Image: Images.EdgeRuntime,
149✔
400
                },
149✔
401
        }}
149✔
402
        for _, apply := range editors {
238✔
403
                apply(&initial)
89✔
404
        }
89✔
405
        return initial
149✔
406
}
407

408
var (
409
        //go:embed templates/config.toml
410
        initConfigEmbed    string
411
        initConfigTemplate = template.Must(template.New("initConfig").Parse(initConfigEmbed))
412

413
        invalidProjectId = regexp.MustCompile("[^a-zA-Z0-9_.-]+")
414
        refPattern       = regexp.MustCompile(`^[a-z]{20}$`)
415
)
416

417
func (c *config) Eject(w io.Writer) error {
108✔
418
        // Defaults to current directory name as project id
108✔
419
        if len(c.ProjectId) == 0 {
197✔
420
                cwd, err := os.Getwd()
89✔
421
                if err != nil {
89✔
422
                        return errors.Errorf("failed to get working directory: %w", err)
×
423
                }
×
424
                c.ProjectId = filepath.Base(cwd)
89✔
425
        }
426
        c.ProjectId = sanitizeProjectId(c.ProjectId)
108✔
427
        // TODO: templatize all fields eventually
108✔
428
        if err := initConfigTemplate.Option("missingkey=error").Execute(w, c); err != nil {
108✔
429
                return errors.Errorf("failed to initialise config: %w", err)
×
430
        }
×
431
        return nil
108✔
432
}
433

434
// Loads custom config file to struct fields tagged with toml.
435
func (c *config) loadFromFile(filename string, fsys fs.FS) error {
65✔
436
        v := viper.NewWithOptions(
65✔
437
                viper.ExperimentalBindStruct(),
65✔
438
                viper.EnvKeyReplacer(strings.NewReplacer(".", "_")),
65✔
439
        )
65✔
440
        v.SetEnvPrefix("SUPABASE")
65✔
441
        v.AutomaticEnv()
65✔
442
        if err := c.mergeDefaultValues(v); err != nil {
65✔
443
                return err
×
444
        } else if err := mergeFileConfig(v, filename, fsys); err != nil {
71✔
445
                return err
6✔
446
        }
6✔
447
        // Find [remotes.*] block to override base config
448
        idToName := map[string]string{}
59✔
449
        for name, remote := range v.GetStringMap("remotes") {
69✔
450
                projectId := v.GetString(fmt.Sprintf("remotes.%s.project_id", name))
10✔
451
                // Track remote project_id to check for duplication
10✔
452
                if other, exists := idToName[projectId]; exists {
10✔
453
                        return errors.Errorf("duplicate project_id for [remotes.%s] and %s", name, other)
×
454
                }
×
455
                idToName[projectId] = fmt.Sprintf("[remotes.%s]", name)
10✔
456
                if projectId == c.ProjectId {
12✔
457
                        fmt.Fprintln(os.Stderr, "Loading config override:", idToName[projectId])
2✔
458
                        if err := mergeRemoteConfig(v, remote.(map[string]any)); err != nil {
2✔
459
                                return err
×
460
                        }
×
461
                }
462
        }
463
        return c.load(v)
59✔
464
}
465

466
func (c *config) mergeDefaultValues(v *viper.Viper) error {
65✔
467
        v.SetConfigType("toml")
65✔
468
        var buf bytes.Buffer
65✔
469
        if err := c.Eject(&buf); err != nil {
65✔
470
                return err
×
471
        } else if err := v.MergeConfig(&buf); err != nil {
65✔
472
                return errors.Errorf("failed to merge default values: %w", err)
×
473
        }
×
474
        return nil
65✔
475
}
476

477
func mergeFileConfig(v *viper.Viper, filename string, fsys fs.FS) error {
65✔
478
        if ext := filepath.Ext(filename); len(ext) > 0 {
130✔
479
                v.SetConfigType(ext[1:])
65✔
480
        }
65✔
481
        f, err := fsys.Open(filename)
65✔
482
        if errors.Is(err, os.ErrNotExist) {
82✔
483
                return nil
17✔
484
        } else if err != nil {
65✔
485
                return errors.Errorf("failed to read file config: %w", err)
×
486
        }
×
487
        defer f.Close()
48✔
488
        if err := v.MergeConfig(f); err != nil {
54✔
489
                return errors.Errorf("failed to merge file config: %w", err)
6✔
490
        }
6✔
491
        return nil
42✔
492
}
493

494
func mergeRemoteConfig(v *viper.Viper, remote map[string]any) error {
2✔
495
        u := viper.New()
2✔
496
        if err := u.MergeConfigMap(remote); err != nil {
2✔
497
                return errors.Errorf("failed to merge remote config: %w", err)
×
498
        }
×
499
        for _, k := range u.AllKeys() {
10✔
500
                v.Set(k, u.Get(k))
8✔
501
        }
8✔
502
        if key := "db.seed.enabled"; !u.IsSet(key) {
3✔
503
                v.Set(key, false)
1✔
504
        }
1✔
505
        return nil
2✔
506
}
507

508
func (c *config) load(v *viper.Viper) error {
59✔
509
        // Set default values for [functions.*] when config struct is empty
59✔
510
        for key, value := range v.GetStringMap("functions") {
70✔
511
                if _, ok := value.(map[string]any); !ok {
13✔
512
                        // Leave validation to decode hook
2✔
513
                        continue
2✔
514
                }
515
                if k := fmt.Sprintf("functions.%s.enabled", key); !v.IsSet(k) {
17✔
516
                        v.Set(k, true)
8✔
517
                }
8✔
518
                if k := fmt.Sprintf("functions.%s.verify_jwt", key); !v.IsSet(k) {
15✔
519
                        v.Set(k, true)
6✔
520
                }
6✔
521
        }
522
        // Set default values when [auth.email.smtp] is defined
523
        if smtp := v.GetStringMap("auth.email.smtp"); len(smtp) > 0 {
64✔
524
                if _, exists := smtp["enabled"]; !exists {
5✔
525
                        v.Set("auth.email.smtp.enabled", true)
×
526
                }
×
527
        }
528
        if err := v.UnmarshalExact(c, func(dc *mapstructure.DecoderConfig) {
177✔
529
                dc.TagName = "toml"
118✔
530
                dc.Squash = true
118✔
531
                dc.ZeroFields = true
118✔
532
                dc.DecodeHook = c.newDecodeHook(LoadEnvHook, ValidateFunctionsHook)
118✔
533
        }); err != nil {
122✔
534
                return errors.Errorf("failed to parse config: %w", err)
4✔
535
        }
4✔
536
        // Convert keys to upper case: https://github.com/spf13/viper/issues/1014
537
        secrets := make(SecretsConfig, len(c.EdgeRuntime.Secrets))
55✔
538
        for k, v := range c.EdgeRuntime.Secrets {
60✔
539
                secrets[strings.ToUpper(k)] = v
5✔
540
        }
5✔
541
        c.EdgeRuntime.Secrets = secrets
55✔
542
        return nil
55✔
543
}
544

545
func (c *config) newDecodeHook(fs ...mapstructure.DecodeHookFunc) mapstructure.DecodeHookFunc {
118✔
546
        fs = append(fs,
118✔
547
                mapstructure.StringToTimeDurationHookFunc(),
118✔
548
                mapstructure.StringToIPHookFunc(),
118✔
549
                mapstructure.StringToSliceHookFunc(","),
118✔
550
                mapstructure.TextUnmarshallerHookFunc(),
118✔
551
                DecryptSecretHookFunc(c.ProjectId),
118✔
552
        )
118✔
553
        return mapstructure.ComposeDecodeHookFunc(fs...)
118✔
554
}
118✔
555

556
func (c *config) Load(path string, fsys fs.FS) error {
65✔
557
        builder := NewPathBuilder(path)
65✔
558
        // Load secrets from .env file
65✔
559
        if err := loadNestedEnv(builder.SupabaseDirPath); err != nil {
65✔
560
                return err
×
561
        }
×
562
        if err := c.loadFromFile(builder.ConfigPath, fsys); err != nil {
75✔
563
                return err
10✔
564
        }
10✔
565
        // Generate JWT tokens
566
        if len(c.Auth.AnonKey.Value) == 0 {
79✔
567
                anonToken := CustomClaims{Role: "anon"}.NewToken()
24✔
568
                if signed, err := anonToken.SignedString([]byte(c.Auth.JwtSecret.Value)); err != nil {
24✔
569
                        return errors.Errorf("failed to generate anon key: %w", err)
×
570
                } else {
24✔
571
                        c.Auth.AnonKey.Value = signed
24✔
572
                }
24✔
573
        }
574
        if len(c.Auth.ServiceRoleKey.Value) == 0 {
79✔
575
                anonToken := CustomClaims{Role: "service_role"}.NewToken()
24✔
576
                if signed, err := anonToken.SignedString([]byte(c.Auth.JwtSecret.Value)); err != nil {
24✔
577
                        return errors.Errorf("failed to generate service_role key: %w", err)
×
578
                } else {
24✔
579
                        c.Auth.ServiceRoleKey.Value = signed
24✔
580
                }
24✔
581
        }
582
        // TODO: move linked pooler connection string elsewhere
583
        if connString, err := fs.ReadFile(fsys, builder.PoolerUrlPath); err == nil && len(connString) > 0 {
55✔
584
                c.Db.Pooler.ConnectionString = string(connString)
×
585
        }
×
586
        if len(c.Api.ExternalUrl) == 0 {
79✔
587
                // Update external api url
24✔
588
                apiUrl := url.URL{Host: net.JoinHostPort(c.Hostname,
24✔
589
                        strconv.FormatUint(uint64(c.Api.Port), 10),
24✔
590
                )}
24✔
591
                if c.Api.Tls.Enabled {
29✔
592
                        apiUrl.Scheme = "https"
5✔
593
                } else {
24✔
594
                        apiUrl.Scheme = "http"
19✔
595
                }
19✔
596
                c.Api.ExternalUrl = apiUrl.String()
24✔
597
        }
598
        // Update image versions
599
        switch c.Db.MajorVersion {
55✔
600
        case 13:
×
601
                c.Db.Image = pg15
×
602
        case 14:
×
603
                c.Db.Image = pg14
×
604
        case 15:
55✔
605
                c.Db.Image = pg15
55✔
606
        }
607
        if c.Db.MajorVersion > 14 {
110✔
608
                if version, err := fs.ReadFile(fsys, builder.PostgresVersionPath); err == nil {
55✔
609
                        // Only replace image if postgres version is above 15.1.0.55
×
610
                        if i := strings.IndexByte(c.Db.Image, ':'); VersionCompare(c.Db.Image[i+1:], "15.1.0.55") >= 0 {
×
611
                                c.Db.Image = replaceImageTag(Images.Pg, string(version))
×
612
                        }
×
613
                }
614
                if version, err := fs.ReadFile(fsys, builder.RestVersionPath); err == nil && len(version) > 0 {
55✔
615
                        c.Api.Image = replaceImageTag(Images.Postgrest, string(version))
×
616
                }
×
617
                if version, err := fs.ReadFile(fsys, builder.GotrueVersionPath); err == nil && len(version) > 0 {
55✔
618
                        c.Auth.Image = replaceImageTag(Images.Gotrue, string(version))
×
619
                }
×
620
        }
621
        if version, err := fs.ReadFile(fsys, builder.StorageVersionPath); err == nil && len(version) > 0 {
55✔
622
                // For backwards compatibility, exclude all strings that look like semver
×
623
                if v := strings.TrimSpace(string(version)); !semver.IsValid(v) {
×
624
                        c.Storage.TargetMigration = v
×
625
                }
×
626
        }
627
        if version, err := fs.ReadFile(fsys, builder.EdgeRuntimeVersionPath); err == nil && len(version) > 0 {
55✔
628
                c.EdgeRuntime.Image = replaceImageTag(Images.EdgeRuntime, string(version))
×
629
        }
×
630
        if version, err := fs.ReadFile(fsys, builder.PoolerVersionPath); err == nil && len(version) > 0 {
55✔
631
                c.Db.Pooler.Image = replaceImageTag(Images.Supavisor, string(version))
×
632
        }
×
633
        if version, err := fs.ReadFile(fsys, builder.RealtimeVersionPath); err == nil && len(version) > 0 {
55✔
634
                c.Realtime.Image = replaceImageTag(Images.Realtime, string(version))
×
635
        }
×
636
        if version, err := fs.ReadFile(fsys, builder.StudioVersionPath); err == nil && len(version) > 0 {
55✔
637
                c.Studio.Image = replaceImageTag(Images.Studio, string(version))
×
638
        }
×
639
        if version, err := fs.ReadFile(fsys, builder.PgmetaVersionPath); err == nil && len(version) > 0 {
55✔
640
                c.Studio.PgmetaImage = replaceImageTag(Images.Pgmeta, string(version))
×
641
        }
×
642
        // TODO: replace derived config resolution with viper decode hooks
643
        if err := c.resolve(builder, fsys); err != nil {
55✔
644
                return err
×
645
        }
×
646
        return c.Validate(fsys)
55✔
647
}
648

649
func VersionCompare(a, b string) int {
34✔
650
        var pA, pB string
34✔
651
        if vA := strings.Split(a, "."); len(vA) > 3 {
62✔
652
                a = strings.Join(vA[:3], ".")
28✔
653
                pA = strings.TrimLeft(strings.Join(vA[3:], "."), "0")
28✔
654
        }
28✔
655
        if vB := strings.Split(b, "."); len(vB) > 3 {
62✔
656
                b = strings.Join(vB[:3], ".")
28✔
657
                pB = strings.TrimLeft(strings.Join(vB[3:], "."), "0")
28✔
658
        }
28✔
659
        if r := semver.Compare("v"+a, "v"+b); r != 0 {
45✔
660
                return r
11✔
661
        }
11✔
662
        return semver.Compare("v"+pA, "v"+pB)
23✔
663
}
664

665
func (c *baseConfig) resolve(builder pathBuilder, fsys fs.FS) error {
55✔
666
        // Update content paths
55✔
667
        for name, tmpl := range c.Auth.Email.Template {
60✔
668
                // FIXME: only email template is relative to repo directory
5✔
669
                cwd := filepath.Dir(builder.SupabaseDirPath)
5✔
670
                if len(tmpl.ContentPath) > 0 && !filepath.IsAbs(tmpl.ContentPath) {
10✔
671
                        tmpl.ContentPath = filepath.Join(cwd, tmpl.ContentPath)
5✔
672
                }
5✔
673
                c.Auth.Email.Template[name] = tmpl
5✔
674
        }
675
        // Update fallback configs
676
        for name, bucket := range c.Storage.Buckets {
60✔
677
                if bucket.FileSizeLimit == 0 {
5✔
678
                        bucket.FileSizeLimit = c.Storage.FileSizeLimit
×
679
                }
×
680
                if len(bucket.ObjectsPath) > 0 && !filepath.IsAbs(bucket.ObjectsPath) {
10✔
681
                        bucket.ObjectsPath = filepath.Join(builder.SupabaseDirPath, bucket.ObjectsPath)
5✔
682
                }
5✔
683
                c.Storage.Buckets[name] = bucket
5✔
684
        }
685
        // Resolve functions config
686
        for slug, function := range c.Functions {
62✔
687
                if len(function.Entrypoint) == 0 {
14✔
688
                        function.Entrypoint = filepath.Join(builder.FunctionsDir, slug, "index.ts")
7✔
689
                } else if !filepath.IsAbs(function.Entrypoint) {
7✔
690
                        // Append supabase/ because paths in configs are specified relative to config.toml
×
691
                        function.Entrypoint = filepath.Join(builder.SupabaseDirPath, function.Entrypoint)
×
692
                }
×
693
                if len(function.ImportMap) == 0 {
11✔
694
                        functionDir := filepath.Dir(function.Entrypoint)
4✔
695
                        denoJsonPath := filepath.Join(functionDir, "deno.json")
4✔
696
                        denoJsoncPath := filepath.Join(functionDir, "deno.jsonc")
4✔
697
                        if _, err := fs.Stat(fsys, denoJsonPath); err == nil {
5✔
698
                                function.ImportMap = denoJsonPath
1✔
699
                        } else if _, err := fs.Stat(fsys, denoJsoncPath); err == nil {
5✔
700
                                function.ImportMap = denoJsoncPath
1✔
701
                        }
1✔
702
                        // Functions may not use import map so we don't set a default value
703
                } else if !filepath.IsAbs(function.ImportMap) {
6✔
704
                        function.ImportMap = filepath.Join(builder.SupabaseDirPath, function.ImportMap)
3✔
705
                }
3✔
706
                for i, val := range function.StaticFiles {
7✔
707
                        if len(val) > 0 && !filepath.IsAbs(val) {
×
708
                                function.StaticFiles[i] = filepath.Join(builder.SupabaseDirPath, val)
×
709
                        }
×
710
                }
711
                c.Functions[slug] = function
7✔
712
        }
713
        if c.Db.Seed.Enabled {
109✔
714
                for i, pattern := range c.Db.Seed.SqlPaths {
108✔
715
                        if len(pattern) > 0 && !filepath.IsAbs(pattern) {
108✔
716
                                c.Db.Seed.SqlPaths[i] = path.Join(builder.SupabaseDirPath, pattern)
54✔
717
                        }
54✔
718
                }
719
        }
720
        for i, pattern := range c.Db.Migrations.SchemaPaths {
60✔
721
                if len(pattern) > 0 && !filepath.IsAbs(pattern) {
10✔
722
                        c.Db.Migrations.SchemaPaths[i] = path.Join(builder.SupabaseDirPath, pattern)
5✔
723
                }
5✔
724
        }
725
        return nil
55✔
726
}
727

728
func (c *config) Validate(fsys fs.FS) error {
55✔
729
        if c.ProjectId == "" {
55✔
730
                return errors.New("Missing required field in config: project_id")
×
731
        } else if sanitized := sanitizeProjectId(c.ProjectId); sanitized != c.ProjectId {
55✔
732
                fmt.Fprintln(os.Stderr, "WARN: project_id field in config is invalid. Auto-fixing to", sanitized)
×
733
                c.ProjectId = sanitized
×
734
        }
×
735
        // Since remote config is merged to base, we only need to validate the project_id field.
736
        for name, remote := range c.Remotes {
65✔
737
                if !refPattern.MatchString(remote.ProjectId) {
10✔
738
                        return errors.Errorf("Invalid config for remotes.%s.project_id. Must be like: abcdefghijklmnopqrst", name)
×
739
                }
×
740
        }
741
        // Validate api config
742
        if c.Api.Enabled {
110✔
743
                if c.Api.Port == 0 {
55✔
744
                        return errors.New("Missing required field in config: api.port")
×
745
                }
×
746
        }
747
        // Validate db config
748
        if c.Db.Port == 0 {
55✔
749
                return errors.New("Missing required field in config: db.port")
×
750
        }
×
751
        switch c.Db.MajorVersion {
55✔
752
        case 0:
×
753
                return errors.New("Missing required field in config: db.major_version")
×
754
        case 12:
×
755
                return errors.New("Postgres version 12.x is unsupported. To use the CLI, either start a new project or follow project migration steps here: https://supabase.com/docs/guides/database#migrating-between-projects.")
×
756
        case 13, 14, 17:
×
757
                // TODO: support oriole db 17 eventually
758
        case 15:
55✔
759
                if len(c.Experimental.OrioleDBVersion) > 0 {
60✔
760
                        c.Db.Image = "supabase/postgres:orioledb-" + c.Experimental.OrioleDBVersion
5✔
761
                        if err := assertEnvLoaded(c.Experimental.S3Host); err != nil {
5✔
762
                                return err
×
763
                        }
×
764
                        if err := assertEnvLoaded(c.Experimental.S3Region); err != nil {
5✔
765
                                return err
×
766
                        }
×
767
                        if err := assertEnvLoaded(c.Experimental.S3AccessKey); err != nil {
5✔
768
                                return err
×
769
                        }
×
770
                        if err := assertEnvLoaded(c.Experimental.S3SecretKey); err != nil {
5✔
771
                                return err
×
772
                        }
×
773
                }
774
        default:
×
775
                return errors.Errorf("Failed reading config: Invalid %s: %v.", "db.major_version", c.Db.MajorVersion)
×
776
        }
777
        // Validate storage config
778
        for name := range c.Storage.Buckets {
60✔
779
                if err := ValidateBucketName(name); err != nil {
5✔
780
                        return err
×
781
                }
×
782
        }
783
        // Validate studio config
784
        if c.Studio.Enabled {
110✔
785
                if c.Studio.Port == 0 {
55✔
786
                        return errors.New("Missing required field in config: studio.port")
×
787
                }
×
788
                if parsed, err := url.Parse(c.Studio.ApiUrl); err != nil {
55✔
789
                        return errors.Errorf("Invalid config for studio.api_url: %w", err)
×
790
                } else if parsed.Host == "" || parsed.Host == c.Hostname {
108✔
791
                        c.Studio.ApiUrl = c.Api.ExternalUrl
53✔
792
                }
53✔
793
        }
794
        // Validate smtp config
795
        if c.Mailpit.Enabled {
110✔
796
                if c.Mailpit.Port == 0 {
55✔
NEW
797
                        return errors.New("Missing required field in config: mailpit.port")
×
UNCOV
798
                }
×
799
        }
800
        // Validate auth config
801
        if c.Auth.Enabled {
110✔
802
                if c.Auth.SiteUrl == "" {
55✔
803
                        return errors.New("Missing required field in config: auth.site_url")
×
804
                }
×
805
                if err := assertEnvLoaded(c.Auth.SiteUrl); err != nil {
55✔
806
                        return err
×
807
                }
×
808
                for i, url := range c.Auth.AdditionalRedirectUrls {
115✔
809
                        if err := assertEnvLoaded(url); err != nil {
60✔
810
                                return errors.Errorf("Invalid config for auth.additional_redirect_urls[%d]: %v", i, err)
×
811
                        }
×
812
                }
813
                if c.Auth.Captcha != nil && c.Auth.Captcha.Enabled {
60✔
814
                        if len(c.Auth.Captcha.Provider) == 0 {
5✔
815
                                return errors.New("Missing required field in config: auth.captcha.provider")
×
816
                        }
×
817
                        if len(c.Auth.Captcha.Secret.Value) == 0 {
5✔
818
                                return errors.Errorf("Missing required field in config: auth.captcha.secret")
×
819
                        }
×
820
                        if err := assertEnvLoaded(c.Auth.Captcha.Secret.Value); err != nil {
5✔
821
                                return err
×
822
                        }
×
823
                }
824
                if err := c.Auth.Hook.validate(); err != nil {
56✔
825
                        return err
1✔
826
                }
1✔
827
                if err := c.Auth.MFA.validate(); err != nil {
54✔
828
                        return err
×
829
                }
×
830
                if err := c.Auth.Email.validate(fsys); err != nil {
54✔
831
                        return err
×
832
                }
×
833
                if err := c.Auth.Sms.validate(); err != nil {
54✔
834
                        return err
×
835
                }
×
836
                if err := c.Auth.External.validate(); err != nil {
54✔
837
                        return err
×
838
                }
×
839
                if err := c.Auth.ThirdParty.validate(); err != nil {
54✔
840
                        return err
×
841
                }
×
842
        }
843
        // Validate functions config
844
        for name := range c.Functions {
61✔
845
                if err := ValidateFunctionSlug(name); err != nil {
7✔
846
                        return err
×
847
                }
×
848
        }
849
        switch c.EdgeRuntime.DenoVersion {
54✔
850
        case 0:
×
851
                return errors.New("Missing required field in config: edge_runtime.deno_version")
×
852
        case 1:
50✔
853
                break
50✔
854
        case 2:
4✔
855
                c.EdgeRuntime.Image = deno2
4✔
856
        default:
×
857
                return errors.Errorf("Failed reading config: Invalid %s: %v.", "edge_runtime.deno_version", c.EdgeRuntime.DenoVersion)
×
858
        }
859
        // Validate logflare config
860
        if c.Analytics.Enabled {
108✔
861
                if c.Analytics.Backend == LogflareBigQuery {
54✔
862
                        if len(c.Analytics.GcpProjectId) == 0 {
×
863
                                return errors.New("Missing required field in config: analytics.gcp_project_id")
×
864
                        }
×
865
                        if len(c.Analytics.GcpProjectNumber) == 0 {
×
866
                                return errors.New("Missing required field in config: analytics.gcp_project_number")
×
867
                        }
×
868
                        if len(c.Analytics.GcpJwtPath) == 0 {
×
869
                                return errors.New("Path to GCP Service Account Key must be provided in config, relative to config.toml: analytics.gcp_jwt_path")
×
870
                        }
×
871
                }
872
        }
873
        if err := c.Experimental.validate(); err != nil {
54✔
874
                return err
×
875
        }
×
876
        return nil
54✔
877
}
878

879
func assertEnvLoaded(s string) error {
167✔
880
        if matches := envPattern.FindStringSubmatch(s); len(matches) > 1 {
182✔
881
                fmt.Fprintln(os.Stderr, "WARN: environment variable is unset:", matches[1])
15✔
882
        }
15✔
883
        return nil
167✔
884
}
885

886
func truncateText(text string, maxLen int) string {
169✔
887
        if len(text) > maxLen {
170✔
888
                return text[:maxLen]
1✔
889
        }
1✔
890
        return text
168✔
891
}
892

893
const maxProjectIdLength = 40
894

895
func sanitizeProjectId(src string) string {
169✔
896
        // A valid project ID must only contain alphanumeric and special characters _.-
169✔
897
        sanitized := invalidProjectId.ReplaceAllString(src, "_")
169✔
898
        // It must also start with an alphanumeric character
169✔
899
        sanitized = strings.TrimLeft(sanitized, "_.-")
169✔
900
        // Truncate sanitized ID to 40 characters since docker hostnames cannot exceed
169✔
901
        // 63 characters, and we need to save space for padding supabase_*_edge_runtime.
169✔
902
        return truncateText(sanitized, maxProjectIdLength)
169✔
903
}
169✔
904

905
func loadNestedEnv(basePath string) error {
65✔
906
        repoDir, err := os.Getwd()
65✔
907
        if err != nil {
65✔
908
                return errors.Errorf("failed to get repo directory: %w", err)
×
909
        }
×
910
        if !filepath.IsAbs(basePath) {
130✔
911
                basePath = filepath.Join(repoDir, basePath)
65✔
912
        }
65✔
913
        env := os.Getenv("SUPABASE_ENV")
65✔
914
        for cwd := basePath; cwd != filepath.Dir(repoDir); cwd = filepath.Dir(cwd) {
194✔
915
                if err := os.Chdir(cwd); err != nil && !errors.Is(err, os.ErrNotExist) {
129✔
916
                        return errors.Errorf("failed to change directory: %w", err)
×
917
                }
×
918
                if err := loadDefaultEnv(env); err != nil {
129✔
919
                        return err
×
920
                }
×
921
        }
922
        if err := os.Chdir(repoDir); err != nil {
65✔
923
                return errors.Errorf("failed to restore directory: %w", err)
×
924
        }
×
925
        return nil
65✔
926
}
927

928
func loadDefaultEnv(env string) error {
129✔
929
        if env == "" {
258✔
930
                env = "development"
129✔
931
        }
129✔
932
        filenames := []string{".env." + env + ".local"}
129✔
933
        if env != "test" {
258✔
934
                filenames = append(filenames, ".env.local")
129✔
935
        }
129✔
936
        filenames = append(filenames, ".env."+env, ".env")
129✔
937
        for _, path := range filenames {
645✔
938
                if err := loadEnvIfExists(path); err != nil {
516✔
939
                        return err
×
940
                }
×
941
        }
942
        return nil
129✔
943
}
944

945
func loadEnvIfExists(path string) error {
521✔
946
        if err := godotenv.Load(path); err != nil && !errors.Is(err, os.ErrNotExist) {
524✔
947
                // If DEBUG=1, return the error as is for full debugability
3✔
948
                if viper.GetBool("DEBUG") {
4✔
949
                        return errors.Errorf("failed to load %s: %w", path, err)
1✔
950
                }
1✔
951
                msg := err.Error()
2✔
952
                switch {
2✔
953
                case strings.HasPrefix(msg, "unexpected character"):
1✔
954
                        // Try to extract the character, fallback to generic
1✔
955
                        start := strings.Index(msg, "unexpected character \"")
1✔
956
                        if start != -1 {
2✔
957
                                start += len("unexpected character \"")
1✔
958
                                end := strings.Index(msg[start:], "\"")
1✔
959
                                if end != -1 {
2✔
960
                                        char := msg[start : start+end]
1✔
961
                                        return errors.Errorf("failed to parse environment file: %s (unexpected character '%s' in variable name)", path, char)
1✔
962
                                }
1✔
963
                        }
964
                        return errors.Errorf("failed to parse environment file: %s (unexpected character in variable name)", path)
×
965
                case strings.HasPrefix(msg, "unterminated quoted value"):
1✔
966
                        return errors.Errorf("failed to parse environment file: %s (unterminated quoted value)", path)
1✔
967
                // If the error message contains newlines, there is a high chance that the actual content of the
968
                // dotenv file is being leaked. In such cases, we return a generic error to avoid unwanted leaks in the logs
969
                case strings.Contains(msg, "\n"):
×
970
                        return errors.Errorf("failed to parse environment file: %s (syntax error)", path)
×
971
                default:
×
972
                        return errors.Errorf("failed to load %s: %w", path, err)
×
973
                }
974
        }
975
        return nil
518✔
976
}
977

978
func (e *email) validate(fsys fs.FS) (err error) {
54✔
979
        for name, tmpl := range e.Template {
58✔
980
                if len(tmpl.ContentPath) == 0 {
4✔
981
                        if tmpl.Content != nil {
×
982
                                return errors.Errorf("Invalid config for auth.email.%s.content: please use content_path instead", name)
×
983
                        }
×
984
                        continue
×
985
                }
986
                if content, err := fs.ReadFile(fsys, tmpl.ContentPath); err != nil {
4✔
987
                        return errors.Errorf("Invalid config for auth.email.%s.content_path: %w", name, err)
×
988
                } else {
4✔
989
                        tmpl.Content = cast.Ptr(string(content))
4✔
990
                }
4✔
991
                e.Template[name] = tmpl
4✔
992
        }
993
        if e.Smtp != nil && e.Smtp.Enabled {
58✔
994
                if len(e.Smtp.Host) == 0 {
4✔
995
                        return errors.New("Missing required field in config: auth.email.smtp.host")
×
996
                }
×
997
                if e.Smtp.Port == 0 {
4✔
998
                        return errors.New("Missing required field in config: auth.email.smtp.port")
×
999
                }
×
1000
                if len(e.Smtp.User) == 0 {
4✔
1001
                        return errors.New("Missing required field in config: auth.email.smtp.user")
×
1002
                }
×
1003
                if len(e.Smtp.Pass.Value) == 0 {
4✔
1004
                        return errors.New("Missing required field in config: auth.email.smtp.pass")
×
1005
                }
×
1006
                if len(e.Smtp.AdminEmail) == 0 {
4✔
1007
                        return errors.New("Missing required field in config: auth.email.smtp.admin_email")
×
1008
                }
×
1009
                if err := assertEnvLoaded(e.Smtp.Pass.Value); err != nil {
4✔
1010
                        return err
×
1011
                }
×
1012
        }
1013
        return nil
54✔
1014
}
1015

1016
func (s *sms) validate() (err error) {
54✔
1017
        switch {
54✔
1018
        case s.Twilio.Enabled:
4✔
1019
                if len(s.Twilio.AccountSid) == 0 {
4✔
1020
                        return errors.New("Missing required field in config: auth.sms.twilio.account_sid")
×
1021
                }
×
1022
                if len(s.Twilio.MessageServiceSid) == 0 {
4✔
1023
                        return errors.New("Missing required field in config: auth.sms.twilio.message_service_sid")
×
1024
                }
×
1025
                if len(s.Twilio.AuthToken.Value) == 0 {
4✔
1026
                        return errors.New("Missing required field in config: auth.sms.twilio.auth_token")
×
1027
                }
×
1028
                if err := assertEnvLoaded(s.Twilio.AuthToken.Value); err != nil {
4✔
1029
                        return err
×
1030
                }
×
1031
        case s.TwilioVerify.Enabled:
×
1032
                if len(s.TwilioVerify.AccountSid) == 0 {
×
1033
                        return errors.New("Missing required field in config: auth.sms.twilio_verify.account_sid")
×
1034
                }
×
1035
                if len(s.TwilioVerify.MessageServiceSid) == 0 {
×
1036
                        return errors.New("Missing required field in config: auth.sms.twilio_verify.message_service_sid")
×
1037
                }
×
1038
                if len(s.TwilioVerify.AuthToken.Value) == 0 {
×
1039
                        return errors.New("Missing required field in config: auth.sms.twilio_verify.auth_token")
×
1040
                }
×
1041
                if err := assertEnvLoaded(s.TwilioVerify.AuthToken.Value); err != nil {
×
1042
                        return err
×
1043
                }
×
1044
        case s.Messagebird.Enabled:
×
1045
                if len(s.Messagebird.Originator) == 0 {
×
1046
                        return errors.New("Missing required field in config: auth.sms.messagebird.originator")
×
1047
                }
×
1048
                if len(s.Messagebird.AccessKey.Value) == 0 {
×
1049
                        return errors.New("Missing required field in config: auth.sms.messagebird.access_key")
×
1050
                }
×
1051
                if err := assertEnvLoaded(s.Messagebird.AccessKey.Value); err != nil {
×
1052
                        return err
×
1053
                }
×
1054
        case s.Textlocal.Enabled:
×
1055
                if len(s.Textlocal.Sender) == 0 {
×
1056
                        return errors.New("Missing required field in config: auth.sms.textlocal.sender")
×
1057
                }
×
1058
                if len(s.Textlocal.ApiKey.Value) == 0 {
×
1059
                        return errors.New("Missing required field in config: auth.sms.textlocal.api_key")
×
1060
                }
×
1061
                if err := assertEnvLoaded(s.Textlocal.ApiKey.Value); err != nil {
×
1062
                        return err
×
1063
                }
×
1064
        case s.Vonage.Enabled:
×
1065
                if len(s.Vonage.From) == 0 {
×
1066
                        return errors.New("Missing required field in config: auth.sms.vonage.from")
×
1067
                }
×
1068
                if len(s.Vonage.ApiKey) == 0 {
×
1069
                        return errors.New("Missing required field in config: auth.sms.vonage.api_key")
×
1070
                }
×
1071
                if len(s.Vonage.ApiSecret.Value) == 0 {
×
1072
                        return errors.New("Missing required field in config: auth.sms.vonage.api_secret")
×
1073
                }
×
1074
                if err := assertEnvLoaded(s.Vonage.ApiKey); err != nil {
×
1075
                        return err
×
1076
                }
×
1077
                if err := assertEnvLoaded(s.Vonage.ApiSecret.Value); err != nil {
×
1078
                        return err
×
1079
                }
×
1080
        case s.EnableSignup:
×
1081
                s.EnableSignup = false
×
1082
                fmt.Fprintln(os.Stderr, "WARN: no SMS provider is enabled. Disabling phone login")
×
1083
        }
1084
        return nil
54✔
1085
}
1086

1087
func (e external) validate() (err error) {
54✔
1088
        for _, ext := range []string{"linkedin", "slack"} {
162✔
1089
                if e[ext].Enabled {
108✔
1090
                        fmt.Fprintf(os.Stderr, `WARN: disabling deprecated "%[1]s" provider. Please use [auth.external.%[1]s_oidc] instead\n`, ext)
×
1091
                }
×
1092
                delete(e, ext)
108✔
1093
        }
1094
        for ext, provider := range e {
112✔
1095
                if !provider.Enabled {
113✔
1096
                        continue
55✔
1097
                }
1098
                if provider.ClientId == "" {
3✔
1099
                        return errors.Errorf("Missing required field in config: auth.external.%s.client_id", ext)
×
1100
                }
×
1101
                if !sliceContains([]string{"apple", "google"}, ext) && len(provider.Secret.Value) == 0 {
3✔
1102
                        return errors.Errorf("Missing required field in config: auth.external.%s.secret", ext)
×
1103
                }
×
1104
                if err := assertEnvLoaded(provider.ClientId); err != nil {
3✔
1105
                        return err
×
1106
                }
×
1107
                if err := assertEnvLoaded(provider.Secret.Value); err != nil {
3✔
1108
                        return err
×
1109
                }
×
1110
                if err := assertEnvLoaded(provider.RedirectUri); err != nil {
3✔
1111
                        return err
×
1112
                }
×
1113
                if err := assertEnvLoaded(provider.Url); err != nil {
3✔
1114
                        return err
×
1115
                }
×
1116
                e[ext] = provider
3✔
1117
        }
1118
        return nil
54✔
1119
}
1120

1121
func (h *hook) validate() error {
55✔
1122
        if hook := h.MFAVerificationAttempt; hook != nil {
55✔
1123
                if err := hook.validate("mfa_verification_attempt"); err != nil {
×
1124
                        return err
×
1125
                }
×
1126
        }
1127
        if hook := h.PasswordVerificationAttempt; hook != nil {
55✔
1128
                if err := hook.validate("password_verification_attempt"); err != nil {
×
1129
                        return err
×
1130
                }
×
1131
        }
1132
        if hook := h.CustomAccessToken; hook != nil {
60✔
1133
                if err := hook.validate("custom_access_token"); err != nil {
5✔
1134
                        return err
×
1135
                }
×
1136
        }
1137
        if hook := h.SendSMS; hook != nil {
60✔
1138
                if err := hook.validate("send_sms"); err != nil {
6✔
1139
                        return err
1✔
1140
                }
1✔
1141
        }
1142
        if hook := h.SendEmail; hook != nil {
54✔
1143
                if err := h.SendEmail.validate("send_email"); err != nil {
×
1144
                        return err
×
1145
                }
×
1146
        }
1147
        return nil
54✔
1148
}
1149

1150
var hookSecretPattern = regexp.MustCompile(`^v1,whsec_[A-Za-z0-9+/=]{32,88}$`)
1151

1152
func (h *hookConfig) validate(hookType string) (err error) {
17✔
1153
        // If not enabled do nothing
17✔
1154
        if !h.Enabled {
17✔
1155
                return nil
×
1156
        }
×
1157
        if h.URI == "" {
17✔
1158
                return errors.Errorf("Missing required field in config: auth.hook.%s.uri", hookType)
×
1159
        }
×
1160
        parsed, err := url.Parse(h.URI)
17✔
1161
        if err != nil {
18✔
1162
                return errors.Errorf("failed to parse template url: %w", err)
1✔
1163
        }
1✔
1164
        switch strings.ToLower(parsed.Scheme) {
16✔
1165
        case "http", "https":
8✔
1166
                if len(h.Secrets.Value) == 0 {
9✔
1167
                        return errors.Errorf("Missing required field in config: auth.hook.%s.secrets", hookType)
1✔
1168
                } else if err := assertEnvLoaded(h.Secrets.Value); err != nil {
8✔
1169
                        return err
×
1170
                }
×
1171
                for _, secret := range strings.Split(h.Secrets.Value, "|") {
14✔
1172
                        if !hookSecretPattern.MatchString(secret) {
8✔
1173
                                return errors.Errorf(`Invalid hook config: auth.hook.%s.secrets must be formatted as "v1,whsec_<base64_encoded_secret>" with a minimum length of 32 characters.`, hookType)
1✔
1174
                        }
1✔
1175
                }
1176
        case "pg-functions":
7✔
1177
                if len(h.Secrets.Value) > 0 {
8✔
1178
                        return errors.Errorf("Invalid hook config: auth.hook.%s.secrets is unsupported for pg-functions URI", hookType)
1✔
1179
                }
1✔
1180
        default:
1✔
1181
                return errors.Errorf("Invalid hook config: auth.hook.%s.uri should be a HTTP, HTTPS, or pg-functions URI", hookType)
1✔
1182
        }
1183
        return nil
12✔
1184
}
1185

1186
func (m *mfa) validate() error {
54✔
1187
        if m.TOTP.EnrollEnabled && !m.TOTP.VerifyEnabled {
54✔
1188
                return errors.Errorf("Invalid MFA config: auth.mfa.totp.enroll_enabled requires verify_enabled")
×
1189
        }
×
1190
        if m.Phone.EnrollEnabled && !m.Phone.VerifyEnabled {
54✔
1191
                return errors.Errorf("Invalid MFA config: auth.mfa.phone.enroll_enabled requires verify_enabled")
×
1192
        }
×
1193
        if m.WebAuthn.EnrollEnabled && !m.WebAuthn.VerifyEnabled {
54✔
1194
                return errors.Errorf("Invalid MFA config: auth.mfa.web_authn.enroll_enabled requires verify_enabled")
×
1195
        }
×
1196
        return nil
54✔
1197
}
1198

1199
// TODO: use field tag validator instead
1200
var funcSlugPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`)
1201

1202
func ValidateFunctionSlug(slug string) error {
7✔
1203
        if !funcSlugPattern.MatchString(slug) {
7✔
1204
                return errors.Errorf(`Invalid Function name: %s. Must start with at least one letter, and only include alphanumeric characters, underscores, and hyphens. (%s)`, slug, funcSlugPattern.String())
×
1205
        }
×
1206
        return nil
7✔
1207
}
1208

1209
// Ref: https://github.com/supabase/storage/blob/master/src/storage/limits.ts#L59
1210
var bucketNamePattern = regexp.MustCompile(`^(\w|!|-|\.|\*|'|\(|\)| |&|\$|@|=|;|:|\+|,|\?)*$`)
1211

1212
func ValidateBucketName(name string) error {
5✔
1213
        if !bucketNamePattern.MatchString(name) {
5✔
1214
                return errors.Errorf("Invalid Bucket name: %s. Only lowercase letters, numbers, dots, hyphens, and spaces are allowed. (%s)", name, bucketNamePattern.String())
×
1215
        }
×
1216
        return nil
5✔
1217
}
1218

1219
func (f *tpaFirebase) issuerURL() string {
×
1220
        return fmt.Sprintf("https://securetoken.google.com/%s", f.ProjectID)
×
1221
}
×
1222

1223
func (f *tpaFirebase) validate() error {
×
1224
        if f.ProjectID == "" {
×
1225
                return errors.New("Invalid config: auth.third_party.firebase is enabled but without a project_id.")
×
1226
        }
×
1227

1228
        return nil
×
1229
}
1230

1231
func (a *tpaAuth0) issuerURL() string {
×
1232
        if a.TenantRegion != "" {
×
1233
                return fmt.Sprintf("https://%s.%s.auth0.com", a.Tenant, a.TenantRegion)
×
1234
        }
×
1235

1236
        return fmt.Sprintf("https://%s.auth0.com", a.Tenant)
×
1237
}
1238

1239
func (a *tpaAuth0) validate() error {
×
1240
        if a.Tenant == "" {
×
1241
                return errors.New("Invalid config: auth.third_party.auth0 is enabled but without a tenant.")
×
1242
        }
×
1243

1244
        return nil
×
1245
}
1246

1247
func (c *tpaCognito) issuerURL() string {
×
1248
        return fmt.Sprintf("https://cognito-idp.%s.amazonaws.com/%s", c.UserPoolRegion, c.UserPoolID)
×
1249
}
×
1250

1251
func (c *tpaCognito) validate() (err error) {
×
1252
        if c.UserPoolID == "" {
×
1253
                return errors.New("Invalid config: auth.third_party.cognito is enabled but without a user_pool_id.")
×
1254
        } else if err := assertEnvLoaded(c.UserPoolID); err != nil {
×
1255
                return err
×
1256
        }
×
1257

1258
        if c.UserPoolRegion == "" {
×
1259
                return errors.New("Invalid config: auth.third_party.cognito is enabled but without a user_pool_region.")
×
1260
        } else if err := assertEnvLoaded(c.UserPoolRegion); err != nil {
×
1261
                return err
×
1262
        }
×
1263

1264
        return nil
×
1265
}
1266

1267
var clerkDomainPattern = regexp.MustCompile("^(clerk([.][a-z0-9-]+){2,}|([a-z0-9-]+[.])+clerk[.]accounts[.]dev)$")
1268

1269
func (c *tpaClerk) issuerURL() string {
×
1270
        return fmt.Sprintf("https://%s", c.Domain)
×
1271
}
×
1272

1273
func (c *tpaClerk) validate() (err error) {
×
1274
        if c.Domain == "" {
×
1275
                return errors.New("Invalid config: auth.third_party.clerk is enabled but without a domain.")
×
1276
        } else if err := assertEnvLoaded(c.Domain); err != nil {
×
1277
                return err
×
1278
        }
×
1279

1280
        if !clerkDomainPattern.MatchString(c.Domain) {
×
1281
                return errors.New("Invalid config: auth.third_party.clerk has invalid domain, it usually is like clerk.example.com or example.clerk.accounts.dev. Check https://clerk.com/setup/supabase on how to find the correct value.")
×
1282
        }
×
1283

1284
        return nil
×
1285
}
1286

1287
func (tpa *thirdParty) validate() error {
54✔
1288
        enabled := 0
54✔
1289

54✔
1290
        if tpa.Firebase.Enabled {
54✔
1291
                enabled += 1
×
1292

×
1293
                if err := tpa.Firebase.validate(); err != nil {
×
1294
                        return err
×
1295
                }
×
1296
        }
1297

1298
        if tpa.Auth0.Enabled {
54✔
1299
                enabled += 1
×
1300

×
1301
                if err := tpa.Auth0.validate(); err != nil {
×
1302
                        return err
×
1303
                }
×
1304
        }
1305

1306
        if tpa.Cognito.Enabled {
54✔
1307
                enabled += 1
×
1308

×
1309
                if err := tpa.Cognito.validate(); err != nil {
×
1310
                        return err
×
1311
                }
×
1312
        }
1313

1314
        if tpa.Clerk.Enabled {
54✔
1315
                enabled += 1
×
1316

×
1317
                if err := tpa.Clerk.validate(); err != nil {
×
1318
                        return err
×
1319
                }
×
1320
        }
1321

1322
        if enabled > 1 {
54✔
1323
                return errors.New("Invalid config: Only one third_party provider allowed to be enabled at a time.")
×
1324
        }
×
1325

1326
        return nil
54✔
1327
}
1328

1329
func (tpa *thirdParty) IssuerURL() string {
2✔
1330
        if tpa.Firebase.Enabled {
2✔
1331
                return tpa.Firebase.issuerURL()
×
1332
        }
×
1333

1334
        if tpa.Auth0.Enabled {
2✔
1335
                return tpa.Auth0.issuerURL()
×
1336
        }
×
1337

1338
        if tpa.Cognito.Enabled {
2✔
1339
                return tpa.Cognito.issuerURL()
×
1340
        }
×
1341

1342
        if tpa.Clerk.Enabled {
2✔
1343
                return tpa.Clerk.issuerURL()
×
1344
        }
×
1345

1346
        return ""
2✔
1347
}
1348

1349
// ResolveJWKS creates the JWKS from the JWT secret and Third-Party Auth
1350
// configs by resolving the JWKS via the OIDC discovery URL.
1351
// It always returns a JWKS string, except when there's an error fetching.
1352
func (a *auth) ResolveJWKS(ctx context.Context) (string, error) {
2✔
1353
        var jwks struct {
2✔
1354
                Keys []json.RawMessage `json:"keys"`
2✔
1355
        }
2✔
1356

2✔
1357
        issuerURL := a.ThirdParty.IssuerURL()
2✔
1358
        if issuerURL != "" {
2✔
1359
                discoveryURL := issuerURL + "/.well-known/openid-configuration"
×
1360

×
1361
                t := &http.Client{Timeout: 10 * time.Second}
×
1362
                client := fetcher.NewFetcher(
×
1363
                        discoveryURL,
×
1364
                        fetcher.WithHTTPClient(t),
×
1365
                        fetcher.WithExpectedStatus(http.StatusOK),
×
1366
                )
×
1367

×
1368
                resp, err := client.Send(ctx, http.MethodGet, "", nil)
×
1369
                if err != nil {
×
1370
                        return "", err
×
1371
                }
×
1372

1373
                type oidcConfiguration struct {
×
1374
                        JWKSURI string `json:"jwks_uri"`
×
1375
                }
×
1376

×
1377
                oidcConfig, err := fetcher.ParseJSON[oidcConfiguration](resp.Body)
×
1378
                if err != nil {
×
1379
                        return "", err
×
1380
                }
×
1381

1382
                if oidcConfig.JWKSURI == "" {
×
1383
                        return "", fmt.Errorf("auth.third_party: OIDC configuration at URL %q does not expose a jwks_uri property", discoveryURL)
×
1384
                }
×
1385

1386
                client = fetcher.NewFetcher(
×
1387
                        oidcConfig.JWKSURI,
×
1388
                        fetcher.WithHTTPClient(t),
×
1389
                        fetcher.WithExpectedStatus(http.StatusOK),
×
1390
                )
×
1391

×
1392
                resp, err = client.Send(ctx, http.MethodGet, "", nil)
×
1393
                if err != nil {
×
1394
                        return "", err
×
1395
                }
×
1396

1397
                type remoteJWKS struct {
×
1398
                        Keys []json.RawMessage `json:"keys"`
×
1399
                }
×
1400

×
1401
                rJWKS, err := fetcher.ParseJSON[remoteJWKS](resp.Body)
×
1402
                if err != nil {
×
1403
                        return "", err
×
1404
                }
×
1405

1406
                if len(rJWKS.Keys) == 0 {
×
1407
                        return "", fmt.Errorf("auth.third_party: JWKS at URL %q as discovered from %q does not contain any JWK keys", oidcConfig.JWKSURI, discoveryURL)
×
1408
                }
×
1409

1410
                jwks.Keys = rJWKS.Keys
×
1411
        }
1412

1413
        var secretJWK struct {
2✔
1414
                KeyType      string `json:"kty"`
2✔
1415
                KeyBase64URL string `json:"k"`
2✔
1416
        }
2✔
1417

2✔
1418
        secretJWK.KeyType = "oct"
2✔
1419
        secretJWK.KeyBase64URL = base64.RawURLEncoding.EncodeToString([]byte(a.JwtSecret.Value))
2✔
1420

2✔
1421
        secretJWKEncoded, err := json.Marshal(&secretJWK)
2✔
1422
        if err != nil {
2✔
1423
                return "", errors.Errorf("failed to marshal secret jwk: %w", err)
×
1424
        }
×
1425

1426
        jwks.Keys = append(jwks.Keys, json.RawMessage(secretJWKEncoded))
2✔
1427

2✔
1428
        jwksEncoded, err := json.Marshal(jwks)
2✔
1429
        if err != nil {
2✔
1430
                return "", errors.Errorf("failed to marshal jwks keys: %w", err)
×
1431
        }
×
1432

1433
        return string(jwksEncoded), nil
2✔
1434
}
1435

1436
func (c *baseConfig) GetServiceImages() []string {
×
1437
        return []string{
×
1438
                c.Db.Image,
×
1439
                c.Auth.Image,
×
1440
                c.Api.Image,
×
1441
                c.Realtime.Image,
×
1442
                c.Storage.Image,
×
1443
                c.EdgeRuntime.Image,
×
1444
                c.Studio.Image,
×
1445
                c.Studio.PgmetaImage,
×
1446
                c.Analytics.Image,
×
1447
                c.Db.Pooler.Image,
×
1448
        }
×
1449
}
×
1450

1451
// Retrieve the final base config to use taking into account the remotes override
1452
// Pre: config must be loaded after setting config.ProjectID = "ref"
1453
func (c *config) GetRemoteByProjectRef(projectRef string) (baseConfig, error) {
×
1454
        base := c.Clone()
×
1455
        for _, remote := range c.Remotes {
×
1456
                if remote.ProjectId == projectRef {
×
1457
                        base.ProjectId = projectRef
×
1458
                        return base, nil
×
1459
                }
×
1460
        }
1461
        return base, errors.Errorf("no remote found for project_id: %s", projectRef)
×
1462
}
1463

1464
func ToTomlBytes(config any) ([]byte, error) {
136✔
1465
        var buf bytes.Buffer
136✔
1466
        enc := toml.NewEncoder(&buf)
136✔
1467
        enc.Indent = ""
136✔
1468
        if err := enc.Encode(config); err != nil {
136✔
1469
                return nil, errors.Errorf("failed to marshal toml config: %w", err)
×
1470
        }
×
1471
        return buf.Bytes(), nil
136✔
1472
}
1473

1474
func (e *experimental) validate() error {
54✔
1475
        if e.Webhooks != nil && !e.Webhooks.Enabled {
54✔
1476
                return errors.Errorf("Webhooks cannot be deactivated. [experimental.webhooks] enabled can either be true or left undefined")
×
1477
        }
×
1478
        return nil
54✔
1479
}
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