@specific.dev/spectest 0.45.0 → 0.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/supabase-config.d.ts +89 -0
- package/dist/components/supabase-config.js +447 -0
- package/dist/components/supabase.d.ts +124 -84
- package/dist/components/supabase.js +395 -136
- package/dist/daemon.js +9 -2
- package/dist/harness/container-run.d.ts +6 -0
- package/dist/harness/container-run.js +13 -0
- package/package.json +1 -1
- package/src/components/supabase-config.test.ts +261 -0
- package/src/components/supabase-config.ts +543 -0
- package/src/components/supabase-dir.test.ts +287 -0
- package/src/components/supabase.ts +581 -230
- package/src/daemon.ts +11 -2
- package/src/harness/container-run.test.ts +8 -0
- package/src/harness/container-run.ts +13 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GoTrue's mail template types, as `config.toml` spells them. The container
|
|
3
|
+
* variable is the id upper-cased (`magic_link` -> `GOTRUE_MAILER_TEMPLATES_MAGIC_LINK`).
|
|
4
|
+
*/
|
|
5
|
+
export declare const MAIL_TEMPLATE_IDS: readonly ["invite", "confirmation", "recovery", "magic_link", "email_change", "reauthentication"];
|
|
6
|
+
export type MailTemplateId = (typeof MAIL_TEMPLATE_IDS)[number];
|
|
7
|
+
/** A template to serve to GoTrue: where the HTML lives, and its subject line. */
|
|
8
|
+
export interface MailTemplateSpec {
|
|
9
|
+
/** Path to the HTML file, relative to the project root (or absolute). */
|
|
10
|
+
path: string;
|
|
11
|
+
/** Subject line. Left to GoTrue's default when absent. */
|
|
12
|
+
subject?: string;
|
|
13
|
+
}
|
|
14
|
+
/** Everything the component takes from the config file, grouped by the service it lands on. */
|
|
15
|
+
export interface MappedSupabaseConfig {
|
|
16
|
+
/** GoTrue (`<name>-auth`) environment. */
|
|
17
|
+
auth: Record<string, string>;
|
|
18
|
+
/** PostgREST (`<name>-rest`) environment. */
|
|
19
|
+
rest: Record<string, string>;
|
|
20
|
+
/** Per-function JWT verification, by function name (`functions.<name>.verify_jwt`). */
|
|
21
|
+
functionVerifyJwt: Record<string, boolean>;
|
|
22
|
+
/** Function secrets (`edge_runtime.secrets`) that carry a literal value. */
|
|
23
|
+
functionSecrets: Record<string, string>;
|
|
24
|
+
/**
|
|
25
|
+
* Secrets the file explicitly defers to the environment — `env(NAME)`, which
|
|
26
|
+
* the Supabase CLI resolves from the developer's shell. There is no such
|
|
27
|
+
* shell here, so the value comes from the `env` option. Keyed by the
|
|
28
|
+
* secret's name, valued by the `env` name to read it from — the two differ
|
|
29
|
+
* whenever the file says `SECRET = "env(OTHER_NAME)"`.
|
|
30
|
+
*/
|
|
31
|
+
deferredSecrets: Record<string, string>;
|
|
32
|
+
/**
|
|
33
|
+
* Settings whose value is an inline `encrypted:` ciphertext. Nothing here
|
|
34
|
+
* can decrypt one — that needs a `DOTENV_PRIVATE_KEY` from the developer's
|
|
35
|
+
* environment — so they are reported and left unset rather than guessed at.
|
|
36
|
+
*/
|
|
37
|
+
encryptedValues: string[];
|
|
38
|
+
/** Custom mail templates, keyed by GoTrue template id. */
|
|
39
|
+
templates: Partial<Record<MailTemplateId, MailTemplateSpec>>;
|
|
40
|
+
/**
|
|
41
|
+
* Social sign-in providers the file declares (`[auth.external.<name>]`),
|
|
42
|
+
* keyed by the provider name GoTrue uses. Whether one can be *served* is the
|
|
43
|
+
* component's business: a provider spectest has an emulator for is stood up
|
|
44
|
+
* inside the environment, the rest are reported.
|
|
45
|
+
*/
|
|
46
|
+
externalProviders: Record<string, ExternalProvider>;
|
|
47
|
+
/**
|
|
48
|
+
* Settings that are real, enabled, and not applied here — an OAuth provider,
|
|
49
|
+
* SMS. Reported so the file never looks applied when it is not; never fatal,
|
|
50
|
+
* because the config belongs to the project and is not ours to veto.
|
|
51
|
+
*/
|
|
52
|
+
unsupported: {
|
|
53
|
+
key: string;
|
|
54
|
+
reason: string;
|
|
55
|
+
}[];
|
|
56
|
+
/** Keys no rule classified — a newer CLI than this map knows about. */
|
|
57
|
+
warnings: string[];
|
|
58
|
+
}
|
|
59
|
+
/** One `[auth.external.<name>]` block, as the file spells it. */
|
|
60
|
+
export interface ExternalProvider {
|
|
61
|
+
enabled: boolean;
|
|
62
|
+
/** Literal values as the file gives them. */
|
|
63
|
+
clientId?: string;
|
|
64
|
+
secret?: string;
|
|
65
|
+
/** …and the `env` names a deferred value is read from. Absent a value, the
|
|
66
|
+
* stack mints its own pair, which an emulator accepts — all that matters
|
|
67
|
+
* is that it and GoTrue agree. */
|
|
68
|
+
clientIdFrom?: string;
|
|
69
|
+
secretFrom?: string;
|
|
70
|
+
/** The authority, where the provider allows one (Azure's tenant, Keycloak). */
|
|
71
|
+
url?: string;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Classify and map every leaf of a parsed config document.
|
|
75
|
+
*
|
|
76
|
+
* Never throws on content: a setting this component does not apply is
|
|
77
|
+
* *reported* (`unsupported`), not vetoed. The file belongs to the project and
|
|
78
|
+
* describes more than one environment — refusing to start over a key that is
|
|
79
|
+
* meaningless in a hermetic VM would make the whole option unadoptable for the
|
|
80
|
+
* projects that need it most. Silence is still the thing to avoid, so nothing
|
|
81
|
+
* unapplied goes unsaid.
|
|
82
|
+
*/
|
|
83
|
+
export declare function mapSupabaseConfig(doc: unknown): MappedSupabaseConfig;
|
|
84
|
+
/**
|
|
85
|
+
* Parse a config file. The format follows the extension — Supabase is moving
|
|
86
|
+
* the canonical file from TOML to JSON, and both are read here so a project is
|
|
87
|
+
* never held back by which CLI version wrote it.
|
|
88
|
+
*/
|
|
89
|
+
export declare function parseSupabaseConfigFile(path: string): unknown;
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
// Reader for the Supabase CLI's project config file — the `supabase/` folder's
|
|
2
|
+
// `config.toml` (or `config.json` on newer CLIs, which is becoming the
|
|
3
|
+
// canonical format with TOML kept for back-compat).
|
|
4
|
+
//
|
|
5
|
+
// WHY THIS EXISTS. `config.toml` is where a project already declares the auth
|
|
6
|
+
// rules it runs locally and in production: custom mail templates, redirect
|
|
7
|
+
// allow lists, auth hooks, per-function JWT verification. None of it reached
|
|
8
|
+
// the environment before, so a suite could pass against rules the real app
|
|
9
|
+
// never runs under — and a flow that depends on a custom template (an invite
|
|
10
|
+
// mail carrying `{{ .Token }}`) simply could not be tested.
|
|
11
|
+
//
|
|
12
|
+
// WHAT IT IS NOT. This is not a reimplementation of the CLI. The file
|
|
13
|
+
// describes a whole local stack — ports, Studio, analytics, its own mailbox —
|
|
14
|
+
// and spectest owns all of that. Only the settings a *test* can observe are
|
|
15
|
+
// mapped; everything else is classified into one of two other buckets, and
|
|
16
|
+
// nothing is ever passed through silently:
|
|
17
|
+
//
|
|
18
|
+
// applied — becomes container config (see RULES below)
|
|
19
|
+
// irrelevant — spectest owns it (ports, topology, Studio); dropped in silence
|
|
20
|
+
// unsupported — real, and we do not apply it; fails env start unless the
|
|
21
|
+
// caller lists it in `ignore`
|
|
22
|
+
//
|
|
23
|
+
// An unclassified key is a *warning*, not a failure: a newer CLI must not stop
|
|
24
|
+
// an existing suite from starting.
|
|
25
|
+
//
|
|
26
|
+
// PROVENANCE. What each option means:
|
|
27
|
+
// https://supabase.com/docs/guides/local-development/cli/config
|
|
28
|
+
// Which container variable it becomes is not in those docs — that mapping only
|
|
29
|
+
// exists in the CLI's own per-service modules, and every applied rule below
|
|
30
|
+
// cites the line it was taken from:
|
|
31
|
+
// supabase/cli apps/cli/src/legacy/commands/start/services/gotrue.service.ts
|
|
32
|
+
// Note the two negated pairs (`enable_signup` -> DISABLE_SIGNUP,
|
|
33
|
+
// `enable_confirmations` -> MAILER_AUTOCONFIRM). They are the mappings a
|
|
34
|
+
// copy-paste gets silently backwards, which is why they are marked at the def
|
|
35
|
+
// site.
|
|
36
|
+
import { readFileSync } from "node:fs";
|
|
37
|
+
/**
|
|
38
|
+
* GoTrue's mail template types, as `config.toml` spells them. The container
|
|
39
|
+
* variable is the id upper-cased (`magic_link` -> `GOTRUE_MAILER_TEMPLATES_MAGIC_LINK`).
|
|
40
|
+
*/
|
|
41
|
+
export const MAIL_TEMPLATE_IDS = [
|
|
42
|
+
"invite",
|
|
43
|
+
"confirmation",
|
|
44
|
+
"recovery",
|
|
45
|
+
"magic_link",
|
|
46
|
+
"email_change",
|
|
47
|
+
"reauthentication",
|
|
48
|
+
];
|
|
49
|
+
// ── value coercion ────────────────────────────────────────────────────────
|
|
50
|
+
const str = (v) => String(v);
|
|
51
|
+
const num = (v) => String(v);
|
|
52
|
+
const bool = (v) => String(v === true);
|
|
53
|
+
/** For the negated pairs: `enable_x = true` becomes `DISABLE_X = false`. */
|
|
54
|
+
const not = (v) => String(v !== true);
|
|
55
|
+
const csv = (v) => (Array.isArray(v) ? v.map(String).join(",") : String(v));
|
|
56
|
+
/**
|
|
57
|
+
* `auth.password_requirements`' enum, expanded to the character classes GoTrue
|
|
58
|
+
* wants (cli gotrue.service.ts:137-142). An unknown value means "no
|
|
59
|
+
* requirement", matching the CLI's `?? ""`.
|
|
60
|
+
*/
|
|
61
|
+
const PASSWORD_REQUIREMENTS_TO_CHAR = {
|
|
62
|
+
letters_digits: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789",
|
|
63
|
+
lower_upper_letters_digits: "abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789",
|
|
64
|
+
lower_upper_letters_digits_symbols: "abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789:!@#$%^&*()_+-=[]{};'\\:\"|<>?,./`~",
|
|
65
|
+
};
|
|
66
|
+
// ── rule constructors ─────────────────────────────────────────────────────
|
|
67
|
+
/** A setting that becomes one GoTrue variable. */
|
|
68
|
+
const auth = (key, envName, coerce) => ({
|
|
69
|
+
key,
|
|
70
|
+
bucket: "applied",
|
|
71
|
+
apply: (v, out) => {
|
|
72
|
+
out.auth[envName] = coerce(v);
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
/** A setting that becomes one PostgREST variable. */
|
|
76
|
+
const rest = (key, envName, coerce) => ({
|
|
77
|
+
key,
|
|
78
|
+
bucket: "applied",
|
|
79
|
+
apply: (v, out) => {
|
|
80
|
+
out.rest[envName] = coerce(v);
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
const irrelevant = (key) => ({ key, bucket: "irrelevant" });
|
|
84
|
+
/**
|
|
85
|
+
* A config value is either usable as-is, or a reference the surrounding
|
|
86
|
+
* environment is expected to supply. The file has two ways of deferring:
|
|
87
|
+
*
|
|
88
|
+
* `env(NAME)` the Supabase CLI reads NAME from `supabase/.env` at
|
|
89
|
+
* `start` / `config push` time
|
|
90
|
+
* `encrypted:…` a dotenvx ciphertext, decrypted at the same moment with
|
|
91
|
+
* `DOTENV_PRIVATE_KEY`
|
|
92
|
+
*
|
|
93
|
+
* A test VM has neither the dotenv (it is blocked from the upload — real
|
|
94
|
+
* credentials must not enter a test environment) nor the private key, so both
|
|
95
|
+
* resolve against the component's `secrets` option instead. What must never
|
|
96
|
+
* happen is passing either through: a function would then read the literal
|
|
97
|
+
* string `env(STRIPE_KEY)`, or a base64 ciphertext, and the ciphertext looks
|
|
98
|
+
* enough like a value to go unnoticed.
|
|
99
|
+
*/
|
|
100
|
+
function configValue(v) {
|
|
101
|
+
const raw = String(v);
|
|
102
|
+
if (raw === "")
|
|
103
|
+
return {};
|
|
104
|
+
// `env(NAME)` is a lookup in the surrounding environment, so NAME is the key.
|
|
105
|
+
const env = /^env\((.+)\)$/.exec(raw);
|
|
106
|
+
if (env)
|
|
107
|
+
return { from: env[1] };
|
|
108
|
+
// An `encrypted:` value is an inline dotenvx ciphertext, not a lookup: it
|
|
109
|
+
// names nothing, and decrypting it needs a `DOTENV_PRIVATE_KEY` that a test
|
|
110
|
+
// environment has no business holding. So it is reported and left unset —
|
|
111
|
+
// never guessed at, and never passed through as base64.
|
|
112
|
+
if (raw.startsWith("encrypted:"))
|
|
113
|
+
return { encrypted: true };
|
|
114
|
+
return { literal: raw };
|
|
115
|
+
}
|
|
116
|
+
const unsupported = (key, reason) => ({ key, bucket: "unsupported", reason });
|
|
117
|
+
// ── the map ───────────────────────────────────────────────────────────────
|
|
118
|
+
//
|
|
119
|
+
// Order matters: the first matching rule wins, so specific keys precede the
|
|
120
|
+
// `**` prefixes that sweep up the rest of a section.
|
|
121
|
+
const RULES = [
|
|
122
|
+
// ── auth: core (cli gotrue.service.ts:440-448) ──────────────────────────
|
|
123
|
+
// NEGATED — `enable_signup = true` means DISABLE_SIGNUP = false.
|
|
124
|
+
auth("auth.enable_signup", "GOTRUE_DISABLE_SIGNUP", not),
|
|
125
|
+
auth("auth.jwt_expiry", "GOTRUE_JWT_EXP", num),
|
|
126
|
+
auth("auth.enable_anonymous_sign_ins", "GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED", bool),
|
|
127
|
+
auth("auth.enable_manual_linking", "GOTRUE_SECURITY_MANUAL_LINKING_ENABLED", bool),
|
|
128
|
+
auth("auth.enable_refresh_token_rotation", "GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED", bool),
|
|
129
|
+
auth("auth.refresh_token_reuse_interval", "GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL", num),
|
|
130
|
+
auth("auth.minimum_password_length", "GOTRUE_PASSWORD_MIN_LENGTH", num),
|
|
131
|
+
auth("auth.password_requirements", "GOTRUE_PASSWORD_REQUIRED_CHARACTERS", (v) => PASSWORD_REQUIREMENTS_TO_CHAR[String(v)] ?? ""),
|
|
132
|
+
// ── auth: email (cli gotrue.service.ts:451-462) ─────────────────────────
|
|
133
|
+
auth("auth.email.enable_signup", "GOTRUE_EXTERNAL_EMAIL_ENABLED", bool),
|
|
134
|
+
auth("auth.email.double_confirm_changes", "GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED", bool),
|
|
135
|
+
// NEGATED — confirmations ON means autoconfirm OFF.
|
|
136
|
+
auth("auth.email.enable_confirmations", "GOTRUE_MAILER_AUTOCONFIRM", not),
|
|
137
|
+
auth("auth.email.secure_password_change", "GOTRUE_SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION", bool),
|
|
138
|
+
auth("auth.email.otp_length", "GOTRUE_MAILER_OTP_LENGTH", num),
|
|
139
|
+
auth("auth.email.otp_expiry", "GOTRUE_MAILER_OTP_EXP", num),
|
|
140
|
+
// Go duration strings ("1s", "1m0s") pass through as written.
|
|
141
|
+
auth("auth.email.max_frequency", "GOTRUE_SMTP_MAX_FREQUENCY", str),
|
|
142
|
+
// The sender identity, which is about the mail rather than the transport —
|
|
143
|
+
// so it is taken from the file even though the host/port below are ours.
|
|
144
|
+
auth("auth.email.smtp.admin_email", "GOTRUE_SMTP_ADMIN_EMAIL", str),
|
|
145
|
+
auth("auth.email.smtp.sender_name", "GOTRUE_SMTP_SENDER_NAME", str),
|
|
146
|
+
// Custom templates. `content_path` is resolved by the caller, which is what
|
|
147
|
+
// serves the file — GoTrue fetches templates over HTTP, so a path alone is
|
|
148
|
+
// not enough (see the component's template server).
|
|
149
|
+
{
|
|
150
|
+
key: "auth.email.template.*.content_path",
|
|
151
|
+
bucket: "applied",
|
|
152
|
+
apply: (v, out, path) => {
|
|
153
|
+
const id = path[3];
|
|
154
|
+
const p = String(v);
|
|
155
|
+
// An empty `content_path` is how the CLI spells "no custom template".
|
|
156
|
+
if (p === "")
|
|
157
|
+
return;
|
|
158
|
+
out.templates[id] = { ...out.templates[id], path: p };
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
key: "auth.email.template.*.subject",
|
|
163
|
+
bucket: "applied",
|
|
164
|
+
apply: (v, out, path) => {
|
|
165
|
+
const id = path[3];
|
|
166
|
+
out.templates[id] = { ...(out.templates[id] ?? { path: "" }), subject: String(v) };
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
// ── auth: social sign-in (cli gotrue.service.ts:361-368) ────────────────
|
|
170
|
+
// Collected here, served by the component: each provider it has an emulator
|
|
171
|
+
// for is stood up inside the environment at that provider's real endpoints,
|
|
172
|
+
// so the app's production configuration keeps working.
|
|
173
|
+
{
|
|
174
|
+
key: "auth.external.*.*",
|
|
175
|
+
bucket: "applied",
|
|
176
|
+
apply: (v, out, path) => {
|
|
177
|
+
const name = path[2];
|
|
178
|
+
const field = path[3];
|
|
179
|
+
const p = (out.externalProviders[name] ??= { enabled: false });
|
|
180
|
+
if (field === "enabled")
|
|
181
|
+
p.enabled = v === true;
|
|
182
|
+
// `env(NAME)` is a reference to the developer's own shell, so it names
|
|
183
|
+
// nothing here — the component supplies its own credentials instead.
|
|
184
|
+
else if (field === "client_id" || field === "secret") {
|
|
185
|
+
const r = configValue(v);
|
|
186
|
+
if (field === "client_id") {
|
|
187
|
+
p.clientId = r.literal;
|
|
188
|
+
p.clientIdFrom = r.from;
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
p.secret = r.literal;
|
|
192
|
+
p.secretFrom = r.from;
|
|
193
|
+
}
|
|
194
|
+
if (r.encrypted)
|
|
195
|
+
out.encryptedValues.push(`auth.external.${name}.${field}`);
|
|
196
|
+
}
|
|
197
|
+
else if (field === "url")
|
|
198
|
+
p.url = configValue(v).literal;
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
// ── auth: hooks (cli gotrue.service.ts:610-614) ─────────────────────────
|
|
202
|
+
// Generic on the hook name, unlike the CLI's fixed list, so a hook added
|
|
203
|
+
// upstream works here without a change: `auth.hook.before_user_created`
|
|
204
|
+
// becomes GOTRUE_HOOK_BEFORE_USER_CREATED_*.
|
|
205
|
+
{
|
|
206
|
+
key: "auth.hook.*.*",
|
|
207
|
+
bucket: "applied",
|
|
208
|
+
apply: (v, out, path) => {
|
|
209
|
+
const prefix = path[2].toUpperCase();
|
|
210
|
+
const field = path[3];
|
|
211
|
+
if (field === "enabled")
|
|
212
|
+
out.auth[`GOTRUE_HOOK_${prefix}_ENABLED`] = bool(v);
|
|
213
|
+
else if (field === "uri")
|
|
214
|
+
out.auth[`GOTRUE_HOOK_${prefix}_URI`] = str(v);
|
|
215
|
+
else if (field === "secrets")
|
|
216
|
+
out.auth[`GOTRUE_HOOK_${prefix}_SECRETS`] = str(v);
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
// ── auth: OAuth server (cli gotrue.service.ts:641-646) ──────────────────
|
|
220
|
+
// Supabase Auth acting as an authorization *server*, so a third-party client
|
|
221
|
+
// (an MCP connector, say) can obtain tokens for the project's own API. Note
|
|
222
|
+
// the key rename: `authorization_url_path` -> `..._AUTHORIZATION_PATH`.
|
|
223
|
+
auth("auth.oauth_server.enabled", "GOTRUE_OAUTH_SERVER_ENABLED", bool),
|
|
224
|
+
auth("auth.oauth_server.authorization_url_path", "GOTRUE_OAUTH_SERVER_AUTHORIZATION_PATH", str),
|
|
225
|
+
auth("auth.oauth_server.allow_dynamic_registration", "GOTRUE_OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION", bool),
|
|
226
|
+
// ── auth: sessions + MFA (cli gotrue.service.ts:539-545, 492-498) ───────
|
|
227
|
+
auth("auth.sessions.timebox", "GOTRUE_SESSIONS_TIMEBOX", str),
|
|
228
|
+
auth("auth.sessions.inactivity_timeout", "GOTRUE_SESSIONS_INACTIVITY_TIMEOUT", str),
|
|
229
|
+
auth("auth.mfa.max_enrolled_factors", "GOTRUE_MFA_MAX_ENROLLED_FACTORS", num),
|
|
230
|
+
auth("auth.mfa.totp.enroll_enabled", "GOTRUE_MFA_TOTP_ENROLL_ENABLED", bool),
|
|
231
|
+
auth("auth.mfa.totp.verify_enabled", "GOTRUE_MFA_TOTP_VERIFY_ENABLED", bool),
|
|
232
|
+
auth("auth.mfa.web_authn.enroll_enabled", "GOTRUE_MFA_WEB_AUTHN_ENROLL_ENABLED", bool),
|
|
233
|
+
auth("auth.mfa.web_authn.verify_enabled", "GOTRUE_MFA_WEB_AUTHN_VERIFY_ENABLED", bool),
|
|
234
|
+
// ── auth: rate limits (cli gotrue.service.ts:499-504) ───────────────────
|
|
235
|
+
auth("auth.rate_limit.email_sent", "GOTRUE_RATE_LIMIT_EMAIL_SENT", num),
|
|
236
|
+
auth("auth.rate_limit.anonymous_users", "GOTRUE_RATE_LIMIT_ANONYMOUS_USERS", num),
|
|
237
|
+
auth("auth.rate_limit.token_refresh", "GOTRUE_RATE_LIMIT_TOKEN_REFRESH", num),
|
|
238
|
+
auth("auth.rate_limit.sign_in_sign_ups", "GOTRUE_RATE_LIMIT_OTP", num),
|
|
239
|
+
auth("auth.rate_limit.token_verifications", "GOTRUE_RATE_LIMIT_VERIFY", num),
|
|
240
|
+
auth("auth.rate_limit.web3", "GOTRUE_RATE_LIMIT_WEB3", num),
|
|
241
|
+
// ── api (PostgREST) ────────────────────────────────────────────────────
|
|
242
|
+
rest("api.schemas", "PGRST_DB_SCHEMAS", csv),
|
|
243
|
+
rest("api.extra_search_path", "PGRST_DB_EXTRA_SEARCH_PATH", csv),
|
|
244
|
+
rest("api.max_rows", "PGRST_DB_MAX_ROWS", num),
|
|
245
|
+
// ── functions ──────────────────────────────────────────────────────────
|
|
246
|
+
{
|
|
247
|
+
key: "functions.*.verify_jwt",
|
|
248
|
+
bucket: "applied",
|
|
249
|
+
apply: (v, out, path) => {
|
|
250
|
+
out.functionVerifyJwt[path[1]] = v === true;
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
key: "edge_runtime.secrets.*",
|
|
255
|
+
bucket: "applied",
|
|
256
|
+
apply: (v, out, path) => {
|
|
257
|
+
const name = path[2];
|
|
258
|
+
// The platform reserves this prefix, and `supabase secrets set` skips
|
|
259
|
+
// such an entry with a warning — so setting it here would work in a
|
|
260
|
+
// test and be absent in production.
|
|
261
|
+
if (name.startsWith("SUPABASE_"))
|
|
262
|
+
return;
|
|
263
|
+
const { literal, from, encrypted } = configValue(v);
|
|
264
|
+
if (literal !== undefined)
|
|
265
|
+
out.functionSecrets[name] = literal;
|
|
266
|
+
else if (from !== undefined)
|
|
267
|
+
out.deferredSecrets[name] = from;
|
|
268
|
+
else if (encrypted)
|
|
269
|
+
out.encryptedValues.push(`edge_runtime.secrets.${name}`);
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
// ── unsupported: real settings we deliberately do not apply ────────────
|
|
273
|
+
unsupported("auth.sms.**", "SMS delivery is not part of the stack"),
|
|
274
|
+
unsupported("auth.mfa.phone.**", "phone MFA needs SMS delivery, which is not part of the stack"),
|
|
275
|
+
unsupported("auth.web3.**", "web3 sign-in is not part of the stack"),
|
|
276
|
+
unsupported("auth.captcha.**", "a captcha provider is not reachable from a hermetic VM"),
|
|
277
|
+
unsupported("auth.third_party.**", "third-party auth providers are not part of the stack"),
|
|
278
|
+
unsupported("auth.email.notification.**", "account-change notification mails are not mapped yet"),
|
|
279
|
+
unsupported("storage.image_transformation.**", "image transformation is always on in this stack"),
|
|
280
|
+
unsupported("db.vault.**", "the vault is not seeded by this component"),
|
|
281
|
+
// ── irrelevant: spectest owns this ─────────────────────────────────────
|
|
282
|
+
// The SMTP *transport* is always the capture mailbox, which is the point of
|
|
283
|
+
// it: a test environment that mailed a real server would be neither hermetic
|
|
284
|
+
// nor assertable. (The sender identity above is a different thing and does
|
|
285
|
+
// come from the file.)
|
|
286
|
+
irrelevant("auth.email.smtp.**"),
|
|
287
|
+
// Addresses, not behaviour: the file's `site_url` / `additional_redirect_urls`
|
|
288
|
+
// name the developer's machine (`http://localhost:3000`), which in a VM is the
|
|
289
|
+
// browser's own namespace rather than the app container. The `auth` option
|
|
290
|
+
// supplies this environment's addresses instead.
|
|
291
|
+
irrelevant("auth.site_url"),
|
|
292
|
+
irrelevant("auth.additional_redirect_urls"),
|
|
293
|
+
irrelevant("auth.enabled"),
|
|
294
|
+
irrelevant("auth.jwt_secret"),
|
|
295
|
+
irrelevant("auth.signing_keys_path"),
|
|
296
|
+
irrelevant("project_id"),
|
|
297
|
+
irrelevant("api.**"),
|
|
298
|
+
irrelevant("db.**"),
|
|
299
|
+
irrelevant("realtime.**"),
|
|
300
|
+
irrelevant("storage.**"),
|
|
301
|
+
irrelevant("studio.**"),
|
|
302
|
+
irrelevant("inbucket.**"),
|
|
303
|
+
irrelevant("analytics.**"),
|
|
304
|
+
irrelevant("edge_runtime.**"),
|
|
305
|
+
irrelevant("functions.**"),
|
|
306
|
+
irrelevant("experimental.**"),
|
|
307
|
+
// Per-environment overrides of any section above. They describe a linked
|
|
308
|
+
// remote project, never the local stack.
|
|
309
|
+
irrelevant("remotes.**"),
|
|
310
|
+
];
|
|
311
|
+
// ── walking ───────────────────────────────────────────────────────────────
|
|
312
|
+
function isTable(v) {
|
|
313
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
314
|
+
}
|
|
315
|
+
function matches(pattern, path) {
|
|
316
|
+
const pat = pattern.split(".");
|
|
317
|
+
for (let i = 0; i < pat.length; i++) {
|
|
318
|
+
if (pat[i] === "**")
|
|
319
|
+
return true;
|
|
320
|
+
if (i >= path.length)
|
|
321
|
+
return false;
|
|
322
|
+
if (pat[i] !== "*" && pat[i] !== path[i])
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
return pat.length === path.length;
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Does any rule live *below* this table? A section rule (`storage.**`) matches
|
|
329
|
+
* the section node itself, so without this check it would swallow the more
|
|
330
|
+
* specific rules written under it — `api.**` would drop `api.schemas`, and
|
|
331
|
+
* `storage.**` would hide the unsupported `storage.image_transformation`.
|
|
332
|
+
*/
|
|
333
|
+
function claimsDeeper(path) {
|
|
334
|
+
return RULES.some((r) => {
|
|
335
|
+
if (r.bucket === "irrelevant")
|
|
336
|
+
return false;
|
|
337
|
+
const pat = r.key.split(".");
|
|
338
|
+
if (pat.length <= path.length)
|
|
339
|
+
return false;
|
|
340
|
+
// The rule that matched this node is not "deeper" than it: `auth.sms.**`
|
|
341
|
+
// is a statement about `[auth.sms]` as a whole, and treating it as deeper
|
|
342
|
+
// would descend and report the section one key at a time.
|
|
343
|
+
if (pat[path.length] === "**")
|
|
344
|
+
return false;
|
|
345
|
+
for (let i = 0; i < path.length; i++) {
|
|
346
|
+
if (pat[i] === "**")
|
|
347
|
+
return false;
|
|
348
|
+
if (pat[i] !== "*" && pat[i] !== path[i])
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
return true;
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Is this section switched off? A feature that is disabled in the file loses
|
|
356
|
+
* nothing by our not applying it, so it is not worth telling the user about.
|
|
357
|
+
* Covers the two spellings the config uses (`enabled`, and `enable_signup` for
|
|
358
|
+
* the email/SMS sections), and a pure container of sub-sections — `[auth.external]`
|
|
359
|
+
* with every provider disabled — where the container has no flag of its own.
|
|
360
|
+
*/
|
|
361
|
+
function isOff(v) {
|
|
362
|
+
if (!isTable(v))
|
|
363
|
+
return false;
|
|
364
|
+
if (v.enabled === false)
|
|
365
|
+
return true;
|
|
366
|
+
if (v.enabled === undefined && v.enable_signup === false)
|
|
367
|
+
return true;
|
|
368
|
+
if (v.enabled === undefined) {
|
|
369
|
+
const values = Object.values(v);
|
|
370
|
+
const tables = values.filter(isTable);
|
|
371
|
+
if (tables.length > 0 && tables.length === values.length)
|
|
372
|
+
return tables.every(isOff);
|
|
373
|
+
}
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Classify and map every leaf of a parsed config document.
|
|
378
|
+
*
|
|
379
|
+
* Never throws on content: a setting this component does not apply is
|
|
380
|
+
* *reported* (`unsupported`), not vetoed. The file belongs to the project and
|
|
381
|
+
* describes more than one environment — refusing to start over a key that is
|
|
382
|
+
* meaningless in a hermetic VM would make the whole option unadoptable for the
|
|
383
|
+
* projects that need it most. Silence is still the thing to avoid, so nothing
|
|
384
|
+
* unapplied goes unsaid.
|
|
385
|
+
*/
|
|
386
|
+
export function mapSupabaseConfig(doc) {
|
|
387
|
+
const out = {
|
|
388
|
+
auth: {},
|
|
389
|
+
rest: {},
|
|
390
|
+
functionVerifyJwt: {},
|
|
391
|
+
functionSecrets: {},
|
|
392
|
+
templates: {},
|
|
393
|
+
externalProviders: {},
|
|
394
|
+
deferredSecrets: {},
|
|
395
|
+
encryptedValues: [],
|
|
396
|
+
unsupported: [],
|
|
397
|
+
warnings: [],
|
|
398
|
+
};
|
|
399
|
+
if (!isTable(doc))
|
|
400
|
+
return out;
|
|
401
|
+
const walk = (table, path) => {
|
|
402
|
+
for (const [k, v] of Object.entries(table)) {
|
|
403
|
+
const next = [...path, k];
|
|
404
|
+
const key = next.join(".");
|
|
405
|
+
const rule = RULES.find((r) => matches(r.key, next));
|
|
406
|
+
// A `**` rule is a statement about a whole subtree, so a section it
|
|
407
|
+
// matches is classified in one line rather than key by key — unless a
|
|
408
|
+
// more specific rule lives below it, in which case descend.
|
|
409
|
+
if (isTable(v)) {
|
|
410
|
+
const wholesale = rule !== undefined && rule.key.includes("**") && !claimsDeeper(next);
|
|
411
|
+
if (!wholesale) {
|
|
412
|
+
walk(v, next);
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
if (rule === undefined) {
|
|
417
|
+
out.warnings.push(key);
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
if (rule.bucket === "irrelevant")
|
|
421
|
+
continue;
|
|
422
|
+
if (rule.bucket === "unsupported") {
|
|
423
|
+
// A feature the file switches off loses nothing by not being applied,
|
|
424
|
+
// so it is not worth a line.
|
|
425
|
+
if (!isOff(v))
|
|
426
|
+
out.unsupported.push({ key, reason: rule.reason ?? "not applied" });
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
rule.apply?.(v, out, next);
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
walk(doc, []);
|
|
433
|
+
return out;
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Parse a config file. The format follows the extension — Supabase is moving
|
|
437
|
+
* the canonical file from TOML to JSON, and both are read here so a project is
|
|
438
|
+
* never held back by which CLI version wrote it.
|
|
439
|
+
*/
|
|
440
|
+
export function parseSupabaseConfigFile(path) {
|
|
441
|
+
const text = readFileSync(path, "utf8");
|
|
442
|
+
if (path.endsWith(".json"))
|
|
443
|
+
return JSON.parse(text);
|
|
444
|
+
// Bun parses TOML natively (no dependency, and synchronous — this runs while
|
|
445
|
+
// the services map is being built).
|
|
446
|
+
return globalThis.Bun.TOML.parse(text);
|
|
447
|
+
}
|