@guard-rail/core 0.9.1 → 0.10.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.
Files changed (51) hide show
  1. package/README.md +168 -36
  2. package/dist/adapters/supabase/auth.js +1 -2
  3. package/dist/adapters/supabase/auth.js.map +1 -1
  4. package/dist/adapters/supabase/client.js +6 -1
  5. package/dist/adapters/supabase/client.js.map +1 -1
  6. package/dist/adapters/supabase/confirm.d.ts +11 -11
  7. package/dist/adapters/supabase/confirm.js +14 -18
  8. package/dist/adapters/supabase/confirm.js.map +1 -1
  9. package/dist/adapters/supabase/index.d.ts +3 -1
  10. package/dist/adapters/supabase/index.js +5 -0
  11. package/dist/adapters/supabase/index.js.map +1 -1
  12. package/dist/adapters/supabase/rateLimit.d.ts +24 -0
  13. package/dist/adapters/supabase/rateLimit.js +37 -0
  14. package/dist/adapters/supabase/rateLimit.js.map +1 -0
  15. package/dist/cli/doctor.d.ts +1 -0
  16. package/dist/cli/doctor.js +911 -0
  17. package/dist/cli/doctor.js.map +1 -0
  18. package/dist/cli/init-db.js +23 -5
  19. package/dist/cli/init-db.js.map +1 -1
  20. package/dist/core/adapter-types.d.ts +71 -1
  21. package/dist/core/config.d.ts +12 -1
  22. package/dist/core/config.js +29 -1
  23. package/dist/core/config.js.map +1 -1
  24. package/dist/core/validate.d.ts +47 -0
  25. package/dist/core/validate.js +118 -0
  26. package/dist/core/validate.js.map +1 -0
  27. package/dist/index.client.d.ts +1 -1
  28. package/dist/index.client.js.map +1 -1
  29. package/dist/index.d.ts +1 -1
  30. package/dist/index.js.map +1 -1
  31. package/dist/react/AuthRedirectSync.js +28 -22
  32. package/dist/react/AuthRedirectSync.js.map +1 -1
  33. package/dist/react/GRail/render.js +3 -2
  34. package/dist/react/GRail/render.js.map +1 -1
  35. package/dist/server/actions.js +52 -31
  36. package/dist/server/actions.js.map +1 -1
  37. package/dist/server/loader.js +4 -4
  38. package/dist/server/loader.js.map +1 -1
  39. package/dist/server/rateLimit.d.ts +21 -0
  40. package/dist/server/rateLimit.js +94 -0
  41. package/dist/server/rateLimit.js.map +1 -0
  42. package/dist/server/repeaterActions.js +9 -1
  43. package/dist/server/repeaterActions.js.map +1 -1
  44. package/dist/server/wrapper.js +18 -1
  45. package/dist/server/wrapper.js.map +1 -1
  46. package/package.json +14 -13
  47. package/supabase/admin/check-grants.sql +41 -0
  48. package/supabase/migrations/20261028000000_require_confirmed_email.sql +53 -0
  49. package/supabase/migrations/20261028010000_rate_limits.sql +67 -0
  50. package/supabase/migrations/20261028020000_member_and_bucket_guards.sql +96 -0
  51. package/scripts/postinstall.mjs +0 -48
