@guard-rail/core 0.10.0 → 0.11.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/README.md CHANGED
@@ -496,6 +496,54 @@ the outline ignores the pointer so your overlaid content stays fully clickable,
496
496
  and the handle is the only thing that opens the editor (it also won't trigger a
497
497
  surrounding `<Link>`).
498
498
 
499
+ ### Theming
500
+
501
+ The chrome is a real stylesheet (`guardrail.css`) that the package imports itself —
502
+ there is nothing to link by hand, and no inline styles, so it works under a strict
503
+ Content-Security-Policy. Every color is a CSS variable, so restyling it is a plain
504
+ `:root` rule in your own CSS:
505
+
506
+ ```css
507
+ :root {
508
+ --gr-accent: #7c3aed;
509
+ --gr-field-outline: #7c3aed;
510
+ --gr-overlay-bg: #1e1b4b;
511
+ --gr-overlay-fg: #e0e7ff;
512
+ }
513
+ ```
514
+
515
+ No `!important` and no selector tricks: Guardrail's defaults are declared with zero
516
+ specificity, so your `:root` rule wins whichever stylesheet loads first.
517
+
518
+ There are two layers. Change a **base** color to retheme everything, or set an
519
+ **overlay** color to restyle only the floating surfaces (the sign-in bar, toolbar,
520
+ edit popups, drawers and image picker). The overlay variables default to the base
521
+ ones, so you only set what you want to differ.
522
+
523
+ | Variable | What it colors |
524
+ |---|---|
525
+ | `--gr-overlay-bg` | Background of the sign-in bar, popups, drawers and picker |
526
+ | `--gr-overlay-bg-glass` | Background of the toolbar and the inline editing bar (translucent) |
527
+ | `--gr-overlay-fg` | Text on all of the above |
528
+ | `--gr-overlay-border` | Their borders |
529
+ | `--gr-field-outline` | The dashed outline around an editable field |
530
+ | `--gr-field-outline-active` | The solid outline while a field is being edited |
531
+ | `--gr-accent`, `--gr-accent-soft`, `--gr-accent-text`, `--gr-on-accent` | Highlights, and text on an accent background |
532
+ | `--gr-primary-bg`, `--gr-primary-fg` | Primary buttons |
533
+ | `--gr-danger`, `--gr-label`, `--gr-badge-bg`, `--gr-badge-fg` | Errors, small labels, the "pending" badge |
534
+ | `--gr-scrim`, `--gr-scrim-modal` | The dimmed backdrop behind drawers and the picker |
535
+ | `--gr-bg`, `--gr-bg-glass`, `--gr-fg`, `--gr-fg2`, `--gr-muted`, `--gr-subtle`, `--gr-border`, `--gr-border-strong`, `--gr-border-soft`, `--gr-input-bg`, `--gr-soft`, `--gr-shadow` | The base palette the overlay variables fall back to, plus inputs and secondary text |
536
+
537
+ Set any of them inside `@media (prefers-color-scheme: dark)` if you want a
538
+ different value in dark mode — the chrome follows the visitor's OS setting. The
539
+ outline around an editable text field is `[data-gr-field="text"]` (and
540
+ `[data-gr-editing]` while it's being edited), so you can restyle or hide it with a
541
+ rule of your own; to hide it, set `--gr-field-outline: transparent`. Every class
542
+ Guardrail adds is prefixed `gr-`.
543
+
544
+ If your setup doesn't bundle CSS imported from a package, import it yourself once,
545
+ in your root layout: `import "@guard-rail/core/styles.css";`.
546
+
499
547
  ## Extending the toolbar
500
548
 
501
549
  The bundled toolbar (edit indicator, Team, Exit edit mode) has one extension point:
@@ -618,8 +666,9 @@ By default it only reads. It checks:
618
666
  | Flag | What it adds |
619
667
  |---|---|
620
668
  | `--fix` | Repairs what can be repaired in local files: copies missing migrations, adds a `GUARDRAIL_SITE_URL` note to `.env.example` / `.env.local`, adds baseline security headers to `next.config`. It never changes secrets or Supabase dashboard settings, and re-running it is safe. Add `--push` to also run `supabase db push`. |
621
- | `--app-url=http://localhost:3000` | Also checks the response headers of a running copy of your site. |
669
+ | `--app-url=http://localhost:3000` | Also checks the response headers of a running copy of your site, including that Guardrail's stylesheet reached the page and that its Content-Security-Policy lets Guardrail's uploads and images through. |
622
670
  | `--e2e` | Runs an end-to-end suite against the database. It creates a throwaway user, a few rows and a test image, then exercises sign-in, claiming an invitation, row-level security (an editor can write their own site and nobody else's; visitors can read but not write), the last-admin guard, rate limiting and image uploads, and deletes everything it made. It asks before touching a database that isn't local (`--yes` skips the question), and needs the service-role key. |
671
+ | `--csp` | Prints a starter `Content-Security-Policy-Report-Only` header built from your `SUPABASE_URL` and exits. |
623
672
  | `--offline` | Skips the advisory lookup (the only network call besides your own Supabase project). Package names and versions are what's sent otherwise, as with `pnpm audit`; offline, Next.js is checked against a built-in list that may be out of date. |
624
673
  | `--production` | Treats production-only requirements as failures instead of warnings. |
625
674
  | `--strict` / `--json` | Exit non-zero on warnings too; machine-readable output for CI. |
@@ -737,6 +786,42 @@ Run `pnpm exec guardrail-doctor --production` to check everything below.
737
786
  - **Add security headers** in `next.config`: at least a `Content-Security-Policy`
738
787
  with `frame-ancestors 'self'` (so the editing chrome can't be framed by another
739
788
  site), `Strict-Transport-Security`, and `X-Content-Type-Options: nosniff`.
789
+ `guardrail-doctor --fix` adds this baseline. For a policy that also restricts
790
+ scripts and styles, see "Content Security Policy" below.
791
+
792
+ ### Content Security Policy
793
+
794
+ Guardrail works under a strict policy — no `'unsafe-inline'` for styles and no
795
+ nonce. Its chrome is a stylesheet, and it renders no inline scripts and uses no
796
+ `eval`. A strict policy only has to allow the two places the browser talks to
797
+ Supabase directly:
798
+
799
+ | Directive | Allow | Why |
800
+ |---|---|---|
801
+ | `connect-src` | `'self'` and your Supabase origin | image uploads are sent straight from the browser to Supabase storage |
802
+ | `img-src` | `'self'` and your Supabase origin | uploaded images and the media picker's thumbnails load from storage |
803
+ | `style-src` | `'self'` | nothing more |
804
+ | `frame-ancestors` | `'self'` | so the editing chrome can't be framed by another site |
805
+
806
+ Everything else — reads, saves, sign-in — goes through your own server, i.e.
807
+ `'self'`. If you build the header in code, `guardrailCspDirectives()` returns
808
+ these for you to merge into your own:
809
+
810
+ ```ts
811
+ import { guardrailCspDirectives, cspString } from "@guard-rail/core";
812
+
813
+ const gr = guardrailCspDirectives({ supabaseUrl: process.env.SUPABASE_URL });
814
+ // gr["connect-src"] -> ["'self'", "https://abc.supabase.co"]
815
+ // cspString(gr) -> "style-src 'self'; img-src 'self' https://abc.supabase.co; …"
816
+ ```
817
+
818
+ To get started, `pnpm exec guardrail-doctor --csp` prints a starter
819
+ `Content-Security-Policy-Report-Only` header built from your `SUPABASE_URL`: send it
820
+ report-only first, tune it for what your own site loads (analytics, fonts,
821
+ embeds), then enforce it. Once the site is running,
822
+ `pnpm exec guardrail-doctor --app-url=https://your-site.com` checks that the live
823
+ policy lets Guardrail's uploads and images through. A script nonce, if you use
824
+ one, is set by your site (Next.js does it from a proxy); Guardrail doesn't need it.
740
825
 
741
826
  ## Known limitations
742
827
 
@@ -14,6 +14,7 @@ import { createRequire } from "node:module";
14
14
  import { createInterface } from "node:readline/promises";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { GR_SCHEMA, GR_MEMBERS_TABLE } from "../core/defs.js";
17
+ import { cspString, guardrailCspDirectives } from "../core/csp.js";
17
18
  const NEXT_MIN_PATCHED = {
18
19
  15: [15, 5, 28],
19
20
  16: [16, 4, 1]
@@ -66,6 +67,9 @@ Options:
66
67
  versions of next, @supabase/* and @guard-rail/core are
67
68
  checked against its published security advisories (only
68
69
  package names and versions are sent, as \`pnpm audit\` does).
70
+ --csp Print a starter Content-Security-Policy-Report-Only header
71
+ for this site (built from SUPABASE_URL) and exit. Add it
72
+ report-only first, watch the reports, then enforce.
69
73
  --production Treat production-only requirements (like
70
74
  GUARDRAIL_SITE_URL) as failures rather than warnings.
71
75
  --strict Exit non-zero on warnings too.
@@ -101,7 +105,7 @@ function parseFlags(argv) {
101
105
  unknown.push(arg);
102
106
  }
103
107
  }
104
- const known = /* @__PURE__ */ new Set(["fix", "push", "e2e", "yes", "production", "strict", "json", "offline", "help", "h"]);
108
+ const known = /* @__PURE__ */ new Set(["fix", "push", "e2e", "yes", "production", "strict", "json", "offline", "csp", "help", "h"]);
105
109
  for (const f of flags) if (!known.has(f)) unknown.push(`--${f}`);
106
110
  for (const k of Object.keys(values)) if (k !== "app-url") unknown.push(`--${k}`);
107
111
  return { flags, values, unknown };
@@ -575,7 +579,71 @@ async function checkDatabase(ctx, sb) {
575
579
  info("Admin guard (can't delete the last admin)", "Verified by --e2e; it's a trigger, which can't be probed read-only.");
576
580
  return true;
577
581
  }
578
- async function checkLiveSite(appUrl) {
582
+ function parseCsp(value) {
583
+ const out = {};
584
+ for (const part of value.split(";")) {
585
+ const [name, ...sources] = part.trim().split(/\s+/);
586
+ if (name) out[name.toLowerCase()] = sources;
587
+ }
588
+ return out;
589
+ }
590
+ function effectiveSources(csp, name) {
591
+ return csp[name] ?? csp["default-src"] ?? null;
592
+ }
593
+ function allowsOrigin(sources, origin) {
594
+ if (sources === null) return true;
595
+ const target = new URL(origin);
596
+ return sources.some((raw) => {
597
+ const src = raw.toLowerCase();
598
+ if (src === "*") return true;
599
+ if (src === `${target.protocol}`) return true;
600
+ if (src === origin.toLowerCase()) return true;
601
+ const wild = src.match(/^(https?:)\/\/\*\.(.+?)(?::(\d+))?$/);
602
+ if (wild) {
603
+ const [, scheme, domain, port] = wild;
604
+ return scheme === target.protocol && target.hostname.endsWith(`.${domain}`) && (!port || port === target.port);
605
+ }
606
+ return false;
607
+ });
608
+ }
609
+ function starterPolicy(supabaseUrl) {
610
+ const gr = guardrailCspDirectives(supabaseUrl ? { supabaseUrl } : {});
611
+ return cspString({
612
+ "default-src": ["'self'"],
613
+ "script-src": ["'self'", "'nonce-REPLACE_PER_REQUEST'", "'strict-dynamic'"],
614
+ "style-src": gr["style-src"],
615
+ "img-src": [...gr["img-src"], "data:"],
616
+ "connect-src": gr["connect-src"],
617
+ "font-src": ["'self'", "data:"],
618
+ "object-src": ["'none'"],
619
+ "base-uri": ["'self'"],
620
+ "form-action": ["'self'"],
621
+ "frame-ancestors": gr["frame-ancestors"]
622
+ });
623
+ }
624
+ function printStarterCsp(ctx) {
625
+ console.log(`Starter Content-Security-Policy
626
+
627
+ Content-Security-Policy-Report-Only: ${starterPolicy(ctx.url)}
628
+
629
+ How to use it
630
+ - Send it as Content-Security-Policy-Report-Only first. Browsers report what it
631
+ would block without blocking anything; tune it for the things your site
632
+ loads itself (analytics, fonts, embeds), then switch the header name to
633
+ Content-Security-Policy.
634
+ - Guardrail needs nothing beyond 'self' for styles and scripts. Its stylesheet is
635
+ a real file and it renders no inline scripts, so it needs no 'unsafe-inline' and no
636
+ nonce. The only extras are your Supabase origin in connect-src (image
637
+ uploads) and img-src (the media picker's thumbnails).${ctx.url ? "" : "\n (Set SUPABASE_URL and run this again to include your Supabase origin.)"}
638
+ - script-src above uses a per-request nonce, which Next.js sets from a proxy
639
+ (see "Content Security Policy" in the Next.js docs). With only a static header
640
+ in next.config you can't use a nonce \u2014 use 'self' 'unsafe-inline' for scripts
641
+ instead, which is a weaker policy but still a useful one.
642
+ - guardrailCspDirectives() from @guard-rail/core returns Guardrail's directives
643
+ if you build the header in code.
644
+ `);
645
+ }
646
+ async function checkLiveSite(appUrl, ctx) {
579
647
  group("Live site");
580
648
  let res;
581
649
  try {
@@ -606,6 +674,69 @@ async function checkLiveSite(appUrl) {
606
674
  const setCookie = typeof h.getSetCookie === "function" ? h.getSetCookie() : [];
607
675
  const session = setCookie.filter((c) => /^sb-.*-auth-token/i.test(c));
608
676
  if (session.length) fail("A session cookie was set for an anonymous request", session.map((c) => c.split("=")[0]).join(", "));
677
+ if (res.status === 200) {
678
+ const html = await res.text().catch(() => "");
679
+ const MARKER = /--gr-bg\b|\.gr-banner\b/;
680
+ const inline = [...html.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)].map((m) => m[1]).join("\n");
681
+ let found = MARKER.test(inline);
682
+ const hrefs = [...html.matchAll(/<link\b[^>]*rel=["']stylesheet["'][^>]*>/gi)].map((m) => m[0].match(/href=["']([^"']+)["']/i)?.[1]).filter((x) => Boolean(x)).slice(0, 8);
683
+ for (const href of hrefs) {
684
+ if (found) break;
685
+ try {
686
+ const css = await fetch(new URL(href, appUrl), { signal: AbortSignal.timeout(8e3) });
687
+ if (css.ok && MARKER.test(await css.text())) found = true;
688
+ } catch {
689
+ }
690
+ }
691
+ if (found) pass("Guardrail's stylesheet is on the page");
692
+ else if (hrefs.length === 0 && !/<style/i.test(html)) skip("Guardrail's stylesheet", "The page has no stylesheets to inspect.");
693
+ else {
694
+ warn(
695
+ "Guardrail's stylesheet isn't on the page",
696
+ "Its editing chrome would render unstyled. The package imports its own CSS, which your bundler may not be picking up.",
697
+ 'Import it once in your root layout: import "@guard-rail/core/styles.css";'
698
+ );
699
+ }
700
+ }
701
+ const enforced = h.get("content-security-policy");
702
+ const reportOnly = h.get("content-security-policy-report-only");
703
+ const policy = enforced ?? reportOnly;
704
+ if (!policy) {
705
+ warn(
706
+ "No Content-Security-Policy",
707
+ "Nothing limits where scripts and styles can load from, so an injected script would run.",
708
+ "Run: pnpm exec guardrail-doctor --csp (prints a starter report-only policy)"
709
+ );
710
+ return;
711
+ }
712
+ const parsed = parseCsp(policy);
713
+ const label = enforced ? "" : " (report-only)";
714
+ if (!parsed["default-src"] && !parsed["script-src"]) {
715
+ info(`Content-Security-Policy only sets ${Object.keys(parsed).join(", ") || "nothing"}`, "It doesn't restrict scripts. Run: pnpm exec guardrail-doctor --csp for a fuller starter.");
716
+ return;
717
+ }
718
+ const style = effectiveSources(parsed, "style-src");
719
+ if (style && style.map((x) => x.toLowerCase()).includes("'unsafe-inline'")) {
720
+ info(`style-src allows 'unsafe-inline'${label}`, "Guardrail no longer needs it \u2014 its chrome is a stylesheet. Remove it unless something else on the site does.");
721
+ } else {
722
+ pass(`Policy${label} doesn't need 'unsafe-inline' for Guardrail's styles`);
723
+ }
724
+ if (ctx.url) {
725
+ let supabase;
726
+ try {
727
+ supabase = new URL(ctx.url).origin;
728
+ } catch {
729
+ }
730
+ if (supabase && supabase !== new URL(appUrl).origin) {
731
+ for (const [directive, why] of [
732
+ ["connect-src", "image uploads go straight from the browser to Supabase storage"],
733
+ ["img-src", "uploaded images and the media picker's thumbnails load from Supabase storage"]
734
+ ]) {
735
+ if (allowsOrigin(effectiveSources(parsed, directive), supabase)) pass(`${directive}${label} allows ${supabase}`);
736
+ else warn(`${directive}${label} blocks ${supabase}`, `Needed because ${why}.`, `Add ${supabase} to ${directive}.`);
737
+ }
738
+ }
739
+ }
609
740
  }
610
741
  const TINY_PNG = Buffer.from(
611
742
  "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
@@ -839,6 +970,10 @@ ${USAGE}`);
839
970
  anonKey: env.SUPABASE_ANON_KEY,
840
971
  serviceKey: env.SUPABASE_SERVICE_ROLE_KEY ?? env.SERVICE_KEY
841
972
  };
973
+ if (flags.has("csp")) {
974
+ printStarterCsp(ctx);
975
+ return 0;
976
+ }
842
977
  const json = flags.has("json");
843
978
  if (!json) console.log("Guardrail doctor");
844
979
  if (flags.has("fix")) {
@@ -876,7 +1011,7 @@ ${paint(1, "Fixes")}`);
876
1011
  checkFiles(ctx);
877
1012
  const sb = await loadSupabase();
878
1013
  const dbReachable = await checkDatabase(ctx, sb);
879
- if (values["app-url"]) await checkLiveSite(values["app-url"]);
1014
+ if (values["app-url"]) await checkLiveSite(values["app-url"], ctx);
880
1015
  if (flags.has("e2e")) {
881
1016
  group("End-to-end");
882
1017
  if (!dbReachable || !sb) {