@nextblock-cms/db 0.22.2 → 0.22.3

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.
@@ -1 +1 @@
1
- "use server";const e=require("./supabase/server.cjs.js");let t=require("next/cache"),n=require("@nextblock-cms/utils/server");async function r(r,i){let a=r.actorUserId,o=a?e.getServiceRoleSupabaseClient():e.createClient(),s;if(a)s=a;else{let{data:{user:e},error:t}=await o.auth.getUser();if(t||!e)return i?{error:`User not authenticated for media record.`}:(0,n.encodedRedirect)(`error`,`/cms/media`,`User not authenticated for media record.`);s=e.id}let{data:c}=await o.from(`profiles`).select(`role`).eq(`id`,s).single();if(!c||![`ADMIN`,`WRITER`].includes(c.role))return i?{error:`Forbidden: Insufficient permissions to record media.`}:(0,n.encodedRedirect)(`error`,`/cms/media`,`Forbidden: Insufficient permissions to record media.`);let l=r.r2Variants.find(e=>e.variantLabel===`original_avif`)||r.r2Variants.find(e=>e.variantLabel===`xlarge_avif`)||r.r2Variants[0]||r.originalImageDetails;l||=r.originalImageDetails||{objectKey:r.r2OriginalKey,url:``,width:0,height:0,fileType:`application/octet-stream`,sizeBytes:0,variantLabel:`fallback_original`};let u=[...r.originalImageDetails&&r.originalImageDetails.objectKey!==l.objectKey?[r.originalImageDetails]:[],...r.r2Variants].filter((e,t,n)=>t===n.findIndex(t=>t.objectKey===e.objectKey)),d=r.description??(l.fileType?.startsWith(`image/`)||r.originalImageDetails?.fileType?.startsWith(`image/`)?(e=>{let t=e.lastIndexOf(`.`);return(t>0?e.substring(0,t):e).replace(/[-+_\\]+/g,` `).replace(/\s+/g,` `).trim().replace(/\b\w+/g,e=>e.charAt(0).toUpperCase()+e.slice(1))})(r.fileName):null),f={uploader_id:s,file_name:r.fileName,object_key:l.objectKey,file_path:l.objectKey,folder:(()=>{let e=l.objectKey.match(/^(.*\/)?.*$/);return e&&e[1]?e[1]:null})(),file_type:l.fileType,size_bytes:l.sizeBytes,description:d,width:l.width,height:l.height,variants:u,blur_data_url:r.blurDataUrl||null},{data:p,error:m}=await o.from(`media`).insert(f).select().single();if(m)return console.error(`Error recording media upload:`,m),i?{error:`Failed to record media: ${m.message}`}:(0,n.encodedRedirect)(`error`,`/cms/media`,`Failed to record media: ${m.message}`);if((0,t.revalidatePath)(`/cms/media`),i)return{success:!0,data:p};(0,n.encodedRedirect)(`success`,`/cms/media`,`Media recorded successfully.`)}exports.recordMediaUpload=r;
1
+ "use server";const e=require("./supabase/server.cjs.js"),t=require("./media-record.cjs.js");require("server-only");let n=require("@nextblock-cms/utils/server");var r=Symbol.for(`nextblock.cms.secondFactorGate`);async function i(e,t){let n=globalThis[r];if(typeof n==`function`)return!!await n();let{data:i}=await e.from(`user_security_settings`).select(`mfa_enabled, mfa_type`).eq(`user_id`,t).maybeSingle();if(!i?.mfa_enabled||!i?.mfa_type)return!0;if(i.mfa_type===`totp`){let{data:t}=await e.auth.mfa.getAuthenticatorAssuranceLevel();return t?.currentLevel===`aal2`}return!1}async function a(r,a){let o=e.createClient(),{data:{user:s},error:c}=await o.auth.getUser();if(c||!s)return a?{error:`User not authenticated for media record.`}:(0,n.encodedRedirect)(`error`,`/cms/media`,`User not authenticated for media record.`);let{data:l}=await o.from(`profiles`).select(`role`).eq(`id`,s.id).single();if(!l||![`ADMIN`,`WRITER`].includes(l.role)||!await i(o,s.id))return a?{error:`Forbidden: Insufficient permissions to record media.`}:(0,n.encodedRedirect)(`error`,`/cms/media`,`Forbidden: Insufficient permissions to record media.`);let u=await t.insertMediaRecord(o,s.id,r);if(`error`in u)return a?u:(0,n.encodedRedirect)(`error`,`/cms/media`,u.error);if(a)return u;(0,n.encodedRedirect)(`success`,`/cms/media`,`Media recorded successfully.`)}exports.recordMediaUpload=a;
@@ -1,35 +1,13 @@
1
- import { Database } from './supabase/types';
2
- type Media = Database['public']['Tables']['media']['Row'];
3
- export interface ImageVariant {
4
- objectKey: string;
5
- url: string;
6
- width: number;
7
- height: number;
8
- fileType: string;
9
- sizeBytes: number;
10
- variantLabel: string;
11
- }
12
- export declare function recordMediaUpload(payload: {
13
- fileName: string;
14
- description?: string;
15
- r2OriginalKey: string;
16
- r2Variants: ImageVariant[];
17
- originalImageDetails: ImageVariant;
18
- blurDataUrl?: string;
19
- /**
20
- * Uploader for callers that have no cookie session — the MCP server, which
21
- * authenticates by bearer token instead. When set, the row is written with the
22
- * service-role client and attributed to this user rather than to `auth.getUser()`.
23
- *
24
- * This grants no new authority: the role check below still runs against the
25
- * supplied id, so a non-ADMIN/WRITER actor is rejected exactly as a cookie
26
- * session would be. The caller is responsible for having authenticated them.
27
- */
28
- actorUserId?: string;
29
- }, returnJustData?: boolean): Promise<{
30
- success: true;
31
- data: Media;
32
- } | {
33
- error: string;
34
- } | void>;
35
- export {};
1
+ import { MediaRecordResult, MediaUploadPayload } from './media-record';
2
+ export type { ImageVariant, MediaRecordResult, MediaUploadPayload } from './media-record';
3
+ /**
4
+ * Record an upload as the signed-in ADMIN or WRITER.
5
+ *
6
+ * A server action is a public POST endpoint, so the uploader is always the cookie
7
+ * session's user, never anything in the payload. Until 2026-10-21 the payload could name
8
+ * one (`actorUserId`, for the MCP server) and the session cross-check ran only when a
9
+ * session existed, so a cookieless POST naming an admin's id recorded media as that admin
10
+ * with the service role. Server routes without a session use `recordMediaUploadAsActor`
11
+ * (`@nextblock-cms/db/server`), which is not a server action.
12
+ */
13
+ export declare function recordMediaUpload(payload: MediaUploadPayload, returnJustData?: boolean): Promise<MediaRecordResult | void>;
@@ -1,54 +1,30 @@
1
1
  "use server";
2
- import { createClient as e, getServiceRoleSupabaseClient as t } from "./supabase/server.es.js";
3
- import { revalidatePath as n } from "next/cache";
4
- import { encodedRedirect as r } from "@nextblock-cms/utils/server";
2
+ import { createClient as e } from "./supabase/server.es.js";
3
+ import { insertMediaRecord as t } from "./media-record.es.js";
4
+ import "server-only";
5
+ import { encodedRedirect as n } from "@nextblock-cms/utils/server";
5
6
  //#region src/lib/media-actions.ts
