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

supabase / cli / 13749414159

09 Mar 2025 02:24PM UTC coverage: 57.847%. Remained the same
13749414159

Pull #3273

github

web-flow
Merge 0dfb5873f into 106b2cc76
Pull Request #3273: fix: clerk tpa domain pattern works for non-production mode domains

0 of 1 new or added line in 1 file covered. (0.0%)

5 existing lines in 1 file now uncovered.

7847 of 13565 relevant lines covered (57.85%)

199.37 hits per line

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

53.49
/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/golang-jwt/jwt/v5"
30
        "github.com/joho/godotenv"
31
        "github.com/mitchellh/mapstructure"
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 {
31✔
42
        size, err := units.RAMInBytes(string(text))
31✔
43
        if err == nil {
61✔
44
                *s = sizeInBytes(size)
30✔
45
        }
30✔
46
        return err
31✔
47
}
48

49
func (s sizeInBytes) MarshalText() (text []byte, err error) {
6✔
50
        return []byte(units.BytesSize(float64(s))), nil
6✔
51
}
6✔
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 {
23✔
61
        allowed := []LogflareBackend{LogflarePostgres, LogflareBigQuery}
23✔
62
        if *b = LogflareBackend(text); !sliceContains(allowed, *b) {
23✔
63
                return errors.Errorf("must be one of %v", allowed)
×
64
        }
×
65
        return nil
23✔
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 {
3✔
76
        allowed := []AddressFamily{AddressIPv6, AddressIPv4}
3✔
77
        if *f = AddressFamily(text); !sliceContains(allowed, *f) {
3✔
78
                return errors.Errorf("must be one of %v", allowed)
×
79
        }
×
80
        return nil
3✔
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 {
23✔
91
        allowed := []RequestPolicy{PolicyPerWorker, PolicyOneshot}
23✔
92
        if *p = RequestPolicy(text); !sliceContains(allowed, *p) {
23✔
93
                return errors.Errorf("must be one of %v", allowed)
×
94
        }
×
95
        return nil
23✔
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) {
4✔
103
        var result []string
4✔
104
        var allErrors []error
4✔
105
        set := make(map[string]struct{})
4✔
106
        for _, pattern := range g {
11✔
107
                // Glob expects / as path separator on windows
7✔
108
                matches, err := fs.Glob(fsys, filepath.ToSlash(pattern))
7✔
109
                if err != nil {
8✔
110
                        allErrors = append(allErrors, errors.Errorf("failed to glob files: %w", err))
1✔
111
                } else if len(matches) == 0 {
8✔
112
                        allErrors = append(allErrors, errors.Errorf("no files matched pattern: %s", pattern))
1✔
113
                }
1✔
114
                sort.Strings(matches)
7✔
115
                // Remove duplicates
7✔
116
                for _, item := range matches {
16✔
117
                        if _, exists := set[item]; !exists {
16✔
118
                                set[item] = struct{}{}
7✔
119
                                result = append(result, item)
7✔
120
                        }
7✔
121
                }
122
        }
123
        return result, errors.Join(allErrors...)
4✔
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 {
18✔
140
        if c.ExpiresAt == nil {
36✔
141
                c.ExpiresAt = jwt.NewNumericDate(time.Unix(defaultJwtExpiry, 0))
18✔
142
        }
18✔
143
        if len(c.Issuer) == 0 {
36✔
144
                c.Issuer = "supabase-demo"
18✔
145
        }
18✔
146
        return jwt.NewWithClaims(jwt.SigningMethodHS256, c)
18✔
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
// If you are adding an internal config or secret that doesn't need to be overridden by the user,
160
// exclude the field from toml serialization. For example,
161
//
162
//        type auth struct {
163
//                AnonKey string `toml:"-" mapstructure:"anon_key"`
164
//        }
165
//
166
// Use `mapstructure:"anon_key"` tag only if you want inject values from a predictable environment
167
// variable, such as SUPABASE_AUTH_ANON_KEY.
168
//
169
// Default values for internal configs should be added to `var Config` initializer.
170
type (
171
        // Common config fields between our "base" config and any "remote" branch specific
172
        baseConfig struct {
173
                ProjectId    string         `toml:"project_id"`
174
                Hostname     string         `toml:"-"`
175
                Api          api            `toml:"api"`
176
                Db           db             `toml:"db" mapstructure:"db"`
177
                Realtime     realtime       `toml:"realtime"`
178
                Studio       studio         `toml:"studio"`
179
                Inbucket     inbucket       `toml:"inbucket"`
180
                Storage      storage        `toml:"storage"`
181
                Auth         auth           `toml:"auth" mapstructure:"auth"`
182
                EdgeRuntime  edgeRuntime    `toml:"edge_runtime"`
183
                Functions    FunctionConfig `toml:"functions"`
184
                Analytics    analytics      `toml:"analytics"`
185
                Experimental experimental   `toml:"experimental"`
186
        }
187

188
        config struct {
189
                baseConfig `mapstructure:",squash"`
190
                Remotes    map[string]baseConfig `toml:"remotes"`
191
        }
192

193
        realtime struct {
194
                Enabled         bool          `toml:"enabled"`
195
                Image           string        `toml:"-"`
196
                IpVersion       AddressFamily `toml:"ip_version"`
197
                MaxHeaderLength uint          `toml:"max_header_length"`
198
                TenantId        string        `toml:"-"`
199
                EncryptionKey   string        `toml:"-"`
200
                SecretKeyBase   string        `toml:"-"`
201
        }
202

203
        studio struct {
204
                Enabled      bool   `toml:"enabled"`
205
                Image        string `toml:"-"`
206
                Port         uint16 `toml:"port"`
207
                ApiUrl       string `toml:"api_url"`
208
                OpenaiApiKey Secret `toml:"openai_api_key"`
209
                PgmetaImage  string `toml:"-"`
210
        }
211

212
        inbucket struct {
213
                Enabled    bool   `toml:"enabled"`
214
                Image      string `toml:"-"`
215
                Port       uint16 `toml:"port"`
216
                SmtpPort   uint16 `toml:"smtp_port"`
217
                Pop3Port   uint16 `toml:"pop3_port"`
218
                AdminEmail string `toml:"admin_email"`
219
                SenderName string `toml:"sender_name"`
220
        }
221

222
        edgeRuntime struct {
223
                Enabled       bool          `toml:"enabled"`
224
                Image         string        `toml:"-"`
225
                Policy        RequestPolicy `toml:"policy"`
226
                InspectorPort uint16        `toml:"inspector_port"`
227
                Secrets       SecretsConfig `toml:"secrets"`
228
        }
229

230
        SecretsConfig  map[string]Secret
231
        FunctionConfig map[string]function
232

233
        function struct {
234
                Enabled     bool   `toml:"enabled" json:"-"`
235
                VerifyJWT   bool   `toml:"verify_jwt" json:"verifyJWT"`
236
                ImportMap   string `toml:"import_map" json:"importMapPath,omitempty"`
237
                Entrypoint  string `toml:"entrypoint" json:"entrypointPath,omitempty"`
238
                StaticFiles Glob   `toml:"static_files" json:"staticFiles,omitempty"`
239
        }
240

241
        analytics struct {
242
                Enabled          bool            `toml:"enabled"`
243
                Image            string          `toml:"-"`
244
                VectorImage      string          `toml:"-"`
245
                Port             uint16          `toml:"port"`
246
                Backend          LogflareBackend `toml:"backend"`
247
                GcpProjectId     string          `toml:"gcp_project_id"`
248
                GcpProjectNumber string          `toml:"gcp_project_number"`
249
                GcpJwtPath       string          `toml:"gcp_jwt_path"`
250
                ApiKey           string          `toml:"-" mapstructure:"api_key"`
251
                // Deprecated together with syslog
252
                VectorPort uint16 `toml:"vector_port"`
253
        }
254

255
        webhooks struct {
256
                Enabled bool `toml:"enabled"`
257
        }
258

259
        experimental struct {
260
                OrioleDBVersion string    `toml:"orioledb_version"`
261
                S3Host          string    `toml:"s3_host"`
262
                S3Region        string    `toml:"s3_region"`
263
                S3AccessKey     string    `toml:"s3_access_key"`
264
                S3SecretKey     string    `toml:"s3_secret_key"`
265
                Webhooks        *webhooks `toml:"webhooks"`
266
        }
267
)
268

269
func (a *auth) Clone() auth {
34✔
270
        copy := *a
34✔
271
        if copy.Captcha != nil {
38✔
272
                capt := *a.Captcha
4✔
273
                copy.Captcha = &capt
4✔
274
        }
4✔
275
        copy.External = maps.Clone(a.External)
34✔
276
        if a.Email.Smtp != nil {
39✔
277
                mailer := *a.Email.Smtp
5✔
278
                copy.Email.Smtp = &mailer
5✔
279
        }
5✔
280
        copy.Email.Template = maps.Clone(a.Email.Template)
34✔
281
        if a.Hook.MFAVerificationAttempt != nil {
38✔
282
                hook := *a.Hook.MFAVerificationAttempt
4✔
283
                copy.Hook.MFAVerificationAttempt = &hook
4✔
284
        }
4✔
285
        if a.Hook.PasswordVerificationAttempt != nil {
36✔
286
                hook := *a.Hook.PasswordVerificationAttempt
2✔
287
                copy.Hook.PasswordVerificationAttempt = &hook
2✔
288
        }
2✔
289
        if a.Hook.CustomAccessToken != nil {
38✔
290
                hook := *a.Hook.CustomAccessToken
4✔
291
                copy.Hook.CustomAccessToken = &hook
4✔
292
        }
4✔
293
        if a.Hook.SendSMS != nil {
38✔
294
                hook := *a.Hook.SendSMS
4✔
295
                copy.Hook.SendSMS = &hook
4✔
296
        }
4✔
297
        if a.Hook.SendEmail != nil {
38✔
298
                hook := *a.Hook.SendEmail
4✔
299
                copy.Hook.SendEmail = &hook
4✔
300
        }
4✔
301
        copy.Sms.TestOTP = maps.Clone(a.Sms.TestOTP)
34✔
302
        return copy
34✔
303
}
304

305
func (s *storage) Clone() storage {
3✔
306
        copy := *s
3✔
307
        copy.Buckets = maps.Clone(s.Buckets)
3✔
308
        if s.ImageTransformation != nil {
4✔
309
                img := *s.ImageTransformation
1✔
310
                copy.ImageTransformation = &img
1✔
311
        }
1✔
312
        return copy
3✔
313
}
314

315
func (c *baseConfig) Clone() baseConfig {
×
316
        copy := *c
×
317
        copy.Db.Vault = maps.Clone(c.Db.Vault)
×
318
        copy.Storage = c.Storage.Clone()
×
319
        copy.EdgeRuntime.Secrets = maps.Clone(c.EdgeRuntime.Secrets)
×
320
        copy.Functions = maps.Clone(c.Functions)
×
321
        copy.Auth = c.Auth.Clone()
×
322
        if c.Experimental.Webhooks != nil {
×
323
                webhooks := *c.Experimental.Webhooks
×
324
                copy.Experimental.Webhooks = &webhooks
×
325
        }
×
326
        return copy
×
327
}
328

329
type ConfigEditor func(*config)
330

331
func WithHostname(hostname string) ConfigEditor {
×
332
        return func(c *config) {
×
333
                c.Hostname = hostname
×
334
        }
×
335
}
336

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

413
var (
414
        //go:embed templates/config.toml
415
        initConfigEmbed    string
416
        initConfigTemplate = template.Must(template.New("initConfig").Parse(initConfigEmbed))
417

418
        invalidProjectId = regexp.MustCompile("[^a-zA-Z0-9_.-]+")
419
        refPattern       = regexp.MustCompile(`^[a-z]{20}$`)
420
)
421

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

439
// Loads custom config file to struct fields tagged with toml.
440
func (c *config) loadFromFile(filename string, fsys fs.FS) error {
12✔
441
        v := viper.New()
12✔
442
        v.SetConfigType("toml")
12✔
443
        // Load default values
12✔
444
        var buf bytes.Buffer
12✔
445
        if err := c.Eject(&buf); err != nil {
12✔
446
                return err
×
447
        } else if err := c.loadFromReader(v, &buf); err != nil {
12✔
448
                return err
×
449
        }
×
450
        // Load custom config
451
        if ext := filepath.Ext(filename); len(ext) > 0 {
24✔
452
                v.SetConfigType(ext[1:])
12✔
453
        }
12✔
454
        f, err := fsys.Open(filename)
12✔
455
        if errors.Is(err, os.ErrNotExist) {
13✔
456
                return nil
1✔
457
        } else if err != nil {
12✔
458
                return errors.Errorf("failed to read file config: %w", err)
×
459
        }
×
460
        defer f.Close()
11✔
461
        return c.loadFromReader(v, f)
11✔
462
}
463

464
func (c *config) loadFromReader(v *viper.Viper, r io.Reader) error {
23✔
465
        if err := v.MergeConfig(r); err != nil {
23✔
466
                return errors.Errorf("failed to merge config: %w", err)
×
467
        }
×
468
        // Find [remotes.*] block to override base config
469
        baseId := v.GetString("project_id")
23✔
470
        idToName := map[string]string{baseId: "base"}
23✔
471
        for name, remote := range v.GetStringMap("remotes") {
29✔
472
                projectId := v.GetString(fmt.Sprintf("remotes.%s.project_id", name))
6✔
473
                // Track remote project_id to check for duplication
6✔
474
                if other, exists := idToName[projectId]; exists {
6✔
475
                        return errors.Errorf("duplicate project_id for [remotes.%s] and %s", name, other)
×
476
                }
×
477
                idToName[projectId] = fmt.Sprintf("[remotes.%s]", name)
6✔
478
                if projectId == c.ProjectId {
6✔
479
                        fmt.Fprintln(os.Stderr, "Loading config override:", idToName[projectId])
×
480
                        if err := v.MergeConfigMap(remote.(map[string]any)); err != nil {
×
481
                                return err
×
482
                        }
×
483
                        v.Set("project_id", baseId)
×
484
                }
485
        }
486
        // Set default values for [functions.*] when config struct is empty
487
        for key, value := range v.GetStringMap("functions") {
30✔
488
                if _, ok := value.(map[string]any); !ok {
9✔
489
                        // Leave validation to decode hook
2✔
490
                        continue
2✔
491
                }
492
                if k := fmt.Sprintf("functions.%s.enabled", key); !v.IsSet(k) {
10✔
493
                        v.Set(k, true)
5✔
494
                }
5✔
495
                if k := fmt.Sprintf("functions.%s.verify_jwt", key); !v.IsSet(k) {
9✔
496
                        v.Set(k, true)
4✔
497
                }
4✔
498
        }
499
        // Set default values when [auth.email.smtp] is defined
500
        if smtp := v.GetStringMap("auth.email.smtp"); len(smtp) > 0 {
26✔
501
                if _, exists := smtp["enabled"]; !exists {
3✔
502
                        v.Set("auth.email.smtp.enabled", true)
×
503
                }
×
504
        }
505
        if err := v.UnmarshalExact(c, func(dc *mapstructure.DecoderConfig) {
46✔
506
                dc.TagName = "toml"
23✔
507
                dc.Squash = true
23✔
508
                dc.ZeroFields = true
23✔
509
                dc.DecodeHook = c.newDecodeHook(LoadEnvHook, ValidateFunctionsHook)
23✔
510
        }); err != nil {
27✔
511
                return errors.Errorf("failed to parse config: %w", err)
4✔
512
        }
4✔
513
        // Convert keys to upper case: https://github.com/spf13/viper/issues/1014
514
        secrets := make(SecretsConfig, len(c.EdgeRuntime.Secrets))
19✔
515
        for k, v := range c.EdgeRuntime.Secrets {
22✔
516
                secrets[strings.ToUpper(k)] = v
3✔
517
        }
3✔
518
        c.EdgeRuntime.Secrets = secrets
19✔
519
        return nil
19✔
520
}
521

522
func (c *config) newDecodeHook(fs ...mapstructure.DecodeHookFunc) mapstructure.DecodeHookFunc {
32✔
523
        fs = append(fs,
32✔
524
                mapstructure.StringToTimeDurationHookFunc(),
32✔
525
                mapstructure.StringToIPHookFunc(),
32✔
526
                mapstructure.StringToSliceHookFunc(","),
32✔
527
                mapstructure.TextUnmarshallerHookFunc(),
32✔
528
                DecryptSecretHookFunc(c.ProjectId),
32✔
529
        )
32✔
530
        return mapstructure.ComposeDecodeHookFunc(fs...)
32✔
531
}
32✔
532

533
// Loads envs prefixed with supabase_ to struct fields tagged with mapstructure.
534
func (c *config) loadFromEnv() error {
9✔
535
        v := viper.New()
9✔
536
        v.SetEnvPrefix("SUPABASE")
9✔
537
        v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
9✔
538
        v.AutomaticEnv()
9✔
539
        // Viper does not parse env vars automatically. Instead of calling viper.BindEnv
9✔
540
        // per key, we decode all keys from an existing struct, and merge them to viper.
9✔
541
        // Ref: https://github.com/spf13/viper/issues/761#issuecomment-859306364
9✔
542
        envKeysMap := map[string]interface{}{}
9✔
543
        if dec, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
9✔
544
                Result:               &envKeysMap,
9✔
545
                IgnoreUntaggedFields: true,
9✔
546
        }); err != nil {
9✔
547
                return errors.Errorf("failed to create decoder: %w", err)
×
548
        } else if err := dec.Decode(c.baseConfig); err != nil {
9✔
549
                return errors.Errorf("failed to decode env: %w", err)
×
550
        } else if err := v.MergeConfigMap(envKeysMap); err != nil {
9✔
551
                return errors.Errorf("failed to merge env config: %w", err)
×
552
        }
×
553
        // Writes viper state back to config struct, with automatic env substitution
554
        if err := v.UnmarshalExact(c, viper.DecodeHook(c.newDecodeHook())); err != nil {
9✔
555
                return errors.Errorf("failed to parse env override: %w", err)
×
556
        }
×
557
        return nil
9✔
558
}
559

560
func (c *config) Load(path string, fsys fs.FS) error {
12✔
561
        builder := NewPathBuilder(path)
12✔
562
        // Load secrets from .env file
12✔
563
        if err := loadNestedEnv(builder.SupabaseDirPath); err != nil {
12✔
564
                return err
×
565
        }
×
566
        if err := c.loadFromFile(builder.ConfigPath, fsys); err != nil {
16✔
567
                return err
4✔
568
        }
4✔
569
        if err := c.loadFromEnv(); err != nil {
8✔
570
                return err
×
571
        }
×
572
        // Generate JWT tokens
573
        if len(c.Auth.AnonKey) == 0 {
16✔
574
                anonToken := CustomClaims{Role: "anon"}.NewToken()
8✔
575
                if signed, err := anonToken.SignedString([]byte(c.Auth.JwtSecret)); err != nil {
8✔
576
                        return errors.Errorf("failed to generate anon key: %w", err)
×
577
                } else {
8✔
578
                        c.Auth.AnonKey = signed
8✔
579
                }
8✔
580
        }
581
        if len(c.Auth.ServiceRoleKey) == 0 {
16✔
582
                anonToken := CustomClaims{Role: "service_role"}.NewToken()
8✔
583
                if signed, err := anonToken.SignedString([]byte(c.Auth.JwtSecret)); err != nil {
8✔
584
                        return errors.Errorf("failed to generate service_role key: %w", err)
×
585
                } else {
8✔
586
                        c.Auth.ServiceRoleKey = signed
8✔
587
                }
8✔
588
        }
589
        // TODO: move linked pooler connection string elsewhere
590
        if connString, err := fs.ReadFile(fsys, builder.PoolerUrlPath); err == nil && len(connString) > 0 {
8✔
591
                c.Db.Pooler.ConnectionString = string(connString)
×
592
        }
×
593
        if len(c.Api.ExternalUrl) == 0 {
16✔
594
                // Update external api url
8✔
595
                apiUrl := url.URL{Host: net.JoinHostPort(c.Hostname,
8✔
596
                        strconv.FormatUint(uint64(c.Api.Port), 10),
8✔
597
                )}
8✔
598
                if c.Api.Tls.Enabled {
11✔
599
                        apiUrl.Scheme = "https"
3✔
600
                } else {
8✔
601
                        apiUrl.Scheme = "http"
5✔
602
                }
5✔
603
                c.Api.ExternalUrl = apiUrl.String()
8✔
604
        }
605
        // Update image versions
606
        if version, err := fs.ReadFile(fsys, builder.PostgresVersionPath); err == nil {
8✔
607
                if strings.HasPrefix(string(version), "15.") && semver.Compare(string(version[3:]), "1.0.55") >= 0 {
×
608
                        c.Db.Image = replaceImageTag(Images.Pg15, string(version))
×
609
                }
×
610
        }
611
        if c.Db.MajorVersion > 14 {
16✔
612
                if version, err := fs.ReadFile(fsys, builder.RestVersionPath); err == nil && len(version) > 0 {
8✔
613
                        c.Api.Image = replaceImageTag(Images.Postgrest, string(version))
×
614
                }
×
615
                if version, err := fs.ReadFile(fsys, builder.StorageVersionPath); err == nil && len(version) > 0 {
8✔
616
                        c.Storage.Image = replaceImageTag(Images.Storage, string(version))
×
617
                }
×
618
                if version, err := fs.ReadFile(fsys, builder.GotrueVersionPath); err == nil && len(version) > 0 {
8✔
619
                        c.Auth.Image = replaceImageTag(Images.Gotrue, string(version))
×
620
                }
×
621
        }
622
        if version, err := fs.ReadFile(fsys, builder.EdgeRuntimeVersionPath); err == nil && len(version) > 0 {
8✔
623
                c.EdgeRuntime.Image = replaceImageTag(Images.EdgeRuntime, string(version))
×
624
        }
×
625
        if version, err := fs.ReadFile(fsys, builder.PoolerVersionPath); err == nil && len(version) > 0 {
8✔
626
                c.Db.Pooler.Image = replaceImageTag(Images.Supavisor, string(version))
×
627
        }
×
628
        if version, err := fs.ReadFile(fsys, builder.RealtimeVersionPath); err == nil && len(version) > 0 {
8✔
629
                c.Realtime.Image = replaceImageTag(Images.Realtime, string(version))
×
630
        }
×
631
        if version, err := fs.ReadFile(fsys, builder.StudioVersionPath); err == nil && len(version) > 0 {
8✔
632
                c.Studio.Image = replaceImageTag(Images.Studio, string(version))
×
633
        }
×
634
        if version, err := fs.ReadFile(fsys, builder.PgmetaVersionPath); err == nil && len(version) > 0 {
8✔
635
                c.Studio.PgmetaImage = replaceImageTag(Images.Pgmeta, string(version))
×
636
        }
×
637
        // TODO: replace derived config resolution with viper decode hooks
638
        if err := c.baseConfig.resolve(builder, fsys); err != nil {
8✔
639
                return err
×
640
        }
×
641
        return c.Validate(fsys)
8✔
642
}
643

644
func (c *baseConfig) resolve(builder pathBuilder, fsys fs.FS) error {
8✔
645
        // Update content paths
8✔
646
        for name, tmpl := range c.Auth.Email.Template {
11✔
647
                // FIXME: only email template is relative to repo directory
3✔
648
                cwd := filepath.Dir(builder.SupabaseDirPath)
3✔
649
                if len(tmpl.ContentPath) > 0 && !filepath.IsAbs(tmpl.ContentPath) {
6✔
650
                        tmpl.ContentPath = filepath.Join(cwd, tmpl.ContentPath)
3✔
651
                }
3✔
652
                c.Auth.Email.Template[name] = tmpl
3✔
653
        }
654
        // Update fallback configs
655
        for name, bucket := range c.Storage.Buckets {
11✔
656
                if bucket.FileSizeLimit == 0 {
3✔
657
                        bucket.FileSizeLimit = c.Storage.FileSizeLimit
×
658
                }
×
659
                if len(bucket.ObjectsPath) > 0 && !filepath.IsAbs(bucket.ObjectsPath) {
6✔
660
                        bucket.ObjectsPath = filepath.Join(builder.SupabaseDirPath, bucket.ObjectsPath)
3✔
661
                }
3✔
662
                c.Storage.Buckets[name] = bucket
3✔
663
        }
664
        // Resolve functions config
665
        for slug, function := range c.Functions {
11✔
666
                if len(function.Entrypoint) == 0 {
6✔
667
                        function.Entrypoint = filepath.Join(builder.FunctionsDir, slug, "index.ts")
3✔
668
                } else if !filepath.IsAbs(function.Entrypoint) {
3✔
669
                        // Append supabase/ because paths in configs are specified relative to config.toml
×
670
                        function.Entrypoint = filepath.Join(builder.SupabaseDirPath, function.Entrypoint)
×
671
                }
×
672
                if len(function.ImportMap) == 0 {
5✔
673
                        functionDir := filepath.Dir(function.Entrypoint)
2✔
674
                        denoJsonPath := filepath.Join(functionDir, "deno.json")
2✔
675
                        denoJsoncPath := filepath.Join(functionDir, "deno.jsonc")
2✔
676
                        if _, err := fs.Stat(fsys, denoJsonPath); err == nil {
3✔
677
                                function.ImportMap = denoJsonPath
1✔
678
                        } else if _, err := fs.Stat(fsys, denoJsoncPath); err == nil {
3✔
679
                                function.ImportMap = denoJsoncPath
1✔
680
                        }
1✔
681
                        // Functions may not use import map so we don't set a default value
682
                } else if !filepath.IsAbs(function.ImportMap) {
2✔
683
                        function.ImportMap = filepath.Join(builder.SupabaseDirPath, function.ImportMap)
1✔
684
                }
1✔
685
                for i, val := range function.StaticFiles {
3✔
686
                        if len(val) > 0 && !filepath.IsAbs(val) {
×
687
                                function.StaticFiles[i] = filepath.Join(builder.SupabaseDirPath, val)
×
688
                        }
×
689
                }
690
                c.Functions[slug] = function
3✔
691
        }
692
        if c.Db.Seed.Enabled {
16✔
693
                for i, pattern := range c.Db.Seed.SqlPaths {
16✔
694
                        if len(pattern) > 0 && !filepath.IsAbs(pattern) {
16✔
695
                                c.Db.Seed.SqlPaths[i] = path.Join(builder.SupabaseDirPath, pattern)
8✔
696
                        }
8✔
697
                }
698
        }
699
        for i, pattern := range c.Db.Migrations.SchemaPaths {
11✔
700
                if len(pattern) > 0 && !filepath.IsAbs(pattern) {
6✔
701
                        c.Db.Migrations.SchemaPaths[i] = path.Join(builder.SupabaseDirPath, pattern)
3✔
702
                }
3✔
703
        }
704
        return nil
8✔
705
}
706

707
func (c *config) Validate(fsys fs.FS) error {
8✔
708
        if c.ProjectId == "" {
8✔
709
                return errors.New("Missing required field in config: project_id")
×
710
        } else if sanitized := sanitizeProjectId(c.ProjectId); sanitized != c.ProjectId {
8✔
711
                fmt.Fprintln(os.Stderr, "WARN: project_id field in config is invalid. Auto-fixing to", sanitized)
×
712
                c.ProjectId = sanitized
×
713
        }
×
714
        // Since remote config is merged to base, we only need to validate the project_id field.
715
        for name, remote := range c.Remotes {
14✔
716
                if !refPattern.MatchString(remote.ProjectId) {
6✔
717
                        return errors.Errorf("Invalid config for remotes.%s.project_id. Must be like: abcdefghijklmnopqrst", name)
×
718
                }
×
719
        }
720
        // Validate api config
721
        if c.Api.Enabled {
16✔
722
                if c.Api.Port == 0 {
8✔
723
                        return errors.New("Missing required field in config: api.port")
×
724
                }
×
725
        }
726
        // Validate db config
727
        if c.Db.Port == 0 {
8✔
728
                return errors.New("Missing required field in config: db.port")
×
729
        }
×
730
        switch c.Db.MajorVersion {
8✔
731
        case 0:
×
732
                return errors.New("Missing required field in config: db.major_version")
×
733
        case 12:
×
734
                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.")
×
735
        case 13:
×
736
                c.Db.Image = pg13
×
737
        case 14:
×
738
                c.Db.Image = pg14
×
739
        case 15:
8✔
740
                if len(c.Experimental.OrioleDBVersion) > 0 {
11✔
741
                        c.Db.Image = "supabase/postgres:orioledb-" + c.Experimental.OrioleDBVersion
3✔
742
                        if err := assertEnvLoaded(c.Experimental.S3Host); err != nil {
3✔
743
                                return err
×
744
                        }
×
745
                        if err := assertEnvLoaded(c.Experimental.S3Region); err != nil {
3✔
746
                                return err
×
747
                        }
×
748
                        if err := assertEnvLoaded(c.Experimental.S3AccessKey); err != nil {
3✔
749
                                return err
×
750
                        }
×
751
                        if err := assertEnvLoaded(c.Experimental.S3SecretKey); err != nil {
3✔
752
                                return err
×
753
                        }
×
754
                }
755
        default:
×
756
                return errors.Errorf("Failed reading config: Invalid %s: %v.", "db.major_version", c.Db.MajorVersion)
×
757
        }
758
        // Validate storage config
759
        for name := range c.Storage.Buckets {
11✔
760
                if err := ValidateBucketName(name); err != nil {
3✔
761
                        return err
×
762
                }
×
763
        }
764
        // Validate studio config
765
        if c.Studio.Enabled {
16✔
766
                if c.Studio.Port == 0 {
8✔
767
                        return errors.New("Missing required field in config: studio.port")
×
768
                }
×
769
                if parsed, err := url.Parse(c.Studio.ApiUrl); err != nil {
8✔
770
                        return errors.Errorf("Invalid config for studio.api_url: %w", err)
×
771
                } else if parsed.Host == "" || parsed.Host == c.Hostname {
16✔
772
                        c.Studio.ApiUrl = c.Api.ExternalUrl
8✔
773
                }
8✔
774
        }
775
        // Validate smtp config
776
        if c.Inbucket.Enabled {
16✔
777
                if c.Inbucket.Port == 0 {
8✔
778
                        return errors.New("Missing required field in config: inbucket.port")
×
779
                }
×
780
        }
781
        // Validate auth config
782
        if c.Auth.Enabled {
16✔
783
                if c.Auth.SiteUrl == "" {
8✔
784
                        return errors.New("Missing required field in config: auth.site_url")
×
785
                }
×
786
                if err := assertEnvLoaded(c.Auth.SiteUrl); err != nil {
8✔
787
                        return err
×
788
                }
×
789
                for i, url := range c.Auth.AdditionalRedirectUrls {
19✔
790
                        if err := assertEnvLoaded(url); err != nil {
11✔
791
                                return errors.Errorf("Invalid config for auth.additional_redirect_urls[%d]: %v", i, err)
×
792
                        }
×
793
                }
794
                if c.Auth.Captcha != nil && c.Auth.Captcha.Enabled {
11✔
795
                        if len(c.Auth.Captcha.Provider) == 0 {
3✔
796
                                return errors.New("Missing required field in config: auth.captcha.provider")
×
797
                        }
×
798
                        if len(c.Auth.Captcha.Secret.Value) == 0 {
3✔
799
                                return errors.Errorf("Missing required field in config: auth.captcha.secret")
×
800
                        }
×
801
                        if err := assertEnvLoaded(c.Auth.Captcha.Secret.Value); err != nil {
3✔
802
                                return err
×
803
                        }
×
804
                }
805
                if err := c.Auth.Hook.validate(); err != nil {
9✔
806
                        return err
1✔
807
                }
1✔
808
                if err := c.Auth.MFA.validate(); err != nil {
7✔
809
                        return err
×
810
                }
×
811
                if err := c.Auth.Email.validate(fsys); err != nil {
7✔
812
                        return err
×
813
                }
×
814
                if err := c.Auth.Sms.validate(); err != nil {
7✔
815
                        return err
×
816
                }
×
817
                if err := c.Auth.External.validate(); err != nil {
7✔
818
                        return err
×
819
                }
×
820
                if err := c.Auth.ThirdParty.validate(); err != nil {
7✔
821
                        return err
×
822
                }
×
823
        }
824
        // Validate functions config
825
        for name := range c.Functions {
10✔
826
                if err := ValidateFunctionSlug(name); err != nil {
3✔
827
                        return err
×
828
                }
×
829
        }
830
        // Validate logflare config
831
        if c.Analytics.Enabled {
14✔
832
                if c.Analytics.Backend == LogflareBigQuery {
7✔
833
                        if len(c.Analytics.GcpProjectId) == 0 {
×
834
                                return errors.New("Missing required field in config: analytics.gcp_project_id")
×
835
                        }
×
836
                        if len(c.Analytics.GcpProjectNumber) == 0 {
×
837
                                return errors.New("Missing required field in config: analytics.gcp_project_number")
×
838
                        }
×
839
                        if len(c.Analytics.GcpJwtPath) == 0 {
×
840
                                return errors.New("Path to GCP Service Account Key must be provided in config, relative to config.toml: analytics.gcp_jwt_path")
×
841
                        }
×
842
                }
843
        }
844
        if err := c.Experimental.validate(); err != nil {
7✔
845
                return err
×
846
        }
×
847
        return nil
7✔
848
}
849

850
func assertEnvLoaded(s string) error {
51✔
851
        if matches := envPattern.FindStringSubmatch(s); len(matches) > 1 {
56✔
852
                fmt.Fprintln(os.Stderr, "WARN: environment variable is unset:", matches[1])
5✔
853
        }
5✔
854
        return nil
51✔
855
}
856

857
func truncateText(text string, maxLen int) string {
27✔
858
        if len(text) > maxLen {
28✔
859
                return text[:maxLen]
1✔
860
        }
1✔
861
        return text
26✔
862
}
863

864
const maxProjectIdLength = 40
865

866
func sanitizeProjectId(src string) string {
27✔
867
        // A valid project ID must only contain alphanumeric and special characters _.-
27✔
868
        sanitized := invalidProjectId.ReplaceAllString(src, "_")
27✔
869
        // It must also start with an alphanumeric character
27✔
870
        sanitized = strings.TrimLeft(sanitized, "_.-")
27✔
871
        // Truncate sanitized ID to 40 characters since docker hostnames cannot exceed
27✔
872
        // 63 characters, and we need to save space for padding supabase_*_edge_runtime.
27✔
873
        return truncateText(sanitized, maxProjectIdLength)
27✔
874
}
27✔
875

876
func loadNestedEnv(basePath string) error {
12✔
877
        repoDir, err := os.Getwd()
12✔
878
        if err != nil {
12✔
879
                return errors.Errorf("failed to get repo directory: %w", err)
×
880
        }
×
881
        if !filepath.IsAbs(basePath) {
24✔
882
                basePath = filepath.Join(repoDir, basePath)
12✔
883
        }
12✔
884
        env := os.Getenv("SUPABASE_ENV")
12✔
885
        for cwd := basePath; cwd != filepath.Dir(repoDir); cwd = filepath.Dir(cwd) {
35✔
886
                if err := os.Chdir(cwd); err != nil && !errors.Is(err, os.ErrNotExist) {
23✔
887
                        return errors.Errorf("failed to change directory: %w", err)
×
888
                }
×
889
                if err := loadDefaultEnv(env); err != nil {
23✔
890
                        return err
×
891
                }
×
892
        }
893
        if err := os.Chdir(repoDir); err != nil {
12✔
894
                return errors.Errorf("failed to restore directory: %w", err)
×
895
        }
×
896
        return nil
12✔
897
}
898

899
func loadDefaultEnv(env string) error {
23✔
900
        if env == "" {
46✔
901
                env = "development"
23✔
902
        }
23✔
903
        filenames := []string{".env." + env + ".local"}
23✔
904
        if env != "test" {
46✔
905
                filenames = append(filenames, ".env.local")
23✔
906
        }
23✔
907
        filenames = append(filenames, ".env."+env, ".env")
23✔
908
        for _, path := range filenames {
115✔
909
                if err := loadEnvIfExists(path); err != nil {
92✔
910
                        return err
×
911
                }
×
912
        }
913
        return nil
23✔
914
}
915

916
func loadEnvIfExists(path string) error {
92✔
917
        if err := godotenv.Load(path); err != nil && !errors.Is(err, os.ErrNotExist) {
92✔
918
                return errors.Errorf("failed to load %s: %w", ".env", err)
×
919
        }
×
920
        return nil
92✔
921
}
922

923
func (e *email) validate(fsys fs.FS) (err error) {
7✔
924
        for name, tmpl := range e.Template {
9✔
925
                if len(tmpl.ContentPath) == 0 {
2✔
926
                        if tmpl.Content != nil {
×
927
                                return errors.Errorf("Invalid config for auth.email.%s.content: please use content_path instead", name)
×
928
                        }
×
929
                        continue
×
930
                }
931
                if content, err := fs.ReadFile(fsys, tmpl.ContentPath); err != nil {
2✔
932
                        return errors.Errorf("Invalid config for auth.email.%s.content_path: %w", name, err)
×
933
                } else {
2✔
934
                        tmpl.Content = cast.Ptr(string(content))
2✔
935
                }
2✔
936
                e.Template[name] = tmpl
2✔
937
        }
938
        if e.Smtp != nil && e.Smtp.Enabled {
9✔
939
                if len(e.Smtp.Host) == 0 {
2✔
940
                        return errors.New("Missing required field in config: auth.email.smtp.host")
×
941
                }
×
942
                if e.Smtp.Port == 0 {
2✔
943
                        return errors.New("Missing required field in config: auth.email.smtp.port")
×
944
                }
×
945
                if len(e.Smtp.User) == 0 {
2✔
946
                        return errors.New("Missing required field in config: auth.email.smtp.user")
×
947
                }
×
948
                if len(e.Smtp.Pass.Value) == 0 {
2✔
949
                        return errors.New("Missing required field in config: auth.email.smtp.pass")
×
950
                }
×
951
                if len(e.Smtp.AdminEmail) == 0 {
2✔
952
                        return errors.New("Missing required field in config: auth.email.smtp.admin_email")
×
953
                }
×
954
                if err := assertEnvLoaded(e.Smtp.Pass.Value); err != nil {
2✔
955
                        return err
×
956
                }
×
957
        }
958
        return nil
7✔
959
}
960

961
func (s *sms) validate() (err error) {
7✔
962
        switch {
7✔
963
        case s.Twilio.Enabled:
2✔
964
                if len(s.Twilio.AccountSid) == 0 {
2✔
965
                        return errors.New("Missing required field in config: auth.sms.twilio.account_sid")
×
966
                }
×
967
                if len(s.Twilio.MessageServiceSid) == 0 {
2✔
968
                        return errors.New("Missing required field in config: auth.sms.twilio.message_service_sid")
×
969
                }
×
970
                if len(s.Twilio.AuthToken.Value) == 0 {
2✔
971
                        return errors.New("Missing required field in config: auth.sms.twilio.auth_token")
×
972
                }
×
973
                if err := assertEnvLoaded(s.Twilio.AuthToken.Value); err != nil {
2✔
974
                        return err
×
975
                }
×
976
        case s.TwilioVerify.Enabled:
×
977
                if len(s.TwilioVerify.AccountSid) == 0 {
×
978
                        return errors.New("Missing required field in config: auth.sms.twilio_verify.account_sid")
×
979
                }
×
980
                if len(s.TwilioVerify.MessageServiceSid) == 0 {
×
981
                        return errors.New("Missing required field in config: auth.sms.twilio_verify.message_service_sid")
×
982
                }
×
983
                if len(s.TwilioVerify.AuthToken.Value) == 0 {
×
984
                        return errors.New("Missing required field in config: auth.sms.twilio_verify.auth_token")
×
985
                }
×
986
                if err := assertEnvLoaded(s.TwilioVerify.AuthToken.Value); err != nil {
×
987
                        return err
×
988
                }
×
989
        case s.Messagebird.Enabled:
×
990
                if len(s.Messagebird.Originator) == 0 {
×
991
                        return errors.New("Missing required field in config: auth.sms.messagebird.originator")
×
992
                }
×
993
                if len(s.Messagebird.AccessKey.Value) == 0 {
×
994
                        return errors.New("Missing required field in config: auth.sms.messagebird.access_key")
×
995
                }
×
996
                if err := assertEnvLoaded(s.Messagebird.AccessKey.Value); err != nil {
×
997
                        return err
×
998
                }
×
999
        case s.Textlocal.Enabled:
×
1000
                if len(s.Textlocal.Sender) == 0 {
×
1001
                        return errors.New("Missing required field in config: auth.sms.textlocal.sender")
×
1002
                }
×
1003
                if len(s.Textlocal.ApiKey.Value) == 0 {
×
1004
                        return errors.New("Missing required field in config: auth.sms.textlocal.api_key")
×
1005
                }
×
1006
                if err := assertEnvLoaded(s.Textlocal.ApiKey.Value); err != nil {
×
1007
                        return err
×
1008
                }
×
1009
        case s.Vonage.Enabled:
×
1010
                if len(s.Vonage.From) == 0 {
×
1011
                        return errors.New("Missing required field in config: auth.sms.vonage.from")
×
1012
                }
×
1013
                if len(s.Vonage.ApiKey) == 0 {
×
1014
                        return errors.New("Missing required field in config: auth.sms.vonage.api_key")
×
1015
                }
×
1016
                if len(s.Vonage.ApiSecret.Value) == 0 {
×
1017
                        return errors.New("Missing required field in config: auth.sms.vonage.api_secret")
×
1018
                }
×
1019
                if err := assertEnvLoaded(s.Vonage.ApiKey); err != nil {
×
1020
                        return err
×
1021
                }
×
1022
                if err := assertEnvLoaded(s.Vonage.ApiSecret.Value); err != nil {
×
1023
                        return err
×
1024
                }
×
1025
        case s.EnableSignup:
×
1026
                s.EnableSignup = false
×
1027
                fmt.Fprintln(os.Stderr, "WARN: no SMS provider is enabled. Disabling phone login")
×
1028
        }
1029
        return nil
7✔
1030
}
1031

1032
func (e external) validate() (err error) {
7✔
1033
        for _, ext := range []string{"linkedin", "slack"} {
21✔
1034
                if e[ext].Enabled {
14✔
1035
                        fmt.Fprintf(os.Stderr, `WARN: disabling deprecated "%[1]s" provider. Please use [auth.external.%[1]s_oidc] instead\n`, ext)
×
1036
                }
×
1037
                delete(e, ext)
14✔
1038
        }
1039
        for ext, provider := range e {
16✔
1040
                if !provider.Enabled {
16✔
1041
                        continue
7✔
1042
                }
1043
                if provider.ClientId == "" {
2✔
1044
                        return errors.Errorf("Missing required field in config: auth.external.%s.client_id", ext)
×
1045
                }
×
1046
                if !sliceContains([]string{"apple", "google"}, ext) && len(provider.Secret.Value) == 0 {
2✔
1047
                        return errors.Errorf("Missing required field in config: auth.external.%s.secret", ext)
×
1048
                }
×
1049
                if err := assertEnvLoaded(provider.ClientId); err != nil {
2✔
1050
                        return err
×
1051
                }
×
1052
                if err := assertEnvLoaded(provider.Secret.Value); err != nil {
2✔
1053
                        return err
×
1054
                }
×
1055
                if err := assertEnvLoaded(provider.RedirectUri); err != nil {
2✔
1056
                        return err
×
1057
                }
×
1058
                if err := assertEnvLoaded(provider.Url); err != nil {
2✔
1059
                        return err
×
1060
                }
×
1061
                e[ext] = provider
2✔
1062
        }
1063
        return nil
7✔
1064
}
1065

1066
func (h *hook) validate() error {
8✔
1067
        if hook := h.MFAVerificationAttempt; hook != nil {
8✔
1068
                if err := hook.validate("mfa_verification_attempt"); err != nil {
×
1069
                        return err
×
1070
                }
×
1071
        }
1072
        if hook := h.PasswordVerificationAttempt; hook != nil {
8✔
1073
                if err := hook.validate("password_verification_attempt"); err != nil {
×
1074
                        return err
×
1075
                }
×
1076
        }
1077
        if hook := h.CustomAccessToken; hook != nil {
11✔
1078
                if err := hook.validate("custom_access_token"); err != nil {
3✔
1079
                        return err
×
1080
                }
×
1081
        }
1082
        if hook := h.SendSMS; hook != nil {
11✔
1083
                if err := hook.validate("send_sms"); err != nil {
4✔
1084
                        return err
1✔
1085
                }
1✔
1086
        }
1087
        if hook := h.SendEmail; hook != nil {
7✔
1088
                if err := h.SendEmail.validate("send_email"); err != nil {
×
1089
                        return err
×
1090
                }
×
1091
        }
1092
        return nil
7✔
1093
}
1094

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

1097
func (h *hookConfig) validate(hookType string) (err error) {
13✔
1098
        // If not enabled do nothing
13✔
1099
        if !h.Enabled {
13✔
1100
                return nil
×
1101
        }
×
1102
        if h.URI == "" {
13✔
1103
                return errors.Errorf("Missing required field in config: auth.hook.%s.uri", hookType)
×
1104
        }
×
1105
        parsed, err := url.Parse(h.URI)
13✔
1106
        if err != nil {
14✔
1107
                return errors.Errorf("failed to parse template url: %w", err)
1✔
1108
        }
1✔
1109
        switch strings.ToLower(parsed.Scheme) {
12✔
1110
        case "http", "https":
6✔
1111
                if len(h.Secrets.Value) == 0 {
7✔
1112
                        return errors.Errorf("Missing required field in config: auth.hook.%s.secrets", hookType)
1✔
1113
                } else if err := assertEnvLoaded(h.Secrets.Value); err != nil {
6✔
1114
                        return err
×
1115
                }
×
1116
                for _, secret := range strings.Split(h.Secrets.Value, "|") {
10✔
1117
                        if !hookSecretPattern.MatchString(secret) {
6✔
1118
                                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✔
1119
                        }
1✔
1120
                }
1121
        case "pg-functions":
5✔
1122
                if len(h.Secrets.Value) > 0 {
6✔
1123
                        return errors.Errorf("Invalid hook config: auth.hook.%s.secrets is unsupported for pg-functions URI", hookType)
1✔
1124
                }
1✔
1125
        default:
1✔
1126
                return errors.Errorf("Invalid hook config: auth.hook.%s.uri should be a HTTP, HTTPS, or pg-functions URI", hookType)
1✔
1127
        }
1128
        return nil
8✔
1129
}
1130

1131
func (m *mfa) validate() error {
7✔
1132
        if m.TOTP.EnrollEnabled && !m.TOTP.VerifyEnabled {
7✔
1133
                return errors.Errorf("Invalid MFA config: auth.mfa.totp.enroll_enabled requires verify_enabled")
×
1134
        }
×
1135
        if m.Phone.EnrollEnabled && !m.Phone.VerifyEnabled {
7✔
1136
                return errors.Errorf("Invalid MFA config: auth.mfa.phone.enroll_enabled requires verify_enabled")
×
1137
        }
×
1138
        if m.WebAuthn.EnrollEnabled && !m.WebAuthn.VerifyEnabled {
7✔
1139
                return errors.Errorf("Invalid MFA config: auth.mfa.web_authn.enroll_enabled requires verify_enabled")
×
1140
        }
×
1141
        return nil
7✔
1142
}
1143

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

1147
func ValidateFunctionSlug(slug string) error {
3✔
1148
        if !funcSlugPattern.MatchString(slug) {
3✔
1149
                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())
×
1150
        }
×
1151
        return nil
3✔
1152
}
1153

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

1157
func ValidateBucketName(name string) error {
3✔
1158
        if !bucketNamePattern.MatchString(name) {
3✔
1159
                return errors.Errorf("Invalid Bucket name: %s. Only lowercase letters, numbers, dots, hyphens, and spaces are allowed. (%s)", name, bucketNamePattern.String())
×
1160
        }
×
1161
        return nil
3✔
1162
}
1163

1164
func (f *tpaFirebase) issuerURL() string {
×
1165
        return fmt.Sprintf("https://securetoken.google.com/%s", f.ProjectID)
×
1166
}
×
1167

1168
func (f *tpaFirebase) validate() error {
×
1169
        if f.ProjectID == "" {
×
1170
                return errors.New("Invalid config: auth.third_party.firebase is enabled but without a project_id.")
×
1171
        }
×
1172

1173
        return nil
×
1174
}
1175

1176
func (a *tpaAuth0) issuerURL() string {
×
1177
        if a.TenantRegion != "" {
×
1178
                return fmt.Sprintf("https://%s.%s.auth0.com", a.Tenant, a.TenantRegion)
×
1179
        }
×
1180

1181
        return fmt.Sprintf("https://%s.auth0.com", a.Tenant)
×
1182
}
1183

1184
func (a *tpaAuth0) validate() error {
×
1185
        if a.Tenant == "" {
×
1186
                return errors.New("Invalid config: auth.third_party.auth0 is enabled but without a tenant.")
×
1187
        }
×
1188

1189
        return nil
×
1190
}
1191

1192
func (c *tpaCognito) issuerURL() string {
×
1193
        return fmt.Sprintf("https://cognito-idp.%s.amazonaws.com/%s", c.UserPoolRegion, c.UserPoolID)
×
1194
}
×
1195

1196
func (c *tpaCognito) validate() (err error) {
×
1197
        if c.UserPoolID == "" {
×
1198
                return errors.New("Invalid config: auth.third_party.cognito is enabled but without a user_pool_id.")
×
1199
        } else if err := assertEnvLoaded(c.UserPoolID); err != nil {
×
1200
                return err
×
1201
        }
×
1202

1203
        if c.UserPoolRegion == "" {
×
1204
                return errors.New("Invalid config: auth.third_party.cognito is enabled but without a user_pool_region.")
×
1205
        } else if err := assertEnvLoaded(c.UserPoolRegion); err != nil {
×
1206
                return err
×
1207
        }
×
1208

1209
        return nil
×
1210
}
1211

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

1214
func (c *tpaClerk) issuerURL() string {
×
1215
        return fmt.Sprintf("https://%s", c.Domain)
×
1216
}
×
1217

1218
func (c *tpaClerk) validate() (err error) {
×
1219
        if c.Domain == "" {
×
1220
                return errors.New("Invalid config: auth.third_party.clerk is enabled but without a domain.")
×
1221
        } else if err := assertEnvLoaded(c.Domain); err != nil {
×
1222
                return err
×
1223
        }
×
1224

1225
        if !clerkDomainPattern.MatchString(c.Domain) {
×
NEW
1226
                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.")
×
1227
        }
×
1228

1229
        return nil
×
1230
}
1231

1232
func (tpa *thirdParty) validate() error {
7✔
1233
        enabled := 0
7✔
1234

7✔
1235
        if tpa.Firebase.Enabled {
7✔
1236
                enabled += 1
×
1237

×
1238
                if err := tpa.Firebase.validate(); err != nil {
×
1239
                        return err
×
1240
                }
×
1241
        }
1242

1243
        if tpa.Auth0.Enabled {
7✔
1244
                enabled += 1
×
1245

×
1246
                if err := tpa.Auth0.validate(); err != nil {
×
1247
                        return err
×
1248
                }
×
1249
        }
1250

1251
        if tpa.Cognito.Enabled {
7✔
1252
                enabled += 1
×
1253

×
1254
                if err := tpa.Cognito.validate(); err != nil {
×
1255
                        return err
×
1256
                }
×
1257
        }
1258

1259
        if tpa.Clerk.Enabled {
7✔
1260
                enabled += 1
×
1261

×
1262
                if err := tpa.Clerk.validate(); err != nil {
×
1263
                        return err
×
1264
                }
×
1265
        }
1266

1267
        if enabled > 1 {
7✔
1268
                return errors.New("Invalid config: Only one third_party provider allowed to be enabled at a time.")
×
1269
        }
×
1270

1271
        return nil
7✔
1272
}
1273

1274
func (tpa *thirdParty) IssuerURL() string {
×
1275
        if tpa.Firebase.Enabled {
×
1276
                return tpa.Firebase.issuerURL()
×
1277
        }
×
1278

1279
        if tpa.Auth0.Enabled {
×
1280
                return tpa.Auth0.issuerURL()
×
1281
        }
×
1282

1283
        if tpa.Cognito.Enabled {
×
1284
                return tpa.Cognito.issuerURL()
×
1285
        }
×
1286

1287
        if tpa.Clerk.Enabled {
×
1288
                return tpa.Clerk.issuerURL()
×
1289
        }
×
1290

1291
        return ""
×
1292
}
1293

1294
// ResolveJWKS creates the JWKS from the JWT secret and Third-Party Auth
1295
// configs by resolving the JWKS via the OIDC discovery URL.
1296
// It always returns a JWKS string, except when there's an error fetching.
1297
func (a *auth) ResolveJWKS(ctx context.Context) (string, error) {
×
1298
        var jwks struct {
×
1299
                Keys []json.RawMessage `json:"keys"`
×
1300
        }
×
1301

×
1302
        issuerURL := a.ThirdParty.IssuerURL()
×
1303
        if issuerURL != "" {
×
1304
                discoveryURL := issuerURL + "/.well-known/openid-configuration"
×
1305

×
1306
                t := &http.Client{Timeout: 10 * time.Second}
×
1307
                client := fetcher.NewFetcher(
×
1308
                        discoveryURL,
×
1309
                        fetcher.WithHTTPClient(t),
×
1310
                        fetcher.WithExpectedStatus(http.StatusOK),
×
1311
                )
×
1312

×
1313
                resp, err := client.Send(ctx, http.MethodGet, "", nil)
×
1314
                if err != nil {
×
1315
                        return "", err
×
1316
                }
×
1317

1318
                type oidcConfiguration struct {
×
1319
                        JWKSURI string `json:"jwks_uri"`
×
1320
                }
×
1321

×
1322
                oidcConfig, err := fetcher.ParseJSON[oidcConfiguration](resp.Body)
×
1323
                if err != nil {
×
1324
                        return "", err
×
1325
                }
×
1326

1327
                if oidcConfig.JWKSURI == "" {
×
1328
                        return "", fmt.Errorf("auth.third_party: OIDC configuration at URL %q does not expose a jwks_uri property", discoveryURL)
×
1329
                }
×
1330

1331
                client = fetcher.NewFetcher(
×
1332
                        oidcConfig.JWKSURI,
×
1333
                        fetcher.WithHTTPClient(t),
×
1334
                        fetcher.WithExpectedStatus(http.StatusOK),
×
1335
                )
×
1336

×
1337
                resp, err = client.Send(ctx, http.MethodGet, "", nil)
×
1338
                if err != nil {
×
1339
                        return "", err
×
1340
                }
×
1341

1342
                type remoteJWKS struct {
×
1343
                        Keys []json.RawMessage `json:"keys"`
×
1344
                }
×
1345

×
1346
                rJWKS, err := fetcher.ParseJSON[remoteJWKS](resp.Body)
×
1347
                if err != nil {
×
1348
                        return "", err
×
1349
                }
×
1350

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

1355
                jwks.Keys = rJWKS.Keys
×
1356
        }
1357

1358
        var secretJWK struct {
×
1359
                KeyType      string `json:"kty"`
×
1360
                KeyBase64URL string `json:"k"`
×
1361
        }
×
1362

×
1363
        secretJWK.KeyType = "oct"
×
1364
        secretJWK.KeyBase64URL = base64.RawURLEncoding.EncodeToString([]byte(a.JwtSecret))
×
1365

×
1366
        secretJWKEncoded, err := json.Marshal(&secretJWK)
×
1367
        if err != nil {
×
1368
                return "", errors.Errorf("failed to marshal secret jwk: %w", err)
×
1369
        }
×
1370

1371
        jwks.Keys = append(jwks.Keys, json.RawMessage(secretJWKEncoded))
×
1372

×
1373
        jwksEncoded, err := json.Marshal(jwks)
×
1374
        if err != nil {
×
1375
                return "", errors.Errorf("failed to marshal jwks keys: %w", err)
×
1376
        }
×
1377

1378
        return string(jwksEncoded), nil
×
1379
}
1380

1381
func (c *baseConfig) GetServiceImages() []string {
×
1382
        return []string{
×
1383
                c.Db.Image,
×
1384
                c.Auth.Image,
×
1385
                c.Api.Image,
×
1386
                c.Realtime.Image,
×
1387
                c.Storage.Image,
×
1388
                c.EdgeRuntime.Image,
×
1389
                c.Studio.Image,
×
1390
                c.Studio.PgmetaImage,
×
1391
                c.Analytics.Image,
×
1392
                c.Db.Pooler.Image,
×
1393
        }
×
1394
}
×
1395

1396
// Retrieve the final base config to use taking into account the remotes override
1397
// Pre: config must be loaded after setting config.ProjectID = "ref"
1398
func (c *config) GetRemoteByProjectRef(projectRef string) (baseConfig, error) {
×
1399
        base := c.baseConfig.Clone()
×
1400
        for _, remote := range c.Remotes {
×
1401
                if remote.ProjectId == projectRef {
×
1402
                        base.ProjectId = projectRef
×
1403
                        return base, nil
×
1404
                }
×
1405
        }
1406
        return base, errors.Errorf("no remote found for project_id: %s", projectRef)
×
1407
}
1408

1409
func ToTomlBytes(config any) ([]byte, error) {
109✔
1410
        var buf bytes.Buffer
109✔
1411
        enc := toml.NewEncoder(&buf)
109✔
1412
        enc.Indent = ""
109✔
1413
        if err := enc.Encode(config); err != nil {
109✔
1414
                return nil, errors.Errorf("failed to marshal toml config: %w", err)
×
1415
        }
×
1416
        return buf.Bytes(), nil
109✔
1417
}
1418

1419
func (e *experimental) validate() error {
7✔
1420
        if e.Webhooks != nil && !e.Webhooks.Enabled {
7✔
1421
                return errors.Errorf("Webhooks cannot be deactivated. [experimental.webhooks] enabled can either be true or left undefined")
×
1422
        }
×
1423
        return nil
7✔
1424
}
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