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

supabase / cli / 12751825638

13 Jan 2025 04:38PM UTC coverage: 58.406% (-0.03%) from 58.44%
12751825638

Pull #3038

github

web-flow
Merge 8acec9537 into 888c23692
Pull Request #3038: fix: defaults to json import map

10 of 16 new or added lines in 2 files covered. (62.5%)

9 existing lines in 4 files now uncovered.

7570 of 12961 relevant lines covered (58.41%)

202.7 hits per line

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

51.97
/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
        "reflect"
20
        "regexp"
21
        "sort"
22
        "strconv"
23
        "strings"
24
        "text/template"
25
        "time"
26

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

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

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

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

54
type LogflareBackend string
55

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

61
type AddressFamily string
62

63
const (
64
        AddressIPv6 AddressFamily = "IPv6"
65
        AddressIPv4 AddressFamily = "IPv4"
66
)
67

68
type RequestPolicy string
69

70
const (
71
        PolicyPerWorker RequestPolicy = "per_worker"
72
        PolicyOneshot   RequestPolicy = "oneshot"
73
)
74

75
type CustomClaims struct {
76
        // Overrides Issuer to maintain json order when marshalling
77
        Issuer string `json:"iss,omitempty"`
78
        Ref    string `json:"ref,omitempty"`
79
        Role   string `json:"role"`
80
        jwt.RegisteredClaims
81
}
82

83
const (
84
        defaultJwtSecret = "super-secret-jwt-token-with-at-least-32-characters-long"
85
        defaultJwtExpiry = 1983812996
86
)
87

88
func (c CustomClaims) NewToken() *jwt.Token {
16✔
89
        if c.ExpiresAt == nil {
32✔
90
                c.ExpiresAt = jwt.NewNumericDate(time.Unix(defaultJwtExpiry, 0))
16✔
91
        }
16✔
92
        if len(c.Issuer) == 0 {
32✔
93
                c.Issuer = "supabase-demo"
16✔
94
        }
16✔
95
        return jwt.NewWithClaims(jwt.SigningMethodHS256, c)
16✔
96
}
97

98
// We follow these rules when adding new config:
99
//  1. Update init_config.toml (and init_config.test.toml) with the new key, default value, and comments to explain usage.
100
//  2. Update config struct with new field and toml tag (spelled in snake_case).
101
//  3. Add custom field validations to LoadConfigFS function for eg. integer range checks.
102
//
103
// If you are adding new user defined secrets, such as OAuth provider secret, the default value in
104
// init_config.toml should be an env var substitution. For example,
105
//
106
// > secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
107
//
108
// If you are adding an internal config or secret that doesn't need to be overridden by the user,
109
// exclude the field from toml serialization. For example,
110
//
111
//        type auth struct {
112
//                AnonKey string `toml:"-" mapstructure:"anon_key"`
113
//        }
114
//
115
// Use `mapstructure:"anon_key"` tag only if you want inject values from a predictable environment
116
// variable, such as SUPABASE_AUTH_ANON_KEY.
117
//
118
// Default values for internal configs should be added to `var Config` initializer.
119
type (
120
        // Common config fields between our "base" config and any "remote" branch specific
121
        baseConfig struct {
122
                ProjectId    string         `toml:"project_id"`
123
                Hostname     string         `toml:"-"`
124
                Api          api            `toml:"api"`
125
                Db           db             `toml:"db" mapstructure:"db"`
126
                Realtime     realtime       `toml:"realtime"`
127
                Studio       studio         `toml:"studio"`
128
                Inbucket     inbucket       `toml:"inbucket"`
129
                Storage      storage        `toml:"storage"`
130
                Auth         auth           `toml:"auth" mapstructure:"auth"`
131
                EdgeRuntime  edgeRuntime    `toml:"edge_runtime"`
132
                Functions    FunctionConfig `toml:"functions"`
133
                Analytics    analytics      `toml:"analytics"`
134
                Experimental experimental   `toml:"experimental"`
135
        }
136

137
        config struct {
138
                baseConfig `mapstructure:",squash"`
139
                Remotes    map[string]baseConfig `toml:"remotes"`
140
        }
141

142
        realtime struct {
143
                Enabled         bool          `toml:"enabled"`
144
                Image           string        `toml:"-"`
145
                IpVersion       AddressFamily `toml:"ip_version"`
146
                MaxHeaderLength uint          `toml:"max_header_length"`
147
                TenantId        string        `toml:"-"`
148
                EncryptionKey   string        `toml:"-"`
149
                SecretKeyBase   string        `toml:"-"`
150
        }
151

152
        studio struct {
153
                Enabled      bool   `toml:"enabled"`
154
                Image        string `toml:"-"`
155
                Port         uint16 `toml:"port"`
156
                ApiUrl       string `toml:"api_url"`
157
                OpenaiApiKey string `toml:"openai_api_key"`
158
                PgmetaImage  string `toml:"-"`
159
        }
160

161
        inbucket struct {
162
                Enabled    bool   `toml:"enabled"`
163
                Image      string `toml:"-"`
164
                Port       uint16 `toml:"port"`
165
                SmtpPort   uint16 `toml:"smtp_port"`
166
                Pop3Port   uint16 `toml:"pop3_port"`
167
                AdminEmail string `toml:"admin_email"`
168
                SenderName string `toml:"sender_name"`
169
        }
170

171
        edgeRuntime struct {
172
                Enabled       bool          `toml:"enabled"`
173
                Image         string        `toml:"-"`
174
                Policy        RequestPolicy `toml:"policy"`
175
                InspectorPort uint16        `toml:"inspector_port"`
176
        }
177

178
        FunctionConfig map[string]function
179

180
        function struct {
181
                Enabled    *bool  `toml:"enabled" json:"-"`
182
                VerifyJWT  *bool  `toml:"verify_jwt" json:"verifyJWT"`
183
                ImportMap  string `toml:"import_map" json:"importMapPath,omitempty"`
184
                Entrypoint string `toml:"entrypoint" json:"entrypointPath,omitempty"`
185
        }
186

187
        analytics struct {
188
                Enabled          bool            `toml:"enabled"`
189
                Image            string          `toml:"-"`
190
                VectorImage      string          `toml:"-"`
191
                Port             uint16          `toml:"port"`
192
                Backend          LogflareBackend `toml:"backend"`
193
                GcpProjectId     string          `toml:"gcp_project_id"`
194
                GcpProjectNumber string          `toml:"gcp_project_number"`
195
                GcpJwtPath       string          `toml:"gcp_jwt_path"`
196
                ApiKey           string          `toml:"-" mapstructure:"api_key"`
197
                // Deprecated together with syslog
198
                VectorPort uint16 `toml:"vector_port"`
199
        }
200

201
        webhooks struct {
202
                Enabled bool `toml:"enabled"`
203
        }
204

205
        experimental struct {
206
                OrioleDBVersion string    `toml:"orioledb_version"`
207
                S3Host          string    `toml:"s3_host"`
208
                S3Region        string    `toml:"s3_region"`
209
                S3AccessKey     string    `toml:"s3_access_key"`
210
                S3SecretKey     string    `toml:"s3_secret_key"`
211
                Webhooks        *webhooks `toml:"webhooks"`
212
        }
213
)
214

215
func (f function) IsEnabled() bool {
×
216
        // If Enabled is not defined, or defined and set to true
×
217
        return f.Enabled == nil || *f.Enabled
×
218
}
×
219