@@ -0,0 +1,911 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import { randomBytes } from "node:crypto";
4
+ import {
5
+ existsSync,
6
+ mkdirSync,
7
+ copyFileSync,
8
+ readdirSync,
9
+ readFileSync,
10
+ writeFileSync
11
+ } from "node:fs";
12
+ import path from "node:path";
13
+ import { createRequire } from "node:module";
14
+ import { createInterface } from "node:readline/promises";
15
+ import { fileURLToPath } from "node:url";
16
+ import { GR_SCHEMA, GR_MEMBERS_TABLE } from "../core/defs.js";
17
+ const NEXT_MIN_PATCHED = {
18
+ 15: [15, 5, 28],
19
+ 16: [16, 4, 1]
20
+ };
21
+ const SECURITY_HEADERS_BLOCK = ` // Baseline response headers. frame-ancestors keeps another site from framing
22
+ // this one (the editing toolbar included) in a clickjacking overlay. A full
23
+ // Content-Security-Policy is specific to what your pages load, so add yours
24
+ // alongside it.
25
+ async headers() {
26
+ return [
27
+ {
28
+ source: "/:path*",
29
+ headers: [
30
+ { key: "Content-Security-Policy", value: "frame-ancestors 'self'" },
31
+ { key: "X-Frame-Options", value: "SAMEORIGIN" },
32
+ { key: "X-Content-Type-Options", value: "nosniff" },
33
+ { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
34
+ { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
35
+ // Only honored over HTTPS; browsers ignore it on plain HTTP.
36
+ { key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains" },
37
+ ],
38
+ },
39
+ ];
40
+ },
41
+ `;
42
+ const SITE_URL_ENV_NOTE = `
43
+ # The site's public address. Every emailed invite/reset link points here, and in
44
+ # production nothing is sent without it (links are never built from the request).
45
+ # GUARDRAIL_SITE_URL=https://example.com
46
+ `;
47
+ const USAGE = `Usage:
48
+ guardrail-doctor [options]
49
+
50
+ Checks this project's Guardrail setup. Read-only unless you ask otherwise.
51
+
52
+ Options:
53
+ --fix Repair what can be repaired in local files: copy missing
54
+ migrations, add a GUARDRAIL_SITE_URL note to your env
55
+ files, add baseline security headers to next.config.
56
+ Never touches secrets or Supabase dashboard settings.
57
+ --push With --fix: also run \`supabase db push\` after copying.
58
+ --app-url=<url> Also check the response headers of a running copy of the
59
+ site (e.g. --app-url=http://localhost:3000).
60
+ --e2e Run the end-to-end suite against the database: creates a
61
+ throwaway user and rows, exercises sign-in, invite claims,
62
+ row-level security, the admin guard, rate limiting and
63
+ storage, then deletes everything it made.
64
+ --yes Don't ask before --e2e touches a non-local database.
65
+ --offline Don't contact the npm registry. By default the installed
66
+ versions of next, @supabase/* and @guard-rail/core are
67
+ checked against its published security advisories (only
68
+ package names and versions are sent, as \`pnpm audit\` does).
69
+ --production Treat production-only requirements (like
70
+ GUARDRAIL_SITE_URL) as failures rather than warnings.
71
+ --strict Exit non-zero on warnings too.
72
+ --json Print results as JSON.
73
+ -h, --help This text.
74
+
75
+ Reads SUPABASE_URL, SUPABASE_ANON_KEY and SUPABASE_SERVICE_ROLE_KEY (or
76
+ SERVICE_KEY) from the environment, then .env.local and .env.`;
77
+ const results = [];
78
+ let currentGroup = "";
79
+ function group(name) {
80
+ currentGroup = name;
81
+ }
82
+ function record(status, title, detail, hint) {
83
+ results.push({ group: currentGroup, status, title, ...detail ? { detail } : {}, ...hint ? { hint } : {} });
84
+ }
85
+ const pass = (t, d) => record("pass", t, d);
86
+ const warn = (t, d, h) => record("warn", t, d, h);
87
+ const fail = (t, d, h) => record("fail", t, d, h);
88
+ const skip = (t, d) => record("skip", t, d);
89
+ const info = (t, d) => record("info", t, d);
90
+ function parseFlags(argv) {
91
+ const flags = /* @__PURE__ */ new Set();
92
+ const values = {};
93
+ const unknown = [];
94
+ for (const arg of argv) {
95
+ const m = arg.match(/^--([^=]+)=([\s\S]*)$/);
96
+ if (m) {
97
+ values[m[1]] = m[2];
98
+ } else if (arg.startsWith("-")) {
99
+ flags.add(arg.replace(/^-+/, ""));
100
+ } else {
101
+ unknown.push(arg);
102
+ }
103
+ }
104
+ const known = /* @__PURE__ */ new Set(["fix", "push", "e2e", "yes", "production", "strict", "json", "offline", "help", "h"]);
105
+ for (const f of flags) if (!known.has(f)) unknown.push(`--${f}`);
106
+ for (const k of Object.keys(values)) if (k !== "app-url") unknown.push(`--${k}`);
107
+ return { flags, values, unknown };
108
+ }
109
+ function readEnvFiles(cwd) {
110
+ const out = {};
111
+ for (const file of [".env", ".env.local"]) {
112
+ const p = path.join(cwd, file);
113
+ if (!existsSync(p)) continue;
114
+ for (const line of readFileSync(p, "utf8").split(/\r?\n/)) {
115
+ const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
116
+ if (!m) continue;
117
+ const value = m[2].trim().replace(/^(['"])(.*)\1$/, "$2");
118
+ if (value) out[m[1]] = value;
119
+ }
120
+ }
121
+ return out;
122
+ }
123
+ function parseVersion(v) {
124
+ const m = v.match(/^(\d+)\.(\d+)\.(\d+)/);
125
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
126
+ }
127
+ function atLeast(v, min) {
128
+ for (let i = 0; i < 3; i++) {
129
+ if (v[i] !== min[i]) return v[i] > min[i];
130
+ }
131
+ return true;
132
+ }
133
+ function jwtRole(key) {
134
+ const parts = key.split(".");
135
+ if (parts.length !== 3) return null;
136
+ try {
137
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
138
+ return typeof payload.role === "string" ? payload.role : null;
139
+ } catch {
140
+ return null;
141
+ }
142
+ }
143
+ function isLocalHost(host) {
144
+ return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]" || host.endsWith(".localhost");
145
+ }
146
+ function commandExists(cmd) {
147
+ const res = process.platform === "win32" ? spawnSync("where", [cmd]) : spawnSync("which", [cmd]);
148
+ return res.status === 0;
149
+ }
150
+ function findNextConfig(cwd) {
151
+ for (const name of ["next.config.ts", "next.config.mjs", "next.config.js", "next.config.cjs", "next.config.mts"]) {
152
+ const p = path.join(cwd, name);
153
+ if (existsSync(p)) return p;
154
+ }
155
+ return null;
156
+ }
157
+ function bundledMigrationsDir() {
158
+ const here = path.dirname(fileURLToPath(import.meta.url));
159
+ return path.join(here, "..", "..", "supabase", "migrations");
160
+ }
161
+ const applied = [];
162
+ function fixMigrations(cwd) {
163
+ const src = bundledMigrationsDir();
164
+ if (!existsSync(src)) return;
165
+ const dest = path.join(cwd, "supabase", "migrations");
166
+ mkdirSync(dest, { recursive: true });
167
+ const have = new Set(readdirSync(dest));
168
+ for (const file of readdirSync(src)) {
169
+ if (have.has(file)) continue;
170
+ copyFileSync(path.join(src, file), path.join(dest, file));
171
+ applied.push(`Copied supabase/migrations/${file}`);
172
+ }
173
+ }
174
+ function fixEnvNotes(cwd) {
175
+ for (const file of [".env.example", ".env.local"]) {
176
+ const p = path.join(cwd, file);
177
+ if (!existsSync(p)) continue;
178
+ const content = readFileSync(p, "utf8");
179
+ if (/GUARDRAIL_SITE_URL/.test(content)) continue;
180
+ writeFileSync(p, content.replace(/\n*$/, "\n") + SITE_URL_ENV_NOTE);
181
+ applied.push(`Added a GUARDRAIL_SITE_URL note to ${file}`);
182
+ }
183
+ }
184
+ const CACHE_COMPONENTS_LINE = /^[ \t]*(?:cacheComponents|partialPrefetching)\s*:\s*true\s*,?[ \t]*\r?\n/gm;
185
+ function fixCacheComponents(cwd) {
186
+ const configPath = findNextConfig(cwd);
187
+ if (!configPath) return false;
188
+ const content = readFileSync(configPath, "utf8");
189
+ const next = content.replace(CACHE_COMPONENTS_LINE, "");
190
+ if (next === content) return false;
191
+ writeFileSync(configPath, next);
192
+ applied.push(`Turned off Cache Components in ${path.basename(configPath)}`);
193
+ return true;
194
+ }
195
+ function fixHeaders(cwd) {
196
+ const configPath = findNextConfig(cwd);
197
+ if (!configPath) return "none";
198
+ const content = readFileSync(configPath, "utf8");
199
+ if (/frame-ancestors|x-frame-options/i.test(content)) return "present";
200
+ if (/\bheaders\s*\(/.test(content)) return "manual";
201
+ const openers = [
202
+ /(const\s+nextConfig\s*(?::\s*[A-Za-z<>[\]| ]+)?=\s*\{[ \t]*\r?\n)/,
203
+ /(export\s+default\s*\{[ \t]*\r?\n)/,
204
+ /(module\.exports\s*=\s*\{[ \t]*\r?\n)/
205
+ ];
206
+ for (const re of openers) {
207
+ if (!re.test(content)) continue;
208
+ writeFileSync(configPath, content.replace(re, `$1${SECURITY_HEADERS_BLOCK}`));
209
+ applied.push(`Added baseline security headers to ${path.basename(configPath)}`);
210
+ return "patched";
211
+ }
212
+ return "manual";
213
+ }
214
+ async function fetchAdvisories(versions) {
215
+ const body = {};
216
+ for (const [name, v] of Object.entries(versions)) body[name] = [v];
217
+ if (Object.keys(body).length === 0) return {};
218
+ try {
219
+ const res = await fetch("https://registry.npmjs.org/-/npm/v1/security/advisories/bulk", {
220
+ method: "POST",
221
+ headers: { "content-type": "application/json" },
222
+ body: JSON.stringify(body),
223
+ signal: AbortSignal.timeout(8e3)
224
+ });
225
+ if (!res.ok) return null;
226
+ return await res.json();
227
+ } catch {
228
+ return null;
229
+ }
230
+ }
231
+ async function checkProject(ctx) {
232
+ group("Project");
233
+ const pkgPath = path.join(ctx.cwd, "package.json");
234
+ if (!existsSync(pkgPath)) {
235
+ fail("No package.json here", "Run this from the root of your Next.js project.");
236
+ return;
237
+ }
238
+ const ownPkg = JSON.parse(readFileSync(path.join(bundledMigrationsDir(), "..", "..", "package.json"), "utf8"));
239
+ pass(`@guard-rail/core ${ownPkg.version}`);
240
+ const require2 = createRequire(pkgPath);
241
+ const installed = (name) => {
242
+ try {
243
+ return JSON.parse(readFileSync(require2.resolve(`${name}/package.json`), "utf8")).version;
244
+ } catch {
245
+ return void 0;
246
+ }
247
+ };
248
+ const nextVersion = installed("next");
249
+ if (!nextVersion) warn("Next.js isn't installed here", "Couldn't resolve `next` from this directory.");
250
+ const checked = {};
251
+ for (const name of ["next", "@supabase/supabase-js", "@supabase/ssr"]) {
252
+ const v = installed(name);
253
+ if (v) checked[name] = v;
254
+ }
255
+ checked["@guard-rail/core"] = ownPkg.version;
256
+ const advisories = ctx.offline ? null : await fetchAdvisories(checked);
257
+ if (advisories) {
258
+ let clean = 0;
259
+ for (const [name, version] of Object.entries(checked)) {
260
+ const found = advisories[name] ?? [];
261
+ const serious = found.filter((a) => a.severity === "critical" || a.severity === "high");
262
+ if (found.length === 0) {
263
+ clean++;
264
+ continue;
265
+ }
266
+ const lines = found.slice(0, 3).map((a) => `${a.severity}: ${a.title} (${a.url})`);
267
+ if (found.length > 3) lines.push(`\u2026and ${found.length - 3} more`);
268
+ (serious.length ? fail : warn)(
269
+ `${name} ${version} has ${found.length} known security advisor${found.length === 1 ? "y" : "ies"}`,
270
+ lines.join("\n "),
271
+ `Upgrade: pnpm add ${name}@latest`
272
+ );
273
+ }
274
+ if (clean) pass(`No known security advisories for ${clean} checked package${clean === 1 ? "" : "s"}`, Object.entries(checked).filter(([n]) => (advisories[n] ?? []).length === 0).map(([n, v]) => `${n} ${v}`).join(", "));
275
+ } else if (nextVersion) {
276
+ const v = parseVersion(nextVersion);
277
+ const floor = v ? NEXT_MIN_PATCHED[v[0]] : void 0;
278
+ const why = ctx.offline ? "offline" : "the advisory service couldn't be reached";
279
+ if (!v) {
280
+ info(`Next.js ${nextVersion}`);
281
+ } else if (floor && !atLeast(v, floor)) {
282
+ fail(
283
+ `Next.js ${nextVersion} has known security vulnerabilities`,
284
+ `Releases before ${floor.join(".")} carry published advisories (checked against this tool's built-in list \u2014 ${why}).`,
285
+ `Upgrade: pnpm add next@latest`
286
+ );
287
+ } else if (floor) {
288
+ pass(`Next.js ${nextVersion}`, `Checked against this tool's built-in list (${why}); it may not know about newer advisories.`);
289
+ } else {
290
+ warn(
291
+ `Next.js ${nextVersion}`,
292
+ `This tool doesn't know the patched release for the ${v[0]}.x line (${why}).`,
293
+ `Make sure you're on its latest patch, or move to the current major.`
294
+ );
295
+ }
296
+ }
297
+ for (const dep of ["@supabase/supabase-js", "@supabase/ssr"]) {
298
+ try {
299
+ require2.resolve(`${dep}/package.json`);
300
+ pass(`${dep} installed`);
301
+ } catch {
302
+ fail(`${dep} isn't installed`, void 0, `pnpm add ${dep}`);
303
+ }
304
+ }
305
+ }
306
+ function checkEnvironment(ctx) {
307
+ group("Environment");
308
+ const { env } = ctx;
309
+ if (!ctx.url) {
310
+ fail("SUPABASE_URL isn't set", "Not in the environment, .env.local or .env.");
311
+ } else {
312
+ try {
313
+ const u = new URL(ctx.url);
314
+ if (u.protocol !== "https:" && u.protocol !== "http:") throw new Error();
315
+ if (u.protocol === "http:" && !isLocalHost(u.hostname)) {
316
+ warn("SUPABASE_URL uses plain http", "Use the project's https:// address.");
317
+ } else {
318
+ pass("SUPABASE_URL", u.host);
319
+ }
320
+ } catch {
321
+ fail("SUPABASE_URL isn't a valid URL", "Expected https://<project>.supabase.co.");
322
+ }
323
+ }
324
+ const anon = ctx.anonKey;
325
+ if (!anon) {
326
+ fail("SUPABASE_ANON_KEY isn't set");
327
+ } else {
328
+ const role = jwtRole(anon);
329
+ if (role === "service_role" || anon.startsWith("sb_secret_")) {
330
+ fail(
331
+ "SUPABASE_ANON_KEY holds the service-role (secret) key",
332
+ "The anon key is meant to be public; the service-role key bypasses every access rule.",
333
+ "Swap them: put the anon / publishable key here."
334
+ );
335
+ } else {
336
+ pass("SUPABASE_ANON_KEY");
337
+ }
338
+ }
339
+ const svc = ctx.serviceKey;
340
+ if (!svc) {
341
+ warn(
342
+ "No service-role key",
343
+ "Invites, password resets and database-backed rate limits need SUPABASE_SERVICE_ROLE_KEY (server-only)."
344
+ );
345
+ } else {
346
+ const role = jwtRole(svc);
347
+ if (role === "anon" || svc.startsWith("sb_publishable_")) {
348
+ fail("SUPABASE_SERVICE_ROLE_KEY holds the anon (publishable) key", void 0, "Use the service-role / secret key here.");
349
+ } else {
350
+ pass("Service-role key present");
351
+ }
352
+ }
353
+ const leaked = Object.keys(env).filter((k) => /^NEXT_PUBLIC_.*(SERVICE|SECRET|RESEND|PRIVATE)/i.test(k));
354
+ if (leaked.length) {
355
+ fail(
356
+ "Secret behind a NEXT_PUBLIC_ name",
357
+ `${leaked.join(", ")} would be published to every visitor's browser.`,
358
+ "Rename it without the NEXT_PUBLIC_ prefix and read it only in server code."
359
+ );
360
+ } else {
361
+ pass("No secrets behind NEXT_PUBLIC_ names");
362
+ }
363
+ const siteUrl = env.GUARDRAIL_SITE_URL;
364
+ if (!siteUrl) {
365
+ (ctx.production ? fail : warn)(
366
+ "GUARDRAIL_SITE_URL isn't set",
367
+ 'Production sends no invite or password-reset emails without it \u2014 and "Forgot password?" fails silently.',
368
+ "Set it to your public address, e.g. https://example.com (or pass siteUrl to withGuardrail())."
369
+ );
370
+ } else {
371
+ try {
372
+ const u = new URL(siteUrl);
373
+ if (u.protocol !== "https:" && u.protocol !== "http:") throw new Error();
374
+ if (u.protocol === "http:" && !isLocalHost(u.hostname)) {
375
+ warn("GUARDRAIL_SITE_URL uses plain http", "Emailed links should point at an https address.");
376
+ } else {
377
+ pass("GUARDRAIL_SITE_URL", u.origin);
378
+ }
379
+ } catch {
380
+ fail("GUARDRAIL_SITE_URL isn't a valid http(s) URL", siteUrl);
381
+ }
382
+ }
383
+ const hasKey = Boolean(env.RESEND_API_KEY);
384
+ const hasFrom = Boolean(env.GUARDRAIL_EMAIL_FROM);
385
+ if (hasKey && hasFrom) {
386
+ pass("Resend email configured");
387
+ } else if (hasKey !== hasFrom) {
388
+ fail(
389
+ `${hasKey ? "GUARDRAIL_EMAIL_FROM" : "RESEND_API_KEY"} is missing`,
390
+ "Email through Resend needs both RESEND_API_KEY and GUARDRAIL_EMAIL_FROM."
391
+ );
392
+ } else {
393
+ info("No Resend settings in the environment", "Fine if you pass your own onInvite / onPasswordReset, or only use consoleEmails() in development.");
394
+ }
395
+ if (env.GUARDRAIL_SITE_ID) pass("GUARDRAIL_SITE_ID", env.GUARDRAIL_SITE_ID);
396
+ else info("GUARDRAIL_SITE_ID isn't set", 'Sites default to the id "default".');
397
+ }
398
+ function checkFiles(ctx) {
399
+ group("Files");
400
+ const src = bundledMigrationsDir();
401
+ const dest = path.join(ctx.cwd, "supabase", "migrations");
402
+ if (existsSync(src)) {
403
+ const shipped = readdirSync(src);
404
+ const have = existsSync(dest) ? new Set(readdirSync(dest)) : /* @__PURE__ */ new Set();
405
+ const missing = shipped.filter((f) => !have.has(f));
406
+ if (missing.length === 0) {
407
+ pass("All Guardrail migrations are in supabase/migrations/");
408
+ } else {
409
+ fail(
410
+ `${missing.length} migration${missing.length === 1 ? "" : "s"} not copied into supabase/migrations/`,
411
+ missing.join(", "),
412
+ "Run: pnpm exec guardrail-doctor --fix (or guardrail-init-db)"
413
+ );
414
+ }
415
+ }
416
+ const configToml = path.join(ctx.cwd, "supabase", "config.toml");
417
+ if (existsSync(configToml)) {
418
+ const toml = readFileSync(configToml, "utf8");
419
+ const schemas = toml.match(/\[api\][\s\S]*?schemas\s*=\s*\[([^\]]*)\]/)?.[1] ?? "";
420
+ if (schemas.includes('"guardrail"')) pass('supabase/config.toml exposes the "guardrail" schema');
421
+ else fail(`supabase/config.toml doesn't expose the "guardrail" schema`, void 0, "Run: pnpm exec guardrail-init-db");
422
+ }
423
+ const layouts = ["app/layout.tsx", "src/app/layout.tsx", "app/layout.jsx", "src/app/layout.jsx", "app/layout.js", "src/app/layout.js"];
424
+ const layout = layouts.map((l) => path.join(ctx.cwd, l)).find(existsSync);
425
+ const wiredIn = (file) => /withGuardrail/.test(readFileSync(file, "utf8"));
426
+ if (!layout) {
427
+ skip("Root layout not found", "Looked for app/layout and src/app/layout.");
428
+ } else if (wiredIn(layout)) {
429
+ pass("Root layout uses withGuardrail()");
430
+ } else {
431
+ warn(
432
+ "Root layout doesn't mention withGuardrail()",
433
+ `${path.relative(ctx.cwd, layout)} \u2014 fine if it's wrapped where the layout is exported from another file.`
434
+ );
435
+ }
436
+ const configPath = findNextConfig(ctx.cwd);
437
+ if (!configPath) {
438
+ skip("next.config not found");
439
+ } else {
440
+ const content = readFileSync(configPath, "utf8");
441
+ if (/^[ \t]*cacheComponents\s*:\s*true/m.test(content)) {
442
+ fail(
443
+ "Cache Components is turned on",
444
+ "Guardrail reads the session cookie to decide what to render, which Next's Cache Components mode refuses outside <Suspense> \u2014 the site won't pass `next build`.",
445
+ "Run: pnpm exec guardrail-doctor --fix (removes `cacheComponents: true` and `partialPrefetching: true`)"
446
+ );
447
+ }
448
+ if (/frame-ancestors|x-frame-options/i.test(content)) {
449
+ pass(`${path.basename(configPath)} sets anti-framing headers`);
450
+ } else {
451
+ warn(
452
+ `${path.basename(configPath)} sets no security headers`,
453
+ "Without frame-ancestors, another site can frame yours \u2014 including the editing toolbar \u2014 under a clickjacking overlay.",
454
+ "Run: pnpm exec guardrail-doctor --fix (skip if your host or middleware sets them; verify with --app-url)"
455
+ );
456
+ }
457
+ }
458
+ const gitignore = path.join(ctx.cwd, ".gitignore");
459
+ const hasEnvFile = [".env.local", ".env"].some((f) => existsSync(path.join(ctx.cwd, f)));
460
+ if (hasEnvFile) {
461
+ const ignores = existsSync(gitignore) ? readFileSync(gitignore, "utf8") : "";
462
+ if (/^\s*\.env(\*|\.local|\.\*)?\s*$/m.test(ignores) || /^\s*\.env\*/m.test(ignores)) {
463
+ pass("Env files are gitignored");
464
+ } else {
465
+ fail(".env files aren't gitignored", "Your Supabase keys could be committed.", "Add `.env*` (and `!.env.example`) to .gitignore.");
466
+ }
467
+ }
468
+ }
469
+ async function loadSupabase() {
470
+ try {
471
+ return await import("@supabase/supabase-js");
472
+ } catch {
473
+ return null;
474
+ }
475
+ }
476
+ async function checkDatabase(ctx, sb) {
477
+ group("Database");
478
+ if (!ctx.url || !ctx.anonKey) {
479
+ skip("Skipped", "Needs SUPABASE_URL and SUPABASE_ANON_KEY.");
480
+ return false;
481
+ }
482
+ if (!sb) {
483
+ skip("Skipped", "@supabase/supabase-js isn't installed.");
484
+ return false;
485
+ }
486
+ const base = ctx.url.replace(/\/$/, "");
487
+ try {
488
+ const res = await fetch(`${base}/rest/v1/gr_content?select=path_key&limit=1`, {
489
+ headers: { apikey: ctx.anonKey, Authorization: `Bearer ${ctx.anonKey}`, "Accept-Profile": GR_SCHEMA }
490
+ });
491
+ const body = res.ok ? "" : await res.text();
492
+ if (res.ok) {
493
+ pass(`"${GR_SCHEMA}" schema is exposed and readable`);
494
+ } else if (/PGRST106|Invalid schema/i.test(body)) {
495
+ fail(
496
+ `The "${GR_SCHEMA}" schema isn't exposed`,
497
+ "PostgREST refuses requests for it.",
498
+ "Supabase dashboard > Settings > API > Exposed schemas: add guardrail."
499
+ );
500
+ return false;
501
+ } else if (/PGRST205|42P01|does not exist|schema cache/i.test(body)) {
502
+ fail("Guardrail's tables don't exist", "The migration hasn't been applied to this project.", "Run: pnpm exec guardrail-init-db --push");
503
+ return false;
504
+ } else if (res.status === 401 || res.status === 403) {
505
+ fail("The anon key was rejected", `HTTP ${res.status}`, "Check SUPABASE_ANON_KEY belongs to this project.");
506
+ return false;
507
+ } else {
508
+ fail("Unexpected response reading content", `HTTP ${res.status}: ${body.slice(0, 160)}`);
509
+ return false;
510
+ }
511
+ } catch (e) {
512
+ fail("Couldn't reach Supabase", e instanceof Error ? e.message : String(e), "Check SUPABASE_URL, and that the project isn't paused.");
513
+ return false;
514
+ }
515
+ try {
516
+ const res = await fetch(`${base}/auth/v1/settings`, { headers: { apikey: ctx.anonKey } });
517
+ if (res.ok) {
518
+ const s = await res.json();
519
+ if (s.external?.email === false) {
520
+ fail("Email sign-in is disabled in Supabase", void 0, "Authentication > Sign In / Providers: enable Email.");
521
+ } else if (s.disable_signup) {
522
+ pass("Public sign-ups are off");
523
+ } else if (s.mailer_autoconfirm) {
524
+ warn(
525
+ "Anyone can sign up, and addresses count as confirmed instantly",
526
+ "Someone could register an invited person's address before they do, and claim the invitation.",
527
+ "Authentication > Sign In / Providers > Email: turn on Confirm email (or turn off public sign-ups)."
528
+ );
529
+ } else {
530
+ pass("Sign-ups require a confirmed email");
531
+ }
532
+ } else {
533
+ skip("Couldn't read the project's sign-up settings", `HTTP ${res.status}`);
534
+ }
535
+ } catch {
536
+ skip("Couldn't read the project's sign-up settings");
537
+ }
538
+ if (!ctx.serviceKey) {
539
+ skip("Service-role checks skipped", "No service-role key to inspect the database's functions with.");
540
+ return true;
541
+ }
542
+ const admin = sb.createClient(ctx.url, ctx.serviceKey, { auth: { persistSession: false, autoRefreshToken: false } });
543
+ const gr = () => admin.schema(GR_SCHEMA);
544
+ for (const table of [GR_MEMBERS_TABLE, "gr_content", "gr_repeater_items", "gr_rate_limits"]) {
545
+ const { error: error2 } = await gr().from(table).select("*", { head: true, count: "exact" }).limit(1);
546
+ if (!error2) pass(`Table ${table}`);
547
+ else if (table === "gr_rate_limits") warn("Table gr_rate_limits is missing", "Rate limits are counted in memory until the migration is applied.", "Run: pnpm exec guardrail-init-db --push");
548
+ else fail(`Table ${table} isn't readable`, error2.message, "Run: pnpm exec guardrail-init-db --push");
549
+ }
550
+ const probes = [
551
+ ["gr_confirmed_email", {}, "Confirmed-email identity check"],
552
+ ["gr_claim_invite", { target_site: "doctor-probe" }, "Invite-claim function"],
553
+ ["gr_ensure_bucket", { target_bucket: "gr-doctor-probe" }, "Media bucket function"]
554
+ ];
555
+ for (const [fn, args, label] of probes) {
556
+ const { error: error2 } = await gr().rpc(fn, args);
557
+ if (!error2 || error2.code === "42501" || /permission denied/i.test(error2.message)) {
558
+ pass(label);
559
+ } else if (error2.code === "PGRST202" || error2.code === "42883" || /could not find the function/i.test(error2.message)) {
560
+ fail(`${label} is missing`, `Function ${fn}() doesn't exist.`, "Apply the latest migrations: pnpm exec guardrail-init-db --push");
561
+ } else {
562
+ pass(label);
563
+ }
564
+ }
565
+ const probeKey = `doctor-${randomBytes(3).toString("hex")}`;
566
+ const { data, error } = await gr().rpc("gr_rate_hit", { p_name: "doctor:probe", p_key: probeKey, p_limit: 1e3, p_window_seconds: 60 });
567
+ if (!error && data) {
568
+ pass("Database-backed rate limiting");
569
+ await gr().from("gr_rate_limits").delete().eq("bucket", `doctor:probe|${probeKey}`);
570
+ } else if (error?.code === "PGRST202" || error?.code === "42883") {
571
+ warn("Rate-limit function is missing", "Limits are counted in memory, which doesn't hold across serverless instances.", "Apply the latest migrations: pnpm exec guardrail-init-db --push");
572
+ } else if (error) {
573
+ warn("Rate-limit function failed", error.message);
574
+ }
575
+ info("Admin guard (can't delete the last admin)", "Verified by --e2e; it's a trigger, which can't be probed read-only.");
576
+ return true;
577
+ }
578
+ async function checkLiveSite(appUrl) {
579
+ group("Live site");
580
+ let res;
581
+ try {
582
+ res = await fetch(appUrl, { redirect: "manual" });
583
+ } catch (e) {
584
+ fail(`Couldn't reach ${appUrl}`, e instanceof Error ? e.message : String(e), "Is the site running?");
585
+ return;
586
+ }
587
+ if (res.status >= 500) {
588
+ fail(`${appUrl} answered HTTP ${res.status}`);
589
+ return;
590
+ }
591
+ pass(`${appUrl} answered HTTP ${res.status}`);
592
+ const h = res.headers;
593
+ const csp = h.get("content-security-policy") ?? "";
594
+ if (/frame-ancestors/i.test(csp) || h.get("x-frame-options")) pass("Can't be framed by another site");
595
+ else fail("Can be framed by another site", "No frame-ancestors or X-Frame-Options header.", "Add the baseline headers: pnpm exec guardrail-doctor --fix");
596
+ if (/nosniff/i.test(h.get("x-content-type-options") ?? "")) pass("X-Content-Type-Options: nosniff");
597
+ else warn("X-Content-Type-Options header missing");
598
+ if (h.get("referrer-policy")) pass(`Referrer-Policy: ${h.get("referrer-policy")}`);
599
+ else warn("Referrer-Policy header missing");
600
+ if (new URL(appUrl).protocol === "https:") {
601
+ if (h.get("strict-transport-security")) pass("Strict-Transport-Security set");
602
+ else warn("Strict-Transport-Security header missing", "Browsers won't insist on HTTPS for this site.");
603
+ } else {
604
+ skip("Strict-Transport-Security", "Only meaningful over HTTPS.");
605
+ }
606
+ const setCookie = typeof h.getSetCookie === "function" ? h.getSetCookie() : [];
607
+ const session = setCookie.filter((c) => /^sb-.*-auth-token/i.test(c));
608
+ if (session.length) fail("A session cookie was set for an anonymous request", session.map((c) => c.split("=")[0]).join(", "));
609
+ }
610
+ const TINY_PNG = Buffer.from(
611
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
612
+ "base64"
613
+ );
614
+ async function confirmE2E(ctx, yes) {
615
+ const host = new URL(ctx.url).host;
616
+ const local = isLocalHost(new URL(ctx.url).hostname);
617
+ console.log(
618
+ `
619
+ The end-to-end suite will create a throwaway user, a few rows and one test image on ${host},
620
+ exercise them as a signed-in editor would, then delete everything it made.
621
+ `
622
+ );
623
+ if (local || yes) return true;
624
+ if (!process.stdin.isTTY) {
625
+ console.log("That's not a local database. Re-run with --yes to allow it.");
626
+ return false;
627
+ }
628
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
629
+ const answer = (await rl.question(`${host} is not a local database. Continue? [y/N] `)).trim().toLowerCase();
630
+ rl.close();
631
+ return answer === "y" || answer === "yes";
632
+ }
633
+ async function runE2E(ctx, sb) {
634
+ group("End-to-end");
635
+ const url = ctx.url;
636
+ const admin = sb.createClient(url, ctx.serviceKey, { auth: { persistSession: false, autoRefreshToken: false } });
637
+ const user = sb.createClient(url, ctx.anonKey, { auth: { persistSession: false, autoRefreshToken: false } });
638
+ const anon = sb.createClient(url, ctx.anonKey, { auth: { persistSession: false, autoRefreshToken: false } });
639
+ const gr = (c) => c.schema(GR_SCHEMA);
640
+ const tag = randomBytes(4).toString("hex");
641
+ const siteId = `doctor-${tag}`;
642
+ const otherSite = `doctor-${tag}-other`;
643
+ const email = `guardrail-doctor-${tag}@example.com`;
644
+ const password = randomBytes(18).toString("base64url");
645
+ const objectPath = `${siteId}/doctor.png`;
646
+ let userId;
647
+ let uploaded = false;
648
+ let storageChecked = false;
649
+ const expect = (ok, title, why, hint, note) => ok ? pass(title, note) : fail(title, why, hint);
650
+ try {
651
+ const created = await admin.auth.admin.createUser({ email, password, email_confirm: true });
652
+ if (created.error || !created.data.user) {
653
+ fail("Create a test account", created.error?.message ?? "No user returned.", "Needs a valid service-role key and Email sign-in enabled.");
654
+ return;
655
+ }
656
+ userId = created.data.user.id;
657
+ pass("Create a test account (service-role key works)");
658
+ const link = await admin.auth.admin.generateLink({ type: "recovery", email });
659
+ expect(
660
+ !link.error && Boolean(link.data?.properties?.hashed_token),
661
+ "Mint a password-reset link",
662
+ link.error?.message,
663
+ "Invites and resets need this to work; check Email sign-in is enabled."
664
+ );
665
+ const seeded = await gr(admin).from(GR_MEMBERS_TABLE).insert({ site_id: siteId, email, role: "admin", status: "pending" });
666
+ if (seeded.error) {
667
+ fail("Record a pending invitation", seeded.error.message);
668
+ return;
669
+ }
670
+ const signedIn = await user.auth.signInWithPassword({ email, password });
671
+ if (signedIn.error) {
672
+ fail("Sign in with a password", signedIn.error.message);
673
+ return;
674
+ }
675
+ pass("Sign in with a password");
676
+ const claim = await gr(user).rpc("gr_claim_invite", { target_site: siteId });
677
+ const after = await gr(user).from(GR_MEMBERS_TABLE).select("status,role").eq("site_id", siteId).eq("email", email).maybeSingle();
678
+ expect(
679
+ !claim.error && after.data?.status === "active" && after.data?.role === "admin",
680
+ "Claim an invitation",
681
+ claim.error?.message ?? (after.data ? void 0 : "The membership row wasn't visible after claiming."),
682
+ "Apply the latest migrations: pnpm exec guardrail-init-db --push"
683
+ );
684
+ const write = await gr(user).from("gr_content").insert({
685
+ site_id: siteId,
686
+ path_key: "doctor.check",
687
+ type: "text",
688
+ value: { type: "text", text: "doctor" }
689
+ });
690
+ expect(!write.error, "Editor can save content to their site", write.error?.message);
691
+ const read = await gr(anon).from("gr_content").select("path_key").eq("site_id", siteId).eq("path_key", "doctor.check");
692
+ expect(!read.error && (read.data?.length ?? 0) === 1, "Visitors can read saved content", read.error?.message);
693
+ const cross = await gr(user).from("gr_content").insert({
694
+ site_id: otherSite,
695
+ path_key: "doctor.check",
696
+ type: "text",
697
+ value: { type: "text", text: "nope" }
698
+ });
699
+ expect(Boolean(cross.error), "Editor can't write to a site they don't belong to", cross.error ? void 0 : "The write was allowed \u2014 row-level security is not protecting content.");
700
+ const anonWrite = await gr(anon).from("gr_content").insert({
701
+ site_id: siteId,
702
+ path_key: "doctor.anon",
703
+ type: "text",
704
+ value: { type: "text", text: "nope" }
705
+ });
706
+ expect(Boolean(anonWrite.error), "Visitors can't write content", anonWrite.error ? void 0 : "An unauthenticated write was allowed.");
707
+ const demote = await gr(user).from(GR_MEMBERS_TABLE).update({ role: "editor" }).eq("site_id", siteId).eq("email", email);
708
+ expect(
709
+ Boolean(demote.error),
710
+ "The last admin can't be demoted",
711
+ demote.error ? void 0 : "The demotion succeeded \u2014 the admin guard trigger isn't installed.",
712
+ "Apply the latest migrations: pnpm exec guardrail-init-db --push"
713
+ );
714
+ await gr(admin).from(GR_MEMBERS_TABLE).upsert(
715
+ { site_id: siteId, email, user_id: userId, role: "admin", status: "active" },
716
+ { onConflict: "site_id,email" }
717
+ );
718
+ const remove = await gr(user).from(GR_MEMBERS_TABLE).delete().eq("site_id", siteId).eq("email", email);
719
+ const stillThere = await gr(admin).from(GR_MEMBERS_TABLE).select("email").eq("site_id", siteId).eq("email", email);
720
+ expect(
721
+ Boolean(remove.error) || (stillThere.data?.length ?? 0) === 1,
722
+ "The last admin can't be removed",
723
+ remove.error ? void 0 : "The row was deleted \u2014 the admin guard trigger isn't installed.",
724
+ "Apply the latest migrations: pnpm exec guardrail-init-db --push"
725
+ );
726
+ await gr(admin).from(GR_MEMBERS_TABLE).upsert(
727
+ { site_id: siteId, email, user_id: userId, role: "admin", status: "active" },
728
+ { onConflict: "site_id,email" }
729
+ );
730
+ const hits = [];
731
+ let rateErr;
732
+ for (let i = 0; i < 3; i++) {
733
+ const r = await gr(admin).rpc("gr_rate_hit", { p_name: "doctor:e2e", p_key: siteId, p_limit: 2, p_window_seconds: 60 });
734
+ if (r.error) {
735
+ rateErr = r.error.message;
736
+ break;
737
+ }
738
+ const row = Array.isArray(r.data) ? r.data[0] : r.data;
739
+ hits.push(Boolean(row?.allowed));
740
+ }
741
+ expect(
742
+ !rateErr && hits.join() === "true,true,false",
743
+ "Rate limiter allows up to the limit, then refuses",
744
+ rateErr ?? (hits.length ? `Got ${hits.join(", ")} instead of true, true, false` : void 0),
745
+ "Apply the latest migrations: pnpm exec guardrail-init-db --push",
746
+ `Allowed ${hits.filter(Boolean).length} of ${hits.length} attempts at a limit of 2`
747
+ );
748
+ const buckets = await admin.storage.listBuckets();
749
+ const mediaBucket = buckets.data?.find((b) => b.id === "gr-media");
750
+ if (!mediaBucket) {
751
+ skip("Storage", 'No "gr-media" bucket \u2014 skipped (this site may not use editable images).');
752
+ } else {
753
+ storageChecked = true;
754
+ const up = await user.storage.from("gr-media").upload(objectPath, TINY_PNG, { contentType: "image/png" });
755
+ uploaded = !up.error;
756
+ expect(!up.error, "Editor can upload an image to their site's folder", up.error?.message, "Check the storage policies from the migration are applied.");
757
+ if (uploaded) {
758
+ const { data } = user.storage.from("gr-media").getPublicUrl(objectPath);
759
+ const res = await fetch(data.publicUrl).catch(() => null);
760
+ expect(Boolean(res?.ok), "Uploaded images are publicly readable", res ? `HTTP ${res.status}` : "Request failed.", "next/image also needs the storage host in remotePatterns.", `HTTP ${res?.status}`);
761
+ }
762
+ const elsewhere = await user.storage.from("gr-media").upload(`${otherSite}/doctor.png`, TINY_PNG, { contentType: "image/png" });
763
+ if (!elsewhere.error) await admin.storage.from("gr-media").remove([`${otherSite}/doctor.png`]);
764
+ expect(Boolean(elsewhere.error), "Editor can't upload into another site's folder", elsewhere.error ? void 0 : "The upload was allowed.");
765
+ }
766
+ } catch (e) {
767
+ fail("The end-to-end run stopped unexpectedly", e instanceof Error ? e.message : String(e));
768
+ } finally {
769
+ const leftovers = [];
770
+ const attempt = async (what, run) => {
771
+ try {
772
+ const { error } = await run();
773
+ if (error) leftovers.push(`${what} (${error.message})`);
774
+ } catch (e) {
775
+ leftovers.push(`${what} (${e instanceof Error ? e.message : String(e)})`);
776
+ }
777
+ };
778
+ if (storageChecked && uploaded) await attempt(`storage object ${objectPath}`, () => admin.storage.from("gr-media").remove([objectPath]));
779
+ await attempt("content rows", () => gr(admin).from("gr_content").delete().in("site_id", [siteId, otherSite]));
780
+ await attempt("membership rows", () => gr(admin).from(GR_MEMBERS_TABLE).delete().eq("site_id", siteId));
781
+ await attempt("rate-limit row", () => gr(admin).from("gr_rate_limits").delete().eq("bucket", `doctor:e2e|${siteId}`));
782
+ if (userId) await attempt(`test account ${email}`, () => admin.auth.admin.deleteUser(userId));
783
+ if (leftovers.length) {
784
+ warn("Cleanup left something behind", leftovers.join("; "), `Everything it made is tagged "${siteId}" / ${email}; delete it by hand.`);
785
+ } else {
786
+ pass("Cleaned up everything the test created");
787
+ }
788
+ }
789
+ }
790
+ const COLOR = process.stdout.isTTY && !process.env.NO_COLOR;
791
+ const paint = (code, s) => COLOR ? `\x1B[${code}m${s}\x1B[0m` : s;
792
+ const ICON = {
793
+ pass: paint(32, "\u2713"),
794
+ warn: paint(33, "!"),
795
+ fail: paint(31, "\u2717"),
796
+ skip: paint(90, "\u2013"),
797
+ info: paint(36, "i")
798
+ };
799
+ function printReport() {
800
+ let last = "";
801
+ for (const r of results) {
802
+ if (r.group !== last) {
803
+ console.log(`
804
+ ${paint(1, r.group)}`);
805
+ last = r.group;
806
+ }
807
+ console.log(` ${ICON[r.status]} ${r.title}`);
808
+ if (r.detail) console.log(` ${paint(90, r.detail)}`);
809
+ if (r.hint && (r.status === "fail" || r.status === "warn")) console.log(` ${paint(90, "\u2192 " + r.hint)}`);
810
+ }
811
+ const count = (s) => results.filter((r) => r.status === s).length;
812
+ const parts = [`${count("pass")} passed`, count("warn") ? paint(33, `${count("warn")} to review`) : null, count("fail") ? paint(31, `${count("fail")} failed`) : null].filter(Boolean);
813
+ console.log(`
814
+ ${parts.join(", ")}
815
+ `);
816
+ }
817
+ async function main() {
818
+ const { flags, values, unknown } = parseFlags(process.argv.slice(2));
819
+ if (flags.has("help") || flags.has("h")) {
820
+ console.log(USAGE);
821
+ return 0;
822
+ }
823
+ if (unknown.length) {
824
+ console.error(`Unknown option${unknown.length === 1 ? "" : "s"}: ${unknown.join(", ")}
825
+
826
+ ${USAGE}`);
827
+ return 2;
828
+ }
829
+ const cwd = process.cwd();
830
+ const fileEnv = readEnvFiles(cwd);
831
+ const env = { ...fileEnv };
832
+ for (const [k, v] of Object.entries(process.env)) if (v) env[k] = v;
833
+ const ctx = {
834
+ cwd,
835
+ env,
836
+ production: flags.has("production"),
837
+ offline: flags.has("offline"),
838
+ url: env.SUPABASE_URL,
839
+ anonKey: env.SUPABASE_ANON_KEY,
840
+ serviceKey: env.SUPABASE_SERVICE_ROLE_KEY ?? env.SERVICE_KEY
841
+ };
842
+ const json = flags.has("json");
843
+ if (!json) console.log("Guardrail doctor");
844
+ if (flags.has("fix")) {
845
+ fixMigrations(cwd);
846
+ fixEnvNotes(cwd);
847
+ fixCacheComponents(cwd);
848
+ const headers = fixHeaders(cwd);
849
+ if (headers === "manual") {
850
+ applied.push(`next.config has a shape I can't patch safely \u2014 add the headers from the README's "Before you deploy" yourself`);
851
+ }
852
+ if (flags.has("push")) {
853
+ if (!commandExists("supabase")) {
854
+ applied.push("Supabase CLI not found \u2014 apply the migrations yourself (`supabase db push`, or paste them into the SQL editor)");
855
+ } else {
856
+ const local = ctx.url ? isLocalHost(new URL(ctx.url).hostname) : false;
857
+ const cmd = local ? ["migration", "up", "--local"] : ["db", "push"];
858
+ const res = spawnSync("supabase", cmd, { stdio: json ? "ignore" : "inherit" });
859
+ applied.push(
860
+ res.status === 0 ? `Applied migrations with \`supabase ${cmd.slice(0, 2).join(" ")}\`` : local ? "`supabase migration up` failed \u2014 is the local stack running? (`supabase start`)" : "`supabase db push` failed \u2014 if the project isn't linked yet, run `supabase link` first"
861
+ );
862
+ }
863
+ }
864
+ if (!json) {
865
+ console.log(`
866
+ ${paint(1, "Fixes")}`);
867
+ if (applied.length === 0) console.log(" Nothing to fix locally.");
868
+ for (const a of applied) console.log(` \u2022 ${a}`);
869
+ }
870
+ } else if (flags.has("push")) {
871
+ console.error("--push only applies together with --fix.");
872
+ return 2;
873
+ }
874
+ await checkProject(ctx);
875
+ checkEnvironment(ctx);
876
+ checkFiles(ctx);
877
+ const sb = await loadSupabase();
878
+ const dbReachable = await checkDatabase(ctx, sb);
879
+ if (values["app-url"]) await checkLiveSite(values["app-url"]);
880
+ if (flags.has("e2e")) {
881
+ group("End-to-end");
882
+ if (!dbReachable || !sb) {
883
+ skip("Skipped", "The database checks above need to pass first.");
884
+ } else if (!ctx.serviceKey) {
885
+ skip("Skipped", "The end-to-end suite needs the service-role key to create and remove its test account.");
886
+ } else if (await confirmE2E(ctx, flags.has("yes"))) {
887
+ await runE2E(ctx, sb);
888
+ } else {
889
+ skip("Skipped", "Not confirmed.");
890
+ }
891
+ }
892
+ if (json) {
893
+ console.log(JSON.stringify({ applied, results }, null, 2));
894
+ } else {
895
+ printReport();
896
+ if (results.some((r) => r.status === "fail")) {
897
+ console.log("Fix the failures above, then run guardrail-doctor again.\n");
898
+ }
899
+ }
900
+ const failed = results.some((r) => r.status === "fail");
901
+ const warned = results.some((r) => r.status === "warn");
902
+ return failed || flags.has("strict") && warned ? 1 : 0;
903
+ }
904
+ main().then(
905
+ (code) => process.exit(code),
906
+ (err) => {
907
+ console.error(err);
908
+ process.exit(1);
909
+ }
910
+ );
911
+ //# sourceMappingURL=doctor.js.map