@nextblock-cms/db 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.
@@ -725,6 +725,45 @@ export declare const getServiceRoleSupabaseClient: () => import('@supabase/supab
725
725
  referencedColumns: ["id"];
726
726
  }];
727
727
  };
728
+ mcp_access_tokens: {
729
+ Row: {
730
+ created_at: string;
731
+ created_by: string | null;
732
+ expires_at: string | null;
733
+ id: string;
734
+ last_used_at: string | null;
735
+ name: string;
736
+ revoked_at: string | null;
737
+ scopes: string[];
738
+ token_hash: string;
739
+ token_prefix: string;
740
+ };
741
+ Insert: {
742
+ created_at?: string;
743
+ created_by?: string | null;
744
+ expires_at?: string | null;
745
+ id?: string;
746
+ last_used_at?: string | null;
747
+ name: string;
748
+ revoked_at?: string | null;
749
+ scopes?: string[];
750
+ token_hash: string;
751
+ token_prefix: string;
752
+ };
753
+ Update: {
754
+ created_at?: string;
755
+ created_by?: string | null;
756
+ expires_at?: string | null;
757
+ id?: string;
758
+ last_used_at?: string | null;
759
+ name?: string;
760
+ revoked_at?: string | null;
761
+ scopes?: string[];
762
+ token_hash?: string;
763
+ token_prefix?: string;
764
+ };
765
+ Relationships: [];
766
+ };
728
767
  media: {
729
768
  Row: {
730
769
  blur_data_url: string | null;
@@ -1083,6 +1122,7 @@ export declare const getServiceRoleSupabaseClient: () => import('@supabase/supab
1083
1122
  language_id: number;
1084
1123
  meta_description: string | null;
1085
1124
  meta_title: string | null;
1125
+ published_at: string | null;
1086
1126
  slug: string;
1087
1127
  status: Database["public"]["Enums"]["page_status"];
1088
1128
  title: string;
@@ -1099,6 +1139,7 @@ export declare const getServiceRoleSupabaseClient: () => import('@supabase/supab
1099
1139
  language_id: number;
1100
1140
  meta_description?: string | null;
1101
1141
  meta_title?: string | null;
1142
+ published_at?: string | null;
1102
1143
  slug: string;
1103
1144
  status?: Database["public"]["Enums"]["page_status"];
1104
1145
  title: string;
@@ -1115,6 +1156,7 @@ export declare const getServiceRoleSupabaseClient: () => import('@supabase/supab
1115
1156
  language_id?: number;
1116
1157
  meta_description?: string | null;
1117
1158
  meta_title?: string | null;
1159
+ published_at?: string | null;
1118
1160
  slug?: string;
1119
1161
  status?: Database["public"]["Enums"]["page_status"];
1120
1162
  title?: string;
@@ -1520,6 +1562,48 @@ export declare const getServiceRoleSupabaseClient: () => import('@supabase/supab
1520
1562
  referencedColumns: ["id"];
1521
1563
  }];
1522
1564
  };
1565
+ product_revisions: {
1566
+ Row: {
1567
+ author_id: string | null;
1568
+ content: import('./types').Json;
1569
+ created_at: string;
1570
+ id: number;
1571
+ product_id: string;
1572
+ revision_type: Database["public"]["Enums"]["revision_type"];
1573
+ version: number;
1574
+ };
1575
+ Insert: {
1576
+ author_id?: string | null;
1577
+ content: import('./types').Json;
1578
+ created_at?: string;
1579
+ id?: number;
1580
+ product_id: string;
1581
+ revision_type: Database["public"]["Enums"]["revision_type"];
1582
+ version: number;
1583
+ };
1584
+ Update: {
1585
+ author_id?: string | null;
1586
+ content?: import('./types').Json;
1587
+ created_at?: string;
1588
+ id?: number;
1589
+ product_id?: string;
1590
+ revision_type?: Database["public"]["Enums"]["revision_type"];
1591
+ version?: number;
1592
+ };
1593
+ Relationships: [{
1594
+ foreignKeyName: "product_revisions_author_id_fkey";
1595
+ columns: ["author_id"];
1596
+ isOneToOne: false;
1597
+ referencedRelation: "profiles";
1598
+ referencedColumns: ["id"];
1599
+ }, {
1600
+ foreignKeyName: "product_revisions_product_id_fkey";
1601
+ columns: ["product_id"];
1602
+ isOneToOne: false;
1603
+ referencedRelation: "products";
1604
+ referencedColumns: ["id"];
1605
+ }];
1606
+ };
1523
1607
  product_variants: {
1524
1608
  Row: {
1525
1609
  created_at: string | null;
@@ -1613,6 +1697,7 @@ export declare const getServiceRoleSupabaseClient: () => import('@supabase/supab
1613
1697
  price: number;
1614
1698
  prices: import('./types').Json;
1615
1699
  product_type: string;
1700
+ published_at: string | null;
1616
1701
  sale_end_at: string | null;
1617
1702
  sale_price: number | null;
1618
1703
  sale_prices: import('./types').Json | null;
@@ -1632,6 +1717,7 @@ export declare const getServiceRoleSupabaseClient: () => import('@supabase/supab
1632
1717
  trial_requires_payment_method: boolean;
1633
1718
  upc: string | null;
1634
1719
  updated_at: string | null;
1720
+ version: number;
1635
1721
  };
1636
1722
  Insert: {
1637
1723
  average_rating?: number;
@@ -1650,6 +1736,7 @@ export declare const getServiceRoleSupabaseClient: () => import('@supabase/supab
1650
1736
  price: number;
1651
1737
  prices?: import('./types').Json;
1652
1738
  product_type: string;
1739
+ published_at?: string | null;
1653
1740
  sale_end_at?: string | null;
1654
1741
  sale_price?: number | null;
1655
1742
  sale_prices?: import('./types').Json | null;
@@ -1669,6 +1756,7 @@ export declare const getServiceRoleSupabaseClient: () => import('@supabase/supab
1669
1756
  trial_requires_payment_method?: boolean;
1670
1757
  upc?: string | null;
1671
1758
  updated_at?: string | null;
1759
+ version?: number;
1672
1760
  };
1673
1761
  Update: {
1674
1762
  average_rating?: number;
@@ -1687,6 +1775,7 @@ export declare const getServiceRoleSupabaseClient: () => import('@supabase/supab
1687
1775
  price?: number;
1688
1776
  prices?: import('./types').Json;
1689
1777
  product_type?: string;
1778
+ published_at?: string | null;
1690
1779
  sale_end_at?: string | null;
1691
1780
  sale_price?: number | null;
1692
1781
  sale_prices?: import('./types').Json | null;
@@ -1706,6 +1795,7 @@ export declare const getServiceRoleSupabaseClient: () => import('@supabase/supab
1706
1795
  trial_requires_payment_method?: boolean;
1707
1796
  upc?: string | null;
1708
1797
  updated_at?: string | null;
1798
+ version?: number;
1709
1799
  };
1710
1800
  Relationships: [{
1711
1801
  foreignKeyName: "products_language_id_fkey";
@@ -749,6 +749,45 @@ export type Database = {
749
749
  }
750
750
  ];
751
751
  };
752
+ mcp_access_tokens: {
753
+ Row: {
754
+ created_at: string;
755
+ created_by: string | null;
756
+ expires_at: string | null;
757
+ id: string;
758
+ last_used_at: string | null;
759
+ name: string;
760
+ revoked_at: string | null;
761
+ scopes: string[];
762
+ token_hash: string;
763
+ token_prefix: string;
764
+ };
765
+ Insert: {
766
+ created_at?: string;
767
+ created_by?: string | null;
768
+ expires_at?: string | null;
769
+ id?: string;
770
+ last_used_at?: string | null;
771
+ name: string;
772
+ revoked_at?: string | null;
773
+ scopes?: string[];
774
+ token_hash: string;
775
+ token_prefix: string;
776
+ };
777
+ Update: {
778
+ created_at?: string;
779
+ created_by?: string | null;
780
+ expires_at?: string | null;
781
+ id?: string;
782
+ last_used_at?: string | null;
783
+ name?: string;
784
+ revoked_at?: string | null;
785
+ scopes?: string[];
786
+ token_hash?: string;
787
+ token_prefix?: string;
788
+ };
789
+ Relationships: [];
790
+ };
752
791
  media: {
753
792
  Row: {
754
793
  blur_data_url: string | null;
@@ -1122,6 +1161,7 @@ export type Database = {
1122
1161
  language_id: number;
1123
1162
  meta_description: string | null;
1124
1163
  meta_title: string | null;
1164
+ published_at: string | null;
1125
1165
  slug: string;
1126
1166
  status: Database["public"]["Enums"]["page_status"];
1127
1167
  title: string;
@@ -1138,6 +1178,7 @@ export type Database = {
1138
1178
  language_id: number;
1139
1179
  meta_description?: string | null;
1140
1180
  meta_title?: string | null;
1181
+ published_at?: string | null;
1141
1182
  slug: string;
1142
1183
  status?: Database["public"]["Enums"]["page_status"];
1143
1184
  title: string;
@@ -1154,6 +1195,7 @@ export type Database = {
1154
1195
  language_id?: number;
1155
1196
  meta_description?: string | null;
1156
1197
  meta_title?: string | null;
1198
+ published_at?: string | null;
1157
1199
  slug?: string;
1158
1200
  status?: Database["public"]["Enums"]["page_status"];
1159
1201
  title?: string;
@@ -1583,6 +1625,51 @@ export type Database = {
1583
1625
  }
1584
1626
  ];
1585
1627
  };
1628
+ product_revisions: {
1629
+ Row: {
1630
+ author_id: string | null;
1631
+ content: Json;
1632
+ created_at: string;
1633
+ id: number;
1634
+ product_id: string;
1635
+ revision_type: Database["public"]["Enums"]["revision_type"];
1636
+ version: number;
1637
+ };
1638
+ Insert: {
1639
+ author_id?: string | null;
1640
+ content: Json;
1641
+ created_at?: string;
1642
+ id?: number;
1643
+ product_id: string;
1644
+ revision_type: Database["public"]["Enums"]["revision_type"];
1645
+ version: number;
1646
+ };
1647
+ Update: {
1648
+ author_id?: string | null;
1649
+ content?: Json;
1650
+ created_at?: string;
1651
+ id?: number;
1652
+ product_id?: string;
1653
+ revision_type?: Database["public"]["Enums"]["revision_type"];
1654
+ version?: number;
1655
+ };
1656
+ Relationships: [
1657
+ {
1658
+ foreignKeyName: "product_revisions_author_id_fkey";
1659
+ columns: ["author_id"];
1660
+ isOneToOne: false;
1661
+ referencedRelation: "profiles";
1662
+ referencedColumns: ["id"];
1663
+ },
1664
+ {
1665
+ foreignKeyName: "product_revisions_product_id_fkey";
1666
+ columns: ["product_id"];
1667
+ isOneToOne: false;
1668
+ referencedRelation: "products";
1669
+ referencedColumns: ["id"];
1670
+ }
1671
+ ];
1672
+ };
1586
1673
  product_variants: {
1587
1674
  Row: {
1588
1675
  created_at: string | null;
@@ -1679,6 +1766,7 @@ export type Database = {
1679
1766
  price: number;
1680
1767
  prices: Json;
1681
1768
  product_type: string;
1769
+ published_at: string | null;
1682
1770
  sale_end_at: string | null;
1683
1771
  sale_price: number | null;
1684
1772
  sale_prices: Json | null;
@@ -1698,6 +1786,7 @@ export type Database = {
1698
1786
  trial_requires_payment_method: boolean;
1699
1787
  upc: string | null;
1700
1788
  updated_at: string | null;
1789
+ version: number;
1701
1790
  };
1702
1791
  Insert: {
1703
1792
  average_rating?: number;
@@ -1716,6 +1805,7 @@ export type Database = {
1716
1805
  price: number;
1717
1806
  prices?: Json;
1718
1807
  product_type: string;
1808
+ published_at?: string | null;
1719
1809
  sale_end_at?: string | null;
1720
1810
  sale_price?: number | null;
1721
1811
  sale_prices?: Json | null;
@@ -1735,6 +1825,7 @@ export type Database = {
1735
1825
  trial_requires_payment_method?: boolean;
1736
1826
  upc?: string | null;
1737
1827
  updated_at?: string | null;
1828
+ version?: number;
1738
1829
  };
1739
1830
  Update: {
1740
1831
  average_rating?: number;
@@ -1753,6 +1844,7 @@ export type Database = {
1753
1844
  price?: number;
1754
1845
  prices?: Json;
1755
1846
  product_type?: string;
1847
+ published_at?: string | null;
1756
1848
  sale_end_at?: string | null;
1757
1849
  sale_price?: number | null;
1758
1850
  sale_prices?: Json | null;
@@ -1772,6 +1864,7 @@ export type Database = {
1772
1864
  trial_requires_payment_method?: boolean;
1773
1865
  upc?: string | null;
1774
1866
  updated_at?: string | null;
1867
+ version?: number;
1775
1868
  };
1776
1869
  Relationships: [
1777
1870
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextblock-cms/db",
3
- "version": "0.14.4",
3
+ "version": "0.14.6",
4
4
  "main": "index.cjs.js",
5
5
  "module": "index.es.js",
6
6
  "types": "index.d.ts",
@@ -0,0 +1,43 @@
1
+ -- Scheduled publishing for pages and products.
2
+ --
3
+ -- Posts already support scheduling: `posts.published_at` exists and every public
4
+ -- read gates on `published_at IS NULL OR published_at <= now()`, so a row with
5
+ -- status='published' and a future date is withheld until the date passes. Pages
6
+ -- and products had no equivalent column, so "go live on Tuesday" was impossible
7
+ -- for them. This adds the same column with the same semantics.
8
+ --
9
+ -- Visibility is derived from the (status, published_at) PAIR — no new enum value:
10
+ --
11
+ -- status = draft/archived -> not public, whatever the date
12
+ -- status = published|active, published_at NULL -> public now
13
+ -- status = published|active, date <= now() -> public now
14
+ -- status = published|active, date > now() -> SCHEDULED (withheld)
15
+ --
16
+ -- Deriving "scheduled" instead of storing it keeps `page_status` unchanged (adding
17
+ -- an enum value can't be done inside a transaction with other DDL in Postgres) and
18
+ -- matches what posts have always done, so one code path covers all three types.
19
+ --
20
+ -- NULL is the safe default: every existing published row keeps rendering exactly as
21
+ -- before, so this migration needs no backfill and changes no current behavior.
22
+ --
23
+ -- Forward-only and idempotent.
24
+
25
+ ALTER TABLE public.pages
26
+ ADD COLUMN IF NOT EXISTS published_at timestamp with time zone;
27
+
28
+ COMMENT ON COLUMN public.pages.published_at IS
29
+ '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").';
30
+
31
+ ALTER TABLE public.products
32
+ ADD COLUMN IF NOT EXISTS published_at timestamp with time zone;
33
+
34
+ COMMENT ON COLUMN public.products.published_at IS
35
+ '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").';
36
+
37
+ -- Public listing/index queries filter on the (status, published_at) pair together
38
+ -- (catalog, sitemap, page lookups), so a composite index serves them in one pass.
39
+ CREATE INDEX IF NOT EXISTS pages_status_published_at_idx
40
+ ON public.pages (status, published_at);
41
+
42
+ CREATE INDEX IF NOT EXISTS products_status_published_at_idx
43
+ ON public.products (status, published_at);
@@ -0,0 +1,310 @@
1
+ -- 00000000000016_product_revisions_and_revision_baseline.sql
2
+ --
3
+ -- Revision History, part 1 of 2 (schema). The application-side rewrite lives in
4
+ -- apps/nextblock/app/cms/revisions/**.
5
+ --
6
+ -- Three things happen here:
7
+ --
8
+ -- 1. products.version — the monotonic counter the hybrid revision engine drives,
9
+ -- mirroring pages.version / posts.version.
10
+ --
11
+ -- 2. product_revisions — a structural mirror of page_revisions / post_revisions.
12
+ -- product_id is uuid (products.id is uuid, not bigint), and
13
+ -- writes are gated on is_admin() to match products_*_policy
14
+ -- rather than the ADMIN|WRITER pattern the page/post revision
15
+ -- tables use. A WRITER who could insert a revision but not
16
+ -- apply a restore would get a silent no-op restore, because
17
+ -- PostgREST returns no error for an UPDATE matching zero rows.
18
+ --
19
+ -- 3. Revision baseline — every page, post and product gets a real `snapshot` row to
20
+ -- restore to. Until now the CMS synthesised a fake "Initial
21
+ -- Version" entry in the UI whose Restore button resolved to
22
+ -- "current metadata + zero blocks" and wiped the content.
23
+ -- There is now an actual stored baseline instead.
24
+ --
25
+ -- Case A (version = 1, no revisions at all): the live row IS
26
+ -- version 1. This covers seeded content — 00000000000003
27
+ -- inserts every page and post at version 1 and writes no
28
+ -- revision rows — and everything authored since the CMS save
29
+ -- path stopped recording revisions. Snapshotting it at
30
+ -- version 1 is what makes "restore the original seeded page"
31
+ -- real for the first time.
32
+ --
33
+ -- Case B (version > 1 but no snapshot at or below it): the
34
+ -- true v1 is unrecoverable and is NOT fabricated. A snapshot
35
+ -- of the current state is stored at the current version so the
36
+ -- diff chain has a valid base and future restores resolve.
37
+ --
38
+ -- Forward-only, idempotent, and it modifies no existing row: every backfill is an
39
+ -- INSERT ... WHERE NOT EXISTS ... ON CONFLICT DO NOTHING.
40
+
41
+ -- ---------------------------------------------------------------------------
42
+ -- 1. products.version
43
+ -- ---------------------------------------------------------------------------
44
+
45
+ ALTER TABLE public.products
46
+ ADD COLUMN IF NOT EXISTS version integer DEFAULT 1 NOT NULL;
47
+
48
+ COMMENT ON COLUMN public.products.version IS 'Monotonic version number for hybrid revisions.';
49
+
50
+ -- ---------------------------------------------------------------------------
51
+ -- 2. product_revisions
52
+ -- ---------------------------------------------------------------------------
53
+
54
+ CREATE TABLE IF NOT EXISTS public.product_revisions (
55
+ id bigint NOT NULL,
56
+ product_id uuid NOT NULL,
57
+ author_id uuid,
58
+ version integer NOT NULL,
59
+ revision_type public.revision_type NOT NULL,
60
+ content jsonb NOT NULL,
61
+ created_at timestamp with time zone DEFAULT now() NOT NULL
62
+ );
63
+
64
+ COMMENT ON TABLE public.product_revisions IS 'Hybrid (snapshot/diff) revisions for products.';
65
+ COMMENT ON COLUMN public.product_revisions.content IS 'If snapshot: full content; if diff: JSON Patch array.';
66
+
67
+ DO $rb$
68
+ BEGIN
69
+ IF NOT EXISTS (
70
+ SELECT 1 FROM pg_attribute
71
+ WHERE attrelid = 'public.product_revisions'::regclass
72
+ AND attname = 'id'
73
+ AND attidentity <> ''
74
+ ) THEN
75
+ ALTER TABLE public.product_revisions
76
+ ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (
77
+ SEQUENCE NAME public.product_revisions_id_seq
78
+ START WITH 1
79
+ INCREMENT BY 1
80
+ NO MINVALUE
81
+ NO MAXVALUE
82
+ CACHE 1
83
+ );
84
+ END IF;
85
+ END $rb$;
86
+
87
+ DO $rb$
88
+ BEGIN
89
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint
90
+ WHERE conname = 'product_revisions_pkey'
91
+ AND conrelid = 'public.product_revisions'::regclass) THEN
92
+ ALTER TABLE ONLY public.product_revisions
93
+ ADD CONSTRAINT product_revisions_pkey PRIMARY KEY (id);
94
+ END IF;
95
+ END $rb$;
96
+
97
+ DO $rb$
98
+ BEGIN
99
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint
100
+ WHERE conname = 'product_revisions_product_version_key'
101
+ AND conrelid = 'public.product_revisions'::regclass) THEN
102
+ ALTER TABLE ONLY public.product_revisions
103
+ ADD CONSTRAINT product_revisions_product_version_key UNIQUE (product_id, version);
104
+ END IF;
105
+ END $rb$;
106
+
107
+ DO $rb$
108
+ BEGIN
109
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint
110
+ WHERE conname = 'product_revisions_author_id_fkey'
111
+ AND conrelid = 'public.product_revisions'::regclass) THEN
112
+ ALTER TABLE ONLY public.product_revisions
113
+ ADD CONSTRAINT product_revisions_author_id_fkey
114
+ FOREIGN KEY (author_id) REFERENCES public.profiles(id) ON DELETE SET NULL;
115
+ END IF;
116
+ END $rb$;
117
+
118
+ DO $rb$
119
+ BEGIN
120
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint
121
+ WHERE conname = 'product_revisions_product_id_fkey'
122
+ AND conrelid = 'public.product_revisions'::regclass) THEN
123
+ ALTER TABLE ONLY public.product_revisions
124
+ ADD CONSTRAINT product_revisions_product_id_fkey
125
+ FOREIGN KEY (product_id) REFERENCES public.products(id) ON DELETE CASCADE;
126
+ END IF;
127
+ END $rb$;
128
+
129
+ CREATE INDEX IF NOT EXISTS idx_product_revisions_author_id
130
+ ON public.product_revisions USING btree (author_id);
131
+
132
+ CREATE INDEX IF NOT EXISTS idx_product_revisions_product_id_version
133
+ ON public.product_revisions USING btree (product_id, version);
134
+
135
+ ALTER TABLE public.product_revisions ENABLE ROW LEVEL SECURITY;
136
+
137
+ DROP POLICY IF EXISTS product_revisions_read_policy ON public.product_revisions;
138
+ CREATE POLICY product_revisions_read_policy ON public.product_revisions
139
+ FOR SELECT TO authenticated USING (true);
140
+
141
+ DROP POLICY IF EXISTS product_revisions_insert_policy ON public.product_revisions;
142
+ CREATE POLICY product_revisions_insert_policy ON public.product_revisions
143
+ FOR INSERT TO authenticated
144
+ WITH CHECK (((SELECT public.is_admin() AS is_admin) IS TRUE));
145
+
146
+ DROP POLICY IF EXISTS product_revisions_update_policy ON public.product_revisions;
147
+ CREATE POLICY product_revisions_update_policy ON public.product_revisions
148
+ FOR UPDATE TO authenticated
149
+ USING (((SELECT public.is_admin() AS is_admin) IS TRUE))
150
+ WITH CHECK (((SELECT public.is_admin() AS is_admin) IS TRUE));
151
+
152
+ DROP POLICY IF EXISTS product_revisions_delete_policy ON public.product_revisions;
153
+ CREATE POLICY product_revisions_delete_policy ON public.product_revisions
154
+ FOR DELETE TO authenticated
155
+ USING (((SELECT public.is_admin() AS is_admin) IS TRUE));
156
+
157
+ GRANT ALL ON TABLE public.product_revisions TO anon;
158
+ GRANT ALL ON TABLE public.product_revisions TO authenticated;
159
+ GRANT ALL ON TABLE public.product_revisions TO service_role;
160
+ GRANT ALL ON SEQUENCE public.product_revisions_id_seq TO anon;
161
+ GRANT ALL ON SEQUENCE public.product_revisions_id_seq TO authenticated;
162
+ GRANT ALL ON SEQUENCE public.product_revisions_id_seq TO service_role;
163
+
164
+ -- ---------------------------------------------------------------------------
165
+ -- 3. Revision baseline backfill
166
+ --
167
+ -- The JSON shape must match FullPageContent / FullPostContent / FullProductContent
168
+ -- in apps/nextblock/app/cms/revisions/utils.ts exactly, or the first diff taken
169
+ -- against a baseline row will be full of phantom operations.
170
+ --
171
+ -- Timestamps are rendered with an explicit millisecond-precision UTC format so they
172
+ -- match JavaScript's Date#toISOString() ("2026-07-03T17:52:15.643Z"). Postgres'
173
+ -- default jsonb rendering of timestamptz ("2026-07-03T17:52:15.643901+00:00") would
174
+ -- differ from the value the application writes and produce a spurious diff on the
175
+ -- very next save.
176
+ -- ---------------------------------------------------------------------------
177
+
178
+ -- 3a. Pages
179
+ INSERT INTO public.page_revisions (page_id, author_id, version, revision_type, content)
180
+ SELECT
181
+ p.id,
182
+ NULL::uuid,
183
+ p.version,
184
+ 'snapshot'::public.revision_type,
185
+ jsonb_build_object(
186
+ 'meta', jsonb_build_object(
187
+ 'title', p.title,
188
+ 'slug', p.slug,
189
+ 'language_id', p.language_id,
190
+ 'status', p.status,
191
+ 'meta_title', p.meta_title,
192
+ 'meta_description', p.meta_description,
193
+ 'custom_canonical', p.custom_canonical,
194
+ 'published_at', to_char(p.published_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
195
+ 'feature_image_id', p.feature_image_id
196
+ ),
197
+ 'blocks', COALESCE((
198
+ SELECT jsonb_agg(
199
+ jsonb_build_object(
200
+ 'language_id', b.language_id,
201
+ 'block_type', b.block_type,
202
+ 'content', b.content,
203
+ 'order', b."order"
204
+ ) ORDER BY b."order" ASC, b.id ASC
205
+ )
206
+ FROM public.blocks b
207
+ WHERE b.page_id = p.id
208
+ ), '[]'::jsonb)
209
+ )
210
+ FROM public.pages p
211
+ WHERE NOT EXISTS (
212
+ SELECT 1 FROM public.page_revisions r
213
+ WHERE r.page_id = p.id
214
+ AND r.revision_type = 'snapshot'
215
+ AND r.version <= p.version
216
+ )
217
+ ON CONFLICT (page_id, version) DO NOTHING;
218
+
219
+ -- 3b. Posts
220
+ INSERT INTO public.post_revisions (post_id, author_id, version, revision_type, content)
221
+ SELECT
222
+ po.id,
223
+ NULL::uuid,
224
+ po.version,
225
+ 'snapshot'::public.revision_type,
226
+ jsonb_build_object(
227
+ 'meta', jsonb_build_object(
228
+ 'title', po.title,
229
+ 'slug', po.slug,
230
+ 'language_id', po.language_id,
231
+ 'status', po.status,
232
+ 'meta_title', po.meta_title,
233
+ 'meta_description', po.meta_description,
234
+ 'custom_canonical', po.custom_canonical,
235
+ 'published_at', to_char(po.published_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
236
+ 'feature_image_id', po.feature_image_id,
237
+ 'label', po.label,
238
+ 'excerpt', po.excerpt,
239
+ 'subtitle', po.subtitle
240
+ ),
241
+ 'blocks', COALESCE((
242
+ SELECT jsonb_agg(
243
+ jsonb_build_object(
244
+ 'language_id', b.language_id,
245
+ 'block_type', b.block_type,
246
+ 'content', b.content,
247
+ 'order', b."order"
248
+ ) ORDER BY b."order" ASC, b.id ASC
249
+ )
250
+ FROM public.blocks b
251
+ WHERE b.post_id = po.id
252
+ ), '[]'::jsonb)
253
+ )
254
+ FROM public.posts po
255
+ WHERE NOT EXISTS (
256
+ SELECT 1 FROM public.post_revisions r
257
+ WHERE r.post_id = po.id
258
+ AND r.revision_type = 'snapshot'
259
+ AND r.version <= po.version
260
+ )
261
+ ON CONFLICT (post_id, version) DO NOTHING;
262
+
263
+ -- 3c. Products.
264
+ --
265
+ -- Content only. price/prices/sale_*/scheduled_*/stock/sku/average_rating/total_reviews
266
+ -- are deliberately excluded from the snapshot: pricing and inventory are mutated from
267
+ -- outside the editor (promotions, Freemius sync, order fulfilment), ratings are derived
268
+ -- aggregates, and inventory_items is keyed by bare SKU text with no FK to products — so
269
+ -- replaying commerce state on restore would reach rows the editor never touched.
270
+ -- Restoring a product restores its content, not its commerce state.
271
+ INSERT INTO public.product_revisions (product_id, author_id, version, revision_type, content)
272
+ SELECT
273
+ pr.id,
274
+ NULL::uuid,
275
+ pr.version,
276
+ 'snapshot'::public.revision_type,
277
+ jsonb_build_object(
278
+ 'meta', jsonb_build_object(
279
+ 'title', pr.title,
280
+ 'slug', pr.slug,
281
+ 'language_id', pr.language_id,
282
+ 'status', pr.status,
283
+ 'meta_title', pr.meta_title,
284
+ 'meta_description', pr.meta_description,
285
+ 'custom_canonical', pr.custom_canonical,
286
+ 'published_at', to_char(pr.published_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
287
+ 'short_description', pr.short_description,
288
+ 'description_json', pr.description_json
289
+ ),
290
+ 'blocks', COALESCE((
291
+ SELECT jsonb_agg(
292
+ jsonb_build_object(
293
+ 'language_id', b.language_id,
294
+ 'block_type', b.block_type,
295
+ 'content', b.content,
296
+ 'order', b."order"
297
+ ) ORDER BY b."order" ASC, b.id ASC
298
+ )
299
+ FROM public.blocks b
300
+ WHERE b.product_id = pr.id
301
+ ), '[]'::jsonb)
302
+ )
303
+ FROM public.products pr
304
+ WHERE NOT EXISTS (
305
+ SELECT 1 FROM public.product_revisions r
306
+ WHERE r.product_id = pr.id
307
+ AND r.revision_type = 'snapshot'
308
+ AND r.version <= pr.version
309
+ )
310
+ ON CONFLICT (product_id, version) DO NOTHING;
@@ -0,0 +1,91 @@
1
+ -- Cortex AI MCP (Model Context Protocol) server access.
2
+ --
3
+ -- Adds the bearer-token store that gates /api/mcp, the endpoint that exposes the
4
+ -- Cortex AI tool registry to external MCP clients (Claude Code, Claude Desktop,
5
+ -- Cursor, VS Code). Two pieces:
6
+ --
7
+ -- 1. public.mcp_access_tokens — one row per issued token. We store ONLY the
8
+ -- SHA-256 hash of the token, never the token itself: the plaintext is shown
9
+ -- to the admin exactly once at mint time and is unrecoverable afterwards, so
10
+ -- a database leak cannot be replayed against the MCP endpoint. `token_prefix`
11
+ -- is the non-secret leading fragment kept purely so the UI can tell two tokens
12
+ -- apart in a list.
13
+ --
14
+ -- 2. cortex_ai_mcp_settings — a non-secret JSON site_settings row holding the
15
+ -- server on/off switch and the localhost-trust flag. It is added to all four
16
+ -- site_settings policies so only authenticated ADMINs can read or write it;
17
+ -- the MCP route itself reads it through the service-role client, which
18
+ -- bypasses RLS.
19
+ --
20
+ -- Forward-only. Recreates the four site_settings policies idempotently, preserving
21
+ -- every key already in each policy's sensitive array (note that
22
+ -- language_detection_settings stays anon-READABLE and so is absent from the SELECT
23
+ -- policy, exactly as migration 00000000000012 left it).
24
+
25
+ CREATE TABLE IF NOT EXISTS public.mcp_access_tokens (
26
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
27
+ name text NOT NULL,
28
+ -- Lowercase hex SHA-256 of the plaintext token. Unique so a lookup is a single
29
+ -- indexed equality probe and duplicate mints are impossible.
30
+ token_hash text NOT NULL UNIQUE,
31
+ -- Non-secret display fragment, e.g. "nbmcp_a1b2c3d4". Never enough to authenticate.
32
+ token_prefix text NOT NULL,
33
+ -- 'read' grants the read-only tools; 'write' additionally grants the mutating ones.
34
+ scopes text[] NOT NULL DEFAULT ARRAY['read', 'write']::text[],
35
+ created_by uuid REFERENCES auth.users (id) ON DELETE SET NULL,
36
+ created_at timestamptz NOT NULL DEFAULT now(),
37
+ last_used_at timestamptz,
38
+ expires_at timestamptz,
39
+ revoked_at timestamptz
40
+ );
41
+
42
+ COMMENT ON TABLE public.mcp_access_tokens IS
43
+ 'Bearer tokens for the Cortex AI MCP server at /api/mcp. Stores SHA-256 hashes only; plaintext is displayed once at mint time.';
44
+
45
+ CREATE INDEX IF NOT EXISTS mcp_access_tokens_token_hash_idx
46
+ ON public.mcp_access_tokens (token_hash);
47
+
48
+ -- Orders the admin token list newest-first without a sort.
49
+ CREATE INDEX IF NOT EXISTS mcp_access_tokens_created_at_idx
50
+ ON public.mcp_access_tokens (created_at DESC);
51
+
52
+ ALTER TABLE public.mcp_access_tokens ENABLE ROW LEVEL SECURITY;
53
+
54
+ -- Tokens are credentials: admin-only, with no anon or WRITER access at all. The
55
+ -- MCP route verifies them with the service-role client, which bypasses RLS.
56
+ DROP POLICY IF EXISTS mcp_access_tokens_admin_select ON public.mcp_access_tokens;
57
+ CREATE POLICY mcp_access_tokens_admin_select ON public.mcp_access_tokens
58
+ FOR SELECT TO authenticated
59
+ USING ((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role);
60
+
61
+ DROP POLICY IF EXISTS mcp_access_tokens_admin_insert ON public.mcp_access_tokens;
62
+ CREATE POLICY mcp_access_tokens_admin_insert ON public.mcp_access_tokens
63
+ FOR INSERT TO authenticated
64
+ WITH CHECK ((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role);
65
+
66
+ DROP POLICY IF EXISTS mcp_access_tokens_admin_update ON public.mcp_access_tokens;
67
+ CREATE POLICY mcp_access_tokens_admin_update ON public.mcp_access_tokens
68
+ FOR UPDATE TO authenticated
69
+ USING ((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role)
70
+ WITH CHECK ((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role);
71
+
72
+ DROP POLICY IF EXISTS mcp_access_tokens_admin_delete ON public.mcp_access_tokens;
73
+ CREATE POLICY mcp_access_tokens_admin_delete ON public.mcp_access_tokens
74
+ FOR DELETE TO authenticated
75
+ USING ((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role);
76
+
77
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.mcp_access_tokens TO authenticated;
78
+ GRANT ALL ON public.mcp_access_tokens TO service_role;
79
+
80
+ -- Add cortex_ai_mcp_settings to the admin-only site_settings group (all four policies).
81
+ DROP POLICY IF EXISTS site_settings_read_policy ON public.site_settings;
82
+ 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))));
83
+
84
+ DROP POLICY IF EXISTS site_settings_insert_policy ON public.site_settings;
85
+ 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))));
86
+
87
+ DROP POLICY IF EXISTS site_settings_update_policy ON public.site_settings;
88
+ 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))));
89
+
90
+ DROP POLICY IF EXISTS site_settings_delete_policy ON public.site_settings;
91
+ 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))));