220
func (a *auth) Clone() auth {
26✔
221
        copy := *a
26✔
222
        copy.External = maps.Clone(a.External)
26✔
223
        if a.Email.Smtp != nil {
29✔
224
                mailer := *a.Email.Smtp
3✔
225
                copy.Email.Smtp = &mailer
3✔
226
        }
3✔
227
        if a.Hook.MFAVerificationAttempt != nil {
30✔
228
                hook := *a.Hook.MFAVerificationAttempt
4✔
229
                copy.Hook.MFAVerificationAttempt = &hook
4✔
230
        }
4✔
231
        if a.Hook.PasswordVerificationAttempt != nil {
28✔
232
                hook := *a.Hook.PasswordVerificationAttempt
2✔
233
                copy.Hook.PasswordVerificationAttempt = &hook
2✔
234
        }
2✔
235
        if a.Hook.CustomAccessToken != nil {
30✔
236
                hook := *a.Hook.CustomAccessToken
4✔
237
                copy.Hook.CustomAccessToken = &hook
4✔
238
        }
4✔
239
        if a.Hook.SendSMS != nil {
30✔
240
                hook := *a.Hook.SendSMS
4✔
241
                copy.Hook.SendSMS = &hook
4✔
242
        }
4✔
243
        if a.Hook.SendEmail != nil {
30✔
244
                hook := *a.Hook.SendEmail
4✔
245
                copy.Hook.SendEmail = &hook
4✔
246
        }
4✔
247
        copy.Email.Template = maps.Clone(a.Email.Template)
26✔
248
        copy.Sms.TestOTP = maps.Clone(a.Sms.TestOTP)
26✔
249
        return copy
26✔
250
}
251

252
func (c *baseConfig) Clone() baseConfig {
×
253
        copy := *c
×
254
        copy.Storage.Buckets = maps.Clone(c.Storage.Buckets)
×
255
        copy.Functions = maps.Clone(c.Functions)
×
256
        copy.Auth = c.Auth.Clone()
×
257
        if c.Experimental.Webhooks != nil {
×
258
                webhooks := *c.Experimental.Webhooks
×
259
                copy.Experimental.Webhooks = &webhooks
×
260
        }
×
261
        return copy
×
262
}
263

264
type ConfigEditor func(*config)
265

266
func WithHostname(hostname string) ConfigEditor {
×
267
        return func(c *config) {
×
268
                c.Hostname = hostname
×
269
        }
×
270
}
271

272
func NewConfig(editors ...ConfigEditor) config {
8✔
273
        initial := config{baseConfig: baseConfig{
8✔
274
                Hostname: "127.0.0.1",
8✔
275
                Api: api{
8✔
276
                        Image:     postgrestImage,
8✔
277
                        KongImage: kongImage,
8✔
278
                },
8✔
279
                Db: db{
8✔
280
                        Image:    Pg15Image,
8✔
281
                        Password: "postgres",
8✔
282
                        RootKey:  "d4dc5b6d4a1d6a10b2c1e76112c994d65db7cec380572cc1839624d4be3fa275",
8✔
283
                        Pooler: pooler{
8✔
284
                                Image:         supavisorImage,
8✔
285
                                TenantId:      "pooler-dev",
8✔
286
                                EncryptionKey: "12345678901234567890123456789032",
8✔
287
                                SecretKeyBase: "EAx3IQ/wRG1v47ZD4NE4/9RzBI8Jmil3x0yhcW4V2NHBP6c2iPIzwjofi2Ep4HIG",
8✔
288
                        },
8✔
289
                        Seed: seed{
8✔
290
                                Enabled:      true,
8✔
291
                                GlobPatterns: []string{"./seed.sql"},
8✔
292
                        },
8✔
293
                },
8✔
294
                Realtime: realtime{
8✔
295
                        Image:           realtimeImage,
8✔
296
                        IpVersion:       AddressIPv4,
8✔
297
                        MaxHeaderLength: 4096,
8✔
298
                        TenantId:        "realtime-dev",
8✔
299
                        EncryptionKey:   "supabaserealtime",
8✔
300
                        SecretKeyBase:   "EAx3IQ/wRG1v47ZD4NE4/9RzBI8Jmil3x0yhcW4V2NHBP6c2iPIzwjofi2Ep4HIG",
8✔
301
                },
8✔
302
                Storage: storage{
8✔
303
                        Image:         storageImage,
8✔
304
                        ImgProxyImage: imageProxyImage,
8✔
305
                        S3Credentials: storageS3Credentials{
8✔
306
                                AccessKeyId:     "625729a08b95bf1b7ff351a663f3a23c",
8✔
307
                                SecretAccessKey: "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907",
8✔
308
                                Region:          "local",
8✔
309
                        },
8✔
310
                },
8✔
311
                Auth: auth{
8✔
312
                        Image: gotrueImage,
8✔
313
                        Email: email{
8✔
314
                                Template: map[string]emailTemplate{},
8✔
315
                        },
8✔
316
                        Sms: sms{
8✔
317
                                TestOTP: map[string]string{},
8✔
318
                        },
8✔
319
                        External:  map[string]provider{},
8✔
320
                        JwtSecret: defaultJwtSecret,
8✔
321
                },
8✔
322
                Inbucket: inbucket{
8✔
323
                        Image:      inbucketImage,
8✔
324
                        AdminEmail: "admin@email.com",
8✔
325
                        SenderName: "Admin",
8✔
326
                },
8✔
327
                Studio: studio{
8✔
328
                        Image:       studioImage,
8✔
329
                        PgmetaImage: pgmetaImage,
8✔
330
                },
8✔
331
                Analytics: analytics{
8✔
332
                        Image:       logflareImage,
8✔
333
                        VectorImage: vectorImage,
8✔
334
                        ApiKey:      "api-key",
8✔
335
                        // Defaults to bigquery for backwards compatibility with existing config.toml
8✔
336
                        Backend: LogflareBigQuery,
8✔
337
                },
8✔
338
                EdgeRuntime: edgeRuntime{
8✔
339
                        Image: edgeRuntimeImage,
8✔
340
                },
8✔
341
        }}
8✔
342
        for _, apply := range editors {
8✔
343
                apply(&initial)
×
344
        }
×
345
        return initial
8✔
346
}
347

348
var (
349
        //go:embed templates/config.toml
350
        initConfigEmbed    string
351
        initConfigTemplate = template.Must(template.New("initConfig").Parse(initConfigEmbed))
352

353
        invalidProjectId = regexp.MustCompile("[^a-zA-Z0-9_.-]+")
354
        envPattern       = regexp.MustCompile(`^env\((.*)\)$`)
355
        refPattern       = regexp.MustCompile(`^[a-z]{20}$`)
356
)
357

358
func (c *config) Eject(w io.Writer) error {
1✔
359
        // Defaults to current directory name as project id
1✔
360
        if len(c.ProjectId) == 0 {
2✔
361
                cwd, err := os.Getwd()
1✔
362
                if err != nil {
1✔
363
                        return errors.Errorf("failed to get working directory: %w", err)
×
364
                }
×
365
                c.ProjectId = filepath.Base(cwd)
1✔
366
        }
367
        c.ProjectId = sanitizeProjectId(c.ProjectId)
1✔
368
        // TODO: templatize all fields eventually
1✔
369
        if err := initConfigTemplate.Option("missingkey=error").Execute(w, c); err != nil {
1✔
370
                return errors.Errorf("failed to initialise config: %w", err)
×
371
        }
×
372
        return nil
1✔
373
}
374

