create-nextblock 0.14.6 → 0.15.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nextblock",
3
- "version": "0.14.6",
3
+ "version": "0.15.1",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -6607,6 +6607,193 @@ DROP POLICY IF EXISTS site_settings_delete_policy ON public.site_settings;
6607
6607
  CREATE POLICY site_settings_delete_policy ON public.site_settings FOR DELETE TO authenticated USING ((((key <> ALL (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role]))) OR ((key = ANY (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role))));
6608
6608
 
6609
6609
 
6610
+ -- >>> FROM: 00000000000018_site_scripts.sql <<<
6611
+ -- Site scripts: admin-authored JavaScript injected into every page of the public site.
6612
+ --
6613
+ -- Rich-text blocks can already carry an inline <script>, but that script belongs to
6614
+ -- one block on one page. This table is for behaviour that spans the site: chat
6615
+ -- widgets, third-party embeds, and the scroll/animation helpers that page classes
6616
+ -- rely on. Each row gets a name, an on/off switch, and a defined injection point.
6617
+ --
6618
+ -- NOT the same thing as \`site_settings.privacy_settings -> custom_scripts\`, which is
6619
+ -- a single consent-gated blob for marketing tags and only fires once a visitor
6620
+ -- accepts cookies. Rows here are functional site code and run unconditionally, so
6621
+ -- anything requiring consent belongs in that setting instead, not this table.
6622
+ --
6623
+ -- Scripts are emitted with the request's CSP nonce by the root layout, so they run
6624
+ -- under the site's existing Content-Security-Policy rather than forcing it open.
6625
+ --
6626
+ -- Security posture: this is arbitrary JavaScript on every page, so writes are
6627
+ -- ADMIN-only (WRITER is deliberately excluded, unlike most content tables) and the
6628
+ -- public may read only rows that are switched on, so a half-written draft is never
6629
+ -- served to a visitor.
6630
+
6631
+ CREATE TABLE IF NOT EXISTS public.site_scripts (
6632
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
6633
+ name text NOT NULL,
6634
+ description text,
6635
+ -- Raw JavaScript, stored WITHOUT the surrounding <script> tag. The layout adds
6636
+ -- the tag so the nonce and attributes are always applied by us, never by the
6637
+ -- author. Ignored when \`src\` is set.
6638
+ code text DEFAULT ''::text NOT NULL,
6639
+ -- When set, an external script is loaded from this URL and \`code\` is ignored.
6640
+ src text,
6641
+ -- Where the tag is emitted. 'head' runs before first paint (blocking, use
6642
+ -- sparingly); 'body_end' runs once the markup exists and is the right default
6643
+ -- for anything that queries the DOM.
6644
+ placement text DEFAULT 'body_end'::text NOT NULL,
6645
+ -- Applies to external \`src\` scripts; inline code ignores it.
6646
+ load_strategy text DEFAULT 'default'::text NOT NULL,
6647
+ is_active boolean DEFAULT false NOT NULL,
6648
+ sort_order integer DEFAULT 0 NOT NULL,
6649
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
6650
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
6651
+ CONSTRAINT site_scripts_pkey PRIMARY KEY (id),
6652
+ CONSTRAINT site_scripts_placement_check
6653
+ CHECK ((placement = ANY (ARRAY['head'::text, 'body_start'::text, 'body_end'::text]))),
6654
+ CONSTRAINT site_scripts_load_strategy_check
6655
+ CHECK ((load_strategy = ANY (ARRAY['default'::text, 'defer'::text, 'async'::text]))),
6656
+ -- An external script must be https so it cannot be downgraded in transit.
6657
+ CONSTRAINT site_scripts_src_scheme_check
6658
+ CHECK ((src IS NULL OR src ~ '^https://')),
6659
+ -- A row has to actually do something: inline code or an external src.
6660
+ CONSTRAINT site_scripts_has_payload_check
6661
+ CHECK ((src IS NOT NULL OR length(btrim(code)) > 0))
6662
+ );
6663
+
6664
+ COMMENT ON TABLE public.site_scripts IS
6665
+ 'Admin-authored JavaScript injected into the public site by the root layout, with the request CSP nonce applied. Only is_active rows are publicly readable; only ADMIN may write. Distinct from privacy_settings.custom_scripts, which is consent-gated marketing tags.';
6666
+
6667
+ CREATE INDEX IF NOT EXISTS site_scripts_active_placement_sort_idx
6668
+ ON public.site_scripts USING btree (is_active, placement, sort_order);
6669
+
6670
+ DROP TRIGGER IF EXISTS set_site_scripts_updated_at ON public.site_scripts;
6671
+ CREATE TRIGGER set_site_scripts_updated_at
6672
+ BEFORE UPDATE ON public.site_scripts
6673
+ FOR EACH ROW EXECUTE FUNCTION public.set_current_timestamp_updated_at();
6674
+
6675
+ ALTER TABLE public.site_scripts ENABLE ROW LEVEL SECURITY;
6676
+
6677
+ GRANT ALL ON TABLE public.site_scripts TO anon;
6678
+ GRANT ALL ON TABLE public.site_scripts TO authenticated;
6679
+ GRANT ALL ON TABLE public.site_scripts TO service_role;
6680
+
6681
+ -- Anonymous visitors need the active scripts to render the page. Inactive rows stay
6682
+ -- private so a half-written script is never exposed before it is switched on.
6683
+ DROP POLICY IF EXISTS "Public read active site scripts" ON public.site_scripts;
6684
+ CREATE POLICY "Public read active site scripts" ON public.site_scripts
6685
+ FOR SELECT TO authenticated, anon USING (is_active);
6686
+
6687
+ DROP POLICY IF EXISTS "Admins read all site scripts" ON public.site_scripts;
6688
+ CREATE POLICY "Admins read all site scripts" ON public.site_scripts
6689
+ FOR SELECT TO authenticated
6690
+ USING (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));
6691
+
6692
+ DROP POLICY IF EXISTS "Admins insert site scripts" ON public.site_scripts;
6693
+ CREATE POLICY "Admins insert site scripts" ON public.site_scripts
6694
+ FOR INSERT TO authenticated
6695
+ WITH CHECK (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));
6696
+
6697
+ DROP POLICY IF EXISTS "Admins update site scripts" ON public.site_scripts;
6698
+ CREATE POLICY "Admins update site scripts" ON public.site_scripts
6699
+ FOR UPDATE TO authenticated
6700
+ USING (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role))
6701
+ WITH CHECK (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));
6702
+
6703
+ DROP POLICY IF EXISTS "Admins delete site scripts" ON public.site_scripts;
6704
+ CREATE POLICY "Admins delete site scripts" ON public.site_scripts
6705
+ FOR DELETE TO authenticated
6706
+ USING (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));
6707
+
6708
+
6709
+ -- >>> FROM: 00000000000019_site_script_revisions.sql <<<
6710
+ -- Audit trail and undo for site scripts.
6711
+ --
6712
+ -- \`site_scripts\` ships arbitrary JavaScript to every visitor, which makes it the
6713
+ -- highest-privilege write in the CMS: a bad or malicious snippet can read cookies,
6714
+ -- watch checkout forms, or phone home. Content has Revision History for exactly this
6715
+ -- reason; code needs it more, not less. Every create/update/delete writes one row
6716
+ -- here, and every row is a complete, restorable snapshot — so this table is both the
6717
+ -- log ("who shipped what, when, from where") and the undo.
6718
+ --
6719
+ -- APPEND-ONLY BY CONSTRUCTION. There are no UPDATE or DELETE policies, and the
6720
+ -- trigger below rejects both even for the service role, which otherwise bypasses
6721
+ -- RLS. An audit trail that the compromised credential can rewrite is not an audit
6722
+ -- trail. Reverting therefore writes a NEW 'revert' row rather than removing history.
6723
+ --
6724
+ -- \`script_id\` and \`actor_user_id\` are deliberately PLAIN uuids with no foreign keys:
6725
+ -- an FK with ON DELETE SET NULL would have to UPDATE this table when a script or a
6726
+ -- profile is deleted, which the append-only trigger forbids. \`script_name\` is
6727
+ -- denormalised so a deleted script is still identifiable in the log.
6728
+
6729
+ CREATE TABLE IF NOT EXISTS public.site_script_revisions (
6730
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
6731
+ script_id uuid,
6732
+ script_name text NOT NULL,
6733
+ revision_type text NOT NULL,
6734
+ -- Null when the actor could not be resolved (e.g. a localhost dev connection).
6735
+ actor_user_id uuid,
6736
+ -- Which surface made the change, so an unexpected edit can be traced back to
6737
+ -- the dashboard or to an MCP token.
6738
+ source text DEFAULT 'cms'::text NOT NULL,
6739
+ summary text,
6740
+ -- Full restorable state of the script at this revision. For 'delete' it is the
6741
+ -- state immediately BEFORE removal, so restoring it brings the script back.
6742
+ snapshot jsonb NOT NULL,
6743
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
6744
+ CONSTRAINT site_script_revisions_pkey PRIMARY KEY (id),
6745
+ CONSTRAINT site_script_revisions_type_check
6746
+ CHECK ((revision_type = ANY (ARRAY['create'::text, 'update'::text, 'delete'::text, 'revert'::text]))),
6747
+ CONSTRAINT site_script_revisions_source_check
6748
+ CHECK ((source = ANY (ARRAY['cms'::text, 'mcp'::text]))),
6749
+ CONSTRAINT site_script_revisions_snapshot_is_object_check
6750
+ CHECK ((jsonb_typeof(snapshot) = 'object'))
6751
+ );
6752
+
6753
+ COMMENT ON TABLE public.site_script_revisions IS
6754
+ 'Append-only audit log and undo history for site_scripts. Each row is a restorable snapshot. UPDATE and DELETE are blocked by trigger, including for the service role.';
6755
+
6756
+ CREATE INDEX IF NOT EXISTS site_script_revisions_script_created_idx
6757
+ ON public.site_script_revisions USING btree (script_id, created_at DESC);
6758
+
6759
+ CREATE INDEX IF NOT EXISTS site_script_revisions_created_idx
6760
+ ON public.site_script_revisions USING btree (created_at DESC);
6761
+
6762
+ -- Enforced in the database rather than the application so it holds for every
6763
+ -- caller, including the service-role client the MCP server uses.
6764
+ CREATE OR REPLACE FUNCTION public.prevent_site_script_revision_rewrite() RETURNS trigger
6765
+ LANGUAGE plpgsql
6766
+ SET search_path = ''
6767
+ AS $$
6768
+ BEGIN
6769
+ RAISE EXCEPTION 'site_script_revisions is append-only; % is not permitted', TG_OP
6770
+ USING ERRCODE = 'restrict_violation';
6771
+ END;
6772
+ $$;
6773
+
6774
+ DROP TRIGGER IF EXISTS trg_site_script_revisions_append_only ON public.site_script_revisions;
6775
+ CREATE TRIGGER trg_site_script_revisions_append_only
6776
+ BEFORE UPDATE OR DELETE ON public.site_script_revisions
6777
+ FOR EACH ROW EXECUTE FUNCTION public.prevent_site_script_revision_rewrite();
6778
+
6779
+ ALTER TABLE public.site_script_revisions ENABLE ROW LEVEL SECURITY;
6780
+
6781
+ GRANT SELECT, INSERT ON TABLE public.site_script_revisions TO authenticated;
6782
+ GRANT ALL ON TABLE public.site_script_revisions TO service_role;
6783
+
6784
+ -- Read is ADMIN-only: snapshots contain the full source of scripts that may not be
6785
+ -- active yet, and the log itself reveals operational history.
6786
+ DROP POLICY IF EXISTS "Admins read site script revisions" ON public.site_script_revisions;
6787
+ CREATE POLICY "Admins read site script revisions" ON public.site_script_revisions
6788
+ FOR SELECT TO authenticated
6789
+ USING (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));
6790
+
6791
+ DROP POLICY IF EXISTS "Admins insert site script revisions" ON public.site_script_revisions;
6792
+ CREATE POLICY "Admins insert site script revisions" ON public.site_script_revisions
6793
+ FOR INSERT TO authenticated
6794
+ WITH CHECK (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));
6795
+
6796
+
6610
6797
  -- Step D: Record the applied migrations in history (truncated in Step B) so
6611
6798
  -- \`npm run db:migrate:check\` reports up to date instead of listing every file as pending.
6612
6799
  INSERT INTO supabase_migrations.schema_migrations (version, name) VALUES
@@ -6627,7 +6814,9 @@ CREATE POLICY site_settings_delete_policy ON public.site_settings FOR DELETE TO
6627
6814
  ('00000000000014', 'site_themes'),
6628
6815
  ('00000000000015', 'scheduled_publishing'),
6629
6816
  ('00000000000016', 'product_revisions_and_revision_baseline'),
6630
- ('00000000000017', 'cortex_ai_mcp_server')
6817
+ ('00000000000017', 'cortex_ai_mcp_server'),
6818
+ ('00000000000018', 'site_scripts'),
6819
+ ('00000000000019', 'site_script_revisions')
6631
6820
  ON CONFLICT (version) DO NOTHING;
6632
6821
 
6633
6822
  -- Step E: Anchor preserved profiles
@@ -55,21 +55,45 @@ const MCP_SKIP_CONFIRMATION = true;
55
55
 
56
56
  type McpAuth = {
57
57
  actorUserId: string | null;
58
+ /**
59
+ * True when this token outlived the account that minted it (`created_by` is
60
+ * `ON DELETE SET NULL`), so `actorUserId` below is a stand-in rather than the
61
+ * principal that actually holds the credential.
62
+ *
63
+ * A stand-in is fine for *attribution* — a revision needs some author — but it
64
+ * must never be the basis for *authorization*, or deleting an administrator would
65
+ * silently promote their leftover token to whichever admin happens to sort first.
66
+ * Offboarding someone is exactly when their credentials should lose power, not
67
+ * inherit someone else's.
68
+ */
69
+ actorFromOrphanedToken: boolean;
58
70
  scopes: CortexAiMcpScope[];
59
71
  source: 'admin-session' | 'localhost' | 'token';
60
72
  };
61
73
 
62
- async function importExternalImageForMcp(input: {
63
- url: string;
64
- altText?: string;
65
- }): Promise<{ id: string } | { error: string }> {
66
- const result = await importExternalImageToMedia({ altText: input.altText, url: input.url });
74
+ /**
75
+ * MCP has no cookie session, so the importer is handed the actor this request already
76
+ * authenticated. Without it every image import fails with "You must be signed in to
77
+ * import an image" which silently strips the imagery out of any page or product
78
+ * built over MCP, since executors treat an import failure as non-fatal.
79
+ */
80
+ function createMcpImageImporter(actorUserId: string | null) {
81
+ return async function importExternalImageForMcp(input: {
82
+ url: string;
83
+ altText?: string;
84
+ }): Promise<{ id: string } | { error: string }> {
85
+ const result = await importExternalImageToMedia({
86
+ ...(actorUserId ? { actorUserId } : {}),
87
+ altText: input.altText,
88
+ url: input.url,
89
+ });
67
90
 
68
- if ('error' in result) {
69
- return { error: result.error };
70
- }
91
+ if ('error' in result) {
92
+ return { error: result.error };
93
+ }
71
94
 
72
- return { id: result.media.id };
95
+ return { id: result.media.id };
96
+ };
73
97
  }
74
98
 
75
99
  /** Mirrors the global-agent route so MCP writes land in Revision History like any other edit. */
@@ -173,7 +197,8 @@ async function authenticateMcpRequest(request: Request): Promise<McpAuth | null>
173
197
  void touchCortexAiMcpToken(serviceClient, verification.token.id);
174
198
 
175
199
  return {
176
- actorUserId: verification.token.created_by,
200
+ actorFromOrphanedToken: !verification.token.created_by,
201
+ actorUserId: verification.token.created_by ?? (await resolveFallbackAdminUserId()),
177
202
  scopes: verification.scopes,
178
203
  source: 'token',
179
204
  };
@@ -182,16 +207,59 @@ async function authenticateMcpRequest(request: Request): Promise<McpAuth | null>
182
207
  const adminUserId = await resolveAdminSessionUserId();
183
208
 
184
209
  if (adminUserId) {
185
- return { actorUserId: adminUserId, scopes: ['read', 'write'], source: 'admin-session' };
210
+ return {
211
+ actorFromOrphanedToken: false,
212
+ actorUserId: adminUserId,
213
+ scopes: ['read', 'write'],
214
+ source: 'admin-session',
215
+ };
186
216
  }
187
217
 
188
218
  if (shouldTrustLocalMcpRequest({ hostHeader: request.headers.get('host'), settings })) {
189
- return { actorUserId: null, scopes: ['read', 'write'], source: 'localhost' };
219
+ // Loopback trust is an explicit opt-in on a development machine, where anyone
220
+ // who can reach this endpoint can already read the service-role key out of
221
+ // .env.local. Not treated as orphaned: it grants nothing new.
222
+ return {
223
+ actorFromOrphanedToken: false,
224
+ actorUserId: await resolveFallbackAdminUserId(),
225
+ scopes: ['read', 'write'],
226
+ source: 'localhost',
227
+ };
190
228
  }
191
229
 
192
230
  return null;
193
231
  }
194
232
 
233
+ /**
234
+ * Every mutating Cortex executor calls `getActorUserId()` and throws without one, so a
235
+ * connection with no identity behind it can read but never write. Two connections have
236
+ * that problem: localhost trust (nobody signed in) and a token whose creator was since
237
+ * deleted (`created_by` is `ON DELETE SET NULL`).
238
+ *
239
+ * Rather than advertise a `write` scope those connections cannot actually use, fall back
240
+ * to an ADMIN profile so the write is attributed to a real person in Revision History.
241
+ * This grants no new authority — reaching here already required either loopback in
242
+ * development or a valid admin-minted token — it only supplies the author field.
243
+ *
244
+ * `id` ordering is arbitrary but stable, which is what matters: the same fallback admin
245
+ * every time, so revision authorship does not jump between people run to run.
246
+ */
247
+ async function resolveFallbackAdminUserId(): Promise<string | null> {
248
+ try {
249
+ const { data } = await getServiceRoleSupabaseClient()
250
+ .from('profiles')
251
+ .select('id')
252
+ .eq('role', 'ADMIN')
253
+ .order('id', { ascending: true })
254
+ .limit(1)
255
+ .maybeSingle();
256
+
257
+ return data?.id ?? null;
258
+ } catch {
259
+ return null;
260
+ }
261
+ }
262
+
195
263
  async function resolveAdminSessionUserId(): Promise<string | null> {
196
264
  try {
197
265
  const supabase = createClient();
@@ -217,8 +285,9 @@ async function resolveAdminSessionUserId(): Promise<string | null> {
217
285
 
218
286
  function buildToolContext(auth: McpAuth): CortexMcpToolContext {
219
287
  return {
288
+ actorFromOrphanedToken: auth.actorFromOrphanedToken,
220
289
  actorUserId: auth.actorUserId,
221
- importExternalImage: importExternalImageForMcp,
290
+ importExternalImage: createMcpImageImporter(auth.actorUserId),
222
291
  // No open editor over MCP: tools that need a target take it in their arguments
223
292
  // (`cmsTarget`, `slug`, `entityId`) rather than inheriting one from a UI.
224
293
  pageContext: null,
@@ -9,7 +9,7 @@ import {
9
9
  LayoutDashboard, FileText, PenTool, Users, Settings, ChevronRight, LogOut, Menu, ListTree, Image as ImageIconLucide, X, Languages as LanguagesIconLucide, MessageSquare,
10
10
  Copyright as CopyrightIcon, ShoppingBag, ListOrdered, CreditCard, Package, Coins,
11
11
  ExternalLink, Paintbrush, Brain, TicketPercent, ShieldAlert, Folder, DatabaseBackup, Boxes, Tag,
12
- ShieldCheck, Cookie, LineChart, Mail, UserPlus, SlidersHorizontal,
12
+ ShieldCheck, Code2, Cookie, LineChart, Mail, UserPlus, SlidersHorizontal,
13
13
  } from "lucide-react"
14
14
  import TwoFactorReminderBanner from "./components/TwoFactorReminderBanner"
15
15
  import SystemAlertsBanner, { type SystemAlertItem } from "./components/SystemAlertsBanner"
@@ -229,6 +229,7 @@ export default function CmsClientLayout({
229
229
  else if (pathname.startsWith("/cms/settings/logos")) pageTitle = "Branding";
230
230
  else if (pathname.startsWith("/cms/settings/copyright")) pageTitle = "Copyright Settings";
231
231
  else if (pathname.startsWith("/cms/settings/global-css")) pageTitle = "Themes & CSS";
232
+ else if (pathname.startsWith("/cms/settings/site-scripts")) pageTitle = "Site Scripts";
232
233
  else if (pathname.startsWith("/cms/settings/extra-translations")) pageTitle = "Extra Translations";
233
234
  else if (pathname.startsWith("/cms/settings/backup-restore")) pageTitle = "Backup And Restore";
234
235
  else if (pathname.startsWith("/cms/settings/currencies")) pageTitle = "Currency Settings";
@@ -424,6 +425,9 @@ export default function CmsClientLayout({
424
425
  <NavItem href="/cms/settings/global-css" icon={Paintbrush} isActive={pathname.startsWith("/cms/settings/global-css")} adminOnly isAdmin={isAdmin} onClick={closeSidebarOnMobile}>
425
426
  Themes &amp; CSS
426
427
  </NavItem>
428
+ <NavItem href="/cms/settings/site-scripts" icon={Code2} isActive={pathname.startsWith("/cms/settings/site-scripts")} adminOnly isAdmin={isAdmin} onClick={closeSidebarOnMobile}>
429
+ Site Scripts
430
+ </NavItem>
427
431
  <NavItem href="/cms/settings/privacy" icon={Cookie} isActive={pathname.startsWith("/cms/settings/privacy")} adminOnly isAdmin={isAdmin} onClick={closeSidebarOnMobile}>
428
432
  Privacy &amp; Consent
429
433
  </NavItem>
@@ -6,7 +6,7 @@ import "server-only";
6
6
  import sharp from "sharp";
7
7
  import { PutObjectCommand } from "@aws-sdk/client-s3";
8
8
 
9
- import { createClient } from "@nextblock-cms/db/server";
9
+ import { createClient, getServiceRoleSupabaseClient } from "@nextblock-cms/db/server";
10
10
  import { recordMediaUpload } from "@nextblock-cms/db";
11
11
  import { getS3Client } from "@nextblock-cms/utils/server";
12
12
 
@@ -36,6 +36,34 @@ type ImportExternalImageResult =
36
36
  * Reject local/loopback/private/link-local hosts and cloud metadata endpoints so an
37
37
  * admin-supplied URL cannot be used to probe internal infrastructure (SSRF).
38
38
  */
39
+ /**
40
+ * Unwrap an IPv4-mapped IPv6 address to dotted-quad.
41
+ *
42
+ * `http://[::ffff:127.0.0.1]/` reaches loopback, but the URL parser normalises it to
43
+ * `::ffff:7f00:1`, which matches none of the IPv4 checks below — a working bypass of
44
+ * the whole blocklist. Mirrors `unwrapMappedIpv4` in
45
+ * libs/cortex/src/lib/ai-global-agent-tools.ts; the two blocklists are duplicated
46
+ * because a published lib cannot import from the app, so fix both together.
47
+ */
48
+ function unwrapMappedIpv4(host: string): string | null {
49
+ const mapped = host.match(/^::ffff:(.+)$/i);
50
+
51
+ if (!mapped) return null;
52
+
53
+ const rest = mapped[1] as string;
54
+
55
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(rest)) return rest;
56
+
57
+ const hextets = rest.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
58
+
59
+ if (!hextets) return null;
60
+
61
+ const high = Number.parseInt(hextets[1] as string, 16);
62
+ const low = Number.parseInt(hextets[2] as string, 16);
63
+
64
+ return [(high >> 8) & 255, high & 255, (low >> 8) & 255, low & 255].join(".");
65
+ }
66
+
39
67
  function isBlockedImportHost(hostname: string): boolean {
40
68
  const host = hostname.trim().toLowerCase().replace(/\.$/, "").replace(/^\[|\]$/g, "");
41
69
 
@@ -50,10 +78,23 @@ function isBlockedImportHost(hostname: string): boolean {
50
78
  return true;
51
79
  }
52
80
 
53
- if (host === "0.0.0.0" || host === "::1" || host.startsWith("fe80:") || host.startsWith("fc") || host.startsWith("fd")) {
81
+ if (
82
+ host === "0.0.0.0" ||
83
+ host === "::" ||
84
+ host === "::1" ||
85
+ host.startsWith("fe80:") ||
86
+ host.startsWith("fc") ||
87
+ host.startsWith("fd")
88
+ ) {
54
89
  return true;
55
90
  }
56
91
 
92
+ const mappedIpv4 = unwrapMappedIpv4(host);
93
+
94
+ if (mappedIpv4) {
95
+ return isBlockedImportHost(mappedIpv4);
96
+ }
97
+
57
98
  const ipv4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
58
99
 
59
100
  if (ipv4) {
@@ -93,15 +134,28 @@ function slugifyFileBase(value: string): string {
93
134
  }
94
135
 
95
136
  /**
96
- * Download an external image (e.g. an AI-inserted stock photo) and persist it into the
97
- * NextBlock media library (R2 or Supabase Storage) so it becomes a permanent, optimized
98
- * asset the page no longer hotlinks. ADMIN/WRITER only.
137
+ * Establish the ADMIN/WRITER this import is attributed to.
138
+ *
139
+ * Two paths: a cookie session (the dashboard) or an explicitly supplied actor (the
140
+ * MCP server, which has already authenticated the caller by bearer token). Both
141
+ * end at the same role check, so the second is a different way to *identify* the
142
+ * uploader, not a way to skip authorization.
99
143
  */
100
- export async function importExternalImageToMedia(input: {
101
- url: string;
102
- altText?: string;
103
- fileName?: string;
104
- }): Promise<ImportExternalImageResult> {
144
+ async function resolveImportActorId(actorUserId?: string): Promise<{ id: string } | { error: string }> {
145
+ if (actorUserId) {
146
+ const { data: profile } = await getServiceRoleSupabaseClient()
147
+ .from("profiles")
148
+ .select("role")
149
+ .eq("id", actorUserId)
150
+ .single();
151
+
152
+ if (!profile || !["ADMIN", "WRITER"].includes(profile.role)) {
153
+ return { error: "You do not have permission to import media." };
154
+ }
155
+
156
+ return { id: actorUserId };
157
+ }
158
+
105
159
  const supabase = createClient();
106
160
  const {
107
161
  data: { user },
@@ -117,6 +171,34 @@ export async function importExternalImageToMedia(input: {
117
171
  return { error: "You do not have permission to import media." };
118
172
  }
119
173
 
174
+ return { id: user.id };
175
+ }
176
+
177
+ /**
178
+ * Download an external image (e.g. an AI-inserted stock photo) and persist it into the
179
+ * NextBlock media library (R2 or Supabase Storage) so it becomes a permanent, optimized
180
+ * asset the page no longer hotlinks. ADMIN/WRITER only.
181
+ */
182
+ export async function importExternalImageToMedia(input: {
183
+ url: string;
184
+ altText?: string;
185
+ fileName?: string;
186
+ /**
187
+ * Uploader for callers with no cookie session — the MCP server authenticates by
188
+ * bearer token, so `auth.getUser()` finds nobody and every import would fail with
189
+ * "You must be signed in". The role check below still runs against this id, so it
190
+ * confers no authority the caller did not already establish.
191
+ */
192
+ actorUserId?: string;
193
+ }): Promise<ImportExternalImageResult> {
194
+ const uploaderId = await resolveImportActorId(input.actorUserId);
195
+
196
+ if ("error" in uploaderId) {
197
+ return { error: uploaderId.error };
198
+ }
199
+
200
+ const userId = uploaderId.id;
201
+
120
202
  let target: URL;
121
203
 
122
204
  try {
@@ -233,7 +315,7 @@ export async function importExternalImageToMedia(input: {
233
315
  Bucket: bucket,
234
316
  ContentType: resolvedContentType,
235
317
  Key: objectKey,
236
- Metadata: { "uploader-user-id": user.id },
318
+ Metadata: { "uploader-user-id": userId },
237
319
  })
238
320
  );
239
321
  }
@@ -258,6 +340,10 @@ export async function importExternalImageToMedia(input: {
258
340
 
259
341
  const record = await recordMediaUpload(
260
342
  {
343
+ // Carried through so the media row is attributed to the same actor the role
344
+ // check above passed — without it the recorder falls back to the cookie
345
+ // session and fails for MCP callers after the upload has already happened.
346
+ actorUserId: input.actorUserId,
261
347
  blurDataUrl: blurDataUrl || undefined,
262
348
  description: altText || undefined,
263
349
  fileName,