@nextblock-cms/db 0.15.9 → 0.16.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/lib/supabase/server.d.ts
CHANGED
|
@@ -173,6 +173,36 @@ export declare const getServiceRoleSupabaseClient: () => import('@supabase/supab
|
|
|
173
173
|
referencedColumns: ["id"];
|
|
174
174
|
}];
|
|
175
175
|
};
|
|
176
|
+
cms_redirects: {
|
|
177
|
+
Row: {
|
|
178
|
+
created_at: string;
|
|
179
|
+
destination_path: string;
|
|
180
|
+
id: string;
|
|
181
|
+
is_active: boolean;
|
|
182
|
+
source_path: string;
|
|
183
|
+
status_code: number;
|
|
184
|
+
updated_at: string;
|
|
185
|
+
};
|
|
186
|
+
Insert: {
|
|
187
|
+
created_at?: string;
|
|
188
|
+
destination_path: string;
|
|
189
|
+
id?: string;
|
|
190
|
+
is_active?: boolean;
|
|
191
|
+
source_path: string;
|
|
192
|
+
status_code?: number;
|
|
193
|
+
updated_at?: string;
|
|
194
|
+
};
|
|
195
|
+
Update: {
|
|
196
|
+
created_at?: string;
|
|
197
|
+
destination_path?: string;
|
|
198
|
+
id?: string;
|
|
199
|
+
is_active?: boolean;
|
|
200
|
+
source_path?: string;
|
|
201
|
+
status_code?: number;
|
|
202
|
+
updated_at?: string;
|
|
203
|
+
};
|
|
204
|
+
Relationships: [];
|
|
205
|
+
};
|
|
176
206
|
content_drafts: {
|
|
177
207
|
Row: {
|
|
178
208
|
author_id: string | null;
|
|
@@ -2722,6 +2752,6 @@ export declare const getServiceRoleSupabaseClient: () => import('@supabase/supab
|
|
|
2722
2752
|
};
|
|
2723
2753
|
CompositeTypes: { [_ in never]: never; };
|
|
2724
2754
|
}, {
|
|
2725
|
-
PostgrestVersion: "14.
|
|
2755
|
+
PostgrestVersion: "14.15";
|
|
2726
2756
|
}>;
|
|
2727
2757
|
export {};
|
package/lib/supabase/types.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export type Json = string | number | boolean | null | {
|
|
|
3
3
|
} | Json[];
|
|
4
4
|
export type Database = {
|
|
5
5
|
__InternalSupabase: {
|
|
6
|
-
PostgrestVersion: "14.
|
|
6
|
+
PostgrestVersion: "14.15";
|
|
7
7
|
};
|
|
8
8
|
public: {
|
|
9
9
|
Tables: {
|
|
@@ -179,6 +179,36 @@ export type Database = {
|
|
|
179
179
|
}
|
|
180
180
|
];
|
|
181
181
|
};
|
|
182
|
+
cms_redirects: {
|
|
183
|
+
Row: {
|
|
184
|
+
created_at: string;
|
|
185
|
+
destination_path: string;
|
|
186
|
+
id: string;
|
|
187
|
+
is_active: boolean;
|
|
188
|
+
source_path: string;
|
|
189
|
+
status_code: number;
|
|
190
|
+
updated_at: string;
|
|
191
|
+
};
|
|
192
|
+
Insert: {
|
|
193
|
+
created_at?: string;
|
|
194
|
+
destination_path: string;
|
|
195
|
+
id?: string;
|
|
196
|
+
is_active?: boolean;
|
|
197
|
+
source_path: string;
|
|
198
|
+
status_code?: number;
|
|
199
|
+
updated_at?: string;
|
|
200
|
+
};
|
|
201
|
+
Update: {
|
|
202
|
+
created_at?: string;
|
|
203
|
+
destination_path?: string;
|
|
204
|
+
id?: string;
|
|
205
|
+
is_active?: boolean;
|
|
206
|
+
source_path?: string;
|
|
207
|
+
status_code?: number;
|
|
208
|
+
updated_at?: string;
|
|
209
|
+
};
|
|
210
|
+
Relationships: [];
|
|
211
|
+
};
|
|
182
212
|
content_drafts: {
|
|
183
213
|
Row: {
|
|
184
214
|
author_id: string | null;
|
|
@@ -2814,9 +2844,9 @@ type DatabaseWithoutInternals = Omit<Database, "__InternalSupabase">;
|
|
|
2814
2844
|
type DefaultSchema = DatabaseWithoutInternals[Extract<keyof Database, "public">];
|
|
2815
2845
|
export type Tables<DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) | {
|
|
2816
2846
|
schema: keyof DatabaseWithoutInternals;
|
|
2817
|
-
}, TableName extends DefaultSchemaTableNameOrOptions extends {
|
|
2847
|
+
}, TableName extends (DefaultSchemaTableNameOrOptions extends {
|
|
2818
2848
|
schema: keyof DatabaseWithoutInternals;
|
|
2819
|
-
} ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) : never = never> = DefaultSchemaTableNameOrOptions extends {
|
|
2849
|
+
} ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) : never) = never> = DefaultSchemaTableNameOrOptions extends {
|
|
2820
2850
|
schema: keyof DatabaseWithoutInternals;
|
|
2821
2851
|
} ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends {
|
|
2822
2852
|
Row: infer R;
|
|
@@ -2825,9 +2855,9 @@ export type Tables<DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema[
|
|
|
2825
2855
|
} ? R : never : never;
|
|
2826
2856
|
export type TablesInsert<DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] | {
|
|
2827
2857
|
schema: keyof DatabaseWithoutInternals;
|
|
2828
|
-
}, TableName extends DefaultSchemaTableNameOrOptions extends {
|
|
2858
|
+
}, TableName extends (DefaultSchemaTableNameOrOptions extends {
|
|
2829
2859
|
schema: keyof DatabaseWithoutInternals;
|
|
2830
|
-
} ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] : never = never> = DefaultSchemaTableNameOrOptions extends {
|
|
2860
|
+
} ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] : never) = never> = DefaultSchemaTableNameOrOptions extends {
|
|
2831
2861
|
schema: keyof DatabaseWithoutInternals;
|
|
2832
2862
|
} ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
|
|
2833
2863
|
Insert: infer I;
|
|
@@ -2836,9 +2866,9 @@ export type TablesInsert<DefaultSchemaTableNameOrOptions extends keyof DefaultSc
|
|
|
2836
2866
|
} ? I : never : never;
|
|
2837
2867
|
export type TablesUpdate<DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] | {
|
|
2838
2868
|
schema: keyof DatabaseWithoutInternals;
|
|
2839
|
-
}, TableName extends DefaultSchemaTableNameOrOptions extends {
|
|
2869
|
+
}, TableName extends (DefaultSchemaTableNameOrOptions extends {
|
|
2840
2870
|
schema: keyof DatabaseWithoutInternals;
|
|
2841
|
-
} ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] : never = never> = DefaultSchemaTableNameOrOptions extends {
|
|
2871
|
+
} ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] : never) = never> = DefaultSchemaTableNameOrOptions extends {
|
|
2842
2872
|
schema: keyof DatabaseWithoutInternals;
|
|
2843
2873
|
} ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
|
|
2844
2874
|
Update: infer U;
|
|
@@ -2847,16 +2877,16 @@ export type TablesUpdate<DefaultSchemaTableNameOrOptions extends keyof DefaultSc
|
|
|
2847
2877
|
} ? U : never : never;
|
|
2848
2878
|
export type Enums<DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"] | {
|
|
2849
2879
|
schema: keyof DatabaseWithoutInternals;
|
|
2850
|
-
}, EnumName extends DefaultSchemaEnumNameOrOptions extends {
|
|
2880
|
+
}, EnumName extends (DefaultSchemaEnumNameOrOptions extends {
|
|
2851
2881
|
schema: keyof DatabaseWithoutInternals;
|
|
2852
|
-
} ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] : never = never> = DefaultSchemaEnumNameOrOptions extends {
|
|
2882
|
+
} ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] : never) = never> = DefaultSchemaEnumNameOrOptions extends {
|
|
2853
2883
|
schema: keyof DatabaseWithoutInternals;
|
|
2854
2884
|
} ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName] : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"] ? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions] : never;
|
|
2855
2885
|
export type CompositeTypes<PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"] | {
|
|
2856
2886
|
schema: keyof DatabaseWithoutInternals;
|
|
2857
|
-
}, CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
|
|
2887
|
+
}, CompositeTypeName extends (PublicCompositeTypeNameOrOptions extends {
|
|
2858
2888
|
schema: keyof DatabaseWithoutInternals;
|
|
2859
|
-
} ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] : never = never> = PublicCompositeTypeNameOrOptions extends {
|
|
2889
|
+
} ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] : never) = never> = PublicCompositeTypeNameOrOptions extends {
|
|
2860
2890
|
schema: keyof DatabaseWithoutInternals;
|
|
2861
2891
|
} ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"] ? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] : never;
|
|
2862
2892
|
export declare const Constants: {
|
package/package.json
CHANGED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
-- SEO engine: managed 301/302 redirects and operator-configurable robots directives.
|
|
2
|
+
--
|
|
3
|
+
-- Two unrelated-looking things ship in one migration because they are the same
|
|
4
|
+
-- feature from an operator's point of view: the `/cms/settings/seo` screen is where
|
|
5
|
+
-- someone goes to say "this URL moved" and "do not crawl that". Splitting them
|
|
6
|
+
-- across two migrations would only mean two files that must always be applied
|
|
7
|
+
-- together.
|
|
8
|
+
--
|
|
9
|
+
-- WHY A TABLE AND NOT next.config.js redirects(). Redirects are content, not
|
|
10
|
+
-- configuration. An editor who renames a page's slug needs the old URL to keep
|
|
11
|
+
-- working immediately, without a redeploy and without touching source control.
|
|
12
|
+
-- That rules out the build-time array; it has to be data.
|
|
13
|
+
--
|
|
14
|
+
-- WHY THE PROXY READS THIS WITH THE ANON KEY. apps/nextblock/proxy.ts (Next 16's
|
|
15
|
+
-- renamed middleware) resolves redirects before rendering, and it holds a Supabase
|
|
16
|
+
-- client built from the anon key. So the public SELECT policy below is load-bearing:
|
|
17
|
+
-- without an explicit `TO authenticated, anon` grant the proxy's lookup would return
|
|
18
|
+
-- zero rows for every anonymous visitor -- silently, with no error -- and no redirect
|
|
19
|
+
-- would ever fire. Only is_active rows are exposed, so a half-written rule is never
|
|
20
|
+
-- live.
|
|
21
|
+
--
|
|
22
|
+
-- WHY status_code IS AN integer AND NOT AN ENUM. The same reasoning migration 27
|
|
23
|
+
-- recorded for `source`: a Postgres enum cannot be extended and used inside the same
|
|
24
|
+
-- transaction, which is exactly the scope of one migration file. A CHECK constraint
|
|
25
|
+
-- is replaceable in a single statement. 301 and 302 are the only two values the
|
|
26
|
+
-- admin UI offers; 307/308 are deliberately not exposed, because their
|
|
27
|
+
-- method-preserving semantics surprise operators who just want "this page moved".
|
|
28
|
+
--
|
|
29
|
+
-- LOOP SAFETY is enforced in application code (wouldCreateLoop in
|
|
30
|
+
-- @nextblock-cms/utils, called by the admin server actions) rather than by a
|
|
31
|
+
-- constraint, because detecting a cycle requires walking the whole table and a CHECK
|
|
32
|
+
-- constraint can only see one row. The self-redirect case IS cheap to check per row,
|
|
33
|
+
-- so that one is a constraint -- it is the cycle operators actually hit.
|
|
34
|
+
--
|
|
35
|
+
-- SECURITY POSTURE. A redirect can send every visitor of a path to an arbitrary
|
|
36
|
+
-- external origin, which makes this table an open-redirect surface and a phishing
|
|
37
|
+
-- lever. Writes are therefore ADMIN-only -- WRITER is deliberately excluded, matching
|
|
38
|
+
-- site_scripts rather than the content tables. Reads are public but limited to active
|
|
39
|
+
-- rows. The robots settings live in site_settings, whose existing read policy is
|
|
40
|
+
-- already public for non-secret keys; nothing here is secret.
|
|
41
|
+
--
|
|
42
|
+
-- Forward-only and idempotent.
|
|
43
|
+
|
|
44
|
+
CREATE TABLE IF NOT EXISTS public.cms_redirects (
|
|
45
|
+
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
|
46
|
+
-- The incoming site-relative path to match, normalized by the application to a
|
|
47
|
+
-- leading slash with no trailing slash (except root). Matching is exact: prefix
|
|
48
|
+
-- and wildcard rules are deliberately not supported, because they are the usual
|
|
49
|
+
-- way an operator builds an accidental loop.
|
|
50
|
+
source_path text NOT NULL,
|
|
51
|
+
-- Where to send the visitor. Either another site-relative path or a fully
|
|
52
|
+
-- qualified https URL for an off-site move.
|
|
53
|
+
destination_path text NOT NULL,
|
|
54
|
+
-- 301 permanent (the SEO-meaningful one: search engines transfer ranking signals
|
|
55
|
+
-- and browsers cache it aggressively) or 302 temporary.
|
|
56
|
+
status_code integer DEFAULT 301 NOT NULL,
|
|
57
|
+
is_active boolean DEFAULT true NOT NULL,
|
|
58
|
+
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
|
59
|
+
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
|
60
|
+
CONSTRAINT cms_redirects_pkey PRIMARY KEY (id),
|
|
61
|
+
-- One rule per source. This UNIQUE constraint also provides the index the proxy
|
|
62
|
+
-- lookup relies on, so no separate plain index on source_path is needed.
|
|
63
|
+
CONSTRAINT cms_redirects_source_path_key UNIQUE (source_path),
|
|
64
|
+
CONSTRAINT cms_redirects_status_code_check
|
|
65
|
+
CHECK ((status_code = ANY (ARRAY[301, 302]))),
|
|
66
|
+
-- A source is always a path on this site; accepting an absolute URL here would
|
|
67
|
+
-- silently never match, since the proxy only ever compares pathnames.
|
|
68
|
+
CONSTRAINT cms_redirects_source_path_check
|
|
69
|
+
CHECK ((source_path ~ '^/')),
|
|
70
|
+
-- A destination is either a site-relative path or an https URL. Plain http is
|
|
71
|
+
-- refused so a redirect can never downgrade a visitor to cleartext.
|
|
72
|
+
CONSTRAINT cms_redirects_destination_path_check
|
|
73
|
+
CHECK (((destination_path ~ '^/') OR (destination_path ~ '^https://'))),
|
|
74
|
+
-- The one cycle a single row can express, and the one operators actually create.
|
|
75
|
+
CONSTRAINT cms_redirects_no_self_redirect_check
|
|
76
|
+
CHECK ((source_path <> destination_path))
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
COMMENT ON TABLE public.cms_redirects IS
|
|
80
|
+
'Operator-managed 301/302 redirects resolved by apps/nextblock/proxy.ts before rendering. Only is_active rows are publicly readable; only ADMIN may write, because a redirect rule is an open-redirect surface.';
|
|
81
|
+
COMMENT ON COLUMN public.cms_redirects.source_path IS
|
|
82
|
+
'Exact site-relative path to match, normalized to a leading slash and no trailing slash (except root). No wildcards, by design.';
|
|
83
|
+
COMMENT ON COLUMN public.cms_redirects.destination_path IS
|
|
84
|
+
'Site-relative path or absolute https URL to send the visitor to.';
|
|
85
|
+
COMMENT ON COLUMN public.cms_redirects.status_code IS
|
|
86
|
+
'301 permanent or 302 temporary. 307/308 are not offered by the admin UI.';
|
|
87
|
+
COMMENT ON COLUMN public.cms_redirects.is_active IS
|
|
88
|
+
'Only active rows are readable by anon, and only active rows are matched by the proxy.';
|
|
89
|
+
|
|
90
|
+
-- The proxy loads the whole active set once per cache window rather than querying per
|
|
91
|
+
-- request, so the hot query is "all active rows" and this partial index is what serves
|
|
92
|
+
-- it. Carrying source_path in the index keeps that read index-only.
|
|
93
|
+
CREATE INDEX IF NOT EXISTS cms_redirects_active_source_idx
|
|
94
|
+
ON public.cms_redirects USING btree (source_path) WHERE (is_active);
|
|
95
|
+
|
|
96
|
+
DROP TRIGGER IF EXISTS set_cms_redirects_updated_at ON public.cms_redirects;
|
|
97
|
+
CREATE TRIGGER set_cms_redirects_updated_at
|
|
98
|
+
BEFORE UPDATE ON public.cms_redirects
|
|
99
|
+
FOR EACH ROW EXECUTE FUNCTION public.set_current_timestamp_updated_at();
|
|
100
|
+
|
|
101
|
+
ALTER TABLE public.cms_redirects ENABLE ROW LEVEL SECURITY;
|
|
102
|
+
|
|
103
|
+
GRANT ALL ON TABLE public.cms_redirects TO anon;
|
|
104
|
+
GRANT ALL ON TABLE public.cms_redirects TO authenticated;
|
|
105
|
+
GRANT ALL ON TABLE public.cms_redirects TO service_role;
|
|
106
|
+
|
|
107
|
+
-- The proxy runs as anon for a logged-out visitor, which is the overwhelming majority
|
|
108
|
+
-- of traffic and the only traffic redirects really matter for. Without this policy the
|
|
109
|
+
-- lookup returns zero rows and the feature is silently dead.
|
|
110
|
+
DROP POLICY IF EXISTS "Public read active redirects" ON public.cms_redirects;
|
|
111
|
+
CREATE POLICY "Public read active redirects" ON public.cms_redirects
|
|
112
|
+
FOR SELECT TO authenticated, anon USING (is_active);
|
|
113
|
+
|
|
114
|
+
DROP POLICY IF EXISTS "Admins read all redirects" ON public.cms_redirects;
|
|
115
|
+
CREATE POLICY "Admins read all redirects" ON public.cms_redirects
|
|
116
|
+
FOR SELECT TO authenticated
|
|
117
|
+
USING (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));
|
|
118
|
+
|
|
119
|
+
DROP POLICY IF EXISTS "Admins insert redirects" ON public.cms_redirects;
|
|
120
|
+
CREATE POLICY "Admins insert redirects" ON public.cms_redirects
|
|
121
|
+
FOR INSERT TO authenticated
|
|
122
|
+
WITH CHECK (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));
|
|
123
|
+
|
|
124
|
+
DROP POLICY IF EXISTS "Admins update redirects" ON public.cms_redirects;
|
|
125
|
+
CREATE POLICY "Admins update redirects" ON public.cms_redirects
|
|
126
|
+
FOR UPDATE TO authenticated
|
|
127
|
+
USING (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role))
|
|
128
|
+
WITH CHECK (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));
|
|
129
|
+
|
|
130
|
+
DROP POLICY IF EXISTS "Admins delete redirects" ON public.cms_redirects;
|
|
131
|
+
CREATE POLICY "Admins delete redirects" ON public.cms_redirects
|
|
132
|
+
FOR DELETE TO authenticated
|
|
133
|
+
USING (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));
|
|
134
|
+
|
|
135
|
+
-- Robots directives live in site_settings rather than a table of their own: there is
|
|
136
|
+
-- exactly one robots.txt per install, so a dedicated table would only ever hold one
|
|
137
|
+
-- row, and site_settings already carries the public-read / staff-write policy this
|
|
138
|
+
-- needs. The stored shape matches `RobotsSettings` in @nextblock-cms/utils, and
|
|
139
|
+
-- `normalizeRobotsSettings` tolerates a missing or malformed value -- so this seed is
|
|
140
|
+
-- a convenience for the settings screen, not a correctness requirement for rendering.
|
|
141
|
+
--
|
|
142
|
+
-- ON CONFLICT DO NOTHING because an install that has already configured robots must
|
|
143
|
+
-- not have its rules reset by a replay of this migration.
|
|
144
|
+
INSERT INTO public.site_settings (key, value)
|
|
145
|
+
VALUES (
|
|
146
|
+
'seo_robots_settings',
|
|
147
|
+
'{"customRules": "", "isIndexingEnabled": true, "sitemapEnabled": true, "userAgentRules": [{"allow": ["/"], "disallow": [], "userAgent": "*"}]}'::jsonb
|
|
148
|
+
)
|
|
149
|
+
ON CONFLICT (key) DO NOTHING;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
-- Restrict writes to the robots.txt settings row to ADMIN only.
|
|
2
|
+
--
|
|
3
|
+
-- `site_settings.seo_robots_settings` is the row that decides whether the whole site
|
|
4
|
+
-- is crawlable: app/robots.ts reads it on every /robots.txt hit and serves a blanket
|
|
5
|
+
-- `Disallow: /` when `isIndexingEnabled` is false. The CMS only offers that switch
|
|
6
|
+
-- under /cms/settings/seo, and `saveRobotsSettings` re-checks for ADMIN before it
|
|
7
|
+
-- writes — but RLS is the independent boundary, and it did not agree. The baseline
|
|
8
|
+
-- write policies let ADMIN *or* WRITER write any key outside the sensitive array, so
|
|
9
|
+
-- a WRITER holding a normal session could PATCH this row through PostgREST and take
|
|
10
|
+
-- the entire site out of Google. That failure is quiet (nothing in the CMS shows it),
|
|
11
|
+
-- slow to notice (search traffic decays over weeks) and hard to attribute after the
|
|
12
|
+
-- fact, which is why the database has to refuse it rather than trusting the one
|
|
13
|
+
-- server action that happens to guard it today.
|
|
14
|
+
--
|
|
15
|
+
-- The SELECT policy is deliberately NOT touched. This key must stay anon-READABLE:
|
|
16
|
+
-- app/robots.ts reads it with the anon (SSG) client on every crawl, and adding the key
|
|
17
|
+
-- to the read policy's sensitive array would make robots.txt fall back to its
|
|
18
|
+
-- permissive defaults for every crawler. Only INSERT/UPDATE/DELETE move to ADMIN-only,
|
|
19
|
+
-- matching the UI boundary — exactly the shape migration 00000000000011 used for
|
|
20
|
+
-- language_detection_settings, which is anon-readable for the same reason.
|
|
21
|
+
--
|
|
22
|
+
-- Every key already present in each policy's array is preserved (dropping one would
|
|
23
|
+
-- silently widen write access back to WRITER for that key); `seo_robots_settings` is
|
|
24
|
+
-- appended to the three write policies only.
|
|
25
|
+
|
|
26
|
+
DROP POLICY IF EXISTS site_settings_insert_policy ON public.site_settings;
|
|
27
|
+
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, 'seo_robots_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, 'seo_robots_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role))));
|
|
28
|
+
|
|
29
|
+
DROP POLICY IF EXISTS site_settings_update_policy ON public.site_settings;
|
|
30
|
+
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, 'seo_robots_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, 'seo_robots_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, 'seo_robots_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, 'seo_robots_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role))));
|
|
31
|
+
|
|
32
|
+
DROP POLICY IF EXISTS site_settings_delete_policy ON public.site_settings;
|
|
33
|
+
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, 'seo_robots_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, 'seo_robots_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role))));
|
|
34
|
+
|
|
35
|
+
-- Forward-only and idempotent.
|