375
// Loads custom config file to struct fields tagged with toml.
376
func (c *config) loadFromFile(filename string, fsys fs.FS) error {
7✔
377
        v := viper.New()
7✔
378
        v.SetConfigType("toml")
7✔
379
        // Load default values
7✔
380
        var buf bytes.Buffer
7✔
381
        if err := initConfigTemplate.Option("missingkey=zero").Execute(&buf, c); err != nil {
7✔
382
                return errors.Errorf("failed to initialise template config: %w", err)
×
383
        } else if err := c.loadFromReader(v, &buf); err != nil {
7✔
384
                return err
×
385
        }
×
386
        // Load custom config
387
        if ext := filepath.Ext(filename); len(ext) > 0 {
14✔
388
                v.SetConfigType(ext[1:])
7✔
389
        }
7✔
390
        f, err := fsys.Open(filename)
7✔
391
        if err != nil {
7✔
392
                return errors.Errorf("failed to read file config: %w", err)
×
393
        }
×
394
        defer f.Close()
7✔
395
        return c.loadFromReader(v, f)
7✔
396
}
397

398
func (c *config) loadFromReader(v *viper.Viper, r io.Reader) error {
14✔
399
        if err := v.MergeConfig(r); err != nil {
14✔
400
                return errors.Errorf("failed to merge config: %w", err)
×
401
        }
×
402
        // Find [remotes.*] block to override base config
403
        baseId := v.GetString("project_id")
14✔
404
        idToName := map[string]string{baseId: "base"}
14✔
405
        for name, remote := range v.GetStringMap("remotes") {
20✔
406
                projectId := v.GetString(fmt.Sprintf("remotes.%s.project_id", name))
6✔
407
                // Track remote project_id to check for duplication
6✔
408
                if other, exists := idToName[projectId]; exists {
6✔
409
                        return errors.Errorf("duplicate project_id for [remotes.%s] and %s", name, other)
×
410
                }
×
411
                idToName[projectId] = fmt.Sprintf("[remotes.%s]", name)
6✔
412
                if projectId == c.ProjectId {
6✔
413
                        fmt.Fprintln(os.Stderr, "Loading config override:", idToName[projectId])
×
414
                        if err := v.MergeConfigMap(remote.(map[string]any)); err != nil {
×
415
                                return err
×
416
                        }
×
417
                        v.Set("project_id", baseId)
×
418
                }
419
        }
420
        // Manually parse [functions.*] to empty struct for backwards compatibility
421
        for key, value := range v.GetStringMap("functions") {
17✔
422
                if m, ok := value.(map[string]any); ok && len(m) == 0 {
5✔
423
                        v.Set("functions."+key, function{})
2✔
424
                }
2✔
425
        }
426
        if err := v.UnmarshalExact(c, func(dc *mapstructure.DecoderConfig) {
28✔
427
                dc.TagName = "toml"
14✔
428
                dc.Squash = true
14✔
429
                dc.ZeroFields = true
14✔
430
                dc.DecodeHook = c.newDecodeHook(LoadEnvHook)
14✔
431
        }); err != nil {
14✔
432
                return errors.Errorf("failed to parse config: %w", err)
×
433
        }
×
434
        return nil
14✔
435
}
436

437
func (c *config) newDecodeHook(fs ...mapstructure.DecodeHookFunc) mapstructure.DecodeHookFunc {
22✔
438
        fs = append(fs,
22✔
439
                mapstructure.StringToTimeDurationHookFunc(),
22✔
440
                mapstructure.StringToIPHookFunc(),
22✔
441
                mapstructure.StringToSliceHookFunc(","),
22✔
442
                mapstructure.TextUnmarshallerHookFunc(),
22✔
443
                DecryptSecretHookFunc(c.ProjectId),
22✔
444
        )
22✔
445
        return mapstructure.ComposeDecodeHookFunc(fs...)
22✔
446
}
22✔
447

448
// Loads envs prefixed with supabase_ to struct fields tagged with mapstructure.
449
func (c *config) loadFromEnv() error {
8✔
450
        v := viper.New()
8✔
451
        v.SetEnvPrefix("SUPABASE")
8✔
452
        v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
8✔
453
        v.AutomaticEnv()
8✔
454
        // Viper does not parse env vars automatically. Instead of calling viper.BindEnv
8✔
455
        // per key, we decode all keys from an existing struct, and merge them to viper.
8✔
456
        // Ref: https://github.com/spf13/viper/issues/761#issuecomment-859306364
8✔
457
        envKeysMap := map[string]interface{}{}
8✔
458
        if dec, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
8✔
459
                Result:               &envKeysMap,
8✔
460
                IgnoreUntaggedFields: true,
8✔
461
        }); err != nil {
8✔
462
                return errors.Errorf("failed to create decoder: %w", err)
×
463
        } else if err := dec.Decode(c.baseConfig); err != nil {
8✔
464
                return errors.Errorf("failed to decode env: %w", err)
×
465
        } else if err := v.MergeConfigMap(envKeysMap); err != nil {
8✔
466
                return errors.Errorf("failed to merge env config: %w", err)
×
467
        }
×
468
        // Writes viper state back to config struct, with automatic env substitution
469
        if err := v.UnmarshalExact(c, viper.DecodeHook(c.newDecodeHook())); err != nil {
8✔
470
                return errors.Errorf("failed to parse env override: %w", err)
×
471
        }
×
472
        return nil
8✔
473
}
474

475
func (c *config) Load(path string, fsys fs.FS) error {
7✔
476
        builder := NewPathBuilder(path)
7✔
477
        // Load secrets from .env file
7✔
478
        if err := loadDefaultEnv(); err != nil {
7✔
479
                return err
×
480
        }
×
481
        if err := c.loadFromFile(builder.ConfigPath, fsys); err != nil {
7✔
482
                return err
×
483
        }
×
484
        if err := c.loadFromEnv(); err != nil {
7✔
485
                return err
×
486
        }
×
487
        // Generate JWT tokens
488
        if len(c.Auth.AnonKey) == 0 {
14✔
489
                anonToken := CustomClaims{Role: "anon"}.NewToken()
7✔
490
                if signed, err := anonToken.SignedString([]byte(c.Auth.JwtSecret)); err != nil {
7✔
491
                        return errors.Errorf("failed to generate anon key: %w", err)
×
492
                } else {
7✔
493
                        c.Auth.AnonKey = signed
7✔
494
                }
7✔
495
        }
496
        if len(c.Auth.ServiceRoleKey) == 0 {
14✔
497
                anonToken := CustomClaims{Role: "service_role"}.NewToken()
7✔
498
                if signed, err := anonToken.SignedString([]byte(c.Auth.JwtSecret)); err != nil {
7✔
499
                        return errors.Errorf("failed to generate service_role key: %w", err)
×
500
                } else {
7✔
501
                        c.Auth.ServiceRoleKey = signed
7✔
502
                }
7✔
503
        }
504
        // TODO: move linked pooler connection string elsewhere
505
        if connString, err := fs.ReadFile(fsys, builder.PoolerUrlPath); err == nil && len(connString) > 0 {
7✔
506
                c.Db.Pooler.ConnectionString = string(connString)
×
507
        }
×
508
        // Update external api url
509
        apiUrl := url.URL{Host: net.JoinHostPort(c.Hostname,
7✔
510
                strconv.FormatUint(uint64(c.Api.Port), 10),
7✔
511
        )}
7✔
512
        if c.Api.Tls.Enabled {
10✔
513
                apiUrl.Scheme = "https"
3✔
514
        } else {
7✔
515
                apiUrl.Scheme = "http"
4✔
516
        }
4✔
517
        c.Api.ExternalUrl = apiUrl.String()
7✔
518
        // Update image versions
7✔
519
        if version, err := fs.ReadFile(fsys, builder.PostgresVersionPath); err == nil {
7✔
520
                if strings.HasPrefix(string(version), "15.") && semver.Compare(string(version[3:]), "1.0.55") >= 0 {
×
521
                        c.Db.Image = replaceImageTag(Pg15Image, string(version))
×
522
                }
×
523
        }
524
        if c.Db.MajorVersion > 14 {
14✔
525
                if version, err := fs.ReadFile(fsys, builder.RestVersionPath); err == nil && len(version) > 0 {
7✔
526
                        c.Api.Image = replaceImageTag(postgrestImage, string(version))
×
527
                }
×
528
                if version, err := fs.ReadFile(fsys, builder.StorageVersionPath); err == nil && len(version) > 0 {
7✔
529
                        c.Storage.Image = replaceImageTag(storageImage, string(version))
×
530
                }
×
531
                if version, err := fs.ReadFile(fsys, builder.GotrueVersionPath); err == nil && len(version) > 0 {
7✔
532
                        c.Auth.Image = replaceImageTag(gotrueImage, string(version))
×
533
                }
×
534
        }
535
        if version, err := fs.ReadFile(fsys, builder.PoolerVersionPath); err == nil && len(version) > 0 {
7✔
536
                c.Db.Pooler.Image = replaceImageTag(supavisorImage, string(version))
×
537
        }
×
538
        if version, err := fs.ReadFile(fsys, builder.RealtimeVersionPath); err == nil && len(version) > 0 {
7✔
539
                c.Realtime.Image = replaceImageTag(realtimeImage, string(version))
×
540
        }
×
541
        if version, err := fs.ReadFile(fsys, builder.StudioVersionPath); err == nil && len(version) > 0 {
7✔
542
                c.Studio.Image = replaceImageTag(studioImage, string(version))
×
543
        }
×
544
        if version, err := fs.ReadFile(fsys, builder.PgmetaVersionPath); err == nil && len(version) > 0 {
7✔
545
                c.Studio.PgmetaImage = replaceImageTag(pgmetaImage, string(version))
×
546
        }
×
547
        // TODO: replace derived config resolution with viper decode hooks
548
        if err := c.baseConfig.resolve(builder, fsys); err != nil {
7✔
549
                return err
×
550
        }
×
551
        return c.Validate(fsys)
7✔
552
}
553

