create-nextblock 0.14.4 → 0.14.6

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 (55) hide show
  1. package/package.json +1 -1
  2. package/templates/nextblock-template/AGENTS.md +9 -0
  3. package/templates/nextblock-template/CLAUDE.md +1 -0
  4. package/templates/nextblock-template/app/[slug]/page.tsx +7 -2
  5. package/templates/nextblock-template/app/[slug]/page.utils.ts +8 -3
  6. package/templates/nextblock-template/app/actions/postActions.ts +3 -0
  7. package/templates/nextblock-template/app/actions/visibilityActions.ts +210 -0
  8. package/templates/nextblock-template/app/actions/visualEditingActions.test.ts +83 -3
  9. package/templates/nextblock-template/app/actions/visualEditingActions.ts +34 -14
  10. package/templates/nextblock-template/app/api/ai/global-agent/route.ts +45 -0
  11. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +457 -1
  12. package/templates/nextblock-template/app/api/mcp/route.ts +346 -0
  13. package/templates/nextblock-template/app/api/view/route.ts +114 -0
  14. package/templates/nextblock-template/app/article/[slug]/page.utils.ts +1 -2
  15. package/templates/nextblock-template/app/cms/components/DraftStatusActions.tsx +10 -0
  16. package/templates/nextblock-template/app/cms/components/VisibilityBadge.tsx +62 -0
  17. package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +528 -0
  18. package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +33 -17
  19. package/templates/nextblock-template/app/cms/pages/[id]/edit/page.tsx +19 -7
  20. package/templates/nextblock-template/app/cms/pages/actions.ts +17 -10
  21. package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +7 -29
  22. package/templates/nextblock-template/app/cms/pages/page.tsx +6 -19
  23. package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +42 -18
  24. package/templates/nextblock-template/app/cms/posts/actions.ts +16 -27
  25. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +3 -60
  26. package/templates/nextblock-template/app/cms/posts/page.tsx +6 -13
  27. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +63 -9
  28. package/templates/nextblock-template/app/cms/revisions/RevisionHistoryButton.tsx +66 -32
  29. package/templates/nextblock-template/app/cms/revisions/actions.ts +332 -285
  30. package/templates/nextblock-template/app/cms/revisions/service.test.ts +498 -0
  31. package/templates/nextblock-template/app/cms/revisions/service.ts +549 -471
  32. package/templates/nextblock-template/app/cms/revisions/utils.ts +304 -132
  33. package/templates/nextblock-template/app/cms/settings/cortex-ai/McpServerSettingsCard.tsx +584 -0
  34. package/templates/nextblock-template/app/cms/settings/cortex-ai/StoredCortexAiSettingsClient.tsx +6 -0
  35. package/templates/nextblock-template/app/cms/settings/cortex-ai/actions.ts +4 -20
  36. package/templates/nextblock-template/app/cms/settings/cortex-ai/mcp-actions.ts +205 -0
  37. package/templates/nextblock-template/app/cms/settings/cortex-ai/page.tsx +64 -1
  38. package/templates/nextblock-template/app/cms/settings/cortex-ai/require-admin.ts +34 -0
  39. package/templates/nextblock-template/app/lib/sitemap-utils.ts +6 -4
  40. package/templates/nextblock-template/app/lib/ucp/server.ts +4 -1
  41. package/templates/nextblock-template/app/page.tsx +6 -3
  42. package/templates/nextblock-template/app/product/[slug]/page.tsx +27 -3
  43. package/templates/nextblock-template/components/visual-editing/NextblockVisualEditing.tsx +4 -1
  44. package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +38 -3
  45. package/templates/nextblock-template/docs/08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md +151 -0
  46. package/templates/nextblock-template/lib/cms-transfer/server.ts +13 -0
  47. package/templates/nextblock-template/lib/full-backup/server.ts +1 -0
  48. package/templates/nextblock-template/lib/publishing/viewUrl.ts +26 -0
  49. package/templates/nextblock-template/lib/search/server.ts +3 -0
  50. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +15 -0
  51. package/templates/nextblock-template/lib/visual-editing/mutations.ts +4 -1
  52. package/templates/nextblock-template/lib/visual-editing/product-drafts.ts +46 -1
  53. package/templates/nextblock-template/next-env.d.ts +2 -2
  54. package/templates/nextblock-template/package.json +1 -1
  55. package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
