diff --git a/editor/app/(api)/private/accounts/organizations/[org]/profile/route.ts b/editor/app/(api)/private/accounts/organizations/[org]/profile/route.ts deleted file mode 100644 index 4ead8e4ce8..0000000000 --- a/editor/app/(api)/private/accounts/organizations/[org]/profile/route.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { createClient } from "@/lib/supabase/server"; -import { NextRequest, NextResponse } from "next/server"; - -type Params = { org: string }; - -export async function POST( - req: NextRequest, - context: { - params: Promise; - } -) { - const origin = req.nextUrl.origin; - const { org } = await context.params; - - const client = await createClient(); - - const body = await req.formData(); - - const display_name = body.get("display_name"); - const email = body.get("email"); - const description = body.get("description"); - const blog = body.get("blog"); - - const { error } = await client - .from("organization") - .update({ - display_name: String(display_name), - email: String(email), - description: description ? String(description) : undefined, - blog: blog ? String(blog) : undefined, - }) - .eq("name", org); - - if (error) { - console.error("organization/profile", error); - return NextResponse.error(); - } - - return NextResponse.redirect( - origin + `/organizations/${org}/settings/profile`, - { - status: 302, - } - ); -} diff --git a/editor/app/(site)/organizations/[organization_name]/settings/profile/__tests__/actions.test.ts b/editor/app/(site)/organizations/[organization_name]/settings/profile/__tests__/actions.test.ts new file mode 100644 index 0000000000..2bd2226a93 --- /dev/null +++ b/editor/app/(site)/organizations/[organization_name]/settings/profile/__tests__/actions.test.ts @@ -0,0 +1,287 @@ +/** + * Unit tests for `updateOrganizationProfile` — the org General settings server + * action. The supabase client and `next/cache` are mocked; these assert the + * pure decision logic: the membership gate, avatar validation, the + * `{id}/avatar` path scheme + upsert upload, and the remove flow. + * + * Note: the upload/remove go through the SAME user-authed `createClient()` as + * the row read/update — there is no `service_role` in this feature (RLS is the + * boundary), so the mock exposes only `createClient`. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Typed `vi.fn` shorthand (repo lint requires explicit mock type parameters). +const fn = () => vi.fn<(...args: never[]) => unknown>(); + +vi.mock("next/cache", () => ({ + revalidatePath: vi.fn<(...args: never[]) => void>(), +})); + +vi.mock("@/lib/supabase/server", () => ({ + createClient: vi.fn<(...args: never[]) => unknown>(), +})); + +import { createClient } from "@/lib/supabase/server"; +import { revalidatePath } from "next/cache"; +import { updateOrganizationProfile } from "../actions"; + +const mockedCreateClient = vi.mocked(createClient); + +const ORG_NAME = "acme"; +const ORG_ID = 42; + +/** + * Build a user-authed client stub. `.from("organization")` drives the + * membership gate + profile update; `.storage.from("avatars")` captures the + * upload/remove calls. + */ +function makeUserClient(opts: { member?: boolean } = {}) { + const member = opts.member ?? true; + const update = fn().mockReturnValue({ + eq: fn().mockResolvedValue({ error: null }), + }); + const upload = fn().mockResolvedValue({ error: null }); + const remove = fn().mockResolvedValue({ error: null }); + const storageFrom = vi + .fn<(bucket: string) => unknown>() + .mockReturnValue({ upload, remove }); + + const client = { + auth: { + getUser: fn().mockResolvedValue({ data: { user: { id: "u1" } } }), + }, + from: vi.fn<(table: string) => unknown>((table: string) => { + if (table !== "organization") + throw new Error(`unexpected table ${table}`); + return { + select: fn().mockReturnValue({ + eq: fn().mockReturnValue({ + single: fn().mockResolvedValue( + member + ? { data: { id: ORG_ID }, error: null } + : { data: null, error: { message: "forbidden" } } + ), + }), + }), + update, + }; + }), + storage: { from: storageFrom }, + }; + return { client, update, upload, remove, storageFrom }; +} + +function form(fields: Record) { + const fd = new FormData(); + for (const [k, v] of Object.entries(fields)) fd.set(k, v as never); + return fd; +} + +// Leading magic bytes the server action sniffs (it ignores `File.type`). +const MAGIC: Record = { + png: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], + jpeg: [0xff, 0xd8, 0xff], + // "RIFF" + 4 size bytes + "WEBP" + webp: [0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50], + // "GIF8" — a real format, but NOT one of the accepted types. + gif: [0x47, 0x49, 0x46, 0x38], +}; + +/** + * Build an image `File`. `magic` controls the real leading bytes (what the + * action sniffs); `type` is the client-declared MIME (which the action must + * NOT trust). Omit `magic` for a buffer of zero bytes (no valid signature). + */ +function imageFile( + type: string, + bytes: number, + name = "a.png", + magic?: number[] +) { + const buf = new Uint8Array(bytes); + if (magic) buf.set(magic.slice(0, bytes), 0); + return new File([buf], name, { type }); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("updateOrganizationProfile", () => { + it("rejects when the caller is not an org member (RLS gate)", async () => { + const { client } = makeUserClient({ member: false }); + // oxlint-disable-next-line typescript-eslint/no-explicit-any -- partial client stub + mockedCreateClient.mockResolvedValue(client as any); + + await expect( + updateOrganizationProfile( + ORG_NAME, + form({ display_name: "Acme", email: "a@acme.com" }) + ) + ).rejects.toThrow(/not found or forbidden/); + }); + + it("updates text fields without touching avatar_path when no file/remove", async () => { + const { client, update, upload, remove } = makeUserClient(); + // oxlint-disable-next-line typescript-eslint/no-explicit-any -- partial client stub + mockedCreateClient.mockResolvedValue(client as any); + + await updateOrganizationProfile( + ORG_NAME, + form({ + display_name: "Acme Inc", + email: "a@acme.com", + description: "hello", + blog: "https://acme.com", + }) + ); + + expect(upload).not.toHaveBeenCalled(); + expect(remove).not.toHaveBeenCalled(); + const payload = update.mock.calls[0][0]; + expect(payload).toMatchObject({ + display_name: "Acme Inc", + email: "a@acme.com", + description: "hello", + blog: "https://acme.com", + }); + expect("avatar_path" in payload).toBe(false); + expect(revalidatePath).toHaveBeenCalledWith( + `/organizations/${ORG_NAME}/settings/profile` + ); + }); + + it("uploads via the user-auth client to {id}/avatar (upsert) and writes avatar_path", async () => { + const { client, update, upload, storageFrom } = makeUserClient(); + // oxlint-disable-next-line typescript-eslint/no-explicit-any -- partial client stub + mockedCreateClient.mockResolvedValue(client as any); + + await updateOrganizationProfile( + ORG_NAME, + form({ + display_name: "Acme", + email: "a@acme.com", + avatar: imageFile("image/png", 1024, "a.png", MAGIC.png), + }) + ); + + // Goes through the user-authed client's storage, not service_role. + expect(storageFrom).toHaveBeenCalledWith("avatars"); + const [path, , options] = upload.mock.calls[0]; + expect(path).toBe(`${ORG_ID}/avatar`); + expect(options).toMatchObject({ upsert: true, contentType: "image/png" }); + expect(update.mock.calls[0][0]).toMatchObject({ + avatar_path: `${ORG_ID}/avatar`, + }); + }); + + it("uses the sniffed type as contentType, ignoring a spoofed File.type", async () => { + const { client, upload } = makeUserClient(); + // oxlint-disable-next-line typescript-eslint/no-explicit-any -- partial client stub + mockedCreateClient.mockResolvedValue(client as any); + + // Declared as PNG, but the real bytes are JPEG — sniffed type wins. + await updateOrganizationProfile( + ORG_NAME, + form({ + display_name: "Acme", + email: "a@acme.com", + avatar: imageFile("image/png", 1024, "a.png", MAGIC.jpeg), + }) + ); + + const [, , options] = upload.mock.calls[0]; + expect(options).toMatchObject({ contentType: "image/jpeg" }); + }); + + it("clears avatar_path on remove, deletes the object, and does not upload", async () => { + const { client, update, upload, remove } = makeUserClient(); + // oxlint-disable-next-line typescript-eslint/no-explicit-any -- partial client stub + mockedCreateClient.mockResolvedValue(client as any); + + await updateOrganizationProfile( + ORG_NAME, + form({ display_name: "Acme", email: "a@acme.com", remove_avatar: "1" }) + ); + + expect(upload).not.toHaveBeenCalled(); + expect(remove).toHaveBeenCalledWith([`${ORG_ID}/avatar`]); + expect(update.mock.calls[0][0]).toMatchObject({ avatar_path: null }); + }); + + it("rejects a spoofed MIME — accepted File.type but disallowed magic bytes", async () => { + const { client, upload } = makeUserClient(); + // oxlint-disable-next-line typescript-eslint/no-explicit-any -- partial client stub + mockedCreateClient.mockResolvedValue(client as any); + + // Claims to be a PNG, but the bytes are a real GIF (not an accepted type). + await expect( + updateOrganizationProfile( + ORG_NAME, + form({ + display_name: "Acme", + email: "a@acme.com", + avatar: imageFile("image/png", 1024, "a.png", MAGIC.gif), + }) + ) + ).rejects.toThrow(/unsupported image type/); + expect(upload).not.toHaveBeenCalled(); + }); + + it("rejects a file whose bytes match no accepted image signature", async () => { + const { client } = makeUserClient(); + // oxlint-disable-next-line typescript-eslint/no-explicit-any -- partial client stub + mockedCreateClient.mockResolvedValue(client as any); + + await expect( + updateOrganizationProfile( + ORG_NAME, + form({ + display_name: "Acme", + email: "a@acme.com", + // Zero-filled buffer: no magic match. + avatar: imageFile("image/png", 1024), + }) + ) + ).rejects.toThrow(/unsupported image type/); + }); + + it("rejects when display_name is missing", async () => { + const { client, update } = makeUserClient(); + // oxlint-disable-next-line typescript-eslint/no-explicit-any -- partial client stub + mockedCreateClient.mockResolvedValue(client as any); + + await expect( + updateOrganizationProfile(ORG_NAME, form({ email: "a@acme.com" })) + ).rejects.toThrow(/display name is required/); + expect(update).not.toHaveBeenCalled(); + }); + + it("rejects when email is missing", async () => { + const { client, update } = makeUserClient(); + // oxlint-disable-next-line typescript-eslint/no-explicit-any -- partial client stub + mockedCreateClient.mockResolvedValue(client as any); + + await expect( + updateOrganizationProfile(ORG_NAME, form({ display_name: "Acme" })) + ).rejects.toThrow(/email is required/); + expect(update).not.toHaveBeenCalled(); + }); + + it("rejects images larger than 2MB", async () => { + const { client } = makeUserClient(); + // oxlint-disable-next-line typescript-eslint/no-explicit-any -- partial client stub + mockedCreateClient.mockResolvedValue(client as any); + + await expect( + updateOrganizationProfile( + ORG_NAME, + form({ + display_name: "Acme", + email: "a@acme.com", + avatar: imageFile("image/png", 2 * 1024 * 1024 + 1), + }) + ) + ).rejects.toThrow(/too large/); + }); +}); diff --git a/editor/app/(site)/organizations/[organization_name]/settings/profile/actions.ts b/editor/app/(site)/organizations/[organization_name]/settings/profile/actions.ts new file mode 100644 index 0000000000..108fc52379 --- /dev/null +++ b/editor/app/(site)/organizations/[organization_name]/settings/profile/actions.ts @@ -0,0 +1,183 @@ +"use server"; + +// Everything here runs through `createClient()` (user-authed, RLS-aware) — the +// org row read/update AND the avatar object read/write/delete. Authorization is +// enforced by RLS end to end: the `organization` SELECT/UPDATE policies +// (`rls_organization`) and the `avatars` bucket storage policies (added in +// migration `*_avatars_bucket_rls.sql`, scoped to org membership via the +// `{organization_id}/avatar` path). No service_role anywhere. The org select +// below is a lightweight gate kept only for a friendly error — RLS is the real +// boundary. + +import { createClient } from "@/lib/supabase/server"; +import { revalidatePath } from "next/cache"; + +const AVATAR_MAX_BYTES = 2 * 1024 * 1024; // ~2MB + +/** + * Sniff the real image type from the file's leading bytes instead of trusting + * the client-supplied `File.type` (which is attacker-controlled). Returns the + * canonical MIME for png/jpeg/webp, or `null` when the magic bytes match none + * of the accepted formats. + */ +function sniffImageType(bytes: Uint8Array): string | null { + // PNG: 89 50 4E 47 0D 0A 1A 0A + if ( + bytes.length >= 8 && + bytes[0] === 0x89 && + bytes[1] === 0x50 && + bytes[2] === 0x4e && + bytes[3] === 0x47 && + bytes[4] === 0x0d && + bytes[5] === 0x0a && + bytes[6] === 0x1a && + bytes[7] === 0x0a + ) { + return "image/png"; + } + + // JPEG: FF D8 FF + if ( + bytes.length >= 3 && + bytes[0] === 0xff && + bytes[1] === 0xd8 && + bytes[2] === 0xff + ) { + return "image/jpeg"; + } + + // WEBP: "RIFF" .... "WEBP" (RIFF container with a WEBP fourCC at offset 8) + if ( + bytes.length >= 12 && + bytes[0] === 0x52 && // R + bytes[1] === 0x49 && // I + bytes[2] === 0x46 && // F + bytes[3] === 0x46 && // F + bytes[8] === 0x57 && // W + bytes[9] === 0x45 && // E + bytes[10] === 0x42 && // B + bytes[11] === 0x50 // P + ) { + return "image/webp"; + } + + return null; +} + +/** + * Update an organization's general profile (display name, contact email, + * description, blog) and, optionally, its avatar. + * + * Bound to the org name at the call site: `action={updateOrganizationProfile.bind(null, organization_name)}`. + */ +export async function updateOrganizationProfile( + organization_name: string, + formData: FormData +): Promise { + const client = await createClient(); + + const { data: auth } = await client.auth.getUser(); + if (!auth.user) { + throw new Error("unauthorized"); + } + + // `Enabled based on membership` RLS returns a row only to org members — this + // select is both the id lookup and the authorization gate for the upload. + const { data: org, error: orgError } = await client + .from("organization") + .select("id") + .eq("name", organization_name) + .single(); + + if (orgError || !org) { + throw new Error("organization not found or forbidden"); + } + + const display_name_raw = formData.get("display_name"); + const email_raw = formData.get("email"); + const description = formData.get("description"); + const blog = formData.get("blog"); + const remove_avatar = formData.get("remove_avatar") === "1"; + const avatar = formData.get("avatar"); + + // `display_name` and `email` are required. Validate presence explicitly — + // `String(null)` would otherwise persist the literal string "null". + const display_name = + typeof display_name_raw === "string" ? display_name_raw.trim() : ""; + if (!display_name) { + throw new Error("display name is required"); + } + const email = typeof email_raw === "string" ? email_raw.trim() : ""; + if (!email) { + throw new Error("email is required"); + } + + // Object path scheme: `{organization_id}/avatar`. The leading folder segment + // is what the storage RLS policy parses to authorize the write. + const path = `${org.id}/avatar`; + + // `undefined` => leave avatar_path untouched. `null` => clear it. + let avatar_path: string | null | undefined = undefined; + + if (remove_avatar) { + // Best-effort object delete (RLS-guarded by the same membership policy); + // the row going null is what drives display, so a missing object is fine. + const { error: removeError } = await client.storage + .from("avatars") + .remove([path]); + if (removeError) { + console.error("organization/profile avatar remove", removeError); + } + avatar_path = null; + } else if (avatar instanceof File && avatar.size > 0) { + if (avatar.size > AVATAR_MAX_BYTES) { + throw new Error("image too large (max 2MB)"); + } + + // Don't trust the client-reported `avatar.type` — sniff the real format + // from the leading bytes and reject anything that isn't png/jpeg/webp. + const buffer = await avatar.arrayBuffer(); + const sniffed = sniffImageType(new Uint8Array(buffer)); + if (!sniffed) { + throw new Error(`unsupported image type: ${avatar.type || "unknown"}`); + } + + // User-authed upload — the `avatars` bucket storage policy authorizes it by + // org membership (parsed from the path). Stable key + upsert overwrites in + // place on replace. Upload the already-read buffer with the sniffed type as + // the authoritative contentType. + const { error: uploadError } = await client.storage + .from("avatars") + .upload(path, buffer, { + contentType: sniffed, + upsert: true, + }); + + if (uploadError) { + console.error("organization/profile avatar upload", uploadError); + throw new Error("failed to upload avatar"); + } + + avatar_path = path; + } + + // `display_name`/`email` are validated non-empty above. Optional fields: + // empty/absent => leave untouched (undefined is omitted from the update). + const { error: updateError } = await client + .from("organization") + .update({ + display_name, + email, + description: description ? String(description) : undefined, + blog: blog ? String(blog) : undefined, + ...(avatar_path !== undefined ? { avatar_path } : {}), + }) + .eq("name", organization_name); + + if (updateError) { + console.error("organization/profile update", updateError); + throw new Error("failed to update organization"); + } + + revalidatePath(`/organizations/${organization_name}/settings/profile`); +} diff --git a/editor/app/(site)/organizations/[organization_name]/settings/profile/avatar-field.tsx b/editor/app/(site)/organizations/[organization_name]/settings/profile/avatar-field.tsx new file mode 100644 index 0000000000..5ae472eb5c --- /dev/null +++ b/editor/app/(site)/organizations/[organization_name]/settings/profile/avatar-field.tsx @@ -0,0 +1,86 @@ +"use client"; + +import React, { useEffect, useRef, useState } from "react"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; + +const ACCEPT = "image/png,image/jpeg,image/webp"; + +/** + * Avatar control inside the org General form. Renders the current avatar (or + * initials fallback), lets the user pick a replacement with a local preview, or + * remove it. The actual write happens on the form's submit via the server + * action — this only carries the `avatar` file and the `remove_avatar` flag. + */ +export function OrganizationAvatarField({ + current_avatar_url, + display_name, +}: { + current_avatar_url?: string | null; + display_name?: string | null; +}) { + const inputRef = useRef(null); + const [preview, setPreview] = useState(null); + const [removed, setRemoved] = useState(false); + + const shown = removed ? null : (preview ?? current_avatar_url ?? null); + + // Revoke the object URL when it's replaced (deps change) or on unmount, so + // picking multiple files (or leaving the page) doesn't leak blob URLs. + useEffect(() => { + if (!preview) return; + return () => URL.revokeObjectURL(preview); + }, [preview]); + + const onPick = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + setRemoved(false); + setPreview(URL.createObjectURL(file)); + }; + + const onRemove = () => { + setPreview(null); + setRemoved(true); + if (inputRef.current) inputRef.current.value = ""; + }; + + return ( +
+ + {shown ? ( + + ) : ( + + {display_name?.charAt(0).toUpperCase()} + + )} + +
+ + + {shown && ( + + )} +
+ {/* Tells the server action to clear avatar_path on submit. */} + {removed && } +
+ ); +} diff --git a/editor/app/(site)/organizations/[organization_name]/settings/profile/page.tsx b/editor/app/(site)/organizations/[organization_name]/settings/profile/page.tsx index 6be1a58e9f..f6c532b7b2 100644 --- a/editor/app/(site)/organizations/[organization_name]/settings/profile/page.tsx +++ b/editor/app/(site)/organizations/[organization_name]/settings/profile/page.tsx @@ -12,6 +12,9 @@ import { notFound, redirect } from "next/navigation"; import { DeleteOrganizationConfirm } from "./delete"; import { Badge } from "@/components/ui/badge"; import { createClient } from "@/lib/supabase/server"; +import { PublicUrls } from "@/services/public-urls"; +import { updateOrganizationProfile } from "./actions"; +import { OrganizationAvatarField } from "./avatar-field"; import Link from "next/link"; type Params = { organization_name: string }; @@ -44,6 +47,10 @@ export default async function OrganizationsSettingsProfilePage({ const iamowner = data.owner_id === auth.user.id; + const avatar_url = data.avatar_path + ? PublicUrls.organization_avatar_url(client)(data.avatar_path) + : null; + return (
@@ -53,12 +60,20 @@ export default async function OrganizationsSettingsProfilePage({
+ + Organization avatar + + Name diff --git a/editor/next.config.ts b/editor/next.config.ts index c79549f695..cf6c6ac1e2 100644 --- a/editor/next.config.ts +++ b/editor/next.config.ts @@ -18,6 +18,12 @@ const nextConfig: NextConfig = { pageExtensions: ["js", "jsx", "mdx", "ts", "tsx"], experimental: { mdxRs: true, + serverActions: { + // Default server-action body limit is 1MB; the org avatar cap is 2MB, so + // a 2MB file + multipart overhead would be rejected before reaching the + // action. Raise the limit to comfortably fit the 2MB cap. + bodySizeLimit: "3mb", + }, }, // CI typechecks via .github/workflows/test.yml (pnpm typecheck). Skipping // the in-build TypeScript pass removes ~30s of redundant work from every diff --git a/supabase/migrations/20260604120000_avatars_bucket_rls.sql b/supabase/migrations/20260604120000_avatars_bucket_rls.sql new file mode 100644 index 0000000000..855ecb3efc --- /dev/null +++ b/supabase/migrations/20260604120000_avatars_bucket_rls.sql @@ -0,0 +1,89 @@ +-- Avatars bucket + org-scoped storage RLS. +-- +-- The `avatars` bucket holds organization avatars (and, by the same +-- public-URL read pattern, user_profile avatars). Organization avatar objects +-- live at the path `{organization_id}/avatar`; the first path segment is the +-- org id, authorized through the existing `public.rls_organization()` helper — +-- mirroring how the `storage` / `www` buckets scope writes via +-- `rls_*( (storage.foldername(name))[1]::... )`. +-- +-- Reconciliation notes: +-- * The bucket already exists in production (created via the dashboard), so +-- the insert is idempotent (`on conflict do nothing`) and a no-op there. +-- This statement exists for local-stack parity. +-- * Each policy is `drop ... if exists` first so this migration coexists with +-- any same-named policy that may already be present on the prod bucket and +-- is safe to (re)apply. +-- * These are the ONLY write policies on the `avatars` bucket. The membership +-- check is wrapped in a CASE so a non-numeric first segment yields `false` +-- instead of raising on the `::bigint` cast. Because storage.objects is +-- deny-by-default under RLS and no other write policy exists, a non-numeric +-- (e.g. uuid-keyed) path is simply DENIED for insert/update/delete — it does +-- not "fall through" to anything. Org avatar objects live at +-- `{organization_id}/avatar`; only that numeric-prefixed shape is writable +-- here today. Reads stay public (see the public-read policy below). + +insert into storage.buckets (id, name, public) +values ('avatars', 'avatars', true) +on conflict (id) do nothing; + +-- SELECT stays public to match the public-URL read pattern (public bucket). +drop policy if exists "avatars public read" on storage.objects; +create policy "avatars public read" +on storage.objects +for select +to public +using (bucket_id = 'avatars'); + +-- INSERT: an org member may create their org's avatar object. +drop policy if exists "avatars org member insert" on storage.objects; +create policy "avatars org member insert" +on storage.objects +for insert +to authenticated +with check ( + bucket_id = 'avatars' + and case + when (storage.foldername(name))[1] ~ '^[0-9]+$' + then public.rls_organization((storage.foldername(name))[1]::bigint) + else false + end +); + +-- UPDATE: covers the upsert (replace) path. +drop policy if exists "avatars org member update" on storage.objects; +create policy "avatars org member update" +on storage.objects +for update +to authenticated +using ( + bucket_id = 'avatars' + and case + when (storage.foldername(name))[1] ~ '^[0-9]+$' + then public.rls_organization((storage.foldername(name))[1]::bigint) + else false + end +) +with check ( + bucket_id = 'avatars' + and case + when (storage.foldername(name))[1] ~ '^[0-9]+$' + then public.rls_organization((storage.foldername(name))[1]::bigint) + else false + end +); + +-- DELETE: covers the remove path. +drop policy if exists "avatars org member delete" on storage.objects; +create policy "avatars org member delete" +on storage.objects +for delete +to authenticated +using ( + bucket_id = 'avatars' + and case + when (storage.foldername(name))[1] ~ '^[0-9]+$' + then public.rls_organization((storage.foldername(name))[1]::bigint) + else false + end +);