554
func (c *baseConfig) resolve(builder pathBuilder, fsys fs.FS) error {
7✔
555
        // Update content paths
7✔
556
        for name, tmpl := range c.Auth.Email.Template {
10✔
557
                // FIXME: only email template is relative to repo directory
3✔
558
                cwd := filepath.Dir(builder.SupabaseDirPath)
3✔
559
                if len(tmpl.ContentPath) > 0 && !filepath.IsAbs(tmpl.ContentPath) {
6✔
560
                        tmpl.ContentPath = filepath.Join(cwd, tmpl.ContentPath)
3✔
561
                }
3✔
562
                c.Auth.Email.Template[name] = tmpl
3✔
563
        }
564
        // Update fallback configs
565
        for name, bucket := range c.Storage.Buckets {
10✔
566
                if bucket.FileSizeLimit == 0 {
3✔
567
                        bucket.FileSizeLimit = c.Storage.FileSizeLimit
×
568
                }
×
569
                if len(bucket.ObjectsPath) > 0 && !filepath.IsAbs(bucket.ObjectsPath) {
6✔
570
                        bucket.ObjectsPath = filepath.Join(builder.SupabaseDirPath, bucket.ObjectsPath)
3✔
571
                }
3✔
572
                c.Storage.Buckets[name] = bucket
3✔
573
        }
574
        // Resolve functions config
575
        for slug, function := range c.Functions {
10✔
576
                if len(function.Entrypoint) == 0 {
6✔
577
                        function.Entrypoint = filepath.Join(builder.FunctionsDir, slug, "index.ts")
3✔
578
                } else if !filepath.IsAbs(function.Entrypoint) {
3✔
579
                        // Append supabase/ because paths in configs are specified relative to config.toml
×
580
                        function.Entrypoint = filepath.Join(builder.SupabaseDirPath, function.Entrypoint)
×
581
                }
×
582
                if len(function.ImportMap) == 0 {
5✔
583
                        functionDir := filepath.Dir(function.Entrypoint)
2✔
584
                        denoJsonPath := filepath.Join(functionDir, "deno.json")
2✔
585
                        denoJsoncPath := filepath.Join(functionDir, "deno.jsonc")
2✔
586
                        if _, err := fs.Stat(fsys, denoJsonPath); err == nil {
4✔
587
                                function.ImportMap = denoJsonPath
2✔
588
                        } else if _, err := fs.Stat(fsys, denoJsoncPath); err == nil {
2✔
UNCOV
589
                                function.ImportMap = denoJsoncPath
×
UNCOV
590
                        }
×
591
                        // Functions may not use import map so we don't set a default value
592
                } else if !filepath.IsAbs(function.ImportMap) {
2✔
593
                        function.ImportMap = filepath.Join(builder.SupabaseDirPath, function.ImportMap)
1✔
594
                }
1✔
595
                c.Functions[slug] = function
3✔
596
        }
597
        return c.Db.Seed.loadSeedPaths(builder.SupabaseDirPath, fsys)
7✔
598
}
599

