-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathConfiguration.swift
More file actions
156 lines (144 loc) · 7.25 KB
/
Copy pathConfiguration.swift
File metadata and controls
156 lines (144 loc) · 7.25 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
//===----------------------------------------------------------------------===//
// Everything the application reads from its environment, in one type, checked
// once at start-up.
//
// `AppEnvironment` does the reading and collects the problems; this file says
// what the variables are, what they default to, and what combinations make no
// sense. Nothing is served until it all checks out, and every problem is
// reported at once rather than one per restart.
//
// Garuda's own flags -- port, workers, TLS, rate limits -- stay on the command
// line (CONFIG.md). This is for what the *application* needs: where the
// database is, what signs its tokens, whether sign-ups are open.
//===----------------------------------------------------------------------===//
import Garuda
public struct StarterConfiguration: Sendable {
public var mode: AppEnvironment.Mode
/// `postgres://user:password@host:port/database?sslmode=require`
public var databaseURL: String
/// An ES256 private key in PEM, what access tokens are signed with. Nil in
/// development means one is made up for this run.
public var signingKeyPEM: String?
public var accessTokenSeconds: Int
public var refreshTokenDays: Int
public var sessionDays: Int
public var signUpsOpen: Bool
/// Accounts that must be administrators, made so at every start-up. A new
/// database has none, and a route that promotes whoever asks is not a
/// route; see Admin.swift.
public var adminEmails: [String]
public var databasePoolSize: Int
/// Where the OpenAPI document and Swagger UI are served, or nil for
/// neither.
public var documentationPath: String?
/// What was read, for `starter env`, with secrets held back.
public var summary: String = ""
public var isProduction: Bool { mode == .production }
public init(mode: AppEnvironment.Mode = .development,
databaseURL: String = "postgres://garuda:[email protected]:5432/starter?sslmode=disable",
signingKeyPEM: String? = nil,
accessTokenSeconds: Int = 15 * 60,
refreshTokenDays: Int = 14,
sessionDays: Int = 90,
signUpsOpen: Bool = true,
adminEmails: [String] = [],
databasePoolSize: Int = 8,
documentationPath: String? = "/docs") {
self.mode = mode
self.databaseURL = databaseURL
self.signingKeyPEM = signingKeyPEM
self.accessTokenSeconds = accessTokenSeconds
self.refreshTokenDays = refreshTokenDays
self.sessionDays = sessionDays
self.signUpsOpen = signUpsOpen
self.adminEmails = adminEmails
self.databasePoolSize = databasePoolSize
self.documentationPath = documentationPath
}
}
extension StarterConfiguration {
/// Reads the environment, or reports everything that is wrong with it.
///
/// | variable | required | default |
/// |---|---|---|
/// | `APP_ENV` | no | `development` |
/// | `DATABASE_URL` | in production | a local database |
/// | `JWT_PRIVATE_KEY` | in production | a key made up per run |
/// | `JWT_PRIVATE_KEY_FILE` | no | read in place of `JWT_PRIVATE_KEY` |
/// | `ACCESS_TOKEN_SECONDS` | no | 900 |
/// | `REFRESH_TOKEN_DAYS` | no | 14 |
/// | `SESSION_DAYS` | no | 90 |
/// | `SIGNUPS_OPEN` | no | true |
/// | `ADMIN_EMAILS` | no | none; `[email protected],[email protected]` are made administrators |
/// | `DATABASE_POOL_SIZE` | no | 8 |
/// | `DOCS_PATH` | no | `/docs`, and `off` serves neither |
public static func fromEnvironment(
_ read: @escaping (String) -> String? = AppEnvironment.processEnvironment
) throws -> StarterConfiguration {
var env = AppEnvironment(read)
var configuration = StarterConfiguration()
configuration.mode = env.mode
// Development may have defaults; production is told or it fails.
let production = env.mode == .production
configuration.databaseURL = env.url("DATABASE_URL",
default: production ? nil : configuration.databaseURL)
if !configuration.databaseURL.isEmpty {
do {
let parsed = try PostgresConfiguration(url: configuration.databaseURL)
if production, parsed.tls == .disable {
env.problem("DATABASE_URL has sslmode=disable, which production should not")
}
} catch {
env.problem("DATABASE_URL cannot be read: \(error)")
}
}
// In development, no key means one made up for the run, which
// `starterApp` does. In production that would sign out every user on
// each deployment, and no two workers would agree.
let key = env.secretOrFile("JWT_PRIVATE_KEY", default: production ? nil : "")
configuration.signingKeyPEM = key.isEmpty ? nil : key
if let pem = configuration.signingKeyPEM {
do {
_ = try JWTKey.pem(pem, algorithm: .ES256)
} catch {
env.problem("the signing key is not an ES256 private key in PEM: \(error)")
}
}
configuration.accessTokenSeconds = env.int("ACCESS_TOKEN_SECONDS", default: 15 * 60, in: 1...86_400)
configuration.refreshTokenDays = env.int("REFRESH_TOKEN_DAYS", default: 14, in: 1...3_650)
configuration.sessionDays = env.int("SESSION_DAYS", default: 90, in: 1...3_650)
configuration.databasePoolSize = env.int("DATABASE_POOL_SIZE", default: 8, in: 1...500)
configuration.signUpsOpen = env.bool("SIGNUPS_OPEN", default: true)
// Addresses, comma separated, normalised the way a sign-up normalises
// one so that they match a row. An address that cannot be one is the
// deployment's mistake and is reported here, not ignored at start-up
// where nobody would see it.
let admins = env.string("ADMIN_EMAILS", default: "")
configuration.adminEmails = admins.split(separator: ",")
.map { normalised(email: String($0)) }
.filter { !$0.isEmpty }
for email in configuration.adminEmails where !Validation.looksLikeEmail(email) {
env.problem("ADMIN_EMAILS has \"\(email)\", which is not an email address")
}
// Settings that are each fine and wrong together.
if configuration.accessTokenSeconds >= configuration.refreshTokenDays * 24 * 3600 {
env.problem("ACCESS_TOKEN_SECONDS is not shorter than REFRESH_TOKEN_DAYS; a short access "
+ "token is the point of having a refresh token")
}
if configuration.refreshTokenDays > configuration.sessionDays {
env.problem("REFRESH_TOKEN_DAYS is longer than SESSION_DAYS, so a session would end "
+ "before its refresh token expires")
}
let docs = env.string("DOCS_PATH", default: "/docs")
if docs == "off" {
configuration.documentationPath = nil
} else if docs.hasPrefix("/") {
configuration.documentationPath = docs
} else {
env.problem("DOCS_PATH is \"\(docs)\"; it is a path beginning with / , or off")
}
try env.check()
configuration.summary = env.summary()
return configuration
}
}