6
- async function i(i, a) {
7
- let o = i.actorUserId, s = o ? t() : e(), c;
8
- if (o) c = o;
9
- else {
10
- let { data: { user: e }, error: t } = await s.auth.getUser();
11
- if (t || !e) return a ? { error: "User not authenticated for media record." } : r("error", "/cms/media", "User not authenticated for media record.");
12
- c = e.id;
7
+ var r = Symbol.for("nextblock.cms.secondFactorGate");
8
+ async function i(e, t) {
9
+ let n = globalThis[r];
10
+ if (typeof n == "function") return !!await n();
11
+ let { data: i } = await e.from("user_security_settings").select("mfa_enabled, mfa_type").eq("user_id", t).maybeSingle();
12
+ if (!i?.mfa_enabled || !i?.mfa_type) return !0;
13
+ if (i.mfa_type === "totp") {
14
+ let { data: t } = await e.auth.mfa.getAuthenticatorAssuranceLevel();
15
+ return t?.currentLevel === "aal2";
13
16
  }
14
- let { data: l } = await s.from("profiles").select("role").eq("id", c).single();
15
- if (!l || !["ADMIN", "WRITER"].includes(l.role)) return a ? { error: "Forbidden: Insufficient permissions to record media." } : r("error", "/cms/media", "Forbidden: Insufficient permissions to record media.");
16
- let u = i.r2Variants.find((e) => e.variantLabel === "original_avif") || i.r2Variants.find((e) => e.variantLabel === "xlarge_avif") || i.r2Variants[0] || i.originalImageDetails;
17
- u ||= i.originalImageDetails || {
18
- objectKey: i.r2OriginalKey,
19
- url: "",
20
- width: 0,
21
- height: 0,
22
- fileType: "application/octet-stream",
23
- sizeBytes: 0,
24
- variantLabel: "fallback_original"
25
- };
26
- let d = [...i.originalImageDetails && i.originalImageDetails.objectKey !== u.objectKey ? [i.originalImageDetails] : [], ...i.r2Variants].filter((e, t, n) => t === n.findIndex((t) => t.objectKey === e.objectKey)), f = i.description ?? (u.fileType?.startsWith("image/") || i.originalImageDetails?.fileType?.startsWith("image/") ? ((e) => {
27
- let t = e.lastIndexOf(".");
28
- return (t > 0 ? e.substring(0, t) : e).replace(/[-+_\\]+/g, " ").replace(/\s+/g, " ").trim().replace(/\b\w+/g, (e) => e.charAt(0).toUpperCase() + e.slice(1));
29
- })(i.fileName) : null), p = {
30
- uploader_id: c,
31
- file_name: i.fileName,
32
- object_key: u.objectKey,
33
- file_path: u.objectKey,
34
- folder: (() => {
35
- let e = u.objectKey.match(/^(.*\/)?.*$/);
36
- return e && e[1] ? e[1] : null;
37
- })(),
38
- file_type: u.fileType,
39
- size_bytes: u.sizeBytes,
40
- description: f,
41
- width: u.width,
42
- height: u.height,
43
- variants: d,
44
- blur_data_url: i.blurDataUrl || null
45
- }, { data: m, error: h } = await s.from("media").insert(p).select().single();
46
- if (h) return console.error("Error recording media upload:", h), a ? { error: `Failed to record media: ${h.message}` } : r("error", "/cms/media", `Failed to record media: ${h.message}`);
47
- if (n("/cms/media"), a) return {
48
- success: !0,
49
- data: m
50
- };
51
- r("success", "/cms/media", "Media recorded successfully.");
17
+ return !1;
18
+ }
19
+ async function a(r, a) {
20
+ let o = e(), { data: { user: s }, error: c } = await o.auth.getUser();
21
+ if (c || !s) return a ? { error: "User not authenticated for media record." } : n("error", "/cms/media", "User not authenticated for media record.");
22
+ let { data: l } = await o.from("profiles").select("role").eq("id", s.id).single();
23
+ if (!l || !["ADMIN", "WRITER"].includes(l.role) || !await i(o, s.id)) return a ? { error: "Forbidden: Insufficient permissions to record media." } : n("error", "/cms/media", "Forbidden: Insufficient permissions to record media.");
24
+ let u = await t(o, s.id, r);
25
+ if ("error" in u) return a ? u : n("error", "/cms/media", u.error);
26
+ if (a) return u;
27
+ n("success", "/cms/media", "Media recorded successfully.");
52
28
  }
53
29
  //#endregion
54
- export { i as recordMediaUpload };
30
+ export { a as recordMediaUpload };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ const e=require("./supabase/server.cjs.js");require("server-only");let t=require("next/cache");var n=[`ADMIN`,`WRITER`];async function r(e,n,r){let i=r.r2Variants.find(e=>e.variantLabel===`original_avif`)||r.r2Variants.find(e=>e.variantLabel===`xlarge_avif`)||r.r2Variants[0]||r.originalImageDetails;i||=r.originalImageDetails||{objectKey:r.r2OriginalKey,url:``,width:0,height:0,fileType:`application/octet-stream`,sizeBytes:0,variantLabel:`fallback_original`};let a=[...r.originalImageDetails&&r.originalImageDetails.objectKey!==i.objectKey?[r.originalImageDetails]:[],...r.r2Variants].filter((e,t,n)=>t===n.findIndex(t=>t.objectKey===e.objectKey)),o=r.description??(i.fileType?.startsWith(`image/`)||r.originalImageDetails?.fileType?.startsWith(`image/`)?(e=>{let t=e.lastIndexOf(`.`);return(t>0?e.substring(0,t):e).replace(/[-+_\\]+/g,` `).replace(/\s+/g,` `).trim().replace(/\b\w+/g,e=>e.charAt(0).toUpperCase()+e.slice(1))})(r.fileName):null),s={uploader_id:n,file_name:r.fileName,object_key:i.objectKey,file_path:i.objectKey,folder:(()=>{let e=i.objectKey.match(/^(.*\/)?.*$/);return e&&e[1]?e[1]:null})(),file_type:i.fileType,size_bytes:i.sizeBytes,description:o,width:i.width,height:i.height,variants:a,blur_data_url:r.blurDataUrl||null},{data:c,error:l}=await e.from(`media`).insert(s).select().single();return l?(console.error(`Error recording media upload:`,l),{error:`Failed to record media: ${l.message}`}):((0,t.revalidatePath)(`/cms/media`),{success:!0,data:c})}async function i(t,i){let a=e.getServiceRoleSupabaseClient(),{data:o}=await a.from(`profiles`).select(`role`).eq(`id`,i).maybeSingle();return!o||!n.includes(o.role)?{error:`Forbidden: Insufficient permissions to record media.`}:r(a,i,t)}exports.insertMediaRecord=r,exports.recordMediaUploadAsActor=i;
@@ -0,0 +1,46 @@
1
+ import { Database } from './supabase/types';
2
+ type Media = Database['public']['Tables']['media']['Row'];
3
+ export interface ImageVariant {
4
+ objectKey: string;
5
+ url: string;
6
+ width: number;
7
+ height: number;
8
+ fileType: string;
9
+ sizeBytes: number;
10
+ variantLabel: string;
11
+ }
12
+ export type MediaUploadPayload = {
13
+ fileName: string;
14
+ description?: string;
15
+ r2OriginalKey: string;
16
+ r2Variants: ImageVariant[];
17
+ originalImageDetails: ImageVariant;
18
+ blurDataUrl?: string;
19
+ };
20
+ export type MediaRecordResult = {
21
+ success: true;
22
+ data: Media;
23
+ } | {
24
+ error: string;
25
+ };
26
+ type MediaInsertClient = {
27
+ from: (table: string) => any;
28
+ };
29
+ /**
30
+ * Insert the `media` row for an upload, attributed to `uploaderId`. Takes the payload's
31
+ * known fields only. The caller has already established who `uploaderId` is: the cookie
32
+ * session (`recordMediaUpload`) or an authenticated server route (`recordMediaUploadAsActor`).
33
+ */
34
+ export declare function insertMediaRecord(supabase: MediaInsertClient, uploaderId: string, payload: MediaUploadPayload): Promise<MediaRecordResult>;
35
+ /**
36
+ * Record an upload for a server route that has no cookie session and has already
37
+ * authenticated its caller: `/api/mcp` (bearer token) and `/api/ai/*`. Writes with the
38
+ * service role, after re-reading the actor's role.
39
+ *
40
+ * Deliberately NOT a server action (no `'use server'`, and not exported from the
41
+ * client-safe `@nextblock-cms/db` entry): until 2026-10-21 `recordMediaUpload` accepted
42
+ * this actor id from its payload, so a cookieless POST naming an admin's id (public via
43
+ * `posts.author_id`) recorded media as that admin.
44
+ */
45
+ export declare function recordMediaUploadAsActor(payload: MediaUploadPayload, actorUserId: string): Promise<MediaRecordResult>;
46
+ export {};
@@ -0,0 +1,47 @@
1
+ import { getServiceRoleSupabaseClient as e } from "./supabase/server.es.js";
2
+ import "server-only";
3
+ import { revalidatePath as t } from "next/cache";
4
+ //#region src/lib/media-record.ts
5
+ var n = ["ADMIN", "WRITER"];
6
+ async function r(e, n, r) {
7
+ let i = r.r2Variants.find((e) => e.variantLabel === "original_avif") || r.r2Variants.find((e) => e.variantLabel === "xlarge_avif") || r.r2Variants[0] || r.originalImageDetails;
8
+ i ||= r.originalImageDetails || {
9
+ objectKey: r.r2OriginalKey,
10
+ url: "",
11
+ width: 0,
12
+ height: 0,
13
+ fileType: "application/octet-stream",
14
+ sizeBytes: 0,
15
+ variantLabel: "fallback_original"
16
+ };
17
+ let a = [...r.originalImageDetails && r.originalImageDetails.objectKey !== i.objectKey ? [r.originalImageDetails] : [], ...r.r2Variants].filter((e, t, n) => t === n.findIndex((t) => t.objectKey === e.objectKey)), o = r.description ?? (i.fileType?.startsWith("image/") || r.originalImageDetails?.fileType?.startsWith("image/") ? ((e) => {
18
+ let t = e.lastIndexOf(".");
19
+ return (t > 0 ? e.substring(0, t) : e).replace(/[-+_\\]+/g, " ").replace(/\s+/g, " ").trim().replace(/\b\w+/g, (e) => e.charAt(0).toUpperCase() + e.slice(1));
20
+ })(r.fileName) : null), s = {
21
+ uploader_id: n,
22
+ file_name: r.fileName,
23
+ object_key: i.objectKey,
24
+ file_path: i.objectKey,
25
+ folder: (() => {
26
+ let e = i.objectKey.match(/^(.*\/)?.*$/);
27
+ return e && e[1] ? e[1] : null;
28
+ })(),
29
+ file_type: i.fileType,
30
+ size_bytes: i.sizeBytes,
31
+ description: o,
32
+ width: i.width,
33
+ height: i.height,
34
+ variants: a,
35
+ blur_data_url: r.blurDataUrl || null
36
+ }, { data: c, error: l } = await e.from("media").insert(s).select().single();
37
+ return l ? (console.error("Error recording media upload:", l), { error: `Failed to record media: ${l.message}` }) : (t("/cms/media"), {
38
+ success: !0,
39
+ data: c
40
+ });
41
+ }
42
+ async function i(t, i) {
43
+ let a = e(), { data: o } = await a.from("profiles").select("role").eq("id", i).maybeSingle();
44
+ return !o || !n.includes(o.role) ? { error: "Forbidden: Insufficient permissions to record media." } : r(a, i, t);
45
+ }
46
+ //#endregion
47
+ export { r as insertMediaRecord, i as recordMediaUploadAsActor };
@@ -1 +1 @@
1
- const e=require("./supabase/server.cjs.js");let t=require("@supabase/supabase-js"),n=require("next/cache");require("server-only");var r=`package-activation`;function i(e){if(typeof e!=`string`||!e.trim())return null;let t=e.trim(),n=/(?:z|[+-]\d{2}:?\d{2})$/i.test(t),r=Date.parse(n?t:`${t.replace(` `,`T`)}Z`);return Number.isFinite(r)?r:null}function a(e){if(!e||typeof e!=`object`||Array.isArray(e))return null;let t=e,n=t.nextblock&&typeof t.nextblock==`object`&&!Array.isArray(t.nextblock)?t.nextblock:null,r=[n?.expiration,n?.trial_ends_at,t.expiration].map(i).filter(e=>e!==null);return r.length>0?Math.min(...r):null}function o(e,t=Date.now()){if(e.status!==`active`)return!1;let n=a(e.meta);return n===null||n>t}async function s(e,t){try{let{data:n,error:r}=await t.from(`package_activations`).select(`status, meta`).eq(`package_id`,e).eq(`status`,`active`).limit(5);return r||!Array.isArray(n)?!1:n.some(e=>o(e))}catch(t){return console.error(`Error verifying package ${e}:`,t),!1}}var c=(0,n.unstable_cache)(async e=>{let n=process.env.NEXT_PUBLIC_SUPABASE_URL||process.env.SUPABASE_URL,r=process.env.SUPABASE_SERVICE_ROLE_KEY||process.env.SUPABASE_SECRET_KEY||process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY||process.env.SUPABASE_ANON_KEY||process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY||process.env.SUPABASE_PUBLISHABLE_KEY;return!n||!r?!1:s(e,(0,t.createClient)(n,r,{auth:{persistSession:!1,autoRefreshToken:!1,detectSessionInUrl:!1}}))},[`package-activation`],{revalidate:60,tags:[r]});async function l(t,n){if(n)return s(t,n);let r=process.env.NEXT_PUBLIC_SUPABASE_URL||process.env.SUPABASE_URL,i=process.env.SUPABASE_SERVICE_ROLE_KEY||process.env.SUPABASE_SECRET_KEY||process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY||process.env.SUPABASE_ANON_KEY||process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY||process.env.SUPABASE_PUBLISHABLE_KEY;return r&&i?c(t):s(t,await e.createClient())}exports.PACKAGE_ACTIVATION_CACHE_TAG=r,exports.isPackageActivationRowValid=o,exports.resolvePackageActivationExpiry=a,exports.verifyPackageOnline=l;
1
+ const e=require("./supabase/server.cjs.js");require("server-only");let t=require("@supabase/supabase-js"),n=require("next/cache");var r=`package-activation`;function i(e){if(typeof e!=`string`||!e.trim())return null;let t=e.trim(),n=/(?:z|[+-]\d{2}:?\d{2})$/i.test(t),r=Date.parse(n?t:`${t.replace(` `,`T`)}Z`);return Number.isFinite(r)?r:null}function a(e){if(!e||typeof e!=`object`||Array.isArray(e))return null;let t=e,n=t.nextblock&&typeof t.nextblock==`object`&&!Array.isArray(t.nextblock)?t.nextblock:null,r=[n?.expiration,n?.trial_ends_at,t.expiration].map(i).filter(e=>e!==null);return r.length>0?Math.min(...r):null}function o(e,t=Date.now()){if(e.status!==`active`)return!1;let n=a(e.meta);return n===null||n>t}async function s(e,t){try{let{data:n,error:r}=await t.from(`package_activations`).select(`status, meta`).eq(`package_id`,e).eq(`status`,`active`).limit(5);return r||!Array.isArray(n)?!1:n.some(e=>o(e))}catch(t){return console.error(`Error verifying package ${e}:`,t),!1}}var c=(0,n.unstable_cache)(async e=>{let n=process.env.NEXT_PUBLIC_SUPABASE_URL||process.env.SUPABASE_URL,r=process.env.SUPABASE_SERVICE_ROLE_KEY||process.env.SUPABASE_SECRET_KEY||process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY||process.env.SUPABASE_ANON_KEY||process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY||process.env.SUPABASE_PUBLISHABLE_KEY;return!n||!r?!1:s(e,(0,t.createClient)(n,r,{auth:{persistSession:!1,autoRefreshToken:!1,detectSessionInUrl:!1}}))},[`package-activation`],{revalidate:60,tags:[r]});async function l(t,n){if(n)return s(t,n);let r=process.env.NEXT_PUBLIC_SUPABASE_URL||process.env.SUPABASE_URL,i=process.env.SUPABASE_SERVICE_ROLE_KEY||process.env.SUPABASE_SECRET_KEY||process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY||process.env.SUPABASE_ANON_KEY||process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY||process.env.SUPABASE_PUBLISHABLE_KEY;return r&&i?c(t):s(t,await e.createClient())}exports.PACKAGE_ACTIVATION_CACHE_TAG=r,exports.isPackageActivationRowValid=o,exports.resolvePackageActivationExpiry=a,exports.verifyPackageOnline=l;
@@ -1,7 +1,7 @@
1
1
  import { createClient as e } from "./supabase/server.es.js";
2
+ import "server-only";
2
3
  import { createClient as t } from "@supabase/supabase-js";
3
4
  import { unstable_cache as n } from "next/cache";
4
- import "server-only";
5
5
  //#region src/lib/package-validation.ts
6
6
  var r = "package-activation";
7
7
  function i(e) {
@@ -1 +1 @@
1
- let e=require("node:crypto");var t=`aes-256-gcm`,n=1;function r(t){let n=t.trim();if(!n)throw Error(`A non-empty encryption secret is required to manage stored secrets.`);return(0,e.createHash)(`sha256`).update(n).digest()}function i(e){if(!e||typeof e!=`object`)return!1;let t=e;return t.algorithm===`aes-256-gcm`&&t.version===1&&typeof t.authTag==`string`&&typeof t.ciphertext==`string`&&typeof t.iv==`string`}function a(e){if(!i(e))throw Error(`Invalid encrypted secret payload.`);let t=e;return{algorithm:t.algorithm,authTag:t.authTag,ciphertext:t.ciphertext,iv:t.iv,last4:typeof t.last4==`string`?t.last4:``,updatedAt:typeof t.updatedAt==`string`?t.updatedAt:``,version:t.version}}function o(n){let i=n.value.trim();if(!i)throw Error(`A non-empty value is required to encrypt a secret.`);let a=r(n.encryptionSecret),o=(0,e.randomBytes)(12),s=(0,e.createCipheriv)(t,a,o),c=Buffer.concat([s.update(i,`utf8`),s.final()]);return{algorithm:t,authTag:s.getAuthTag().toString(`base64`),ciphertext:c.toString(`base64`),iv:o.toString(`base64`),last4:i.slice(-4),updatedAt:(n.now??new Date).toISOString(),version:1}}function s(n){let i=a(n.envelope),o=r(n.encryptionSecret);try{let n=(0,e.createDecipheriv)(t,o,Buffer.from(i.iv,`base64`));return n.setAuthTag(Buffer.from(i.authTag,`base64`)),Buffer.concat([n.update(Buffer.from(i.ciphertext,`base64`)),n.final()]).toString(`utf8`)}catch{throw Error(`Failed to decrypt stored secret.`)}}function c(e){let t=(e??``).trim();return t?`•••• ${t}`:`Stored secret`}function l(e){if(!i(e))return{hasStoredValue:!1,last4:null,maskedValue:null,updatedAt:null};let t=e,n=typeof t.last4==`string`&&t.last4?t.last4:null;return{hasStoredValue:!0,last4:n,maskedValue:c(n),updatedAt:typeof t.updatedAt==`string`&&t.updatedAt?t.updatedAt:null}}var u=`nextblock-secret-encryption-key-v1`;function d(){let t=process.env.SUPABASE_SERVICE_ROLE_KEY??process.env.SUPABASE_SECRET_KEY;return typeof t!=`string`||!t.trim()?null:(0,e.createHmac)(`sha256`,t.trim()).update(u).digest(`hex`)}function f(){let e=[],t=t=>{let n=typeof t==`string`?t.trim():``;n&&!e.includes(n)&&e.push(n)};return t(process.env.NEXTBLOCK_ENCRYPTION_KEY),t(process.env.CORTEX_AI_ENCRYPTION_KEY),t(d()),e}function p(){return f()[0]??null}function m(){return p()!==null}function h(){let e=p();if(!e)throw Error(`An encryption key (NEXTBLOCK_ENCRYPTION_KEY or CORTEX_AI_ENCRYPTION_KEY) is required to manage stored secrets.`);return e}function g(e,t){return o({value:e,encryptionSecret:h(),now:t})}function _(e){return s({envelope:e,encryptionSecret:h()})}function v(e){if(!i(e))return null;for(let t of f())try{return s({envelope:e,encryptionSecret:t})}catch{}return null}exports.SECRET_ENVELOPE_ALGORITHM=t,exports.SECRET_ENVELOPE_VERSION=n,exports.decryptSecret=s,exports.decryptWithEnvKey=_,exports.encryptSecret=o,exports.encryptWithEnvKey=g,exports.getMaskedSecret=c,exports.getSecretEnvelopeStatus=l,exports.hasSecretEncryptionKey=m,exports.isEncryptedSecretEnvelope=i,exports.requireSecretEncryptionKey=h,exports.resolveSecretEncryptionKey=p,exports.tryDecryptWithEnvKey=v;
1
+ require("server-only");let e=require("node:crypto");var t=`aes-256-gcm`,n=1;function r(t){let n=t.trim();if(!n)throw Error(`A non-empty encryption secret is required to manage stored secrets.`);return(0,e.createHash)(`sha256`).update(n).digest()}function i(e){if(!e||typeof e!=`object`)return!1;let t=e;return t.algorithm===`aes-256-gcm`&&t.version===1&&typeof t.authTag==`string`&&typeof t.ciphertext==`string`&&typeof t.iv==`string`}function a(e){if(!i(e))throw Error(`Invalid encrypted secret payload.`);let t=e;return{algorithm:t.algorithm,authTag:t.authTag,ciphertext:t.ciphertext,iv:t.iv,last4:typeof t.last4==`string`?t.last4:``,updatedAt:typeof t.updatedAt==`string`?t.updatedAt:``,version:t.version}}function o(n){let i=n.value.trim();if(!i)throw Error(`A non-empty value is required to encrypt a secret.`);let a=r(n.encryptionSecret),o=(0,e.randomBytes)(12),s=(0,e.createCipheriv)(t,a,o),c=Buffer.concat([s.update(i,`utf8`),s.final()]);return{algorithm:t,authTag:s.getAuthTag().toString(`base64`),ciphertext:c.toString(`base64`),iv:o.toString(`base64`),last4:i.slice(-4),updatedAt:(n.now??new Date).toISOString(),version:1}}function s(n){let i=a(n.envelope),o=r(n.encryptionSecret);try{let n=(0,e.createDecipheriv)(t,o,Buffer.from(i.iv,`base64`));return n.setAuthTag(Buffer.from(i.authTag,`base64`)),Buffer.concat([n.update(Buffer.from(i.ciphertext,`base64`)),n.final()]).toString(`utf8`)}catch{throw Error(`Failed to decrypt stored secret.`)}}function c(e){let t=(e??``).trim();return t?`•••• ${t}`:`Stored secret`}function l(e){if(!i(e))return{hasStoredValue:!1,last4:null,maskedValue:null,updatedAt:null};let t=e,n=typeof t.last4==`string`&&t.last4?t.last4:null;return{hasStoredValue:!0,last4:n,maskedValue:c(n),updatedAt:typeof t.updatedAt==`string`&&t.updatedAt?t.updatedAt:null}}var u=`nextblock-secret-encryption-key-v1`;function d(){let t=process.env.SUPABASE_SERVICE_ROLE_KEY??process.env.SUPABASE_SECRET_KEY;return typeof t!=`string`||!t.trim()?null:(0,e.createHmac)(`sha256`,t.trim()).update(u).digest(`hex`)}function f(){let e=[],t=t=>{let n=typeof t==`string`?t.trim():``;n&&!e.includes(n)&&e.push(n)};return t(process.env.NEXTBLOCK_ENCRYPTION_KEY),t(process.env.CORTEX_AI_ENCRYPTION_KEY),t(d()),e}function p(){return f()[0]??null}function m(){return p()!==null}function h(){let e=p();if(!e)throw Error(`An encryption key (NEXTBLOCK_ENCRYPTION_KEY or CORTEX_AI_ENCRYPTION_KEY) is required to manage stored secrets.`);return e}function g(e,t){return o({value:e,encryptionSecret:h(),now:t})}function _(e){return s({envelope:e,encryptionSecret:h()})}function v(e){if(!i(e))return null;for(let t of f())try{return s({envelope:e,encryptionSecret:t})}catch{}return null}exports.SECRET_ENVELOPE_ALGORITHM=t,exports.SECRET_ENVELOPE_VERSION=n,exports.decryptSecret=s,exports.decryptWithEnvKey=_,exports.encryptSecret=o,exports.encryptWithEnvKey=g,exports.getMaskedSecret=c,exports.getSecretEnvelopeStatus=l,exports.hasSecretEncryptionKey=m,exports.isEncryptedSecretEnvelope=i,exports.requireSecretEncryptionKey=h,exports.resolveSecretEncryptionKey=p,exports.tryDecryptWithEnvKey=v;
@@ -1,3 +1,4 @@
1
+ import "server-only";
1
2
  import { createCipheriv as e, createDecipheriv as t, createHash as n, createHmac as r, randomBytes as i } from "node:crypto";
2
3
  //#region src/lib/secret-crypto.ts
3
4
  var a = "aes-256-gcm", o = 1;
@@ -1 +1 @@
1
- let e=require("@supabase/ssr"),t=require("@supabase/supabase-js"),n=require("next/headers");var r=`This module cannot be imported from a Client Component module. It should only be used from a Server Component.`;if(typeof window<`u`)throw Error(r);var i=()=>{let t=process.env.NEXT_PUBLIC_SUPABASE_URL||process.env.SUPABASE_URL||`https://dummy.supabase.co`,r=process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY||process.env.SUPABASE_ANON_KEY||process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY||process.env.SUPABASE_PUBLISHABLE_KEY||`dummy-anon-key`;return(0,e.createServerClient)(t,r,{cookies:{getAll:async()=>{try{return(await(0,n.cookies)()).getAll()}catch{return[]}},setAll:async e=>{try{let t=await(0,n.cookies)();for(let{name:n,value:r,options:i}of e)r&&r.length>0?t.set({name:n,value:r,...i}):i&&Object.keys(i).length>0?t.delete({name:n,...i}):t.delete(n)}catch{}}}})};async function a(e){let{data:t,error:n}=await i().from(`profiles`).select(`id, full_name, avatar_url, role, updated_at, website`).eq(`id`,e).single();return n||!t?(console.error(`Error fetching profile (server-side):`,n?.message),null):t}async function o(){let{data:e,error:t}=await i().from(`languages`).select(`id, code, name, is_default, is_active, created_at, updated_at`).order(`name`,{ascending:!0});return t?(console.error(`Error fetching languages (server-side):`,t.message),[]):e||[]}var s=()=>{let e=process.env.NEXT_PUBLIC_SUPABASE_URL||process.env.SUPABASE_URL,n=process.env.SUPABASE_SERVICE_ROLE_KEY||process.env.SUPABASE_SECRET_KEY;if(!e||!n)throw Error(`Missing Supabase Service Role environment variables`);return(0,t.createClient)(e,n,{auth:{autoRefreshToken:!1,persistSession:!1}})};exports.createClient=i,exports.getActiveLanguagesServerSide=o,exports.getProfileWithRoleServerSide=a,exports.getServiceRoleSupabaseClient=s;
1
+ let e=require("@supabase/ssr");require("server-only");let t=require("@supabase/supabase-js"),n=require("next/headers");var r=`This module cannot be imported from a Client Component module. It should only be used from a Server Component.`;if(typeof window<`u`)throw Error(r);var i=()=>{let t=process.env.NEXT_PUBLIC_SUPABASE_URL||process.env.SUPABASE_URL||`https://dummy.supabase.co`,r=process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY||process.env.SUPABASE_ANON_KEY||process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY||process.env.SUPABASE_PUBLISHABLE_KEY||`dummy-anon-key`;return(0,e.createServerClient)(t,r,{cookies:{getAll:async()=>{try{return(await(0,n.cookies)()).getAll()}catch{return[]}},setAll:async e=>{try{let t=await(0,n.cookies)();for(let{name:n,value:r,options:i}of e)r&&r.length>0?t.set({name:n,value:r,...i}):i&&Object.keys(i).length>0?t.delete({name:n,...i}):t.delete(n)}catch{}}}})};async function a(e){let{data:t,error:n}=await i().from(`profiles`).select(`id, full_name, avatar_url, role, updated_at, website`).eq(`id`,e).single();return n||!t?(console.error(`Error fetching profile (server-side):`,n?.message),null):t}async function o(){let{data:e,error:t}=await i().from(`languages`).select(`id, code, name, is_default, is_active, created_at, updated_at`).order(`name`,{ascending:!0});return t?(console.error(`Error fetching languages (server-side):`,t.message),[]):e||[]}var s=()=>{let e=process.env.NEXT_PUBLIC_SUPABASE_URL||process.env.SUPABASE_URL,n=process.env.SUPABASE_SERVICE_ROLE_KEY||process.env.SUPABASE_SECRET_KEY;if(!e||!n)throw Error(`Missing Supabase Service Role environment variables`);return(0,t.createClient)(e,n,{auth:{autoRefreshToken:!1,persistSession:!1}})};exports.createClient=i,exports.getActiveLanguagesServerSide=o,exports.getProfileWithRoleServerSide=a,exports.getServiceRoleSupabaseClient=s;
@@ -1,4 +1,5 @@
1
1
  import { createServerClient as e } from "@supabase/ssr";
2
+ import "server-only";
2
3
  import { createClient as t } from "@supabase/supabase-js";
3
4
  import { cookies as n } from "next/headers";
4
5
  //#region src/lib/supabase/server.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextblock-cms/db",
3
- "version": "0.22.2",
3
+ "version": "0.22.3",
4
4
  "license": "AGPL-3.0-or-later",
5
5
  "repository": {
6
6
  "type": "git",
package/server.cjs.js CHANGED
@@ -1 +1 @@
1
- Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=require("./lib/supabase/server.cjs.js"),t=require("./lib/supabase/ssg-client.cjs.js"),n=require("./lib/package-validation.cjs.js"),r=require("./lib/secret-crypto.cjs.js"),i=require("./lib/config-resolve.cjs.js");exports.PACKAGE_ACTIVATION_CACHE_TAG=n.PACKAGE_ACTIVATION_CACHE_TAG,exports.SECRET_ENVELOPE_ALGORITHM=r.SECRET_ENVELOPE_ALGORITHM,exports.SECRET_ENVELOPE_VERSION=r.SECRET_ENVELOPE_VERSION,exports.createClient=e.createClient,exports.decryptSecret=r.decryptSecret,exports.decryptWithEnvKey=r.decryptWithEnvKey,exports.encryptSecret=r.encryptSecret,exports.encryptWithEnvKey=r.encryptWithEnvKey,exports.getActiveLanguagesServerSide=e.getActiveLanguagesServerSide,exports.getMaskedSecret=r.getMaskedSecret,exports.getProfileWithRoleServerSide=e.getProfileWithRoleServerSide,exports.getSecretEnvelopeStatus=r.getSecretEnvelopeStatus,exports.getServiceRoleSupabaseClient=e.getServiceRoleSupabaseClient,exports.getSsgSupabaseClient=t.getSsgSupabaseClient,exports.hasSecretEncryptionKey=r.hasSecretEncryptionKey,exports.isEncryptedSecretEnvelope=r.isEncryptedSecretEnvelope,exports.isPackageActivationRowValid=n.isPackageActivationRowValid,exports.isSandboxEnvironment=i.isSandboxEnvironment,exports.requireSecretEncryptionKey=r.requireSecretEncryptionKey,exports.resolveConfigValue=i.resolveConfigValue,exports.resolvePackageActivationExpiry=n.resolvePackageActivationExpiry,exports.resolveSecretEncryptionKey=r.resolveSecretEncryptionKey,exports.tryDecryptWithEnvKey=r.tryDecryptWithEnvKey,exports.verifyPackageOnline=n.verifyPackageOnline;
1
+ Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=require("./lib/supabase/server.cjs.js"),t=require("./lib/media-record.cjs.js"),n=require("./lib/supabase/ssg-client.cjs.js"),r=require("./lib/package-validation.cjs.js"),i=require("./lib/secret-crypto.cjs.js"),a=require("./lib/config-resolve.cjs.js");exports.PACKAGE_ACTIVATION_CACHE_TAG=r.PACKAGE_ACTIVATION_CACHE_TAG,exports.SECRET_ENVELOPE_ALGORITHM=i.SECRET_ENVELOPE_ALGORITHM,exports.SECRET_ENVELOPE_VERSION=i.SECRET_ENVELOPE_VERSION,exports.createClient=e.createClient,exports.decryptSecret=i.decryptSecret,exports.decryptWithEnvKey=i.decryptWithEnvKey,exports.encryptSecret=i.encryptSecret,exports.encryptWithEnvKey=i.encryptWithEnvKey,exports.getActiveLanguagesServerSide=e.getActiveLanguagesServerSide,exports.getMaskedSecret=i.getMaskedSecret,exports.getProfileWithRoleServerSide=e.getProfileWithRoleServerSide,exports.getSecretEnvelopeStatus=i.getSecretEnvelopeStatus,exports.getServiceRoleSupabaseClient=e.getServiceRoleSupabaseClient,exports.getSsgSupabaseClient=n.getSsgSupabaseClient,exports.hasSecretEncryptionKey=i.hasSecretEncryptionKey,exports.isEncryptedSecretEnvelope=i.isEncryptedSecretEnvelope,exports.isPackageActivationRowValid=r.isPackageActivationRowValid,exports.isSandboxEnvironment=a.isSandboxEnvironment,exports.recordMediaUploadAsActor=t.recordMediaUploadAsActor,exports.requireSecretEncryptionKey=i.requireSecretEncryptionKey,exports.resolveConfigValue=a.resolveConfigValue,exports.resolvePackageActivationExpiry=r.resolvePackageActivationExpiry,exports.resolveSecretEncryptionKey=i.resolveSecretEncryptionKey,exports.tryDecryptWithEnvKey=i.tryDecryptWithEnvKey,exports.verifyPackageOnline=r.verifyPackageOnline;
package/server.d.ts CHANGED
@@ -3,3 +3,4 @@ export { getSsgSupabaseClient } from './lib/supabase/ssg-client';
3
3
  export * from './lib/package-validation';
4
4
  export * from './lib/secret-crypto';
5
5
  export * from './lib/config-resolve';
6
+ export { recordMediaUploadAsActor, type ImageVariant, type MediaRecordResult, type MediaUploadPayload, } from './lib/media-record';
package/server.es.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createClient as e, getActiveLanguagesServerSide as t, getProfileWithRoleServerSide as n, getServiceRoleSupabaseClient as r } from "./lib/supabase/server.es.js";
2
- import { getSsgSupabaseClient as i } from "./lib/supabase/ssg-client.es.js";
3
- import { PACKAGE_ACTIVATION_CACHE_TAG as a, isPackageActivationRowValid as o, resolvePackageActivationExpiry as s, verifyPackageOnline as c } from "./lib/package-validation.es.js";
4
- import { SECRET_ENVELOPE_ALGORITHM as l, SECRET_ENVELOPE_VERSION as u, decryptSecret as d, decryptWithEnvKey as f, encryptSecret as p, encryptWithEnvKey as m, getMaskedSecret as h, getSecretEnvelopeStatus as g, hasSecretEncryptionKey as _, isEncryptedSecretEnvelope as v, requireSecretEncryptionKey as y, resolveSecretEncryptionKey as b, tryDecryptWithEnvKey as x } from "./lib/secret-crypto.es.js";
5
- import { isSandboxEnvironment as S, resolveConfigValue as C } from "./lib/config-resolve.es.js";
6
- export { a as PACKAGE_ACTIVATION_CACHE_TAG, l as SECRET_ENVELOPE_ALGORITHM, u as SECRET_ENVELOPE_VERSION, e as createClient, d as decryptSecret, f as decryptWithEnvKey, p as encryptSecret, m as encryptWithEnvKey, t as getActiveLanguagesServerSide, h as getMaskedSecret, n as getProfileWithRoleServerSide, g as getSecretEnvelopeStatus, r as getServiceRoleSupabaseClient, i as getSsgSupabaseClient, _ as hasSecretEncryptionKey, v as isEncryptedSecretEnvelope, o as isPackageActivationRowValid, S as isSandboxEnvironment, y as requireSecretEncryptionKey, C as resolveConfigValue, s as resolvePackageActivationExpiry, b as resolveSecretEncryptionKey, x as tryDecryptWithEnvKey, c as verifyPackageOnline };
2
+ import { recordMediaUploadAsActor as i } from "./lib/media-record.es.js";
3
+ import { getSsgSupabaseClient as a } from "./lib/supabase/ssg-client.es.js";
4
+ import { PACKAGE_ACTIVATION_CACHE_TAG as o, isPackageActivationRowValid as s, resolvePackageActivationExpiry as c, verifyPackageOnline as l } from "./lib/package-validation.es.js";
5
+ import { SECRET_ENVELOPE_ALGORITHM as u, SECRET_ENVELOPE_VERSION as d, decryptSecret as f, decryptWithEnvKey as p, encryptSecret as m, encryptWithEnvKey as h, getMaskedSecret as g, getSecretEnvelopeStatus as _, hasSecretEncryptionKey as v, isEncryptedSecretEnvelope as y, requireSecretEncryptionKey as b, resolveSecretEncryptionKey as x, tryDecryptWithEnvKey as S } from "./lib/secret-crypto.es.js";
6
+ import { isSandboxEnvironment as C, resolveConfigValue as w } from "./lib/config-resolve.es.js";
7
+ export { o as PACKAGE_ACTIVATION_CACHE_TAG, u as SECRET_ENVELOPE_ALGORITHM, d as SECRET_ENVELOPE_VERSION, e as createClient, f as decryptSecret, p as decryptWithEnvKey, m as encryptSecret, h as encryptWithEnvKey, t as getActiveLanguagesServerSide, g as getMaskedSecret, n as getProfileWithRoleServerSide, _ as getSecretEnvelopeStatus, r as getServiceRoleSupabaseClient, a as getSsgSupabaseClient, v as hasSecretEncryptionKey, y as isEncryptedSecretEnvelope, s as isPackageActivationRowValid, C as isSandboxEnvironment, i as recordMediaUploadAsActor, b as requireSecretEncryptionKey, w as resolveConfigValue, c as resolvePackageActivationExpiry, x as resolveSecretEncryptionKey, S as tryDecryptWithEnvKey, l as verifyPackageOnline };
@@ -0,0 +1,66 @@
1
+ -- 02025: grant-level hardening for the API roles (2026-10-21 posture review of 02021-02024).
2
+ --
3
+ -- Grants decide whether a role may touch a table at all; RLS decides which rows. This file
4
+ -- removes three places where RLS had become the only barrier. It changes no row a legitimate
5
+ -- request can reach: every statement below takes away a privilege that no RLS policy lets the
6
+ -- role use.
7
+ --
8
+ -- 1. anon is read-only at the grant level.
9
+ -- Supabase's historical default privileges, and the baseline (02003), granted anon
10
+ -- INSERT/UPDATE/DELETE/REFERENCES/TRIGGER on every table in `public`. No policy in the
11
+ -- schema lets anon write anything (checked against pg_policies on 2026-10-21: zero
12
+ -- INSERT/UPDATE/DELETE/ALL policies reach anon or PUBLIC), so those grants only meant that
13
+ -- a single permissive policy added by mistake would publish a table for writing to anyone
14
+ -- with the public key. Every anonymous write path in the app (forms, consent, comments,
15
+ -- checkout) already goes through a server route with the service role. Sequence USAGE and
16
+ -- UPDATE go too: without INSERT, anon never draws a value.
17
+ --
18
+ -- 2. cortex_request_log is service-role only again.
19
+ -- 02022 created it with REVOKE ALL FROM anon, authenticated and no API policy. The
20
+ -- post-apply pass of both apply paths (lib/setup/schema-apply.ts and the build hook in
21
+ -- tools/lib/migrate-core.mjs) then ran `grant all on all tables ... to authenticated` and
22
+ -- `grant select ... to anon` on every deploy, handing the table back to both roles. RLS
23
+ -- still returned no row, but a signed-in WRITER could issue DELETEs against the counter
24
+ -- that rate-limits the AI routes, and tools/scripts/security-verify.sql failed three checks
25
+ -- on any deployed database. Both apply paths now repeat this revoke (API_ROLE_REVOKE_SQL).
26
+ --
27
+ -- 3. profiles.role is pinned by grants, not only by policy and trigger.
28
+ -- The column was protected by profiles_update_policy's WITH CHECK and the
29
+ -- protect_profile_role trigger (02021). authenticated now holds INSERT and UPDATE on every
30
+ -- profiles column EXCEPT `role` (and `id` on UPDATE), so a policy regression can no longer
31
+ -- reopen self-promotion to ADMIN. User-session writes only ever touch full_name,
32
+ -- avatar_url, website, phone and updated_at (account page, checkout autofill); role changes
33
+ -- go through the service role in app/cms/users/actions.ts, which these grants do not
34
+ -- affect. DELETE, REFERENCES and TRIGGER on profiles are revoked from the API roles: no
35
+ -- policy grants DELETE to them and account deletion runs through auth.admin. The column
36
+ -- list is read from the catalog so a column added later keeps today's behaviour.
37
+ --
38
+ -- Both apply paths repeat all three after their blanket grants, so a deploy cannot undo
39
+ -- them; tools/scripts/security-verify.sql asserts them. Idempotent: safe to re-run.
40
+
41
+ -- 1. anon: read-only at the grant level
42
+ REVOKE INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER ON ALL TABLES IN SCHEMA public FROM anon;
43
+ REVOKE USAGE, UPDATE ON ALL SEQUENCES IN SCHEMA public FROM anon;
44
+
45
+ -- 2. cortex_request_log: service_role only (its SELECT, INSERT, DELETE grant from 02022 stays)
46
+ REVOKE ALL ON TABLE public.cortex_request_log FROM PUBLIC, anon, authenticated;
47
+
48
+ -- 3. profiles: every column but `role` (and `id` on UPDATE) for authenticated
49
+ DO $nb_profiles$
50
+ DECLARE
51
+ v_insert_cols text;
52
+ v_update_cols text;
53
+ BEGIN
54
+ SELECT string_agg(quote_ident(a.attname), ', ' ORDER BY a.attnum) FILTER (WHERE a.attname <> 'role'),
55
+ string_agg(quote_ident(a.attname), ', ' ORDER BY a.attnum) FILTER (WHERE a.attname NOT IN ('id', 'role'))
56
+ INTO v_insert_cols, v_update_cols
57
+ FROM pg_attribute a
58
+ WHERE a.attrelid = 'public.profiles'::regclass AND a.attnum > 0 AND NOT a.attisdropped;
59
+
60
+ -- Revoking the table-level privilege also revokes every column-level grant of that kind,
61
+ -- so the column grants below are the complete list afterwards.
62
+ REVOKE INSERT, UPDATE, DELETE, REFERENCES, TRIGGER ON TABLE public.profiles FROM PUBLIC, anon, authenticated;
63
+ EXECUTE format('GRANT INSERT (%s) ON TABLE public.profiles TO authenticated', v_insert_cols);
64
+ EXECUTE format('GRANT UPDATE (%s) ON TABLE public.profiles TO authenticated', v_update_cols);
65
+ END
66
+ $nb_profiles$;
@@ -0,0 +1,91 @@
1
+ -- 02026: authenticated holds no table write privilege that no RLS policy lets it use
2
+ -- (2026-10-21, the follow-up 02025 left open).
3
+ --
4
+ -- 02025 made anon read-only at the grant level. authenticated still held SELECT, INSERT,
5
+ -- UPDATE, DELETE, REFERENCES and TRIGGER on every table in `public` (the baseline 02003, then
6
+ -- `grant all on all tables in schema public to authenticated` in the post-apply pass on every
7
+ -- deploy). On the tables below RLS was the only barrier between a signed-in session and a
8
+ -- write: no permissive policy for authenticated or PUBLIC covers the listed commands, only a
9
+ -- `TO service_role` policy or none at all. Every app write to them runs server-side with the
10
+ -- service role after its own checks: 2FA challenges, trusted devices and security settings
11
+ -- (lib/auth, settings/security), consent logs, contact-seller enquiries and message threads,
12
+ -- UCP carts, coupon redemptions (checkout providers), license activations (lib/packages),
13
+ -- the Freemius catalog sync, the update checker's alerts and Cortex's mutation audit.
14
+ --
15
+ -- Behaviour-neutral by construction: authenticated could never use a revoked privilege. Its
16
+ -- INSERT failed the RLS check and its UPDATE or DELETE matched no row; now the grant refuses
17
+ -- the statement before RLS is consulted, and no row any request reaches changes. (One CMS
18
+ -- action, updateFreemiusOverride in libs/ecommerce, updates freemius_pricing through the
19
+ -- cookie client: RLS has always discarded that UPDATE while the action reported success, and
20
+ -- it now reports the permission error instead.) Every command that has a policy keeps its
21
+ -- grant: the ADMIN UPDATE on message_threads, product_inquiries and system_alerts, the ADMIN
22
+ -- INSERT on site_script_revisions, the owner DELETE on user_trusted_devices, and every SELECT.
23
+ -- The pre-flight block re-checks that against pg_policies on the target database and aborts
24
+ -- without changing anything if a policy has appeared since this list was taken (a replay
25
+ -- through 02025 plus the post-apply pass, 2026-10-21).
26
+ --
27
+ -- REFERENCES and TRIGGER go from every table, as 02025 did for anon: they only let a role
28
+ -- create a foreign key or a trigger, and no API request runs DDL. Foreign-key actions are not
29
+ -- affected by any of this: ON DELETE CASCADE / SET NULL run as the owner of the referencing
30
+ -- table, so an ADMIN deleting a product still removes its freemius_plans and freemius_pricing
31
+ -- rows, and deleting an order still removes its coupon_redemptions.
32
+ --
33
+ -- Both apply paths repeat these revokes after their blanket grants (API_ROLE_REVOKE_SQL). To
34
+ -- give authenticated one of these privileges later, add the policy and the GRANT in the same
35
+ -- migration and take the table out of both copies of that list, or the next deploy revokes it
36
+ -- again. tools/scripts/security-verify.sql sections 26-27 fail on a write grant no policy uses
37
+ -- and on a write policy whose grant is missing. Idempotent: safe to re-run.
38
+
39
+ DO $nb_preflight$
40
+ DECLARE
41
+ offenders text;
42
+ BEGIN
43
+ SELECT string_agg(format('%s %s (policy %s)', l.tbl, r.cmd, p.policyname), ', ' ORDER BY l.tbl, r.cmd, p.policyname)
44
+ INTO offenders
45
+ FROM (VALUES
46
+ ('cortex_ai_db_mutation_audit', ARRAY['INSERT', 'UPDATE', 'DELETE']),
47
+ ('coupon_redemptions', ARRAY['INSERT', 'UPDATE', 'DELETE']),
48
+ ('email_2fa_challenges', ARRAY['INSERT', 'UPDATE', 'DELETE']),
49
+ ('freemius_plans', ARRAY['INSERT', 'UPDATE', 'DELETE']),
50
+ ('freemius_pricing', ARRAY['INSERT', 'UPDATE', 'DELETE']),
51
+ ('package_activations', ARRAY['INSERT', 'UPDATE', 'DELETE']),
52
+ ('privacy_consent_logs', ARRAY['INSERT', 'UPDATE', 'DELETE']),
53
+ ('thread_messages', ARRAY['INSERT', 'UPDATE', 'DELETE']),
54
+ ('ucp_cart_sessions', ARRAY['INSERT', 'UPDATE', 'DELETE']),
55
+ ('user_security_settings', ARRAY['INSERT', 'UPDATE', 'DELETE']),
56
+ ('message_threads', ARRAY['INSERT', 'DELETE']),
57
+ ('product_inquiries', ARRAY['INSERT', 'DELETE']),
58
+ ('system_alerts', ARRAY['INSERT', 'DELETE']),
59
+ ('site_script_revisions', ARRAY['UPDATE', 'DELETE']),
60
+ ('user_trusted_devices', ARRAY['INSERT', 'UPDATE'])
61
+ ) AS l(tbl, cmds)
62
+ CROSS JOIN LATERAL unnest(l.cmds) AS r(cmd)
63
+ JOIN pg_policies p
64
+ ON p.schemaname = 'public' AND p.tablename = l.tbl
65
+ AND p.permissive = 'PERMISSIVE' AND p.cmd IN (r.cmd, 'ALL')
66
+ AND p.roles && ARRAY['authenticated', 'public']::name[];
67
+ IF offenders IS NOT NULL THEN
68
+ RAISE EXCEPTION '02026 would revoke a privilege that an RLS policy lets authenticated use: %. Nothing was changed; drop that table/command from this file and from API_ROLE_REVOKE_SQL, or drop the policy.', offenders;
69
+ END IF;
70
+ END
71
+ $nb_preflight$;
72
+
73
+ -- 1. writes that no policy lets authenticated use
74
+ REVOKE INSERT, UPDATE, DELETE ON TABLE
75
+ public.cortex_ai_db_mutation_audit,
76
+ public.coupon_redemptions,
77
+ public.email_2fa_challenges,
78
+ public.freemius_plans,
79
+ public.freemius_pricing,
80
+ public.package_activations,
81
+ public.privacy_consent_logs,
82
+ public.thread_messages,
83
+ public.ucp_cart_sessions,
84
+ public.user_security_settings
85
+ FROM authenticated;
86
+ REVOKE INSERT, DELETE ON TABLE public.message_threads, public.product_inquiries, public.system_alerts FROM authenticated;
87
+ REVOKE UPDATE, DELETE ON TABLE public.site_script_revisions FROM authenticated;
88
+ REVOKE INSERT, UPDATE ON TABLE public.user_trusted_devices FROM authenticated;
89
+
90
+ -- 2. REFERENCES / TRIGGER: DDL-only, unused by every API request
91
+ REVOKE REFERENCES, TRIGGER ON ALL TABLES IN SCHEMA public FROM authenticated;
@@ -0,0 +1,150 @@
1
+ -- 02027: anon and authenticated hold SELECT only where an RLS policy lets them read
2
+ -- (2026-10-21, the read side of 02025/02026).
3
+ --
4
+ -- 02025 made anon read-only and 02026 left authenticated only the writes some policy uses.
5
+ -- SELECT was still granted to both roles on every table in `public` (the baseline 02003, then
6
+ -- `grant select on all tables in schema public to anon` and `grant all ... to authenticated` in
7
+ -- the post-apply pass on every deploy). On the tables below no permissive SELECT or ALL policy
8
+ -- reaches the role (or PUBLIC), so RLS was the only barrier between the public key and orders,
9
+ -- profiles, coupons, licenses, MCP tokens, revisions, drafts, 2FA challenges, carts and
10
+ -- messages: one permissive policy added by mistake, or a table created with RLS off, would
11
+ -- have published it to anyone holding that key.
12
+ --
13
+ -- anon: every table that only staff, an owner or the service role reads (29, listed below).
14
+ -- authenticated: email_2fa_challenges and ucp_cart_sessions, whose only policy is TO
15
+ -- service_role (lib/auth/twoFactor and the UCP routes read them with the service role;
16
+ -- 02026 already took authenticated's writes on both).
17
+ -- profile_cards (02021): anon loses SELECT, authenticated loses INSERT/UPDATE/DELETE. It is a
18
+ -- security_invoker view over profiles, so anon could only ever read it through a SELECT on
19
+ -- profiles, which goes here. The public author card does not read the view: author()
20
+ -- returns its row type from private.author_card(), a SECURITY DEFINER function, so
21
+ -- `cms_interactions?select=*,author(full_name,avatar_url)` keeps working for anon (checked
22
+ -- through PostgREST on 2026-10-21). The view is a read card; nothing writes through it.
23
+ --
24
+ -- Unlike a write, a SELECT can be needed on a table the request never names: a policy
25
+ -- subquery (`EXISTS (SELECT ... FROM x)`), a security_invoker view, an invoker function or a
26
+ -- PostgREST embed (`select=*,x(...)`) all read x as the calling role, and they fail with 42501
27
+ -- once the grant is gone, where RLS used to return no row (or a null embed). The census behind
28
+ -- this list (a replay through 02026 plus the post-apply pass, 2026-10-21) found:
29
+ -- - one policy that reads another of these tables, order_items' owner policy (orders): it is
30
+ -- TO authenticated, which keeps SELECT on orders;
31
+ -- - one view, profile_cards (above);
32
+ -- - invoker functions reading them: apply_order_inventory_deduction (orders, order_items),
33
+ -- called by the app only with the service role and already refused to anon by its
34
+ -- SELECT ... FOR UPDATE since 02025 (FOR UPDATE needs UPDATE), and the trigger functions of
35
+ -- site_script_revisions / thread_messages, which fire only on writes anon cannot make;
36
+ -- - no anonymous query in the app (getSsgSupabaseClient, the browser client, the cookie client
37
+ -- on signed-out paths) embeds a listed table; the public author card goes through author().
38
+ -- The anonymous paths that need one of these tables (checkout success, order access tokens,
39
+ -- contact seller, threads, consent, forms, UCP, MCP tokens, the sign-up read of
40
+ -- system_configuration) read it with the service role after their own checks. The few
41
+ -- cookie-client reads a signed-out request can reach (a draft lookup in draft mode or with
42
+ -- NEXTBLOCK_VISUAL_EDITING_ENABLED, verifyPackageOnline on an install with no service key,
43
+ -- the MCP route's revision capture, a custom block's `profiles` relation) already got no
44
+ -- row and handle the error the same way: no draft, not licensed, no revision, no record.
45
+ -- Behaviour-neutral for every request that worked: an anon or authenticated read of a listed
46
+ -- table returned no row before and is refused by the grant now. The pre-flight block re-checks
47
+ -- the catalog side on the target database and aborts without changing anything if a policy
48
+ -- lets the role read a listed table, if a policy the role is subject to reads one, or if a view
49
+ -- the role can read depends on one.
50
+ --
51
+ -- Both apply paths repeat these revokes after their blanket grants (API_ROLE_REVOKE_SQL). To let
52
+ -- one of these roles read a listed table later, add the policy and GRANT SELECT in the same
53
+ -- migration and take the table out of both copies of that list, or the next deploy revokes it
54
+ -- again. tools/scripts/security-verify.sql sections 28-29 fail on a SELECT grant no policy uses
55
+ -- and on a read policy whose grant is missing. Idempotent: safe to re-run.
56
+
57
+ DO $nb_preflight$
58
+ DECLARE
59
+ offenders text;
60
+ BEGIN
61
+ WITH l(role, tbl) AS (
62
+ SELECT 'anon'::name, t
63
+ FROM unnest(ARRAY[
64
+ 'content_drafts', 'cortex_ai_db_mutation_audit', 'coupon_freemius_mappings',
65
+ 'coupon_products', 'coupon_redemptions', 'coupons', 'email_2fa_challenges',
66
+ 'form_endpoints', 'mcp_access_tokens', 'message_threads', 'order_items', 'orders',
67
+ 'package_activations', 'page_revisions', 'post_revisions', 'privacy_consent_logs',
68
+ 'product_drafts', 'product_freemius_sale_coupons', 'product_inquiries',
69
+ 'product_revisions', 'profile_cards', 'profiles', 'site_script_revisions',
70
+ 'system_alerts', 'system_configuration', 'thread_messages', 'ucp_cart_sessions',
71
+ 'user_addresses', 'user_security_settings', 'user_trusted_devices']::name[]) AS t
72
+ UNION ALL
73
+ SELECT 'authenticated'::name, t
74
+ FROM unnest(ARRAY['email_2fa_challenges', 'ucp_cart_sessions']::name[]) AS t
75
+ ), r AS (
76
+ SELECT l.role, l.tbl, to_regclass('public.' || quote_ident(l.tbl)) AS oid FROM l
77
+ ), hits(what) AS (
78
+ -- a policy that lets the role read a listed table
79
+ SELECT format('%s reads %s through policy %s', r.role, r.tbl, p.policyname)
80
+ FROM r
81
+ JOIN pg_policies p
82
+ ON p.schemaname = 'public' AND p.tablename = r.tbl
83
+ AND p.permissive = 'PERMISSIVE' AND p.cmd IN ('SELECT', 'ALL')
84
+ AND p.roles && ARRAY[r.role, 'public']::name[]
85
+ UNION ALL
86
+ -- a policy the role is subject to, on any other table, whose expression reads a listed table
87
+ SELECT format('policy %s on %s reads %s as %s', pol.polname, pol.polrelid::regclass, r.tbl, r.role)
88
+ FROM r
89
+ JOIN pg_depend d
90
+ ON d.classid = 'pg_policy'::regclass AND d.refclassid = 'pg_class'::regclass AND d.refobjid = r.oid
91
+ JOIN pg_policy pol ON pol.oid = d.objid
92
+ WHERE pol.polrelid <> r.oid
93
+ AND (0::oid = ANY (pol.polroles) OR (SELECT oid FROM pg_roles WHERE rolname = r.role) = ANY (pol.polroles))
94
+ UNION ALL
95
+ -- a view the role keeps SELECT on that reads a listed table
96
+ SELECT format('view %s reads %s and stays readable by %s', rw.ev_class::regclass, r.tbl, r.role)
97
+ FROM r
98
+ JOIN pg_depend d
99
+ ON d.classid = 'pg_rewrite'::regclass AND d.refclassid = 'pg_class'::regclass AND d.refobjid = r.oid
100
+ JOIN pg_rewrite rw ON rw.oid = d.objid
101
+ WHERE rw.ev_class <> r.oid
102
+ AND has_table_privilege(r.role, rw.ev_class, 'SELECT')
103
+ AND NOT EXISTS (SELECT 1 FROM r r2 WHERE r2.role = r.role AND r2.oid = rw.ev_class)
104
+ )
105
+ SELECT string_agg(DISTINCT what, '; ') INTO offenders FROM hits;
106
+ IF offenders IS NOT NULL THEN
107
+ RAISE EXCEPTION '02027 would revoke a SELECT that a request still needs: %. Nothing was changed; drop that table from this file and from API_ROLE_REVOKE_SQL, or change the policy or view.', offenders;
108
+ END IF;
109
+ END
110
+ $nb_preflight$;
111
+
112
+ -- 1. anon: SELECT on every table no policy lets anon read
113
+ REVOKE SELECT ON TABLE
114
+ public.content_drafts,
115
+ public.cortex_ai_db_mutation_audit,
116
+ public.coupon_freemius_mappings,
117
+ public.coupon_products,
118
+ public.coupon_redemptions,
119
+ public.coupons,
120
+ public.email_2fa_challenges,
121
+ public.form_endpoints,
122
+ public.mcp_access_tokens,
123
+ public.message_threads,
124
+ public.order_items,
125
+ public.orders,
126
+ public.package_activations,
127
+ public.page_revisions,
128
+ public.post_revisions,
129
+ public.privacy_consent_logs,
130
+ public.product_drafts,
131
+ public.product_freemius_sale_coupons,
132
+ public.product_inquiries,
133
+ public.product_revisions,
134
+ public.profiles,
135
+ public.site_script_revisions,
136
+ public.system_alerts,
137
+ public.system_configuration,
138
+ public.thread_messages,
139
+ public.ucp_cart_sessions,
140
+ public.user_addresses,
141
+ public.user_security_settings,
142
+ public.user_trusted_devices
143
+ FROM anon;
144
+
145
+ -- 2. authenticated: the two tables only the service role reads
146
+ REVOKE SELECT ON TABLE public.email_2fa_challenges, public.ucp_cart_sessions FROM authenticated;
147
+
148
+ -- 3. profile_cards: a read card for staff and owners (through profiles' policies)
149
+ REVOKE SELECT ON TABLE public.profile_cards FROM anon;
150
+ REVOKE INSERT, UPDATE, DELETE ON TABLE public.profile_cards FROM authenticated;
@@ -0,0 +1,284 @@
1
+ -- 02028: close the database holes the 2026-10-21 audit confirmed on a local replay (each one
2
+ -- reproduced over PostgREST: SET LOCAL ROLE authenticated + request.jwt.claims, rolled back).
3
+ -- Append-only, forward-only, idempotent: every policy is dropped by name and recreated, every
4
+ -- function is CREATE OR REPLACE, every grant is safe to repeat. 02022/02023 are the model.
5
+ --
6
+ -- 1. site_settings: the WRITER write branch becomes an allow-list (HIGH).
7
+ -- 02022/02023 let ADMIN *or WRITER* write every key outside a 13-key deny-list. A WRITER
8
+ -- could therefore PATCH `email_public` to point SMTP at their own server, and
9
+ -- lib/config/email-settings.ts combines that host with the decrypted ADMIN-only
10
+ -- `email_secret` credentials: the SMTP password and every outgoing mail (ADMIN email-2FA
11
+ -- codes included) went to the attacker. The same branch let a WRITER turn the captcha off
12
+ -- (`bot_protection_public`), rewrite payment and invoice settings, redirect the store,
13
+ -- form and moderation notification addresses, change `migration_baseline_generation`
14
+ -- (which 02000 trusts) and the Cortex agent settings. An unfiltered PATCH updated 13 rows.
15
+ -- Writes now fall into three tiers, and a key in none of the first two is in the third:
16
+ -- * WRITER tier (ADMIN or WRITER, first factor): exactly the keys staff code writes
17
+ -- through the cookie client — site identity (site_title, site_description,
18
+ -- site_keywords, site_social_image, active_logo_id: app/cms/settings/logos/actions.ts),
19
+ -- the footer (footer_copyright, footer_show_attribution: settings/copyright/actions.ts)
20
+ -- and onboarding_state (lib/onboarding/actions.ts, the dashboard's dismiss button).
21
+ -- * ADMIN tier (ADMIN, first factor): the two keys an ADMIN still writes through the
22
+ -- cookie client, enabled_payment_providers and ecommerce_inventory_settings
23
+ -- (libs/ecommerce payments, shipping, taxes). Moving those writes to the service role
24
+ -- lets them drop into the tier below (docs/04 lists it as a follow-up).
25
+ -- * everything else: ADMIN with a satisfied second factor (public.is_admin_mfa()). That
26
+ -- is the old deny-list plus email_public, bot_protection_public, payment_public,
27
+ -- invoice_settings, store_contact, forms_contact, interactions_notification_emails,
28
+ -- migration_baseline_generation, the cortex_ai_* settings and any key added later.
29
+ -- The app writes all of them with the service role after requireCmsAdmin() /
30
+ -- requireEcomAdmin(), Cortex too (its context is the service client); the three that
31
+ -- still used the cookie client move in the same change (logos/actions.ts
32
+ -- saveInvoiceSettings, cms/inquiries saveStoreContactEmail, the interactions
33
+ -- notification-address actions). The last two were already broken for an aal1 admin:
34
+ -- an upsert's ON CONFLICT path checks the read policy, and both keys sit above the
35
+ -- staff read tier. Reads are untouched (02021/02023 tiers).
36
+ --
37
+ -- 2. The email second factor counts in RLS (HIGH).
38
+ -- private.meets_aal_requirement() passed whenever the caller had no verified row in
39
+ -- auth.mfa_factors. The app's email factor never creates one (only TOTP does), so for an
40
+ -- email-2FA ADMIN every is_admin_mfa() policy was a plain role check: a stolen password and
41
+ -- an aal1 JWT reached mcp_access_tokens (a persistent MCP bearer), package_activations,
42
+ -- the secret tier of site_settings and other accounts' profiles. The no-factor branch now
43
+ -- also requires that the caller has no enrolled factor in public.user_security_settings
44
+ -- (`mfa_enabled AND mfa_type IS NOT NULL`, written only by the service role since 02026).
45
+ -- An email factor never yields an aal2 Supabase session, so for those admins every
46
+ -- is_admin_mfa() row is now reachable only through the service role, after the app's own
47
+ -- check (lib/auth/cms-actor.ts getCmsActor() refuses a session whose factor is pending).
48
+ -- A TOTP admin keeps aal2, and an admin who enrolled nothing is never locked out.
49
+ --
50
+ -- 3. ADMIN branches that skipped the second factor (MEDIUM).
51
+ -- site_scripts / site_script_revisions writes (scripts render with the CSP nonce on every
52
+ -- route, /cms included, so this was script injection into a real admin's session),
53
+ -- cms_redirects writes (an open redirect on any path), system_configuration (sign-up
54
+ -- policy) and the PII reads and updates on product_inquiries, message_threads and
55
+ -- thread_messages now use is_admin_mfa(). The CMS actions for all of them run on the
56
+ -- service role after requireCmsAdmin() (site scripts, redirects, registration/security,
57
+ -- messages); the public thread page and the contact paths already did. The public read
58
+ -- policies (active scripts, active redirects) are unchanged.
59
+ --
60
+ -- 4. media: uploader_id is pinned on insert, and storage keys in use are visible (MEDIUM).
61
+ -- media_insert_policy only checked the role, so a WRITER could insert a row attributed to
62
+ -- anyone; nothing pins object_key or variants either, and app/cms/media/actions.ts deleted
63
+ -- every variants[].objectKey of a deleted row with server credentials. A WRITER could
64
+ -- insert a decoy row listing other uploaders' keys and delete their files. Insert now pins
65
+ -- uploader_id to the caller (ADMIN keeps any). The delete and move actions keep only the
66
+ -- keys derived from the row's own object_key (lib/storage/media-object-keys.ts) and ask
67
+ -- public.media_storage_keys_in_use() which keys another row still references, so a shared
68
+ -- key is never deleted. The function is SECURITY INVOKER and service-role only: the app
69
+ -- calls it after its own role check, and the API roles have no use for it.
70
+ --
71
+ -- 5. package_activations: license keys are not readable over the API (MEDIUM, sandbox).
72
+ -- The public demo signs every visitor in as an ADMIN with no factor, so is_admin_mfa()
73
+ -- passes and "Staff read package activations" handed out the plaintext license key.
74
+ -- authenticated now holds SELECT on every column except license_key and meta (meta keeps
75
+ -- the Freemius activation response, install API token included). Every app read of the
76
+ -- table already uses the service role (packages page, dashboard, verifyPackageOnline,
77
+ -- revalidation, activation). Both apply paths repeat the column grant after their blanket
78
+ -- grants (API_ROLE_REVOKE_SQL), the same shape as profiles in 02025.
79
+ --
80
+ -- 6. media_read_policy stays USING (true) for anon (LOW, decision recorded).
81
+ -- The bucket is public-read and every rendered image carries its object URL, so a media
82
+ -- row discloses nothing the object URL does not, beyond its file name, description and
83
+ -- uploader id. Scoping it to "media referenced by live content" would have to follow every
84
+ -- reference (image blocks, section backgrounds, posts, products, logos, site_settings) and
85
+ -- would break images on any path it missed. docs/04 records the decision.
86
+ --
87
+ -- tools/scripts/security-verify.sql sections 30-34 check every item above, before and after.
88
+
89
+ -- ---------------------------------------------------------------------------
90
+ -- 1. site_settings: three write tiers, WRITER on an allow-list
91
+ -- ---------------------------------------------------------------------------
92
+ DROP POLICY IF EXISTS site_settings_insert_policy ON public.site_settings;
93
+ CREATE POLICY site_settings_insert_policy ON public.site_settings
94
+ FOR INSERT TO authenticated
95
+ WITH CHECK (
96
+ ((key = ANY (ARRAY['site_title','site_description','site_keywords','site_social_image','active_logo_id','footer_copyright','footer_show_attribution','onboarding_state']::text[]))
97
+ AND (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role,'WRITER'::public.user_role]))
98
+ OR ((key = ANY (ARRAY['enabled_payment_providers','ecommerce_inventory_settings']::text[]))
99
+ AND (SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role)
100
+ OR ((SELECT public.is_admin_mfa()) IS TRUE)
101
+ );
102
+
103
+ DROP POLICY IF EXISTS site_settings_update_policy ON public.site_settings;
104
+ CREATE POLICY site_settings_update_policy ON public.site_settings
105
+ FOR UPDATE TO authenticated
106
+ USING (
107
+ ((key = ANY (ARRAY['site_title','site_description','site_keywords','site_social_image','active_logo_id','footer_copyright','footer_show_attribution','onboarding_state']::text[]))
108
+ AND (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role,'WRITER'::public.user_role]))
109
+ OR ((key = ANY (ARRAY['enabled_payment_providers','ecommerce_inventory_settings']::text[]))
110
+ AND (SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role)
111
+ OR ((SELECT public.is_admin_mfa()) IS TRUE)
112
+ )
113
+ WITH CHECK (
114
+ ((key = ANY (ARRAY['site_title','site_description','site_keywords','site_social_image','active_logo_id','footer_copyright','footer_show_attribution','onboarding_state']::text[]))
115
+ AND (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role,'WRITER'::public.user_role]))
116
+ OR ((key = ANY (ARRAY['enabled_payment_providers','ecommerce_inventory_settings']::text[]))
117
+ AND (SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role)
118
+ OR ((SELECT public.is_admin_mfa()) IS TRUE)
119
+ );
120
+
121
+ DROP POLICY IF EXISTS site_settings_delete_policy ON public.site_settings;
122
+ CREATE POLICY site_settings_delete_policy ON public.site_settings
123
+ FOR DELETE TO authenticated
124
+ USING (
125
+ ((key = ANY (ARRAY['site_title','site_description','site_keywords','site_social_image','active_logo_id','footer_copyright','footer_show_attribution','onboarding_state']::text[]))
126
+ AND (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role,'WRITER'::public.user_role]))
127
+ OR ((key = ANY (ARRAY['enabled_payment_providers','ecommerce_inventory_settings']::text[]))
128
+ AND (SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role)
129
+ OR ((SELECT public.is_admin_mfa()) IS TRUE)
130
+ );
131
+
132
+ -- ---------------------------------------------------------------------------
133
+ -- 2. meets_aal_requirement(): an enrolled email factor counts as a factor
134
+ -- ---------------------------------------------------------------------------
135
+ -- True when the session is aal2, or when the caller enrolled no second factor at all: no
136
+ -- verified Supabase factor (TOTP) AND no enabled factor in user_security_settings (TOTP or the
137
+ -- app's email codes). Same signature, owner and grants as 02022; CREATE OR REPLACE keeps the
138
+ -- is_admin_mfa() wrapper and every policy that calls it.
139
+ CREATE OR REPLACE FUNCTION private.meets_aal_requirement()
140
+ RETURNS boolean
141
+ LANGUAGE sql STABLE SECURITY DEFINER
142
+ SET search_path TO ''
143
+ AS $$
144
+ SELECT COALESCE((auth.jwt() ->> 'aal'), 'aal1') = 'aal2'
145
+ OR (
146
+ NOT EXISTS (
147
+ SELECT 1 FROM auth.mfa_factors f
148
+ WHERE f.user_id = auth.uid() AND f.status = 'verified'
149
+ )
150
+ AND NOT EXISTS (
151
+ SELECT 1 FROM public.user_security_settings s
152
+ WHERE s.user_id = auth.uid() AND s.mfa_enabled AND s.mfa_type IS NOT NULL
153
+ )
154
+ );
155
+ $$;
156
+ REVOKE ALL ON FUNCTION private.meets_aal_requirement() FROM PUBLIC;
157
+ GRANT EXECUTE ON FUNCTION private.meets_aal_requirement() TO anon, authenticated, service_role;
158
+
159
+ -- ---------------------------------------------------------------------------
160
+ -- 3. Second factor on the remaining ADMIN branches
161
+ -- ---------------------------------------------------------------------------
162
+ -- site_scripts: INSERT / UPDATE / DELETE (the two read policies of 02013 are unchanged)
163
+ DROP POLICY IF EXISTS "Admins insert site scripts" ON public.site_scripts;
164
+ CREATE POLICY "Admins insert site scripts" ON public.site_scripts
165
+ FOR INSERT TO authenticated WITH CHECK (((SELECT public.is_admin_mfa()) IS TRUE));
166
+ DROP POLICY IF EXISTS "Admins update site scripts" ON public.site_scripts;
167
+ CREATE POLICY "Admins update site scripts" ON public.site_scripts
168
+ FOR UPDATE TO authenticated USING (((SELECT public.is_admin_mfa()) IS TRUE)) WITH CHECK (((SELECT public.is_admin_mfa()) IS TRUE));
169
+ DROP POLICY IF EXISTS "Admins delete site scripts" ON public.site_scripts;
170
+ CREATE POLICY "Admins delete site scripts" ON public.site_scripts
171
+ FOR DELETE TO authenticated USING (((SELECT public.is_admin_mfa()) IS TRUE));
172
+
173
+ -- site_script_revisions: INSERT (UPDATE / DELETE have no policy and no grant since 02026)
174
+ DROP POLICY IF EXISTS "Admins insert site script revisions" ON public.site_script_revisions;
175
+ CREATE POLICY "Admins insert site script revisions" ON public.site_script_revisions
176
+ FOR INSERT TO authenticated WITH CHECK (((SELECT public.is_admin_mfa()) IS TRUE));
177
+
178
+ -- cms_redirects: INSERT / UPDATE / DELETE (the two read policies of 02013 are unchanged)
179
+ DROP POLICY IF EXISTS "Admins insert redirects" ON public.cms_redirects;
180
+ CREATE POLICY "Admins insert redirects" ON public.cms_redirects
181
+ FOR INSERT TO authenticated WITH CHECK (((SELECT public.is_admin_mfa()) IS TRUE));
182
+ DROP POLICY IF EXISTS "Admins update redirects" ON public.cms_redirects;
183
+ CREATE POLICY "Admins update redirects" ON public.cms_redirects
184
+ FOR UPDATE TO authenticated USING (((SELECT public.is_admin_mfa()) IS TRUE)) WITH CHECK (((SELECT public.is_admin_mfa()) IS TRUE));
185
+ DROP POLICY IF EXISTS "Admins delete redirects" ON public.cms_redirects;
186
+ CREATE POLICY "Admins delete redirects" ON public.cms_redirects
187
+ FOR DELETE TO authenticated USING (((SELECT public.is_admin_mfa()) IS TRUE));
188
+
189
+ -- system_configuration: every command (the sign-up path reads it with the service role)
190
+ DROP POLICY IF EXISTS system_configuration_admin_select ON public.system_configuration;
191
+ CREATE POLICY system_configuration_admin_select ON public.system_configuration
192
+ FOR SELECT TO authenticated USING (((SELECT public.is_admin_mfa()) IS TRUE));
193
+ DROP POLICY IF EXISTS system_configuration_admin_insert ON public.system_configuration;
194
+ CREATE POLICY system_configuration_admin_insert ON public.system_configuration
195
+ FOR INSERT TO authenticated WITH CHECK (((SELECT public.is_admin_mfa()) IS TRUE));
196
+ DROP POLICY IF EXISTS system_configuration_admin_update ON public.system_configuration;
197
+ CREATE POLICY system_configuration_admin_update ON public.system_configuration
198
+ FOR UPDATE TO authenticated USING (((SELECT public.is_admin_mfa()) IS TRUE)) WITH CHECK (((SELECT public.is_admin_mfa()) IS TRUE));
199
+ DROP POLICY IF EXISTS system_configuration_admin_delete ON public.system_configuration;
200
+ CREATE POLICY system_configuration_admin_delete ON public.system_configuration
201
+ FOR DELETE TO authenticated USING (((SELECT public.is_admin_mfa()) IS TRUE));
202
+
203
+ -- product_inquiries / message_threads: ADMIN read and update (INSERT / DELETE revoked in 02026)
204
+ DROP POLICY IF EXISTS product_inquiries_admin_read_policy ON public.product_inquiries;
205
+ CREATE POLICY product_inquiries_admin_read_policy ON public.product_inquiries
206
+ FOR SELECT TO authenticated USING (((SELECT public.is_admin_mfa()) IS TRUE));
207
+ DROP POLICY IF EXISTS product_inquiries_admin_update_policy ON public.product_inquiries;
208
+ CREATE POLICY product_inquiries_admin_update_policy ON public.product_inquiries
209
+ FOR UPDATE TO authenticated USING (((SELECT public.is_admin_mfa()) IS TRUE)) WITH CHECK (((SELECT public.is_admin_mfa()) IS TRUE));
210
+
211
+ DROP POLICY IF EXISTS message_threads_admin_read_policy ON public.message_threads;
212
+ CREATE POLICY message_threads_admin_read_policy ON public.message_threads
213
+ FOR SELECT TO authenticated USING (((SELECT public.is_admin_mfa()) IS TRUE));
214
+ DROP POLICY IF EXISTS message_threads_admin_update_policy ON public.message_threads;
215
+ CREATE POLICY message_threads_admin_update_policy ON public.message_threads
216
+ FOR UPDATE TO authenticated USING (((SELECT public.is_admin_mfa()) IS TRUE)) WITH CHECK (((SELECT public.is_admin_mfa()) IS TRUE));
217
+
218
+ -- thread_messages: ADMIN read (every write is the service role's, 02026)
219
+ DROP POLICY IF EXISTS thread_messages_admin_read_policy ON public.thread_messages;
220
+ CREATE POLICY thread_messages_admin_read_policy ON public.thread_messages
221
+ FOR SELECT TO authenticated USING (((SELECT public.is_admin_mfa()) IS TRUE));
222
+
223
+ -- ---------------------------------------------------------------------------
224
+ -- 4. media: pin uploader_id on insert; which storage keys a row still references
225
+ -- ---------------------------------------------------------------------------
226
+ -- Same shape as 02022's update/delete policies: a WRITER inserts rows attributed to
227
+ -- themselves, an ADMIN may attribute a row to anyone (imports, restores).
228
+ DROP POLICY IF EXISTS media_insert_policy ON public.media;
229
+ CREATE POLICY media_insert_policy ON public.media
230
+ FOR INSERT TO authenticated
231
+ WITH CHECK (
232
+ (SELECT public.is_admin()) IS TRUE
233
+ OR (uploader_id = (SELECT auth.uid())
234
+ AND (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role,'WRITER'::public.user_role]))
235
+ );
236
+
237
+ -- The keys of p_keys that some media row other than p_exclude_id references as its object_key,
238
+ -- its file_path or a variants[].objectKey. The CMS delete and move actions call it (service
239
+ -- role) after removing or claiming their own rows, and keep every key it returns.
240
+ CREATE OR REPLACE FUNCTION public.media_storage_keys_in_use(p_keys text[], p_exclude_id uuid DEFAULT NULL)
241
+ RETURNS SETOF text
242
+ LANGUAGE sql STABLE SECURITY INVOKER
243
+ SET search_path TO ''
244
+ AS $$
245
+ SELECT DISTINCT k.key
246
+ FROM unnest(p_keys) AS k(key)
247
+ WHERE EXISTS (
248
+ SELECT 1
249
+ FROM public.media m
250
+ WHERE m.id IS DISTINCT FROM p_exclude_id
251
+ AND (m.object_key = k.key
252
+ OR m.file_path = k.key
253
+ OR m.variants @> jsonb_build_array(jsonb_build_object('objectKey', k.key)))
254
+ );
255
+ $$;
256
+ REVOKE ALL ON FUNCTION public.media_storage_keys_in_use(text[], uuid) FROM PUBLIC, anon, authenticated;
257
+ GRANT EXECUTE ON FUNCTION public.media_storage_keys_in_use(text[], uuid) TO service_role;
258
+
259
+ -- ---------------------------------------------------------------------------
260
+ -- 5. package_activations: no license_key / meta for authenticated
261
+ -- ---------------------------------------------------------------------------
262
+ -- Table-level SELECT goes (revoking it also drops any column grant), then SELECT comes back on
263
+ -- every column except the two secrets. The column list is read from the catalog so the
264
+ -- statement matches API_ROLE_REVOKE_SQL; a new column is readable by an aal2 ADMIN, add it to
265
+ -- the exclusions there and here if it is sensitive.
266
+ DO $nb_package_activations$
267
+ DECLARE
268
+ v_cols text;
269
+ BEGIN
270
+ SELECT string_agg(quote_ident(a.attname), ', ' ORDER BY a.attnum)
271
+ INTO v_cols
272
+ FROM pg_attribute a
273
+ WHERE a.attrelid = 'public.package_activations'::regclass AND a.attnum > 0 AND NOT a.attisdropped
274
+ AND a.attname NOT IN ('license_key', 'meta');
275
+ REVOKE SELECT ON TABLE public.package_activations FROM authenticated;
276
+ IF v_cols IS NOT NULL THEN
277
+ EXECUTE format('GRANT SELECT (%s) ON TABLE public.package_activations TO authenticated', v_cols);
278
+ END IF;
279
+ END
280
+ $nb_package_activations$;
281
+
282
+ -- ---------------------------------------------------------------------------
283
+ -- 6. media_read_policy: unchanged on purpose (see the header)
284
+ -- ---------------------------------------------------------------------------