600
func (c *config) Validate(fsys fs.FS) error {
7✔
601
        if c.ProjectId == "" {
7✔
602
                return errors.New("Missing required field in config: project_id")
×
603
        } else if sanitized := sanitizeProjectId(c.ProjectId); sanitized != c.ProjectId {
7✔
604
                fmt.Fprintln(os.Stderr, "WARN: project_id field in config is invalid. Auto-fixing to", sanitized)
×
605
                c.ProjectId = sanitized
×
606
        }
×
607
        // Since remote config is merged to base, we only need to validate the project_id field.
608
        for name, remote := range c.Remotes {
13✔
609
                if !refPattern.MatchString(remote.ProjectId) {
6✔
610
                        return errors.Errorf("Invalid config for remotes.%s.project_id. Must be like: abcdefghijklmnopqrst", name)
×
611
                }
×
612
        }
613
        // Validate api config
614
        if c.Api.Enabled {
14✔
615
                if c.Api.Port == 0 {
7✔
616
                        return errors.New("Missing required field in config: api.port")
×
617
                }
×
618
        }
619
        // Validate db config
620
        if c.Db.Settings.SessionReplicationRole != nil {
7✔
621
                allowedRoles := []SessionReplicationRole{SessionReplicationRoleOrigin, SessionReplicationRoleReplica, SessionReplicationRoleLocal}
×
622
                if !sliceContains(allowedRoles, *c.Db.Settings.SessionReplicationRole) {
×
623
                        return errors.Errorf("Invalid config for db.session_replication_role. Must be one of: %v", allowedRoles)
×
624
                }
×
625
        }
626
        if c.Db.Port == 0 {
7✔
627
                return errors.New("Missing required field in config: db.port")
×
628
        }
×
629
        switch c.Db.MajorVersion {
7✔
630
        case 0:
×
631
                return errors.New("Missing required field in config: db.major_version")
×
632
        case 12:
×
633
                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.")
×
634
        case 13:
×
635
                c.Db.Image = pg13Image
×
636
        case 14:
×
637
                c.Db.Image = pg14Image
×
638
        case 15:
7✔
639
                if len(c.Experimental.OrioleDBVersion) > 0 {
10✔
640
                        c.Db.Image = "supabase/postgres:orioledb-" + c.Experimental.OrioleDBVersion
3✔
641
                        if err := assertEnvLoaded(c.Experimental.S3Host); err != nil {
3✔
642
                                return err
×
643
                        }
×
644
                        if err := assertEnvLoaded(c.Experimental.S3Region); err != nil {
3✔
645
                                return err
×
646
                        }
×
647
                        if err := assertEnvLoaded(c.Experimental.S3AccessKey); err != nil {
3✔
648
                                return err
×
649
                        }
×
650
                        if err := assertEnvLoaded(c.Experimental.S3SecretKey); err != nil {
3✔
651
                                return err
×
652
                        }
×
653
                }
654
        default:
×
655
                return errors.Errorf("Failed reading config: Invalid %s: %v.", "db.major_version", c.Db.MajorVersion)
×
656
        }
657
        // Validate pooler config
658
        if c.Db.Pooler.Enabled {
10✔
659
                allowed := []PoolMode{TransactionMode, SessionMode}
3✔
660
                if !sliceContains(allowed, c.Db.Pooler.PoolMode) {
3✔
661
                        return errors.Errorf("Invalid config for db.pooler.pool_mode. Must be one of: %v", allowed)
×
662
                }
×
663
        }
664
        // Validate realtime config
665
        if c.Realtime.Enabled {
14✔
666
                allowed := []AddressFamily{AddressIPv6, AddressIPv4}
7✔
667
                if !sliceContains(allowed, c.Realtime.IpVersion) {
7✔
668
                        return errors.Errorf("Invalid config for realtime.ip_version. Must be one of: %v", allowed)
×
669
                }
×
670
        }
671
        // Validate storage config
672
        for name := range c.Storage.Buckets {
10✔
673
                if err := ValidateBucketName(name); err != nil {
3✔
674
                        return err
×
675
                }
×
676
        }
677
        // Validate studio config
678
        if c.Studio.Enabled {
14✔
679
                if c.Studio.Port == 0 {
7✔
680
                        return errors.New("Missing required field in config: studio.port")
×
681
                }
×
682
                if parsed, err := url.Parse(c.Studio.ApiUrl); err != nil {
7✔
683
                        return errors.Errorf("Invalid config for studio.api_url: %w", err)
×
684
                } else if parsed.Host == "" || parsed.Host == c.Hostname {
14✔
685
                        c.Studio.ApiUrl = c.Api.ExternalUrl
7✔
686
                }
7✔
687
        }
688
        // Validate smtp config
689
        if c.Inbucket.Enabled {
14✔
690
                if c.Inbucket.Port == 0 {
7✔
691
                        return errors.New("Missing required field in config: inbucket.port")
×
692
                }
×
693
        }
694
        // Validate auth config
695
        if c.Auth.Enabled {
14✔
696
                if c.Auth.SiteUrl == "" {
7✔
697
                        return errors.New("Missing required field in config: auth.site_url")
×
698
                }
×
699
                if err := assertEnvLoaded(c.Auth.SiteUrl); err != nil {
7✔
700
                        return err
×
701
                }
×
702
                for i, url := range c.Auth.AdditionalRedirectUrls {
17✔
703
                        if err := assertEnvLoaded(url); err != nil {
11✔
704
                                return errors.Errorf("Invalid config for auth.additional_redirect_urls[%d]: %v", i, err)
1✔
705
                        }
1✔
706
                }
707
                allowed := []PasswordRequirements{NoRequirements, LettersDigits, LowerUpperLettersDigits, LowerUpperLettersDigitsSymbols}
6✔
708
                if !sliceContains(allowed, c.Auth.PasswordRequirements) {
6✔
709
                        return errors.Errorf("Invalid config for auth.password_requirements. Must be one of: %v", allowed)
×
710
                }
×
711
                if err := c.Auth.Hook.validate(); err != nil {
6✔
712
                        return err
×
713
                }
×
714
                if err := c.Auth.MFA.validate(); err != nil {
6✔
715
                        return err
×
716
                }
×
717
                if err := c.Auth.Email.validate(fsys); err != nil {
6✔
718
                        return err
×
719
                }
×
720
                if err := c.Auth.Sms.validate(); err != nil {
6✔
721
                        return err
×
722
                }
×
723
                if err := c.Auth.External.validate(); err != nil {
6✔
724
                        return err
×
725
                }
×
726
                if err := c.Auth.ThirdParty.validate(); err != nil {
6✔
727
                        return err
×
728
                }
×
729
        }
730
        // Validate functions config
731
        if c.EdgeRuntime.Enabled {
12✔
732
                allowed := []RequestPolicy{PolicyPerWorker, PolicyOneshot}
6✔
733
                if !sliceContains(allowed, c.EdgeRuntime.Policy) {
6✔
734
                        return errors.Errorf("Invalid config for edge_runtime.policy. Must be one of: %v", allowed)
×
735
                }
×
736
        }
737
        for name := range c.Functions {
9✔
738
                if err := ValidateFunctionSlug(name); err != nil {
3✔
739
                        return err
×
740
                }
×
741
        }
742
        // Validate logflare config
743
        if c.Analytics.Enabled {
12✔
744
                switch c.Analytics.Backend {
6✔
745
                case LogflareBigQuery:
×
746
                        if len(c.Analytics.GcpProjectId) == 0 {
×
747
                                return errors.New("Missing required field in config: analytics.gcp_project_id")
×
748
                        }
×
749
                        if len(c.Analytics.GcpProjectNumber) == 0 {
×
750
                                return errors.New("Missing required field in config: analytics.gcp_project_number")
×
751
                        }
×
752
                        if len(c.Analytics.GcpJwtPath) == 0 {
×
753
                                return errors.New("Path to GCP Service Account Key must be provided in config, relative to config.toml: analytics.gcp_jwt_path")
×
754
                        }
×
755
                case LogflarePostgres:
6✔
756
                        break
6✔
757
                default:
×
758
                        allowed := []LogflareBackend{LogflarePostgres, LogflareBigQuery}
×
759
                        return errors.Errorf("Invalid config for analytics.backend. Must be one of: %v", allowed)
×
760
                }
761
        }
762
        if err := c.Experimental.validate(); err != nil {
6✔
763
                return err
×
764
        }
×
765
        return nil
6✔
766
}
767

768
func assertEnvLoaded(s string) error {
45✔
769
        if matches := envPattern.FindStringSubmatch(s); len(matches) > 1 {
46✔
770
                return errors.Errorf(`Error evaluating "%s": environment variable %s is unset.`, s, matches[1])
1✔
771
        }
1✔
772
        return nil
44✔
773
}
774

775
func LoadEnvHook(f reflect.Kind, t reflect.Kind, data interface{}) (interface{}, error) {
1,838✔
776
        if f != reflect.String {
3,102✔
777
                return data, nil
1,264✔
778
        }
1,264✔
779
        value := data.(string)
574✔
780
        if matches := envPattern.FindStringSubmatch(value); len(matches) > 1 {
675✔
781
                if env, exists := os.LookupEnv(matches[1]); exists {
113✔
782
                        value = env
12✔
783
                }
12✔
784
        }
785
        return value, nil
574✔
786
}
787

788
func truncateText(text string, maxLen int) string {
14✔
789
        if len(text) > maxLen {
15✔
790
                return text[:maxLen]
1✔
791
        }
1✔
792
        return text
13✔
793
}
794

795
const maxProjectIdLength = 40
796

797
func sanitizeProjectId(src string) string {
14✔
798
        // A valid project ID must only contain alphanumeric and special characters _.-
14✔
799
        sanitized := invalidProjectId.ReplaceAllString(src, "_")
14✔
800
        // It must also start with an alphanumeric character
14✔
801
        sanitized = strings.TrimLeft(sanitized, "_.-")
14✔
802
        // Truncate sanitized ID to 40 characters since docker hostnames cannot exceed
14✔
803
        // 63 characters, and we need to save space for padding supabase_*_edge_runtime.
14✔
804
        return truncateText(sanitized, maxProjectIdLength)
14✔
805
}
14✔
806

807
func loadDefaultEnv() error {
7✔
808
        env := viper.GetString("ENV")
7✔
809
        if env == "" {
14✔
810
                env = "development"
7✔
811
        }
7✔
812
        filenames := []string{".env." + env + ".local"}
7✔
813
        if env != "test" {
14✔
814
                filenames = append(filenames, ".env.local")
7✔
815
        }
7✔
816
        filenames = append(filenames, ".env."+env, ".env")
7✔
817
        for _, path := range filenames {
35✔
818
                if err := loadEnvIfExists(path); err != nil {
28✔
819
                        return err
×
820
                }
×
821
        }
822
        return nil
7✔
823
}
824