@@ -6154,6 +6154,459 @@ VALUES
6154
6154
  ON CONFLICT (slug) DO NOTHING;
6155
6155
 
6156
6156
 
6157
+ -- >>> FROM: 00000000000015_scheduled_publishing.sql <<<
6158
+ -- Scheduled publishing for pages and products.
6159
+ --
6160
+ -- Posts already support scheduling: \`posts.published_at\` exists and every public
6161
+ -- read gates on \`published_at IS NULL OR published_at <= now()\`, so a row with
6162
+ -- status='published' and a future date is withheld until the date passes. Pages
6163
+ -- and products had no equivalent column, so "go live on Tuesday" was impossible
6164
+ -- for them. This adds the same column with the same semantics.
6165
+ --
6166
+ -- Visibility is derived from the (status, published_at) PAIR — no new enum value:
6167
+ --
6168
+ -- status = draft/archived -> not public, whatever the date
6169
+ -- status = published|active, published_at NULL -> public now
6170
+ -- status = published|active, date <= now() -> public now
6171
+ -- status = published|active, date > now() -> SCHEDULED (withheld)
6172
+ --
6173
+ -- Deriving "scheduled" instead of storing it keeps \`page_status\` unchanged (adding
6174
+ -- an enum value can't be done inside a transaction with other DDL in Postgres) and
6175
+ -- matches what posts have always done, so one code path covers all three types.
6176
+ --
6177
+ -- NULL is the safe default: every existing published row keeps rendering exactly as
6178
+ -- before, so this migration needs no backfill and changes no current behavior.
6179
+ --
6180
+ -- Forward-only and idempotent.
6181
+
6182
+ ALTER TABLE public.pages
6183
+ ADD COLUMN IF NOT EXISTS published_at timestamp with time zone;
6184
+
6185
+ COMMENT ON COLUMN public.pages.published_at IS
6186
+ 'Optional go-live moment. NULL = live as soon as status is published. A future value withholds the page from public reads until it passes (status stays "published"; the CMS renders that pair as "Scheduled").';
6187
+
6188
+ ALTER TABLE public.products
6189
+ ADD COLUMN IF NOT EXISTS published_at timestamp with time zone;
6190
+
6191
+ COMMENT ON COLUMN public.products.published_at IS
6192
+ 'Optional go-live moment. NULL = live as soon as status is active. A future value withholds the product from public reads until it passes (status stays "active"; the CMS renders that pair as "Scheduled").';
6193
+
6194
+ -- Public listing/index queries filter on the (status, published_at) pair together
6195
+ -- (catalog, sitemap, page lookups), so a composite index serves them in one pass.
6196
+ CREATE INDEX IF NOT EXISTS pages_status_published_at_idx
6197
+ ON public.pages (status, published_at);
6198
+
6199
+ CREATE INDEX IF NOT EXISTS products_status_published_at_idx
6200
+ ON public.products (status, published_at);
6201
+
6202
+
6203
+ -- >>> FROM: 00000000000016_product_revisions_and_revision_baseline.sql <<<
6204
+ -- 00000000000016_product_revisions_and_revision_baseline.sql
6205
+ --
6206
+ -- Revision History, part 1 of 2 (schema). The application-side rewrite lives in
6207
+ -- apps/nextblock/app/cms/revisions/**.
6208
+ --
6209
+ -- Three things happen here:
6210
+ --
6211
+ -- 1. products.version — the monotonic counter the hybrid revision engine drives,
6212
+ -- mirroring pages.version / posts.version.
6213
+ --
6214
+ -- 2. product_revisions — a structural mirror of page_revisions / post_revisions.
6215
+ -- product_id is uuid (products.id is uuid, not bigint), and
6216
+ -- writes are gated on is_admin() to match products_*_policy
6217
+ -- rather than the ADMIN|WRITER pattern the page/post revision
6218
+ -- tables use. A WRITER who could insert a revision but not
6219
+ -- apply a restore would get a silent no-op restore, because
6220
+ -- PostgREST returns no error for an UPDATE matching zero rows.
6221
+ --
6222
+ -- 3. Revision baseline — every page, post and product gets a real \`snapshot\` row to
6223
+ -- restore to. Until now the CMS synthesised a fake "Initial
6224
+ -- Version" entry in the UI whose Restore button resolved to
6225
+ -- "current metadata + zero blocks" and wiped the content.
6226
+ -- There is now an actual stored baseline instead.
6227
+ --
6228
+ -- Case A (version = 1, no revisions at all): the live row IS
6229
+ -- version 1. This covers seeded content — 00000000000003
6230
+ -- inserts every page and post at version 1 and writes no
6231
+ -- revision rows — and everything authored since the CMS save
6232
+ -- path stopped recording revisions. Snapshotting it at
6233
+ -- version 1 is what makes "restore the original seeded page"
6234
+ -- real for the first time.
6235
+ --
6236
+ -- Case B (version > 1 but no snapshot at or below it): the
6237
+ -- true v1 is unrecoverable and is NOT fabricated. A snapshot
6238
+ -- of the current state is stored at the current version so the
6239
+ -- diff chain has a valid base and future restores resolve.
6240
+ --
6241
+ -- Forward-only, idempotent, and it modifies no existing row: every backfill is an
6242
+ -- INSERT ... WHERE NOT EXISTS ... ON CONFLICT DO NOTHING.
6243
+
6244
+ -- ---------------------------------------------------------------------------
6245
+ -- 1. products.version
6246
+ -- ---------------------------------------------------------------------------
6247
+
6248
+ ALTER TABLE public.products
6249
+ ADD COLUMN IF NOT EXISTS version integer DEFAULT 1 NOT NULL;
6250
+
6251
+ COMMENT ON COLUMN public.products.version IS 'Monotonic version number for hybrid revisions.';
6252
+
6253
+ -- ---------------------------------------------------------------------------
6254
+ -- 2. product_revisions
6255
+ -- ---------------------------------------------------------------------------
6256
+
6257
+ CREATE TABLE IF NOT EXISTS public.product_revisions (
6258
+ id bigint NOT NULL,
6259
+ product_id uuid NOT NULL,
6260
+ author_id uuid,
6261
+ version integer NOT NULL,
6262
+ revision_type public.revision_type NOT NULL,
6263
+ content jsonb NOT NULL,
6264
+ created_at timestamp with time zone DEFAULT now() NOT NULL
6265
+ );
6266
+
6267
+ COMMENT ON TABLE public.product_revisions IS 'Hybrid (snapshot/diff) revisions for products.';
6268
+ COMMENT ON COLUMN public.product_revisions.content IS 'If snapshot: full content; if diff: JSON Patch array.';
6269
+
6270
+ DO $rb$
6271
+ BEGIN
6272
+ IF NOT EXISTS (
6273
+ SELECT 1 FROM pg_attribute
6274
+ WHERE attrelid = 'public.product_revisions'::regclass
6275
+ AND attname = 'id'
6276
+ AND attidentity <> ''
6277
+ ) THEN
6278
+ ALTER TABLE public.product_revisions
6279
+ ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (
6280
+ SEQUENCE NAME public.product_revisions_id_seq
6281
+ START WITH 1
6282
+ INCREMENT BY 1
6283
+ NO MINVALUE
6284
+ NO MAXVALUE
6285
+ CACHE 1
6286
+ );
6287
+ END IF;
6288
+ END $rb$;
6289
+
6290
+ DO $rb$
6291
+ BEGIN
6292
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint
6293
+ WHERE conname = 'product_revisions_pkey'
6294
+ AND conrelid = 'public.product_revisions'::regclass) THEN
6295
+ ALTER TABLE ONLY public.product_revisions
6296
+ ADD CONSTRAINT product_revisions_pkey PRIMARY KEY (id);
6297
+ END IF;
6298
+ END $rb$;
6299
+
6300
+ DO $rb$
6301
+ BEGIN
6302
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint
6303
+ WHERE conname = 'product_revisions_product_version_key'
6304
+ AND conrelid = 'public.product_revisions'::regclass) THEN
6305
+ ALTER TABLE ONLY public.product_revisions
6306
+ ADD CONSTRAINT product_revisions_product_version_key UNIQUE (product_id, version);
6307
+ END IF;
6308
+ END $rb$;
6309
+
6310
+ DO $rb$
6311
+ BEGIN
6312
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint
6313
+ WHERE conname = 'product_revisions_author_id_fkey'
6314
+ AND conrelid = 'public.product_revisions'::regclass) THEN
6315
+ ALTER TABLE ONLY public.product_revisions
6316
+ ADD CONSTRAINT product_revisions_author_id_fkey
6317
+ FOREIGN KEY (author_id) REFERENCES public.profiles(id) ON DELETE SET NULL;
6318
+ END IF;
6319
+ END $rb$;
6320
+
6321
+ DO $rb$
6322
+ BEGIN
6323
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint
6324
+ WHERE conname = 'product_revisions_product_id_fkey'
6325
+ AND conrelid = 'public.product_revisions'::regclass) THEN
6326
+ ALTER TABLE ONLY public.product_revisions
6327
+ ADD CONSTRAINT product_revisions_product_id_fkey
6328
+ FOREIGN KEY (product_id) REFERENCES public.products(id) ON DELETE CASCADE;
6329
+ END IF;
6330
+ END $rb$;
6331
+
6332
+ CREATE INDEX IF NOT EXISTS idx_product_revisions_author_id
6333
+ ON public.product_revisions USING btree (author_id);
6334
+
6335
+ CREATE INDEX IF NOT EXISTS idx_product_revisions_product_id_version
6336
+ ON public.product_revisions USING btree (product_id, version);
6337
+
6338
+ ALTER TABLE public.product_revisions ENABLE ROW LEVEL SECURITY;
6339
+
6340
+ DROP POLICY IF EXISTS product_revisions_read_policy ON public.product_revisions;
6341
+ CREATE POLICY product_revisions_read_policy ON public.product_revisions
6342
+ FOR SELECT TO authenticated USING (true);
6343
+
6344
+ DROP POLICY IF EXISTS product_revisions_insert_policy ON public.product_revisions;
6345
+ CREATE POLICY product_revisions_insert_policy ON public.product_revisions
6346
+ FOR INSERT TO authenticated
6347
+ WITH CHECK (((SELECT public.is_admin() AS is_admin) IS TRUE));
6348
+
6349
+ DROP POLICY IF EXISTS product_revisions_update_policy ON public.product_revisions;
6350
+ CREATE POLICY product_revisions_update_policy ON public.product_revisions
6351
+ FOR UPDATE TO authenticated
6352
+ USING (((SELECT public.is_admin() AS is_admin) IS TRUE))
6353
+ WITH CHECK (((SELECT public.is_admin() AS is_admin) IS TRUE));
6354
+
6355
+ DROP POLICY IF EXISTS product_revisions_delete_policy ON public.product_revisions;
6356
+ CREATE POLICY product_revisions_delete_policy ON public.product_revisions
6357
+ FOR DELETE TO authenticated
6358
+ USING (((SELECT public.is_admin() AS is_admin) IS TRUE));
6359
+
6360
+ GRANT ALL ON TABLE public.product_revisions TO anon;
6361
+ GRANT ALL ON TABLE public.product_revisions TO authenticated;
6362
+ GRANT ALL ON TABLE public.product_revisions TO service_role;
6363
+ GRANT ALL ON SEQUENCE public.product_revisions_id_seq TO anon;
6364
+ GRANT ALL ON SEQUENCE public.product_revisions_id_seq TO authenticated;
6365
+ GRANT ALL ON SEQUENCE public.product_revisions_id_seq TO service_role;
6366
+
6367
+ -- ---------------------------------------------------------------------------
6368
+ -- 3. Revision baseline backfill
6369
+ --
6370
+ -- The JSON shape must match FullPageContent / FullPostContent / FullProductContent
6371
+ -- in apps/nextblock/app/cms/revisions/utils.ts exactly, or the first diff taken
6372
+ -- against a baseline row will be full of phantom operations.
6373
+ --
6374
+ -- Timestamps are rendered with an explicit millisecond-precision UTC format so they
6375
+ -- match JavaScript's Date#toISOString() ("2026-07-03T17:52:15.643Z"). Postgres'
6376
+ -- default jsonb rendering of timestamptz ("2026-07-03T17:52:15.643901+00:00") would
6377
+ -- differ from the value the application writes and produce a spurious diff on the
6378
+ -- very next save.
6379
+ -- ---------------------------------------------------------------------------
6380
+
6381
+ -- 3a. Pages
6382
+ INSERT INTO public.page_revisions (page_id, author_id, version, revision_type, content)
6383
+ SELECT
6384
+ p.id,
6385
+ NULL::uuid,
6386
+ p.version,
6387
+ 'snapshot'::public.revision_type,
6388
+ jsonb_build_object(
6389
+ 'meta', jsonb_build_object(
6390
+ 'title', p.title,
6391
+ 'slug', p.slug,
6392
+ 'language_id', p.language_id,
6393
+ 'status', p.status,
6394
+ 'meta_title', p.meta_title,
6395
+ 'meta_description', p.meta_description,
6396
+ 'custom_canonical', p.custom_canonical,
6397
+ 'published_at', to_char(p.published_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
6398
+ 'feature_image_id', p.feature_image_id
6399
+ ),
6400
+ 'blocks', COALESCE((
6401
+ SELECT jsonb_agg(
6402
+ jsonb_build_object(
6403
+ 'language_id', b.language_id,
6404
+ 'block_type', b.block_type,
6405
+ 'content', b.content,
6406
+ 'order', b."order"
6407
+ ) ORDER BY b."order" ASC, b.id ASC
6408
+ )
6409
+ FROM public.blocks b
6410
+ WHERE b.page_id = p.id
6411
+ ), '[]'::jsonb)
6412
+ )
6413
+ FROM public.pages p
6414
+ WHERE NOT EXISTS (
6415
+ SELECT 1 FROM public.page_revisions r
6416
+ WHERE r.page_id = p.id
6417
+ AND r.revision_type = 'snapshot'
6418
+ AND r.version <= p.version
6419
+ )
6420
+ ON CONFLICT (page_id, version) DO NOTHING;
6421
+
6422
+ -- 3b. Posts
6423
+ INSERT INTO public.post_revisions (post_id, author_id, version, revision_type, content)
6424
+ SELECT
6425
+ po.id,
6426
+ NULL::uuid,
6427
+ po.version,
6428
+ 'snapshot'::public.revision_type,
6429
+ jsonb_build_object(
6430
+ 'meta', jsonb_build_object(
6431
+ 'title', po.title,
6432
+ 'slug', po.slug,
6433
+ 'language_id', po.language_id,
6434
+ 'status', po.status,
6435
+ 'meta_title', po.meta_title,
6436
+ 'meta_description', po.meta_description,
6437
+ 'custom_canonical', po.custom_canonical,
6438
+ 'published_at', to_char(po.published_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
6439
+ 'feature_image_id', po.feature_image_id,
6440
+ 'label', po.label,
6441
+ 'excerpt', po.excerpt,
6442
+ 'subtitle', po.subtitle
6443
+ ),
6444
+ 'blocks', COALESCE((
6445
+ SELECT jsonb_agg(
6446
+ jsonb_build_object(
6447
+ 'language_id', b.language_id,
6448
+ 'block_type', b.block_type,
6449
+ 'content', b.content,
6450
+ 'order', b."order"
6451
+ ) ORDER BY b."order" ASC, b.id ASC
6452
+ )
6453
+ FROM public.blocks b
6454
+ WHERE b.post_id = po.id
6455
+ ), '[]'::jsonb)
6456
+ )
6457
+ FROM public.posts po
6458
+ WHERE NOT EXISTS (
6459
+ SELECT 1 FROM public.post_revisions r
6460
+ WHERE r.post_id = po.id
6461
+ AND r.revision_type = 'snapshot'
6462
+ AND r.version <= po.version
6463
+ )
6464
+ ON CONFLICT (post_id, version) DO NOTHING;
6465
+
6466
+ -- 3c. Products.
6467
+ --
6468
+ -- Content only. price/prices/sale_*/scheduled_*/stock/sku/average_rating/total_reviews
6469
+ -- are deliberately excluded from the snapshot: pricing and inventory are mutated from
6470
+ -- outside the editor (promotions, Freemius sync, order fulfilment), ratings are derived
6471
+ -- aggregates, and inventory_items is keyed by bare SKU text with no FK to products — so
6472
+ -- replaying commerce state on restore would reach rows the editor never touched.
6473
+ -- Restoring a product restores its content, not its commerce state.
6474
+ INSERT INTO public.product_revisions (product_id, author_id, version, revision_type, content)
6475
+ SELECT
6476
+ pr.id,
6477
+ NULL::uuid,
6478
+ pr.version,
6479
+ 'snapshot'::public.revision_type,
6480
+ jsonb_build_object(
6481
+ 'meta', jsonb_build_object(
6482
+ 'title', pr.title,
6483
+ 'slug', pr.slug,
6484
+ 'language_id', pr.language_id,
6485
+ 'status', pr.status,
6486
+ 'meta_title', pr.meta_title,
6487
+ 'meta_description', pr.meta_description,
6488
+ 'custom_canonical', pr.custom_canonical,
6489
+ 'published_at', to_char(pr.published_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
6490
+ 'short_description', pr.short_description,
6491
+ 'description_json', pr.description_json
6492
+ ),
6493
+ 'blocks', COALESCE((
6494
+ SELECT jsonb_agg(
6495
+ jsonb_build_object(
6496
+ 'language_id', b.language_id,
6497
+ 'block_type', b.block_type,
6498
+ 'content', b.content,
6499
+ 'order', b."order"
6500
+ ) ORDER BY b."order" ASC, b.id ASC
6501
+ )
6502
+ FROM public.blocks b
6503
+ WHERE b.product_id = pr.id
6504
+ ), '[]'::jsonb)
6505
+ )
6506
+ FROM public.products pr
6507
+ WHERE NOT EXISTS (
6508
+ SELECT 1 FROM public.product_revisions r
6509
+ WHERE r.product_id = pr.id
6510
+ AND r.revision_type = 'snapshot'
6511
+ AND r.version <= pr.version
6512
+ )
6513
+ ON CONFLICT (product_id, version) DO NOTHING;
6514
+
6515
+
6516
+ -- >>> FROM: 00000000000017_cortex_ai_mcp_server.sql <<<
6517
+ -- Cortex AI MCP (Model Context Protocol) server access.
6518
+ --
6519
+ -- Adds the bearer-token store that gates /api/mcp, the endpoint that exposes the
6520
+ -- Cortex AI tool registry to external MCP clients (Claude Code, Claude Desktop,
6521
+ -- Cursor, VS Code). Two pieces:
6522
+ --
6523
+ -- 1. public.mcp_access_tokens — one row per issued token. We store ONLY the
6524
+ -- SHA-256 hash of the token, never the token itself: the plaintext is shown
6525
+ -- to the admin exactly once at mint time and is unrecoverable afterwards, so
6526
+ -- a database leak cannot be replayed against the MCP endpoint. \`token_prefix\`
6527
+ -- is the non-secret leading fragment kept purely so the UI can tell two tokens
6528
+ -- apart in a list.
6529
+ --
6530
+ -- 2. cortex_ai_mcp_settings — a non-secret JSON site_settings row holding the
6531
+ -- server on/off switch and the localhost-trust flag. It is added to all four
6532
+ -- site_settings policies so only authenticated ADMINs can read or write it;
6533
+ -- the MCP route itself reads it through the service-role client, which
6534
+ -- bypasses RLS.
6535
+ --
6536
+ -- Forward-only. Recreates the four site_settings policies idempotently, preserving
6537
+ -- every key already in each policy's sensitive array (note that
6538
+ -- language_detection_settings stays anon-READABLE and so is absent from the SELECT
6539
+ -- policy, exactly as migration 00000000000012 left it).
6540
+
6541
+ CREATE TABLE IF NOT EXISTS public.mcp_access_tokens (
6542
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
6543
+ name text NOT NULL,
6544
+ -- Lowercase hex SHA-256 of the plaintext token. Unique so a lookup is a single
6545
+ -- indexed equality probe and duplicate mints are impossible.
6546
+ token_hash text NOT NULL UNIQUE,
6547
+ -- Non-secret display fragment, e.g. "nbmcp_a1b2c3d4". Never enough to authenticate.
6548
+ token_prefix text NOT NULL,
6549
+ -- 'read' grants the read-only tools; 'write' additionally grants the mutating ones.
6550
+ scopes text[] NOT NULL DEFAULT ARRAY['read', 'write']::text[],
6551
+ created_by uuid REFERENCES auth.users (id) ON DELETE SET NULL,
6552
+ created_at timestamptz NOT NULL DEFAULT now(),
6553
+ last_used_at timestamptz,
6554
+ expires_at timestamptz,
6555
+ revoked_at timestamptz
6556
+ );
6557
+
6558
+ COMMENT ON TABLE public.mcp_access_tokens IS
6559
+ 'Bearer tokens for the Cortex AI MCP server at /api/mcp. Stores SHA-256 hashes only; plaintext is displayed once at mint time.';
6560
+
6561
+ CREATE INDEX IF NOT EXISTS mcp_access_tokens_token_hash_idx
6562
+ ON public.mcp_access_tokens (token_hash);
6563
+
6564
+ -- Orders the admin token list newest-first without a sort.
6565
+ CREATE INDEX IF NOT EXISTS mcp_access_tokens_created_at_idx
6566
+ ON public.mcp_access_tokens (created_at DESC);
6567
+
6568
+ ALTER TABLE public.mcp_access_tokens ENABLE ROW LEVEL SECURITY;
6569
+
6570
+ -- Tokens are credentials: admin-only, with no anon or WRITER access at all. The
6571
+ -- MCP route verifies them with the service-role client, which bypasses RLS.
6572
+ DROP POLICY IF EXISTS mcp_access_tokens_admin_select ON public.mcp_access_tokens;
6573
+ CREATE POLICY mcp_access_tokens_admin_select ON public.mcp_access_tokens
6574
+ FOR SELECT TO authenticated
6575
+ USING ((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role);
6576
+
6577
+ DROP POLICY IF EXISTS mcp_access_tokens_admin_insert ON public.mcp_access_tokens;
6578
+ CREATE POLICY mcp_access_tokens_admin_insert ON public.mcp_access_tokens
6579
+ FOR INSERT TO authenticated
6580
+ WITH CHECK ((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role);
6581
+
6582
+ DROP POLICY IF EXISTS mcp_access_tokens_admin_update ON public.mcp_access_tokens;
6583
+ CREATE POLICY mcp_access_tokens_admin_update ON public.mcp_access_tokens
6584
+ FOR UPDATE TO authenticated
6585
+ USING ((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role)
6586
+ WITH CHECK ((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role);
6587
+
6588
+ DROP POLICY IF EXISTS mcp_access_tokens_admin_delete ON public.mcp_access_tokens;
6589
+ CREATE POLICY mcp_access_tokens_admin_delete ON public.mcp_access_tokens
6590
+ FOR DELETE TO authenticated
6591
+ USING ((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role);
6592
+
6593
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.mcp_access_tokens TO authenticated;
6594
+ GRANT ALL ON public.mcp_access_tokens TO service_role;
6595
+
6596
+ -- Add cortex_ai_mcp_settings to the admin-only site_settings group (all four policies).
6597
+ DROP POLICY IF EXISTS site_settings_read_policy ON public.site_settings;
6598
+ CREATE POLICY site_settings_read_policy ON public.site_settings FOR SELECT USING (((key <> ALL (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text])) OR ((key = ANY (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text])) AND (( SELECT auth.role() AS role) = 'authenticated'::text) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role))));
6599
+
6600
+ DROP POLICY IF EXISTS site_settings_insert_policy ON public.site_settings;
6601
+ CREATE POLICY site_settings_insert_policy ON public.site_settings FOR INSERT TO authenticated WITH CHECK ((((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))));
6602
+
6603
+ DROP POLICY IF EXISTS site_settings_update_policy ON public.site_settings;
6604
+ CREATE POLICY site_settings_update_policy ON public.site_settings FOR UPDATE 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)))) WITH CHECK ((((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))));
6605
+
6606
+ DROP POLICY IF EXISTS site_settings_delete_policy ON public.site_settings;
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
+
6609
+
6157
6610
  -- Step D: Record the applied migrations in history (truncated in Step B) so
6158
6611
  -- \`npm run db:migrate:check\` reports up to date instead of listing every file as pending.
6159
6612
  INSERT INTO supabase_migrations.schema_migrations (version, name) VALUES
@@ -6171,7 +6624,10 @@ ON CONFLICT (slug) DO NOTHING;
6171
6624
  ('00000000000011', 'language_detection_admin_only'),
6172
6625
  ('00000000000012', 'cortex_ai_stock_photo_settings'),
6173
6626
  ('00000000000013', 'youtube_nocookie_embeds'),
6174
- ('00000000000014', 'site_themes')
6627
+ ('00000000000014', 'site_themes'),
6628
+ ('00000000000015', 'scheduled_publishing'),
6629
+ ('00000000000016', 'product_revisions_and_revision_baseline'),
6630
+ ('00000000000017', 'cortex_ai_mcp_server')
6175
6631
  ON CONFLICT (version) DO NOTHING;
6176
6632
 
6177
6633
  -- Step E: Anchor preserved profiles