825
func loadEnvIfExists(path string) error {
28✔
826
        if err := godotenv.Load(path); err != nil && !errors.Is(err, os.ErrNotExist) {
28✔
827
                return errors.Errorf("failed to load %s: %w", ".env", err)
×
828
        }
×
829
        return nil
28✔
830
}
831

832
// Match the glob patterns from the config to get a deduplicated
833
// array of all migrations files to apply in the declared order.
834
func (c *seed) loadSeedPaths(basePath string, fsys fs.FS) error {
11✔
835
        if !c.Enabled {
11✔
836
                return nil
×
837
        }
×
838
        if c.SqlPaths != nil {
11✔
839
                // Reuse already allocated array
×
840
                c.SqlPaths = c.SqlPaths[:0]
×
841
        }
×
842
        set := make(map[string]struct{})
11✔
843
        for _, pattern := range c.GlobPatterns {
25✔
844
                // Glob expects / as path separator on windows
14✔
845
                pattern = filepath.ToSlash(pattern)
14✔
846
                if !filepath.IsAbs(pattern) {
28✔
847
                        pattern = path.Join(basePath, pattern)
14✔
848
                }
14✔
849
                matches, err := fs.Glob(fsys, pattern)
14✔
850
                if err != nil {
15✔
851
                        return errors.Errorf("failed to apply glob pattern: %w", err)
1✔
852
                }
1✔
853
                if len(matches) == 0 {
21✔
854
                        fmt.Fprintln(os.Stderr, "WARN: no seed files matched pattern:", pattern)
8✔
855
                }
8✔
856
                sort.Strings(matches)
13✔
857
                // Remove duplicates
13✔
858
                for _, item := range matches {
22✔
859
                        if _, exists := set[item]; !exists {
16✔
860
                                set[item] = struct{}{}
7✔
861
                                c.SqlPaths = append(c.SqlPaths, item)
7✔
862
                        }
7✔
863
                }
864
        }
865
        return nil
10✔
866
}
867

868
func (e *email) validate(fsys fs.FS) (err error) {
6✔
869
        for name, tmpl := range e.Template {
8✔
870
                if len(tmpl.ContentPath) == 0 {
2✔
871
                        if tmpl.Content != nil {
×
872
                                return errors.Errorf("Invalid config for auth.email.%s.content: please use content_path instead", name)
×
873
                        }
×
874
                        continue
×
875
                }
876
                if content, err := fs.ReadFile(fsys, tmpl.ContentPath); err != nil {
2✔
877
                        return errors.Errorf("Invalid config for auth.email.%s.content_path: %w", name, err)
×
878
                } else {
2✔
879
                        tmpl.Content = cast.Ptr(string(content))
2✔
880
                }
2✔
881
                e.Template[name] = tmpl
2✔
882
        }
883
        if e.Smtp != nil && e.Smtp.IsEnabled() {
8✔
884
                if len(e.Smtp.Host) == 0 {
2✔
885
                        return errors.New("Missing required field in config: auth.email.smtp.host")
×
886
                }
×
887
                if e.Smtp.Port == 0 {
2✔
888
                        return errors.New("Missing required field in config: auth.email.smtp.port")
×
889
                }
×
890
                if len(e.Smtp.User) == 0 {
2✔
891
                        return errors.New("Missing required field in config: auth.email.smtp.user")
×
892
                }
×
893
                if len(e.Smtp.Pass.Value) == 0 {
2✔
894
                        return errors.New("Missing required field in config: auth.email.smtp.pass")
×
895
                }
×
896
                if len(e.Smtp.AdminEmail) == 0 {
2✔
897
                        return errors.New("Missing required field in config: auth.email.smtp.admin_email")
×
898
                }
×
899
                if err := assertEnvLoaded(e.Smtp.Pass.Value); err != nil {
2✔
900
                        return err
×
901
                }
×
902
        }
903
        return nil
6✔
904
}
905

906
func (s *sms) validate() (err error) {
6✔
907
        switch {
6✔
908
        case s.Twilio.Enabled:
2✔
909
                if len(s.Twilio.AccountSid) == 0 {
2✔
910
                        return errors.New("Missing required field in config: auth.sms.twilio.account_sid")
×
911
                }
×
912
                if len(s.Twilio.MessageServiceSid) == 0 {
2✔
913
                        return errors.New("Missing required field in config: auth.sms.twilio.message_service_sid")
×
914
                }
×
915
                if len(s.Twilio.AuthToken.Value) == 0 {
2✔
916
                        return errors.New("Missing required field in config: auth.sms.twilio.auth_token")
×
917
                }
×
918
                if err := assertEnvLoaded(s.Twilio.AuthToken.Value); err != nil {
2✔
919
                        return err
×
920
                }
×
921
        case s.TwilioVerify.Enabled:
×
922
                if len(s.TwilioVerify.AccountSid) == 0 {
×
923
                        return errors.New("Missing required field in config: auth.sms.twilio_verify.account_sid")
×
924
                }
×
925
                if len(s.TwilioVerify.MessageServiceSid) == 0 {
×
926
                        return errors.New("Missing required field in config: auth.sms.twilio_verify.message_service_sid")
×
927
                }
×
928
                if len(s.TwilioVerify.AuthToken.Value) == 0 {
×
929
                        return errors.New("Missing required field in config: auth.sms.twilio_verify.auth_token")
×
930
                }
×
931
                if err := assertEnvLoaded(s.TwilioVerify.AuthToken.Value); err != nil {
×
932
                        return err
×
933
                }
×
934
        case s.Messagebird.Enabled:
×
935
                if len(s.Messagebird.Originator) == 0 {
×
936
                        return errors.New("Missing required field in config: auth.sms.messagebird.originator")
×
937
                }
×
938
                if len(s.Messagebird.AccessKey.Value) == 0 {
×
939
                        return errors.New("Missing required field in config: auth.sms.messagebird.access_key")
×
940
                }
×
941
                if err := assertEnvLoaded(s.Messagebird.AccessKey.Value); err != nil {
×
942
                        return err
×
943
                }
×
944
        case s.Textlocal.Enabled:
×
945
                if len(s.Textlocal.Sender) == 0 {
×
946
                        return errors.New("Missing required field in config: auth.sms.textlocal.sender")
×
947
                }
×
948
                if len(s.Textlocal.ApiKey.Value) == 0 {
×
949
                        return errors.New("Missing required field in config: auth.sms.textlocal.api_key")
×
950
                }
×
951
                if err := assertEnvLoaded(s.Textlocal.ApiKey.Value); err != nil {
×
952
                        return err
×
953
                }
×
954
        case s.Vonage.Enabled:
×
955
                if len(s.Vonage.From) == 0 {
×
956
                        return errors.New("Missing required field in config: auth.sms.vonage.from")
×
957
                }
×
958
                if len(s.Vonage.ApiKey) == 0 {
×
959
                        return errors.New("Missing required field in config: auth.sms.vonage.api_key")
×
960
                }
×
961
                if len(s.Vonage.ApiSecret.Value) == 0 {
×
962
                        return errors.New("Missing required field in config: auth.sms.vonage.api_secret")
×
963
                }
×
964
                if err := assertEnvLoaded(s.Vonage.ApiKey); err != nil {
×
965
                        return err
×
966
                }
×
967
                if err := assertEnvLoaded(s.Vonage.ApiSecret.Value); err != nil {
×
968
                        return err
×
969
                }
×
970
        case s.EnableSignup:
×
971
                s.EnableSignup = false
×
972
                fmt.Fprintln(os.Stderr, "WARN: no SMS provider is enabled. Disabling phone login")
×
973
        }
974
        return nil
6✔
975
}
976

977
func (e external) validate() (err error) {
6✔
978
        for _, ext := range []string{"linkedin", "slack"} {
18✔
979
                if e[ext].Enabled {
12✔
980
                        fmt.Fprintf(os.Stderr, `WARN: disabling deprecated "%[1]s" provider. Please use [auth.external.%[1]s_oidc] instead\n`, ext)
×
981
                }
×
982
                delete(e, ext)
12✔
983
        }
984
        for ext, provider := range e {
14✔
985
                if !provider.Enabled {
14✔
986
                        continue
6✔
987
                }
988
                if provider.ClientId == "" {
2✔
989
                        return errors.Errorf("Missing required field in config: auth.external.%s.client_id", ext)
×
990
                }
×
991
                if !sliceContains([]string{"apple", "google"}, ext) && len(provider.Secret.Value) == 0 {
2✔
992
                        return errors.Errorf("Missing required field in config: auth.external.%s.secret", ext)
×
993
                }
×
994
                if err := assertEnvLoaded(provider.ClientId); err != nil {
2✔
995
                        return err
×
996
                }
×
997
                if err := assertEnvLoaded(provider.Secret.Value); err != nil {
2✔
998
                        return err
×
999
                }
×
1000
                if err := assertEnvLoaded(provider.RedirectUri); err != nil {
2✔
1001
                        return err
×
1002
                }
×
1003
                if err := assertEnvLoaded(provider.Url); err != nil {
2✔
1004
                        return err
×
1005
                }
×
1006
                e[ext] = provider
2✔
1007
        }
1008
        return nil
6✔
1009
}
1010

1011
func (h *hook) validate() error {
6✔
1012
        if hook := h.MFAVerificationAttempt; hook != nil {
6✔
1013
                if err := hook.validate("mfa_verification_attempt"); err != nil {
×
1014
                        return err
×
1015
                }
×
1016
        }
1017
        if hook := h.PasswordVerificationAttempt; hook != nil {
6✔
1018
                if err := hook.validate("password_verification_attempt"); err != nil {
×
1019
                        return err
×
1020
                }
×
1021
        }
1022
        if hook := h.CustomAccessToken; hook != nil {
8✔
1023
                if err := hook.validate("custom_access_token"); err != nil {
2✔
1024
                        return err
×
1025
                }
×
1026
        }
1027
        if hook := h.SendSMS; hook != nil {
8✔
1028
                if err := hook.validate("send_sms"); err != nil {
2✔
1029
                        return err
×
1030
                }
×
1031
        }
1032
        if hook := h.SendEmail; hook != nil {
6✔
1033
                if err := h.SendEmail.validate("send_email"); err != nil {
×
1034
                        return err
×
1035
                }
×
1036
        }
1037
        return nil
6✔
1038
}
1039

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

1042
func (h *hookConfig) validate(hookType string) (err error) {
11✔
1043
        // If not enabled do nothing
11✔
1044
        if !h.Enabled {
11✔
1045
                return nil
×
1046
        }
×
1047
        if h.URI == "" {
11✔
1048
                return errors.Errorf("Missing required field in config: auth.hook.%s.uri", hookType)
×
1049
        }
×
1050
        parsed, err := url.Parse(h.URI)
11✔
1051
        if err != nil {
12✔
1052
                return errors.Errorf("failed to parse template url: %w", err)
1✔
1053
        }
1✔
1054
        switch strings.ToLower(parsed.Scheme) {
10✔
1055
        case "http", "https":
5✔
1056
                if len(h.Secrets.Value) == 0 {
6✔
1057
                        return errors.Errorf("Missing required field in config: auth.hook.%s.secrets", hookType)
1✔
1058
                } else if err := assertEnvLoaded(h.Secrets.Value); err != nil {
5✔
1059
                        return err
×
1060
                }
×
1061
                for _, secret := range strings.Split(h.Secrets.Value, "|") {
8✔
1062
                        if !hookSecretPattern.MatchString(secret) {
4✔
1063
                                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)
×
1064
                        }
×
1065
                }
1066
        case "pg-functions":
4✔
1067
                if len(h.Secrets.Value) > 0 {
5✔
1068
                        return errors.Errorf("Invalid hook config: auth.hook.%s.secrets is unsupported for pg-functions URI", hookType)
1✔
1069
                }
1✔
1070
        default:
1✔
1071
                return errors.Errorf("Invalid hook config: auth.hook.%s.uri should be a HTTP, HTTPS, or pg-functions URI", hookType)
1✔
1072
        }
1073
        return nil
7✔
1074
}
1075

1076
func (m *mfa) validate() error {
6✔
1077
        if m.TOTP.EnrollEnabled && !m.TOTP.VerifyEnabled {
6✔
1078
                return errors.Errorf("Invalid MFA config: auth.mfa.totp.enroll_enabled requires verify_enabled")
×
1079
        }
×
1080
        if m.Phone.EnrollEnabled && !m.Phone.VerifyEnabled {
6✔
1081
                return errors.Errorf("Invalid MFA config: auth.mfa.phone.enroll_enabled requires verify_enabled")
×
1082
        }
×
1083
        if m.WebAuthn.EnrollEnabled && !m.WebAuthn.VerifyEnabled {
6✔
1084
                return errors.Errorf("Invalid MFA config: auth.mfa.web_authn.enroll_enabled requires verify_enabled")
×
1085
        }
×
1086
        return nil
6✔
1087
}
1088

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

1092
func ValidateFunctionSlug(slug string) error {
3✔
1093
        if !funcSlugPattern.MatchString(slug) {
3✔
1094
                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())
×
1095
        }
×
1096
        return nil
3✔
1097
}
1098

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

1102
func ValidateBucketName(name string) error {
3✔
1103
        if !bucketNamePattern.MatchString(name) {
3✔
1104
                return errors.Errorf("Invalid Bucket name: %s. Only lowercase letters, numbers, dots, hyphens, and spaces are allowed. (%s)", name, bucketNamePattern.String())
×
1105
        }
×
1106
        return nil
3✔
1107
}
1108

1109
func (f *tpaFirebase) issuerURL() string {
×
1110
        return fmt.Sprintf("https://securetoken.google.com/%s", f.ProjectID)
×
1111
}
×
1112

1113
func (f *tpaFirebase) validate() error {
×
1114
        if f.ProjectID == "" {
×
1115
                return errors.New("Invalid config: auth.third_party.firebase is enabled but without a project_id.")
×
1116
        }
×
1117

1118
        return nil
×
1119
}
1120

1121
func (a *tpaAuth0) issuerURL() string {
×
1122
        if a.TenantRegion != "" {
×
1123
                return fmt.Sprintf("https://%s.%s.auth0.com", a.Tenant, a.TenantRegion)
×
1124
        }
×
1125

1126
        return fmt.Sprintf("https://%s.auth0.com", a.Tenant)
×
1127
}
1128

1129
func (a *tpaAuth0) validate() error {
×
1130
        if a.Tenant == "" {
×
1131
                return errors.New("Invalid config: auth.third_party.auth0 is enabled but without a tenant.")
×
1132
        }
×
1133

1134
        return nil
×
1135
}
1136

1137
func (c *tpaCognito) issuerURL() string {
×
1138
        return fmt.Sprintf("https://cognito-idp.%s.amazonaws.com/%s", c.UserPoolRegion, c.UserPoolID)
×
1139
}
×
1140

1141
func (c *tpaCognito) validate() (err error) {
×
1142
        if c.UserPoolID == "" {
×
1143
                return errors.New("Invalid config: auth.third_party.cognito is enabled but without a user_pool_id.")
×
1144
        } else if err := assertEnvLoaded(c.UserPoolID); err != nil {
×
1145
                return err
×
1146
        }
×
1147

1148
        if c.UserPoolRegion == "" {
×
1149
                return errors.New("Invalid config: auth.third_party.cognito is enabled but without a user_pool_region.")
×
1150
        } else if err := assertEnvLoaded(c.UserPoolRegion); err != nil {
×
1151
                return err
×
1152
        }
×
1153

1154
        return nil
×
1155
}
1156

1157
func (tpa *thirdParty) validate() error {
6✔
1158
        enabled := 0
6✔
1159

6✔
1160
        if tpa.Firebase.Enabled {
6✔
1161
                enabled += 1
×
1162

×
1163
                if err := tpa.Firebase.validate(); err != nil {
×
1164
                        return err
×
1165
                }
×
1166
        }
1167

1168
        if tpa.Auth0.Enabled {
6✔
1169
                enabled += 1
×
1170

×
1171
                if err := tpa.Auth0.validate(); err != nil {
×
1172
                        return err
×
1173
                }
×
1174
        }
1175

1176
        if tpa.Cognito.Enabled {
6✔
1177
                enabled += 1
×
1178

×
1179
                if err := tpa.Cognito.validate(); err != nil {
×
1180
                        return err
×
1181
                }
×
1182
        }
1183

1184
        if enabled > 1 {
6✔
1185
                return errors.New("Invalid config: Only one third_party provider allowed to be enabled at a time.")
×
1186
        }
×
1187

1188
        return nil
6✔
1189
}
1190

1191
func (tpa *thirdParty) IssuerURL() string {
×
1192
        if tpa.Firebase.Enabled {
×
1193
                return tpa.Firebase.issuerURL()
×
1194
        }
×
1195

1196
        if tpa.Auth0.Enabled {
×
1197
                return tpa.Auth0.issuerURL()
×
1198
        }
×
1199

1200
        if tpa.Cognito.Enabled {
×
1201
                return tpa.Cognito.issuerURL()
×
1202
        }
×
1203

1204
        return ""
×
1205
}
1206

1207
// ResolveJWKS creates the JWKS from the JWT secret and Third-Party Auth
1208
// configs by resolving the JWKS via the OIDC discovery URL.
1209
// It always returns a JWKS string, except when there's an error fetching.
1210
func (a *auth) ResolveJWKS(ctx context.Context) (string, error) {
×
1211
        var jwks struct {
×
1212
                Keys []json.RawMessage `json:"keys"`
×
1213
        }
×
1214

×
1215
        issuerURL := a.ThirdParty.IssuerURL()
×
1216
        if issuerURL != "" {
×
1217
                discoveryURL := issuerURL + "/.well-known/openid-configuration"
×
1218

×
1219
                t := &http.Client{Timeout: 10 * time.Second}
×
1220
                client := fetcher.NewFetcher(
×
1221
                        discoveryURL,
×
1222
                        fetcher.WithHTTPClient(t),
×
1223
                        fetcher.WithExpectedStatus(http.StatusOK),
×
1224
                )
×
1225

×
1226
                resp, err := client.Send(ctx, http.MethodGet, "", nil)
×
1227
                if err != nil {
×
1228
                        return "", err
×
1229
                }
×
1230

1231
                type oidcConfiguration struct {
×
1232
                        JWKSURI string `json:"jwks_uri"`
×
1233
                }
×
1234

×
1235
                oidcConfig, err := fetcher.ParseJSON[oidcConfiguration](resp.Body)
×
1236
                if err != nil {
×
1237
                        return "", err
×
1238
                }
×
1239

1240
                if oidcConfig.JWKSURI == "" {
×
1241
                        return "", fmt.Errorf("auth.third_party: OIDC configuration at URL %q does not expose a jwks_uri property", discoveryURL)
×
1242
                }
×
1243

1244
                client = fetcher.NewFetcher(
×
1245
                        oidcConfig.JWKSURI,
×
1246
                        fetcher.WithHTTPClient(t),
×
1247
                        fetcher.WithExpectedStatus(http.StatusOK),
×
1248
                )
×
1249

×
1250
                resp, err = client.Send(ctx, http.MethodGet, "", nil)
×
1251
                if err != nil {
×
1252
                        return "", err
×
1253
                }
×
1254

1255
                type remoteJWKS struct {
×
1256
                        Keys []json.RawMessage `json:"keys"`
×
1257
                }
×
1258

×
1259
                rJWKS, err := fetcher.ParseJSON[remoteJWKS](resp.Body)
×
1260
                if err != nil {
×
1261
                        return "", err
×
1262
                }
×
1263

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

1268
                jwks.Keys = rJWKS.Keys
×
1269
        }
1270

1271
        var secretJWK struct {
×
1272
                KeyType      string `json:"kty"`
×
1273
                KeyBase64URL string `json:"k"`
×
1274
        }
×
1275

×
1276
        secretJWK.KeyType = "oct"
×
1277
        secretJWK.KeyBase64URL = base64.RawURLEncoding.EncodeToString([]byte(a.JwtSecret))
×
1278

×
1279
        secretJWKEncoded, err := json.Marshal(&secretJWK)
×
1280
        if err != nil {
×
1281
                return "", errors.Errorf("failed to marshal secret jwk: %w", err)
×
1282
        }
×
1283

1284
        jwks.Keys = append(jwks.Keys, json.RawMessage(secretJWKEncoded))
×
1285

×
1286
        jwksEncoded, err := json.Marshal(jwks)
×
1287
        if err != nil {
×
1288
                return "", errors.Errorf("failed to marshal jwks keys: %w", err)
×
1289
        }
×
1290

1291
        return string(jwksEncoded), nil
×
1292
}
1293

1294
func (c *baseConfig) GetServiceImages() []string {
×
1295
        return []string{
×
1296
                c.Db.Image,
×
1297
                c.Auth.Image,
×
1298
                c.Api.Image,
×
1299
                c.Realtime.Image,
×
1300
                c.Storage.Image,
×
1301
                c.EdgeRuntime.Image,
×
1302
                c.Studio.Image,
×
1303
                c.Studio.PgmetaImage,
×
1304
                c.Analytics.Image,
×
1305
                c.Db.Pooler.Image,
×
1306
        }
×
1307
}
×
1308

1309
// Retrieve the final base config to use taking into account the remotes override
1310
// Pre: config must be loaded after setting config.ProjectID = "ref"
1311
func (c *config) GetRemoteByProjectRef(projectRef string) (baseConfig, error) {
×
1312
        base := c.baseConfig.Clone()
×
1313
        for _, remote := range c.Remotes {
×
1314
                if remote.ProjectId == projectRef {
×
1315
                        base.ProjectId = projectRef
×
1316
                        return base, nil
×
1317
                }
×
1318
        }
1319
        return base, errors.Errorf("no remote found for project_id: %s", projectRef)
×
1320
}
1321

1322
func ToTomlBytes(config any) ([]byte, error) {
93✔
1323
        var buf bytes.Buffer
93✔
1324
        enc := toml.NewEncoder(&buf)
93✔
1325
        enc.Indent = ""
93✔
1326
        if err := enc.Encode(config); err != nil {
93✔
1327
                return nil, errors.Errorf("failed to marshal toml config: %w", err)
×
1328
        }
×
1329
        return buf.Bytes(), nil
93✔
1330
}
1331

1332
func (e *experimental) validate() error {
6✔
1333
        if e.Webhooks != nil && !e.Webhooks.Enabled {
6✔
1334
                return errors.Errorf("Webhooks cannot be deactivated. [experimental.webhooks] enabled can either be true or left undefined")
×
1335
        }
×
1336
        return nil
6✔
1337
}
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