From a55994b9d001f24d6a3edf8f7e719d0b0cb4b8bb Mon Sep 17 00:00:00 2001 From: John Jeong Date: Tue, 1 Sep 2026 02:06:35 +0900 Subject: [PATCH] feat: gate workspace features by plan capability Resolve Team and Enterprise access per workspace and enforce it across desktop and server flows. --- .../src/enterprise-capture/lifecycle.test.tsx | 34 + .../src/enterprise-capture/lifecycle.tsx | 28 +- apps/desktop/src/settings/team/client.test.ts | 25 + apps/desktop/src/settings/team/client.ts | 59 ++ apps/desktop/src/settings/team/index.test.tsx | 76 ++ apps/desktop/src/settings/team/index.tsx | 326 +++--- ...0901090000_workspace_plan_capabilities.sql | 973 ++++++++++++++++++ ...1091000_workspace_sharing_capabilities.sql | 521 ++++++++++ .../tests/010-session-share-snapshots.sql | 4 + .../012-shared-note-gateway-handoffs.sql | 4 + supabase/tests/014-session-share-deletion.sql | 11 +- .../tests/017-session-share-attachments.sql | 4 + .../tests/023-session-share-collaboration.sql | 4 + .../tests/029-session-share-link-previews.sql | 4 + .../030-session-share-preview-metadata.sql | 4 + .../tests/033-workspace-billing-and-seats.sql | 8 +- .../tests/036-session-share-short-links.sql | 4 + supabase/tests/037-team-pro-entitlement.sql | 17 +- ...-workspace-policies-identity-analytics.sql | 5 + .../tests/042-workspace-share-subdomains.sql | 8 + .../tests/043-stable-session-share-links.sql | 4 + supabase/tests/044-required-sso.sql | 5 + supabase/tests/045-workspace-logo.sql | 14 +- supabase/tests/046-team-plan-split.sql | 8 +- .../tests/047-workspace-plan-capabilities.sql | 264 +++++ 25 files changed, 2268 insertions(+), 146 deletions(-) create mode 100644 supabase/migrations/20260901090000_workspace_plan_capabilities.sql create mode 100644 supabase/migrations/20260901091000_workspace_sharing_capabilities.sql create mode 100644 supabase/tests/047-workspace-plan-capabilities.sql diff --git a/apps/desktop/src/enterprise-capture/lifecycle.test.tsx b/apps/desktop/src/enterprise-capture/lifecycle.test.tsx index 8dd8e9c47bd..c6fd1db7c4a 100644 --- a/apps/desktop/src/enterprise-capture/lifecycle.test.tsx +++ b/apps/desktop/src/enterprise-capture/lifecycle.test.tsx @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ dispatchPendingEnterpriseCompletions: vi.fn(), getFingerprint: vi.fn(), + getWorkspaceAccess: vi.fn(), syncEnterpriseWorkspace: vi.fn(), useQuery: vi.fn(), workspaces: [{ workspaceId: "workspace-1" }], @@ -20,9 +21,14 @@ vi.mock("./sync", () => ({ })); vi.mock("~/auth", () => ({ useAuth: () => ({ + supabase: {}, session: { access_token: "access-token", user: { id: "user-1" } }, }), })); +vi.mock("~/settings/team/client", () => ({ + getWorkspaceAccess: mocks.getWorkspaceAccess, + requireTeamContext: (auth: unknown) => auth, +})); vi.mock("~/env", () => ({ env: { VITE_ENTERPRISE_API_URL: "https://capture.example.test" }, })); @@ -42,6 +48,9 @@ describe("EnterpriseCaptureSync", () => { vi.clearAllMocks(); mocks.workspaces = [{ workspaceId: "workspace-1" }]; mocks.dispatchPendingEnterpriseCompletions.mockResolvedValue(undefined); + mocks.getWorkspaceAccess.mockResolvedValue({ + capabilities: ["enterprise.capture"], + }); mocks.syncEnterpriseWorkspace.mockResolvedValue(undefined); mocks.useQuery.mockReturnValue({}); }); @@ -97,4 +106,29 @@ describe("EnterpriseCaptureSync", () => { ).toEqual(["workspace-1", "workspace-2"]); expect(mocks.dispatchPendingEnterpriseCompletions).toHaveBeenCalledOnce(); }); + + it("syncs only workspaces with the Enterprise capture capability", async () => { + mocks.workspaces = [ + { workspaceId: "workspace-1" }, + { workspaceId: "workspace-2" }, + ]; + mocks.getFingerprint.mockResolvedValue({ + status: "ok", + data: "device-1", + }); + mocks.getWorkspaceAccess.mockImplementation( + async (_context: unknown, workspaceId: string) => ({ + capabilities: + workspaceId === "workspace-2" ? ["enterprise.capture"] : [], + }), + ); + render(); + + await expect(getQueryFn()()).resolves.toBeNull(); + + expect(mocks.syncEnterpriseWorkspace).toHaveBeenCalledOnce(); + expect(mocks.syncEnterpriseWorkspace).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: "workspace-2" }), + ); + }); }); diff --git a/apps/desktop/src/enterprise-capture/lifecycle.tsx b/apps/desktop/src/enterprise-capture/lifecycle.tsx index 6fe709ee97d..dedb53d6817 100644 --- a/apps/desktop/src/enterprise-capture/lifecycle.tsx +++ b/apps/desktop/src/enterprise-capture/lifecycle.tsx @@ -10,6 +10,7 @@ import { import { useAuth } from "~/auth"; import { env } from "~/env"; +import { getWorkspaceAccess, requireTeamContext } from "~/settings/team/client"; import { useMyWorkspacesWithMirror } from "~/settings/team/mirror"; const POLL_INTERVAL_MS = 15_000; @@ -28,12 +29,33 @@ export function EnterpriseCaptureSync() { session?.user.id, workspaces.data?.map((workspace) => workspace.workspaceId).sort(), ], - enabled: Boolean(serverUrl && session && workspaces.data), + enabled: Boolean(serverUrl && auth.supabase && session && workspaces.data), queryFn: async () => { - if (!serverUrl || !session || !workspaces.data) return null; - const consumerId = await getDeviceFingerprint(deviceFingerprint); + if (!serverUrl || !auth.supabase || !session || !workspaces.data) { + return null; + } + const context = requireTeamContext(auth); + const eligibleWorkspaces: NonNullable = []; let workspaceError: unknown; for (const workspace of workspaces.data) { + try { + const access = await getWorkspaceAccess( + context, + workspace.workspaceId, + ); + if (access.capabilities.includes("enterprise.capture")) { + eligibleWorkspaces.push(workspace); + } + } catch (error) { + workspaceError ??= error; + } + } + if (eligibleWorkspaces.length === 0) { + if (workspaceError) throw workspaceError; + return null; + } + const consumerId = await getDeviceFingerprint(deviceFingerprint); + for (const workspace of eligibleWorkspaces) { try { await syncEnterpriseWorkspace({ serverUrl, diff --git a/apps/desktop/src/settings/team/client.test.ts b/apps/desktop/src/settings/team/client.test.ts index 0ada039aa0d..f79c8230224 100644 --- a/apps/desktop/src/settings/team/client.test.ts +++ b/apps/desktop/src/settings/team/client.test.ts @@ -4,6 +4,7 @@ import { createWorkspace, createWorkspaceInvitation, getSeatUsage, + getWorkspaceAccess, getWorkspacePolicy, intersectAllowedShareScopes, listWorkspaceInvitations, @@ -47,6 +48,30 @@ describe("requireTeamContext", () => { }); describe("workspace reads", () => { + it("parses workspace-scoped capabilities and ignores future additions", async () => { + const { context: ctx } = context([ + { + workspace_role: "admin", + workspace_tier: "enterprise", + capabilities: [ + "team.manage_members", + "enterprise.capture", + "future.capability", + ], + seat_limit: 12, + used_seats: 4, + }, + ]); + + await expect(getWorkspaceAccess(ctx, WORKSPACE_ID)).resolves.toEqual({ + role: "admin", + tier: "enterprise", + capabilities: ["team.manage_members", "enterprise.capture"], + seatLimit: 12, + usedSeats: 4, + }); + }); + it("keeps only active members and normalizes their role", async () => { const { context: ctx } = context([ { diff --git a/apps/desktop/src/settings/team/client.ts b/apps/desktop/src/settings/team/client.ts index 66c3690ad18..716747f51a0 100644 --- a/apps/desktop/src/settings/team/client.ts +++ b/apps/desktop/src/settings/team/client.ts @@ -25,6 +25,31 @@ export type WorkspaceSeatUsage = { isBilled: boolean; }; +export const WORKSPACE_CAPABILITIES = [ + "team.shared_notes", + "team.manage_workspace", + "team.manage_members", + "team.manage_policies", + "team.view_usage", + "team.custom_subdomain", + "enterprise.sso", + "enterprise.scim", + "enterprise.retention", + "enterprise.audit_logs", + "enterprise.capture", +] as const; + +export type WorkspaceCapability = (typeof WORKSPACE_CAPABILITIES)[number]; +export type WorkspaceTier = "free" | "team" | "enterprise"; + +export type WorkspaceAccess = { + role: WorkspaceRole; + tier: WorkspaceTier; + capabilities: WorkspaceCapability[]; + seatLimit: number | null; + usedSeats: number; +}; + export class TeamError extends Error { constructor(message = "Workspace request failed") { super(message); @@ -173,6 +198,29 @@ export async function getSeatUsage( }; } +export async function getWorkspaceAccess( + context: TeamContext, + workspaceId: string, +): Promise { + assertWorkspaceId(workspaceId); + const row = rows( + await callRpc(context, "get_workspace_access", { + p_workspace_id: workspaceId, + }), + )[0]; + if (!row) throw new TeamError(); + const capabilities = Array.isArray(row.capabilities) + ? row.capabilities.filter(isWorkspaceCapability) + : []; + return { + role: role(row.workspace_role), + tier: workspaceTier(row.workspace_tier), + capabilities, + seatLimit: typeof row.seat_limit === "number" ? row.seat_limit : null, + usedSeats: typeof row.used_seats === "number" ? row.used_seats : 0, + }; +} + const INVITE_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/; const INVITATION_EMAIL_TIMEOUT_MS = 10_000; @@ -551,6 +599,17 @@ function normalizeEmail(value: string) { return email; } +function isWorkspaceCapability(value: unknown): value is WorkspaceCapability { + return WORKSPACE_CAPABILITIES.some((capability) => capability === value); +} + +function workspaceTier(value: unknown): WorkspaceTier { + if (value !== "free" && value !== "team" && value !== "enterprise") { + throw new TeamError(); + } + return value; +} + function inviteTokenValue(value: unknown) { if (typeof value !== "string" || !INVITE_TOKEN_PATTERN.test(value)) { throw new TeamError(); diff --git a/apps/desktop/src/settings/team/index.test.tsx b/apps/desktop/src/settings/team/index.test.tsx index ce1095e702f..0f9906cc20f 100644 --- a/apps/desktop/src/settings/team/index.test.tsx +++ b/apps/desktop/src/settings/team/index.test.tsx @@ -31,6 +31,20 @@ const mocks = vi.hoisted(() => ({ isPending: false, }, client: { + access: { + role: "owner" as const, + tier: "team" as "free" | "team" | "enterprise", + capabilities: [ + "team.shared_notes", + "team.manage_workspace", + "team.manage_members", + "team.manage_policies", + "team.view_usage", + "team.custom_subdomain", + ] as string[], + seatLimit: 1 as number | null, + usedSeats: 1, + }, members: [] as Array<{ userId: string; email: string; @@ -139,6 +153,7 @@ vi.mock("./client", () => ({ setMemberRole: vi.fn(() => Promise.resolve()), transferOwnership: vi.fn(() => Promise.resolve()), getWorkspaceUsageOverview: () => Promise.resolve(mocks.client.usage), + getWorkspaceAccess: () => Promise.resolve(mocks.client.access), getWorkspacePolicy: mocks.client.getWorkspacePolicy, setWorkspacePolicy: vi.fn(() => Promise.resolve()), setWorkspaceShareSlug: mocks.client.setWorkspaceShareSlug, @@ -181,6 +196,20 @@ describe("SettingsTeam", () => { usedSeats: 1, isBilled: true, }; + mocks.client.access = { + role: "owner", + tier: "team", + capabilities: [ + "team.shared_notes", + "team.manage_workspace", + "team.manage_members", + "team.manage_policies", + "team.view_usage", + "team.custom_subdomain", + ], + seatLimit: 1, + usedSeats: 1, + }; mocks.client.revokeInvitation.mockClear(); mocks.client.renameWorkspace.mockClear(); mocks.client.setWorkspaceLogo.mockClear(); @@ -207,6 +236,9 @@ describe("SettingsTeam", () => { it("keeps an unbilled workspace accessible and offers Team checkout", async () => { mocks.client.usage.isBilled = false; mocks.client.usage.seatLimit = null; + mocks.client.access.tier = "free"; + mocks.client.access.capabilities = []; + mocks.client.access.seatLimit = null; mocks.workspaces.data = [ { workspaceId: "00000000-0000-4000-8000-000000000001", @@ -392,6 +424,50 @@ describe("SettingsTeam", () => { ); }); + it("keeps Enterprise policy controls hidden on Team", async () => { + mocks.workspaces.data = [ + { + workspaceId: "00000000-0000-4000-8000-000000000001", + name: "Fastrepl", + ownerUserId: "user-1", + role: "owner", + }, + ]; + + renderTeam(); + + await screen.findByText("Policies"); + expect(screen.queryByText("Require SSO")).toBeNull(); + expect(screen.queryByText("Retention (days)")).toBeNull(); + expect(screen.queryByText("SCIM bearer token")).toBeNull(); + }); + + it("shows Enterprise policy controls only with Enterprise capabilities", async () => { + mocks.client.access.tier = "enterprise"; + mocks.client.access.capabilities = [ + ...mocks.client.access.capabilities, + "enterprise.sso", + "enterprise.scim", + "enterprise.retention", + "enterprise.audit_logs", + "enterprise.capture", + ]; + mocks.workspaces.data = [ + { + workspaceId: "00000000-0000-4000-8000-000000000001", + name: "Fastrepl", + ownerUserId: "user-1", + role: "owner", + }, + ]; + + renderTeam(); + + expect(await screen.findByText("Require SSO")).toBeTruthy(); + expect(screen.getByText("Retention (days)")).toBeTruthy(); + expect(screen.getByText("SCIM bearer token")).toBeTruthy(); + }); + it("resends a pending invitation by delivering a fresh invite", async () => { mocks.workspaces.data = [ { diff --git a/apps/desktop/src/settings/team/index.tsx b/apps/desktop/src/settings/team/index.tsx index 490c61782e4..2e064396f81 100644 --- a/apps/desktop/src/settings/team/index.tsx +++ b/apps/desktop/src/settings/team/index.tsx @@ -26,6 +26,7 @@ import { claimWorkspaceDomain, createWorkspace, deleteWorkspace, + getWorkspaceAccess, getWorkspacePolicy, getWorkspaceUsageOverview, leaveWorkspace, @@ -41,6 +42,7 @@ import { setWorkspacePolicy, setWorkspaceShareSlug, transferOwnership, + type WorkspaceCapability, type WorkspaceMember, type WorkspacePolicy, type WorkspaceRole, @@ -304,6 +306,29 @@ function WorkspacePanel({ const [isOpeningBilling, setIsOpeningBilling] = useState(false); const isManager = workspaceRole === "owner" || workspaceRole === "admin"; + const access = useQuery({ + queryKey: ["team-access", workspaceId], + queryFn: () => getWorkspaceAccess(requireTeamContext(auth), workspaceId), + retry: false, + }); + const hasCapability = (capability: WorkspaceCapability) => + access.data?.capabilities.includes(capability) === true; + const canManageWorkspace = + isManager && hasCapability("team.manage_workspace"); + const canManageMembers = isManager && hasCapability("team.manage_members"); + const canManagePolicies = isManager && hasCapability("team.manage_policies"); + const canViewUsage = isManager && hasCapability("team.view_usage"); + const canUseCustomSubdomain = + isManager && hasCapability("team.custom_subdomain"); + const canConfigureSso = isManager && hasCapability("enterprise.sso"); + const canConfigureScim = isManager && hasCapability("enterprise.scim"); + const canConfigureRetention = + isManager && hasCapability("enterprise.retention"); + const canUseEnterpriseCapture = + isManager && hasCapability("enterprise.capture"); + const hasPaidWorkspacePlan = + access.data?.tier === "team" || access.data?.tier === "enterprise"; + // The roster, invitation, and seat RPCs are manager-only, so a plain member // gets a permission error rather than data. Retrying cannot fix that. const members = useQuery({ @@ -322,16 +347,19 @@ function WorkspacePanel({ queryFn: () => getWorkspaceUsageOverview(requireTeamContext(auth), workspaceId), retry: false, - enabled: isManager, + enabled: canViewUsage, }); const policy = useQuery({ queryKey: ["team-policy", workspaceId], queryFn: () => getWorkspacePolicy(requireTeamContext(auth), workspaceId), retry: false, - enabled: isManager && usage.data?.isBilled === true, + enabled: canManagePolicies, }); const refresh = () => { + void queryClient.invalidateQueries({ + queryKey: ["team-access", workspaceId], + }); void queryClient.invalidateQueries({ queryKey: ["team-members", workspaceId], }); @@ -421,8 +449,12 @@ function WorkspacePanel({ const viewerId = auth.session?.user.id; const viewerRole = workspaceRole; - const canManage = usage.data?.isBilled === true && isManager; const trimmedEmail = email.trim(); + const hasAdminControls = + canManagePolicies || + canUseCustomSubdomain || + canViewUsage || + (canUseEnterpriseCapture && Boolean(env.VITE_ENTERPRISE_API_URL)); const actionError = invite.error?.message ?? changeRole.error?.message ?? @@ -448,7 +480,7 @@ function WorkspacePanel({ const url = await buildWebAppUrl("/app/team-checkout", { workspace_id: workspaceId, period: "monthly", - quantity: String(Math.max(usage.data?.usedSeats ?? 1, 1)), + quantity: String(Math.max(access.data?.usedSeats ?? 1, 1)), }); await openUrlWithInstruction(url, "billing", (value) => openerCommands.openUrl(value, null), @@ -465,12 +497,12 @@ function WorkspacePanel({ logoDataUrl={workspaceLogoDataUrl} label={t`Change workspace logo`} removeLabel={t`Remove workspace logo`} - canManage={canManage} + canManage={canManageWorkspace} pending={setLogo.isPending} onUpload={(dataUrl) => setLogo.mutate(dataUrl)} onRemove={() => setLogo.mutate(null)} /> - {canManage ? ( + {canManageWorkspace ? ( setNameDraft(event.target.value)} @@ -501,7 +533,7 @@ function WorkspacePanel({

- {usage.data?.isBilled ? ( + {hasPaidWorkspacePlan ? ( Team plan ) : ( Start Team @@ -518,13 +550,13 @@ function WorkspacePanel({ type="button" size="sm" className="w-fit" - disabled={usage.isPending || isOpeningBilling} + disabled={access.isPending || isOpeningBilling} onClick={() => void openTeamBilling()} > {isOpeningBilling ? ( ) : null} - {usage.data?.isBilled ? ( + {hasPaidWorkspacePlan ? ( Manage Team billing ) : ( Continue to Team checkout @@ -538,7 +570,7 @@ function WorkspacePanel({

Members

- {canManage ? ( + {canManageMembers ? (
{ @@ -584,7 +616,8 @@ function WorkspacePanel({ key={member.userId} member={member} isViewer={member.userId === viewerId} - viewerRole={canManage ? viewerRole : undefined} + viewerRole={isManager ? viewerRole : undefined} + canManageMembers={canManageMembers} onRoleChange={(role) => changeRole.mutate({ userId: member.userId, role }) } @@ -603,26 +636,29 @@ function WorkspacePanel({

- {canManage ? ( + {isManager ? (
- + {canManageMembers ? ( + + ) : null} - -
{ - event.preventDefault(); - if (domain.trim() && scimToken.trim().length >= 32) { - rotateScim.mutate(); - } - }} - > - - +
+ ) : null} + {canConfigureScim ? ( +
{ + event.preventDefault(); + if (domain.trim() && scimToken.trim().length >= 32) { + rotateScim.mutate(); + } + }} > - Save SCIM token - -
+ {!canConfigureSso ? ( + + ) : null} + + + + ) : null}
) : null}

@@ -1104,6 +1169,7 @@ function MemberRow({ member, isViewer, viewerRole, + canManageMembers, onRoleChange, onRemove, onTransfer, @@ -1111,6 +1177,7 @@ function MemberRow({ member: WorkspaceMember; isViewer: boolean; viewerRole?: WorkspaceRole; + canManageMembers: boolean; onRoleChange: (role: "admin" | "member") => void; onRemove: () => void; onTransfer: () => void; @@ -1120,6 +1187,7 @@ function MemberRow({ // Mirrors the server: owners change any role, admins may only raise a member // to admin, and nobody may remove a peer admin or the owner. const canEditRole = + canManageMembers && !isOwner && (viewerRole === "owner" || (viewerRole === "admin" && member.role === "member")); @@ -1128,7 +1196,7 @@ function MemberRow({ !isViewer && (viewerRole === "owner" || (viewerRole === "admin" && member.role === "member")); - const canTransfer = viewerRole === "owner" && !isOwner; + const canTransfer = canManageMembers && viewerRole === "owner" && !isOwner; return ( diff --git a/supabase/migrations/20260901090000_workspace_plan_capabilities.sql b/supabase/migrations/20260901090000_workspace_plan_capabilities.sql new file mode 100644 index 00000000000..aa16568dc7e --- /dev/null +++ b/supabase/migrations/20260901090000_workspace_plan_capabilities.sql @@ -0,0 +1,973 @@ +-- Resolve paid workspace features from the workspace's own Stripe customer. +-- Personal entitlements must never unlock another workspace's controls. + +BEGIN; + +SET LOCAL lock_timeout = '30s'; + +CREATE OR REPLACE FUNCTION private.workspace_capabilities( + p_workspace_id uuid +) +RETURNS text[] +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ + WITH paid_features AS ( + SELECT + COALESCE(bool_or(entitlement.lookup_key = 'hyprnote_team'), false) + AS has_team, + COALESCE(bool_or(entitlement.lookup_key = 'hyprnote_enterprise'), false) + AS has_enterprise + FROM public.workspaces AS workspace + JOIN stripe.subscriptions AS subscription + ON subscription.customer = workspace.stripe_customer_id + AND subscription.status IN ('trialing', 'active') + JOIN stripe.active_entitlements AS entitlement + ON entitlement.customer = workspace.stripe_customer_id + WHERE workspace.id = p_workspace_id + AND workspace.kind = 'shared' + AND workspace.deleted_at IS NULL + ) + SELECT CASE + WHEN paid_features.has_enterprise THEN ARRAY[ + 'team.shared_notes', + 'team.manage_workspace', + 'team.manage_members', + 'team.manage_policies', + 'team.view_usage', + 'team.custom_subdomain', + 'enterprise.sso', + 'enterprise.scim', + 'enterprise.retention', + 'enterprise.audit_logs', + 'enterprise.capture' + ]::text[] + WHEN paid_features.has_team THEN ARRAY[ + 'team.shared_notes', + 'team.manage_workspace', + 'team.manage_members', + 'team.manage_policies', + 'team.view_usage', + 'team.custom_subdomain' + ]::text[] + ELSE ARRAY[]::text[] + END + FROM paid_features; +$$; + +CREATE OR REPLACE FUNCTION private.workspace_has_capability( + p_workspace_id uuid, + p_capability text +) +RETURNS boolean +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ + SELECT p_capability = ANY (private.workspace_capabilities(p_workspace_id)); +$$; + +CREATE OR REPLACE FUNCTION private.require_workspace_capability( + p_workspace_id uuid, + p_capability text +) +RETURNS void +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + IF NOT private.workspace_has_capability(p_workspace_id, p_capability) THEN + RAISE EXCEPTION 'workspace capability required: %', p_capability + USING ERRCODE = '42501'; + END IF; +END; +$$; + +CREATE OR REPLACE FUNCTION private.require_workspace_or_pro_capability( + p_workspace_id uuid, + p_workspace_capability text +) +RETURNS void +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_workspace_kind text; +BEGIN + SELECT workspace.kind + INTO v_workspace_kind + FROM public.workspaces AS workspace + WHERE workspace.id = p_workspace_id + AND workspace.deleted_at IS NULL; + + IF v_workspace_kind = 'shared' THEN + PERFORM private.require_workspace_capability( + p_workspace_id, + p_workspace_capability + ); + ELSIF v_workspace_kind IS NOT NULL THEN + PERFORM private.require_hyprnote_pro_entitlement(); + ELSE + RAISE EXCEPTION 'workspace operation not permitted' + USING ERRCODE = '42501'; + END IF; +END; +$$; + +REVOKE ALL ON FUNCTION private.workspace_capabilities(uuid) + FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION private.workspace_has_capability(uuid, text) + FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION private.require_workspace_capability(uuid, text) + FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION private.require_workspace_or_pro_capability(uuid, text) + FROM PUBLIC, anon, authenticated; + +CREATE OR REPLACE FUNCTION public.get_workspace_access( + p_workspace_id uuid +) +RETURNS TABLE ( + workspace_role text, + workspace_tier text, + capabilities text[], + seat_limit integer, + used_seats integer +) +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_capabilities text[]; +BEGIN + PERFORM private.require_workspace_member(p_workspace_id); + v_capabilities := private.workspace_capabilities(p_workspace_id); + + RETURN QUERY + SELECT + membership.role, + CASE + WHEN 'enterprise.sso' = ANY (v_capabilities) THEN 'enterprise' + WHEN 'team.shared_notes' = ANY (v_capabilities) THEN 'team' + ELSE 'free' + END, + v_capabilities, + usage.seat_limit, + usage.used_seats + FROM public.workspace_memberships AS membership + CROSS JOIN LATERAL private.workspace_seat_usage(p_workspace_id) AS usage + WHERE membership.workspace_id = p_workspace_id + AND membership.user_id = auth.uid() + AND membership.deleted_at IS NULL; +END; +$$; + +REVOKE ALL ON FUNCTION public.get_workspace_access(uuid) + FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION public.get_workspace_access(uuid) + TO authenticated; + +CREATE OR REPLACE FUNCTION private.protected_rename_workspace( + p_workspace_id uuid, + p_name text +) +RETURNS TABLE ( + workspace_id uuid, + workspace_name text +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_workspace_id uuid; + v_workspace_name text; +BEGIN + SELECT renamed.workspace_id, renamed.workspace_name + INTO v_workspace_id, v_workspace_name + FROM private.rename_workspace(p_workspace_id, p_name) AS renamed; + + PERFORM private.require_workspace_capability( + p_workspace_id, + 'team.manage_workspace' + ); + + RETURN QUERY SELECT v_workspace_id, v_workspace_name; +END; +$$; + +CREATE OR REPLACE FUNCTION private.protected_set_workspace_membership_role( + p_workspace_id uuid, + p_user_id uuid, + p_role text +) +RETURNS TABLE ( + membership_id uuid, + membership_role text +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_membership_id uuid; + v_membership_role text; +BEGIN + SELECT changed.membership_id, changed.membership_role + INTO v_membership_id, v_membership_role + FROM private.set_workspace_membership_role( + p_workspace_id, + p_user_id, + p_role + ) AS changed; + + PERFORM private.require_workspace_capability( + p_workspace_id, + 'team.manage_members' + ); + + RETURN QUERY SELECT v_membership_id, v_membership_role; +END; +$$; + +CREATE OR REPLACE FUNCTION private.protected_transfer_workspace_ownership( + p_workspace_id uuid, + p_user_id uuid +) +RETURNS TABLE ( + workspace_id uuid, + owner_user_id uuid +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_workspace_id uuid; + v_owner_user_id uuid; +BEGIN + SELECT transferred.workspace_id, transferred.owner_user_id + INTO v_workspace_id, v_owner_user_id + FROM private.transfer_workspace_ownership( + p_workspace_id, + p_user_id + ) AS transferred; + + PERFORM private.require_workspace_capability( + p_workspace_id, + 'team.manage_members' + ); + + RETURN QUERY SELECT v_workspace_id, v_owner_user_id; +END; +$$; + +CREATE OR REPLACE FUNCTION private.protected_create_workspace_invitation( + p_workspace_id uuid, + p_invitee_email text +) +RETURNS TABLE ( + invitation_id uuid, + invite_token text, + invitation_expires_at timestamptz, + was_created boolean +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_invitation_id uuid; + v_invite_token text; + v_invitation_expires_at timestamptz; + v_was_created boolean; +BEGIN + SELECT + invitation.invitation_id, + invitation.invite_token, + invitation.invitation_expires_at, + invitation.was_created + INTO + v_invitation_id, + v_invite_token, + v_invitation_expires_at, + v_was_created + FROM private.create_workspace_invitation( + p_workspace_id, + p_invitee_email + ) AS invitation; + + PERFORM private.require_workspace_capability( + p_workspace_id, + 'team.manage_members' + ); + + RETURN QUERY SELECT + v_invitation_id, + v_invite_token, + v_invitation_expires_at, + v_was_created; +END; +$$; + +CREATE OR REPLACE FUNCTION private.protected_resend_workspace_invitation( + p_invitation_id uuid +) +RETURNS TABLE ( + invitation_id uuid, + invite_token text, + invitation_expires_at timestamptz +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_workspace_id uuid; +BEGIN + SELECT invitation.workspace_id + INTO v_workspace_id + FROM public.workspace_invitations AS invitation + WHERE invitation.id = p_invitation_id; + + PERFORM private.require_workspace_capability( + v_workspace_id, + 'team.manage_members' + ); + + RETURN QUERY + SELECT * + FROM private.resend_workspace_invitation(p_invitation_id); +END; +$$; + +CREATE OR REPLACE FUNCTION private.set_workspace_logo( + p_workspace_id uuid, + p_logo_data text +) +RETURNS TABLE ( + workspace_id uuid, + workspace_logo_data text +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_actor_id uuid := auth.uid(); + v_actor_role text; + v_logo_data text := p_logo_data; +BEGIN + PERFORM private.require_workspace_capability( + p_workspace_id, + 'team.manage_workspace' + ); + + SELECT membership.role + INTO v_actor_role + FROM public.workspaces AS workspace + JOIN public.workspace_memberships AS membership + ON membership.workspace_id = workspace.id + JOIN auth.users AS actor + ON actor.id = membership.user_id + WHERE workspace.id = p_workspace_id + AND workspace.kind = 'shared' + AND workspace.deleted_at IS NULL + AND membership.user_id = v_actor_id + AND membership.role IN ('owner', 'admin') + AND membership.deleted_at IS NULL + AND actor.email_confirmed_at IS NOT NULL + AND COALESCE(actor.is_anonymous, false) = false + FOR UPDATE OF workspace; + + IF v_actor_role IS NULL THEN + RAISE EXCEPTION 'workspace logo operation not permitted' + USING ERRCODE = '42501'; + END IF; + + IF v_logo_data IS NOT NULL THEN + v_logo_data := btrim(v_logo_data); + IF char_length(v_logo_data) < 30 + OR char_length(v_logo_data) > 120000 + OR v_logo_data !~ '^data:image/jpeg;base64,[A-Za-z0-9+/]+={0,2}$' + THEN + RAISE EXCEPTION 'invalid workspace logo' + USING ERRCODE = '22023'; + END IF; + END IF; + + UPDATE public.workspaces + SET + logo_data = v_logo_data, + updated_at = now() + WHERE id = p_workspace_id; + + RETURN QUERY + SELECT p_workspace_id, v_logo_data; +END; +$$; + +CREATE OR REPLACE FUNCTION private.set_workspace_share_slug( + p_workspace_id uuid, + p_slug text +) +RETURNS TABLE ( + workspace_id uuid, + workspace_share_slug text, + share_base_url text +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_actor_id uuid := auth.uid(); + v_actor_role text; + v_slug text := lower(btrim(p_slug)); +BEGIN + PERFORM private.require_workspace_capability( + p_workspace_id, + 'team.custom_subdomain' + ); + + SELECT membership.role + INTO v_actor_role + FROM public.workspaces AS workspace + JOIN public.workspace_memberships AS membership + ON membership.workspace_id = workspace.id + JOIN auth.users AS actor + ON actor.id = membership.user_id + WHERE workspace.id = p_workspace_id + AND workspace.kind = 'shared' + AND workspace.deleted_at IS NULL + AND membership.user_id = v_actor_id + AND membership.role IN ('owner', 'admin') + AND membership.deleted_at IS NULL + AND actor.email_confirmed_at IS NOT NULL + AND COALESCE(actor.is_anonymous, false) = false + FOR UPDATE OF workspace; + + IF v_actor_role IS NULL THEN + RAISE EXCEPTION 'workspace subdomain operation not permitted' + USING ERRCODE = '42501'; + END IF; + + IF v_slug IS NULL + OR v_slug !~ '^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$' + OR v_slug IN ( + 'admin', + 'api', + 'app', + 'assets', + 'auth', + 'cdn', + 'dev', + 'docs', + 'mail', + 'staging', + 'static', + 'status', + 'support', + 'www' + ) + THEN + RAISE EXCEPTION 'invalid workspace subdomain' + USING ERRCODE = '22023'; + END IF; + + BEGIN + UPDATE public.workspaces + SET + share_slug = v_slug, + updated_at = now() + WHERE id = p_workspace_id; + EXCEPTION + WHEN unique_violation THEN + RAISE EXCEPTION 'workspace subdomain is already taken' + USING ERRCODE = '23505'; + END; + + RETURN QUERY + SELECT + p_workspace_id, + v_slug, + format('https://%s.anarlog.so', v_slug); +END; +$$; + +CREATE OR REPLACE FUNCTION public.get_workspace_usage_overview( + p_workspace_id uuid +) +RETURNS TABLE ( + member_count integer, + pending_invitations integer, + enrolled_devices integer, + shares_created_30d integer, + share_access_events_30d integer, + seat_limit integer, + used_seats integer, + is_billed boolean +) +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + PERFORM private.require_workspace_manager(p_workspace_id); + PERFORM private.require_workspace_capability( + p_workspace_id, + 'team.view_usage' + ); + + RETURN QUERY + SELECT + ( + SELECT count(*)::integer + FROM public.workspace_memberships AS membership + WHERE membership.workspace_id = p_workspace_id + AND membership.deleted_at IS NULL + ), + ( + SELECT count(*)::integer + FROM public.workspace_invitations AS invitation + WHERE invitation.workspace_id = p_workspace_id + AND invitation.accepted_at IS NULL + AND invitation.revoked_at IS NULL + AND invitation.expires_at > now() + ), + ( + SELECT count(*)::integer + FROM public.sync_devices AS device + JOIN public.workspace_memberships AS membership + ON membership.user_id = device.user_id + WHERE membership.workspace_id = p_workspace_id + AND membership.deleted_at IS NULL + ), + ( + SELECT count(*)::integer + FROM public.session_shares AS share + WHERE share.workspace_id = p_workspace_id + AND share.created_at >= now() - interval '30 days' + ), + ( + SELECT count(*)::integer + FROM public.session_access_events AS event + JOIN public.session_shares AS share + ON share.id = event.share_id + WHERE share.workspace_id = p_workspace_id + AND event.created_at >= now() - interval '30 days' + ), + usage.seat_limit, + usage.used_seats, + true + FROM private.workspace_seat_usage(p_workspace_id) AS usage; +END; +$$; + +CREATE OR REPLACE FUNCTION public.set_workspace_policy( + p_workspace_id uuid, + p_allowed_share_scopes text[], + p_default_share_scope text, + p_retention_days integer, + p_model_training_opt_out boolean, + p_consent_notification_enabled boolean, + p_require_sso boolean +) +RETURNS TABLE ( + workspace_id uuid, + allowed_share_scopes text[], + default_share_scope text, + retention_days integer, + model_training_opt_out boolean, + consent_notification_enabled boolean, + require_sso boolean +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +#variable_conflict use_column +DECLARE + v_existing public.workspace_policies%ROWTYPE; +BEGIN + PERFORM private.require_workspace_manager(p_workspace_id); + PERFORM private.require_workspace_capability( + p_workspace_id, + 'team.manage_policies' + ); + + SELECT policy.* + INTO v_existing + FROM public.workspace_policies AS policy + WHERE policy.workspace_id = p_workspace_id; + + IF COALESCE(p_require_sso, false) + AND NOT COALESCE(v_existing.require_sso, false) + THEN + PERFORM private.require_workspace_capability( + p_workspace_id, + 'enterprise.sso' + ); + END IF; + + IF p_retention_days IS NOT NULL + AND p_retention_days IS DISTINCT FROM v_existing.retention_days + THEN + PERFORM private.require_workspace_capability( + p_workspace_id, + 'enterprise.retention' + ); + END IF; + + IF COALESCE(p_require_sso, false) + AND NOT EXISTS ( + SELECT 1 + FROM public.workspace_verified_domains AS claimed + WHERE claimed.workspace_id = p_workspace_id + ) + THEN + RAISE EXCEPTION 'claim an email domain before requiring SSO' + USING ERRCODE = '22023'; + END IF; + + INSERT INTO public.workspace_policies ( + workspace_id, + allowed_share_scopes, + default_share_scope, + retention_days, + model_training_opt_out, + consent_notification_enabled, + require_sso, + updated_at + ) VALUES ( + p_workspace_id, + p_allowed_share_scopes, + p_default_share_scope, + p_retention_days, + COALESCE(p_model_training_opt_out, true), + COALESCE(p_consent_notification_enabled, true), + COALESCE(p_require_sso, false), + now() + ) + ON CONFLICT (workspace_id) DO UPDATE SET + allowed_share_scopes = EXCLUDED.allowed_share_scopes, + default_share_scope = EXCLUDED.default_share_scope, + retention_days = EXCLUDED.retention_days, + model_training_opt_out = EXCLUDED.model_training_opt_out, + consent_notification_enabled = EXCLUDED.consent_notification_enabled, + require_sso = EXCLUDED.require_sso, + updated_at = now(); + + RETURN QUERY + SELECT + policy.workspace_id, + policy.allowed_share_scopes, + policy.default_share_scope, + policy.retention_days, + policy.model_training_opt_out, + policy.consent_notification_enabled, + policy.require_sso + FROM public.workspace_policies AS policy + WHERE policy.workspace_id = p_workspace_id; +END; +$$; + +CREATE OR REPLACE FUNCTION public.claim_workspace_domain( + p_workspace_id uuid, + p_domain text +) +RETURNS TABLE ( + workspace_id uuid, + domain text +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +#variable_conflict use_column +DECLARE + v_domain text := lower(btrim(p_domain)); + v_actor_id uuid; +BEGIN + v_actor_id := private.require_workspace_manager(p_workspace_id); + PERFORM private.require_workspace_capability( + p_workspace_id, + 'enterprise.sso' + ); + + INSERT INTO public.workspace_verified_domains ( + workspace_id, + domain, + created_by_user_id + ) VALUES ( + p_workspace_id, + v_domain, + v_actor_id + ) + ON CONFLICT (workspace_id, domain) DO NOTHING; + + RETURN QUERY + SELECT claimed.workspace_id, claimed.domain + FROM public.workspace_verified_domains AS claimed + WHERE claimed.workspace_id = p_workspace_id + AND claimed.domain = v_domain; +END; +$$; + +CREATE OR REPLACE FUNCTION public.rotate_workspace_scim_token( + p_workspace_id uuid, + p_domain text, + p_token text +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + PERFORM private.require_workspace_manager(p_workspace_id); + PERFORM private.require_workspace_capability( + p_workspace_id, + 'enterprise.scim' + ); + + IF p_token IS NULL OR octet_length(p_token) < 32 THEN + RAISE EXCEPTION 'invalid scim token' + USING ERRCODE = '22023'; + END IF; + + INSERT INTO public.workspace_identity_providers ( + workspace_id, + protocol, + domain, + scim_token_hash, + updated_at + ) VALUES ( + p_workspace_id, + 'saml', + lower(btrim(p_domain)), + extensions.digest(p_token, 'sha256'), + now() + ) + ON CONFLICT (workspace_id) DO UPDATE SET + domain = EXCLUDED.domain, + scim_token_hash = EXCLUDED.scim_token_hash, + updated_at = now(); +END; +$$; + +CREATE OR REPLACE FUNCTION public.scim_apply_user( + p_token text, + p_email text, + p_active boolean +) +RETURNS TABLE ( + user_id uuid, + workspace_id uuid, + active boolean +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +#variable_conflict use_column +DECLARE + v_provider public.workspace_identity_providers%ROWTYPE; + v_user_id uuid; +BEGIN + IF p_token IS NULL OR octet_length(p_token) < 32 THEN + RAISE EXCEPTION 'invalid scim token' + USING ERRCODE = '42501'; + END IF; + + SELECT provider.* + INTO v_provider + FROM public.workspace_identity_providers AS provider + WHERE provider.scim_token_hash = extensions.digest(p_token, 'sha256'); + + IF NOT FOUND OR NOT private.workspace_has_capability( + v_provider.workspace_id, + 'enterprise.scim' + ) THEN + RAISE EXCEPTION 'invalid scim token' + USING ERRCODE = '42501'; + END IF; + + SELECT users.id + INTO v_user_id + FROM auth.users AS users + WHERE lower(users.email) = lower(btrim(p_email)); + + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'scim user not found' + USING ERRCODE = 'P0002'; + END IF; + + IF COALESCE(p_active, false) THEN + INSERT INTO public.workspace_memberships ( + workspace_id, + user_id, + role + ) VALUES ( + v_provider.workspace_id, + v_user_id, + 'member' + ) + ON CONFLICT DO NOTHING; + + UPDATE public.workspace_memberships AS membership + SET + deleted_at = NULL, + updated_at = now() + WHERE membership.workspace_id = v_provider.workspace_id + AND membership.user_id = v_user_id + AND membership.deleted_at IS NOT NULL; + ELSE + UPDATE public.workspace_memberships AS membership + SET + deleted_at = now(), + updated_at = now() + WHERE membership.workspace_id = v_provider.workspace_id + AND membership.user_id = v_user_id + AND membership.deleted_at IS NULL; + + DELETE FROM public.sync_devices AS device + WHERE device.user_id = v_user_id; + END IF; + + RETURN QUERY + SELECT + v_user_id, + v_provider.workspace_id, + COALESCE(p_active, false); +END; +$$; + +CREATE OR REPLACE FUNCTION private.email_domain_requires_sso(p_email text) +RETURNS boolean +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ + SELECT EXISTS ( + SELECT 1 + FROM public.workspace_verified_domains AS claimed + JOIN public.workspaces AS workspace + ON workspace.id = claimed.workspace_id + JOIN public.workspace_policies AS policy + ON policy.workspace_id = claimed.workspace_id + WHERE claimed.domain = lower(split_part(btrim(COALESCE(p_email, '')), '@', 2)) + AND position('@' in COALESCE(p_email, '')) > 0 + AND workspace.deleted_at IS NULL + AND workspace.kind = 'shared' + AND policy.require_sso + AND private.workspace_has_capability( + claimed.workspace_id, + 'enterprise.sso' + ) + ); +$$; + +CREATE OR REPLACE FUNCTION private.capture_user_into_verified_domain_workspace() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_email text := lower(COALESCE(NEW.email, '')); + v_domain text; + v_workspace_id uuid; +BEGIN + IF v_email IS NULL OR position('@' in v_email) = 0 THEN + RETURN NEW; + END IF; + + v_domain := split_part(v_email, '@', 2); + + SELECT claimed.workspace_id + INTO v_workspace_id + FROM public.workspace_verified_domains AS claimed + JOIN public.workspaces AS workspace + ON workspace.id = claimed.workspace_id + WHERE claimed.domain = v_domain + AND workspace.deleted_at IS NULL + AND workspace.kind = 'shared' + AND private.workspace_has_capability( + claimed.workspace_id, + 'enterprise.sso' + ) + LIMIT 1; + + IF v_workspace_id IS NULL THEN + RETURN NEW; + END IF; + + INSERT INTO public.workspace_memberships ( + workspace_id, + user_id, + role + ) VALUES ( + v_workspace_id, + NEW.id, + 'member' + ) + ON CONFLICT DO NOTHING; + + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION private.enforce_workspace_retention() +RETURNS integer +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_deleted integer := 0; +BEGIN + UPDATE public.session_shares AS share + SET + deleted_at = now(), + updated_at = now() + FROM public.workspace_policies AS policy + WHERE policy.workspace_id = share.workspace_id + AND policy.retention_days IS NOT NULL + AND private.workspace_has_capability( + policy.workspace_id, + 'enterprise.retention' + ) + AND share.deleted_at IS NULL + AND share.created_at < now() - make_interval(days => policy.retention_days); + + GET DIAGNOSTICS v_deleted = ROW_COUNT; + + DELETE FROM public.session_share_snapshots AS snapshot + USING public.session_shares AS share + JOIN public.workspace_policies AS policy + ON policy.workspace_id = share.workspace_id + WHERE snapshot.share_id = share.id + AND policy.retention_days IS NOT NULL + AND private.workspace_has_capability( + policy.workspace_id, + 'enterprise.retention' + ) + AND share.deleted_at IS NOT NULL + AND share.created_at < now() - make_interval(days => policy.retention_days); + + RETURN v_deleted; +END; +$$; + +COMMIT; diff --git a/supabase/migrations/20260901091000_workspace_sharing_capabilities.sql b/supabase/migrations/20260901091000_workspace_sharing_capabilities.sql new file mode 100644 index 00000000000..f099bfd2063 --- /dev/null +++ b/supabase/migrations/20260901091000_workspace_sharing_capabilities.sql @@ -0,0 +1,521 @@ +-- Personal Pro pays for personal sharing. Shared workspace expansion is paid +-- by that workspace and is therefore checked against its Team capability. + +BEGIN; + +SET LOCAL lock_timeout = '30s'; + +CREATE OR REPLACE FUNCTION private.protected_create_session_share( + p_workspace_id uuid, + p_session_id text +) +RETURNS TABLE ( + share_id uuid, + general_scope text, + public_slug text, + access_version bigint, + was_created boolean +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_result record; +BEGIN + SELECT * + INTO v_result + FROM private.create_session_share(p_workspace_id, p_session_id); + + IF v_result.was_created THEN + PERFORM private.require_workspace_or_pro_capability( + p_workspace_id, + 'team.shared_notes' + ); + END IF; + + RETURN QUERY + SELECT + v_result.share_id, + v_result.general_scope, + v_result.public_slug, + v_result.access_version, + v_result.was_created; +END; +$$; + +CREATE OR REPLACE FUNCTION private.protected_reactivate_session_share( + p_workspace_id uuid, + p_session_id text +) +RETURNS TABLE ( + share_id uuid, + general_scope text, + public_slug text, + access_version bigint, + was_reactivated boolean +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_actor_id uuid := private.require_permanent_user(); + v_session_id text := btrim(p_session_id); + v_share_id uuid; + v_share public.session_shares%ROWTYPE; +BEGIN + IF v_session_id IS NULL + OR v_session_id = '' + OR v_session_id ~ '[[:cntrl:]]' + OR octet_length(v_session_id) > 128 + THEN + RAISE EXCEPTION 'invalid session id' + USING ERRCODE = '22023'; + END IF; + + PERFORM 1 + FROM public.workspaces AS workspace + JOIN public.workspace_memberships AS membership + ON membership.workspace_id = workspace.id + WHERE workspace.id = p_workspace_id + AND workspace.deleted_at IS NULL + AND membership.user_id = v_actor_id + AND membership.role IN ('owner', 'admin') + AND membership.deleted_at IS NULL; + + IF NOT FOUND THEN + RAISE EXCEPTION 'session access operation not permitted' + USING ERRCODE = '42501'; + END IF; + + SELECT share.id + INTO v_share_id + FROM public.session_shares AS share + WHERE share.workspace_id = p_workspace_id + AND share.session_id = v_session_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'session share is unavailable' + USING ERRCODE = '22023'; + END IF; + + PERFORM pg_catalog.pg_advisory_xact_lock( + pg_catalog.hashtextextended(v_share_id::text, 0) + ); + + PERFORM 1 + FROM public.workspace_memberships AS membership + WHERE membership.workspace_id = p_workspace_id + AND membership.user_id = v_actor_id + AND membership.role IN ('owner', 'admin') + AND membership.deleted_at IS NULL + FOR UPDATE; + + IF NOT FOUND THEN + RAISE EXCEPTION 'session access operation not permitted' + USING ERRCODE = '42501'; + END IF; + + SELECT share.* + INTO v_share + FROM public.session_shares AS share + JOIN public.workspaces AS workspace + ON workspace.id = share.workspace_id + WHERE share.id = v_share_id + AND share.workspace_id = p_workspace_id + AND share.session_id = v_session_id + AND workspace.deleted_at IS NULL + FOR UPDATE OF share; + + IF NOT FOUND THEN + RAISE EXCEPTION 'session share is unavailable' + USING ERRCODE = '22023'; + END IF; + + PERFORM private.require_workspace_or_pro_capability( + p_workspace_id, + 'team.shared_notes' + ); + + IF v_share.deleted_at IS NULL THEN + RETURN QUERY + SELECT + v_share.id, + v_share.general_scope, + v_share.public_slug, + v_share.access_version, + false; + RETURN; + END IF; + + UPDATE public.session_share_links AS target_link + SET + revoked_by_user_id = v_actor_id, + revoked_at = now() + WHERE target_link.share_id = v_share.id + AND target_link.revoked_at IS NULL; + + UPDATE public.session_access_grants AS target_grant + SET + revoked_by_user_id = v_actor_id, + revoked_at = now(), + updated_at = now() + WHERE target_grant.share_id = v_share.id + AND target_grant.revoked_at IS NULL; + + UPDATE public.session_access_invitations AS target_invitation + SET + revoked_by_user_id = v_actor_id, + revoked_at = now(), + updated_at = now() + WHERE target_invitation.share_id = v_share.id + AND target_invitation.accepted_at IS NULL + AND target_invitation.revoked_at IS NULL; + + UPDATE public.session_access_requests AS target_request + SET + status = 'cancelled', + updated_at = now() + WHERE target_request.share_id = v_share.id + AND target_request.status = 'pending'; + + DELETE FROM private.session_share_handoffs AS handoff + WHERE handoff.share_id = v_share.id; + + UPDATE public.session_shares AS target_share + SET + general_scope = 'restricted', + general_workspace_id = NULL, + access_version = target_share.access_version + 1, + updated_at = now(), + deleted_at = NULL + WHERE target_share.id = v_share.id + RETURNING * INTO v_share; + + PERFORM private.write_session_access_event( + v_share.id, + 'share_reactivated', + v_actor_id + ); + + RETURN QUERY + SELECT + v_share.id, + v_share.general_scope, + v_share.public_slug, + v_share.access_version, + true; +END; +$$; + +CREATE OR REPLACE FUNCTION private.protected_set_session_share_scope( + p_share_id uuid, + p_general_scope text, + p_general_workspace_id uuid DEFAULT NULL +) +RETURNS TABLE ( + share_id uuid, + general_scope text, + general_workspace_id uuid, + public_slug text, + access_version bigint +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_result record; + v_share public.session_shares%ROWTYPE; +BEGIN + SELECT share.* + INTO v_share + FROM public.session_shares AS share + WHERE share.id = p_share_id; + + IF FOUND THEN + PERFORM private.assert_allowed_share_scope( + v_share.workspace_id, + p_general_scope + ); + END IF; + + SELECT * + INTO v_result + FROM private.set_session_share_scope( + p_share_id, + p_general_scope, + p_general_workspace_id + ); + + IF p_general_scope <> 'restricted' THEN + PERFORM private.require_workspace_or_pro_capability( + v_share.workspace_id, + 'team.shared_notes' + ); + END IF; + + RETURN QUERY + SELECT + v_result.share_id, + v_result.general_scope, + v_result.general_workspace_id, + v_result.public_slug, + v_result.access_version; +END; +$$; + +CREATE OR REPLACE FUNCTION private.protected_issue_session_share_link( + p_share_id uuid, + p_force_rotate boolean +) +RETURNS TABLE ( + share_id uuid, + link_id uuid, + link_token text, + access_version bigint, + was_created boolean +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_result record; + v_share public.session_shares%ROWTYPE; +BEGIN + SELECT share.* + INTO v_share + FROM public.session_shares AS share + WHERE share.id = p_share_id; + + IF FOUND THEN + PERFORM private.assert_allowed_share_scope(v_share.workspace_id, 'link'); + END IF; + + SELECT * + INTO v_result + FROM private.issue_session_share_link(p_share_id, p_force_rotate); + + IF p_force_rotate OR v_result.was_created THEN + PERFORM private.require_workspace_or_pro_capability( + v_share.workspace_id, + 'team.shared_notes' + ); + END IF; + + RETURN QUERY + SELECT + v_result.share_id, + v_result.link_id, + v_result.link_token, + v_result.access_version, + v_result.was_created; +END; +$$; + +CREATE OR REPLACE FUNCTION private.protected_create_session_access_invitation( + p_share_id uuid, + p_invitee_email text, + p_capability text +) +RETURNS TABLE ( + invitation_id uuid, + invite_token text, + invitation_expires_at timestamptz, + was_created boolean +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_result record; + v_workspace_id uuid; +BEGIN + SELECT share.workspace_id + INTO v_workspace_id + FROM public.session_shares AS share + WHERE share.id = p_share_id; + + SELECT * + INTO v_result + FROM private.create_session_access_invitation( + p_share_id, + p_invitee_email, + p_capability + ); + + IF v_result.was_created THEN + PERFORM private.require_workspace_or_pro_capability( + v_workspace_id, + 'team.shared_notes' + ); + END IF; + + RETURN QUERY + SELECT + v_result.invitation_id, + v_result.invite_token, + v_result.invitation_expires_at, + v_result.was_created; +END; +$$; + +CREATE OR REPLACE FUNCTION private.protected_resend_session_access_invitation( + p_invitation_id uuid +) +RETURNS TABLE ( + invitation_id uuid, + invite_token text, + invitation_expires_at timestamptz +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_result record; + v_workspace_id uuid; +BEGIN + SELECT share.workspace_id + INTO v_workspace_id + FROM public.session_access_invitations AS invitation + JOIN public.session_shares AS share + ON share.id = invitation.share_id + WHERE invitation.id = p_invitation_id; + + SELECT * + INTO v_result + FROM private.resend_session_access_invitation(p_invitation_id); + + PERFORM private.require_workspace_or_pro_capability( + v_workspace_id, + 'team.shared_notes' + ); + + RETURN QUERY + SELECT + v_result.invitation_id, + v_result.invite_token, + v_result.invitation_expires_at; +END; +$$; + +CREATE OR REPLACE FUNCTION private.protected_update_session_access_grant( + p_grant_id uuid, + p_capability text +) +RETURNS TABLE ( + grant_id uuid, + capability text, + access_version bigint +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_grant public.session_access_grants%ROWTYPE; + v_workspace_id uuid; +BEGIN + IF private.session_capability_rank(p_capability) = 0 THEN + RAISE EXCEPTION 'invalid session access capability' + USING ERRCODE = '22023'; + END IF; + + SELECT access_grant.* + INTO v_grant + FROM public.session_access_grants AS access_grant + WHERE access_grant.id = p_grant_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'session access operation not permitted' + USING ERRCODE = '42501'; + END IF; + + PERFORM private.require_session_share_manager(v_grant.share_id); + + SELECT access_grant.* + INTO v_grant + FROM public.session_access_grants AS access_grant + WHERE access_grant.id = p_grant_id + FOR UPDATE; + + IF v_grant.revoked_at IS NOT NULL THEN + RAISE EXCEPTION 'session access grant is unavailable' + USING ERRCODE = '22023'; + END IF; + + IF private.session_capability_rank(p_capability) + > private.session_capability_rank(v_grant.capability) + THEN + SELECT share.workspace_id + INTO v_workspace_id + FROM public.session_shares AS share + WHERE share.id = v_grant.share_id; + + PERFORM private.require_workspace_or_pro_capability( + v_workspace_id, + 'team.shared_notes' + ); + END IF; + + RETURN QUERY + SELECT * + FROM private.update_session_access_grant(p_grant_id, p_capability); +END; +$$; + +CREATE OR REPLACE FUNCTION private.protected_review_session_access_request( + p_request_id uuid, + p_decision text, + p_capability text DEFAULT NULL +) +RETURNS TABLE ( + request_id uuid, + status text, + grant_id uuid, + capability text +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_result record; + v_workspace_id uuid; +BEGIN + SELECT share.workspace_id + INTO v_workspace_id + FROM public.session_access_requests AS access_request + JOIN public.session_shares AS share + ON share.id = access_request.share_id + WHERE access_request.id = p_request_id; + + SELECT * + INTO v_result + FROM private.review_session_access_request( + p_request_id, + p_decision, + p_capability + ); + + IF p_decision = 'approved' THEN + PERFORM private.require_workspace_or_pro_capability( + v_workspace_id, + 'team.shared_notes' + ); + END IF; + + RETURN QUERY + SELECT + v_result.request_id, + v_result.status, + v_result.grant_id, + v_result.capability; +END; +$$; + +COMMIT; diff --git a/supabase/tests/010-session-share-snapshots.sql b/supabase/tests/010-session-share-snapshots.sql index fa3b59ea1f6..fa8389c0cb9 100644 --- a/supabase/tests/010-session-share-snapshots.sql +++ b/supabase/tests/010-session-share-snapshots.sql @@ -113,6 +113,10 @@ select from session_snapshot_test_state where name = 'target_workspace'; +select tests.enable_workspace_plan(workspace_id) +from session_snapshot_test_state +where name in ('source_workspace', 'target_workspace'); + select tests.clear_authentication(); reset role; diff --git a/supabase/tests/012-shared-note-gateway-handoffs.sql b/supabase/tests/012-shared-note-gateway-handoffs.sql index eb42588e8e4..f542f7cc99b 100644 --- a/supabase/tests/012-shared-note-gateway-handoffs.sql +++ b/supabase/tests/012-shared-note-gateway-handoffs.sql @@ -41,6 +41,10 @@ select from shared_note_gateway_test_state where name = 'workspace'; +select tests.enable_workspace_plan(workspace_id) +from shared_note_gateway_test_state +where name = 'workspace'; + select tests.clear_authentication(); reset role; diff --git a/supabase/tests/014-session-share-deletion.sql b/supabase/tests/014-session-share-deletion.sql index 8d8a5fe0393..3532328e3e4 100644 --- a/supabase/tests/014-session-share-deletion.sql +++ b/supabase/tests/014-session-share-deletion.sql @@ -76,6 +76,10 @@ select from session_share_deletion_test_state where name = 'workspace'; +select tests.enable_workspace_plan(workspace_id) +from session_share_deletion_test_state +where name = 'workspace'; + select tests.clear_authentication(); reset role; @@ -217,8 +221,11 @@ select ok( ) and lower(pg_get_functiondef( 'private.protected_reactivate_session_share(uuid,text)'::regprocedure - )) like '%require_hyprnote_pro_entitlement%', - 'Deletion is ungated while explicit reactivation is a hardened Pro-only RPC' + )) like '%require_workspace_or_pro_capability%' + and lower(pg_get_functiondef( + 'private.protected_reactivate_session_share(uuid,text)'::regprocedure + )) like '%team.shared_notes%', + 'Deletion is ungated while reactivation requires the applicable sharing capability' ); select ok( diff --git a/supabase/tests/017-session-share-attachments.sql b/supabase/tests/017-session-share-attachments.sql index 5e910c95e01..44c1a3925f4 100644 --- a/supabase/tests/017-session-share-attachments.sql +++ b/supabase/tests/017-session-share-attachments.sql @@ -48,6 +48,10 @@ select from session_share_attachment_test_state where name = 'source_workspace'; +select tests.enable_workspace_plan(workspace_id) +from session_share_attachment_test_state +where name = 'source_workspace'; + select tests.clear_authentication(); reset role; diff --git a/supabase/tests/023-session-share-collaboration.sql b/supabase/tests/023-session-share-collaboration.sql index 787679cea8c..23b844bb961 100644 --- a/supabase/tests/023-session-share-collaboration.sql +++ b/supabase/tests/023-session-share-collaboration.sql @@ -119,6 +119,10 @@ select from session_share_collaboration_test_state where name = 'share'; +select tests.enable_workspace_plan(workspace_id) +from session_share_collaboration_test_state +where name = 'share'; + insert into public.session_shares ( id, workspace_id, diff --git a/supabase/tests/029-session-share-link-previews.sql b/supabase/tests/029-session-share-link-previews.sql index 3bfe69699e7..0b2baf94247 100644 --- a/supabase/tests/029-session-share-link-previews.sql +++ b/supabase/tests/029-session-share-link-previews.sql @@ -41,6 +41,10 @@ select from link_preview_test_state where name = 'workspace'; +select tests.enable_workspace_plan(workspace_id) +from link_preview_test_state +where name = 'workspace'; + select tests.clear_authentication(); select tests.authenticate_as_hyprnote_pro('link_preview_owner'); diff --git a/supabase/tests/030-session-share-preview-metadata.sql b/supabase/tests/030-session-share-preview-metadata.sql index d17f853439e..18974384612 100644 --- a/supabase/tests/030-session-share-preview-metadata.sql +++ b/supabase/tests/030-session-share-preview-metadata.sql @@ -41,6 +41,10 @@ select from preview_metadata_test_state where name = 'workspace'; +select tests.enable_workspace_plan(workspace_id) +from preview_metadata_test_state +where name = 'workspace'; + select tests.clear_authentication(); select tests.authenticate_as_hyprnote_pro('preview_metadata_owner'); diff --git a/supabase/tests/033-workspace-billing-and-seats.sql b/supabase/tests/033-workspace-billing-and-seats.sql index e674158e618..ad5d9a1ad6a 100644 --- a/supabase/tests/033-workspace-billing-and-seats.sql +++ b/supabase/tests/033-workspace-billing-and-seats.sql @@ -76,7 +76,9 @@ values ('sub_seat_team', 'cus_seat_team', 'active'::stripe.subscription_status) on conflict (id) do nothing; insert into stripe.active_entitlements (id, customer, lookup_key) -values ('ent_seat_team', 'cus_seat_team', 'hyprnote_pro') +values + ('ent_seat_pro', 'cus_seat_team', 'hyprnote_pro'), + ('ent_seat_team', 'cus_seat_team', 'hyprnote_team') on conflict (id) do nothing; select tests.clear_authentication(); @@ -176,8 +178,8 @@ select results_eq( ) ) -> 'claims' -> 'entitlements' $$, - array['["hyprnote_pro"]'::jsonb], - 'A member inherits the workspace entitlement without buying their own plan' + array['["hyprnote_pro", "hyprnote_team"]'::jsonb], + 'A member inherits the workspace capabilities without buying their own plan' ); select results_eq( diff --git a/supabase/tests/036-session-share-short-links.sql b/supabase/tests/036-session-share-short-links.sql index e4e3715c360..0c98e3969ea 100644 --- a/supabase/tests/036-session-share-short-links.sql +++ b/supabase/tests/036-session-share-short-links.sql @@ -41,6 +41,10 @@ select from short_link_test_state where name = 'workspace'; +select tests.enable_workspace_plan(workspace_id) +from short_link_test_state +where name = 'workspace'; + select tests.clear_authentication(); select tests.authenticate_as_hyprnote_pro('short_link_owner'); diff --git a/supabase/tests/037-team-pro-entitlement.sql b/supabase/tests/037-team-pro-entitlement.sql index d49116449c1..f54c6826234 100644 --- a/supabase/tests/037-team-pro-entitlement.sql +++ b/supabase/tests/037-team-pro-entitlement.sql @@ -1,5 +1,5 @@ begin; -select plan(9); +select plan(10); select tests.create_supabase_user('team_pro_owner', 'team-pro-owner@example.com'); select tests.create_supabase_user('team_free_member', 'team-free-member@example.com'); @@ -67,6 +67,17 @@ select lives_ok( 'A free account can create a Team checkout shell' ); +select results_eq( + $$ + select workspace_tier + from public.get_workspace_access( + (select workspace_id from team_pro_test_state where name = 'hq') + ) + $$, + array['free'::text], + 'A new checkout shell has no paid workspace capabilities' +); + select throws_ok( $$ select * @@ -76,7 +87,7 @@ select throws_ok( ) $$, '42501', - 'active Team subscription required', + 'workspace capability required: team.manage_workspace', 'An unpaid workspace cannot use Team management' ); @@ -92,7 +103,7 @@ select throws_ok( ) $$, '42501', - 'active Team subscription required', + 'workspace capability required: team.manage_members', 'Personal Pro cannot invite members into an unpaid workspace' ); diff --git a/supabase/tests/041-workspace-policies-identity-analytics.sql b/supabase/tests/041-workspace-policies-identity-analytics.sql index 9b729dae778..f62d6a45e3c 100644 --- a/supabase/tests/041-workspace-policies-identity-analytics.sql +++ b/supabase/tests/041-workspace-policies-identity-analytics.sql @@ -33,6 +33,11 @@ select lives_ok( 'The owner creates a shared workspace' ); +select tests.enable_workspace_plan( + (select workspace_id from workspace_policy_test_state where name = 'hq'), + 'enterprise' +); + select results_eq( $$ select default_share_scope, retention_days, model_training_opt_out diff --git a/supabase/tests/042-workspace-share-subdomains.sql b/supabase/tests/042-workspace-share-subdomains.sql index cadd7f477e2..67f1f56be16 100644 --- a/supabase/tests/042-workspace-share-subdomains.sql +++ b/supabase/tests/042-workspace-share-subdomains.sql @@ -29,6 +29,10 @@ insert into workspace_subdomain_test_state (name, workspace_id) select 'owner', workspace_id from public.create_workspace('Fastrepl'); +select tests.enable_workspace_plan( + (select workspace_id from workspace_subdomain_test_state where name = 'owner') +); + select lives_ok( $$ select * from public.set_workspace_share_slug( @@ -131,6 +135,10 @@ insert into workspace_subdomain_test_state (name, workspace_id) select 'other', workspace_id from public.create_workspace('Other Company'); +select tests.enable_workspace_plan( + (select workspace_id from workspace_subdomain_test_state where name = 'other') +); + select throws_ok( $$ select * from public.set_workspace_share_slug( diff --git a/supabase/tests/043-stable-session-share-links.sql b/supabase/tests/043-stable-session-share-links.sql index 363a28c456f..7c8ae56aa88 100644 --- a/supabase/tests/043-stable-session-share-links.sql +++ b/supabase/tests/043-stable-session-share-links.sql @@ -40,6 +40,10 @@ select from stable_share_test_state where name = 'workspace'; +select tests.enable_workspace_plan(workspace_id) +from stable_share_test_state +where name = 'workspace'; + select tests.clear_authentication(); select tests.authenticate_as_hyprnote_pro('stable_share_owner'); diff --git a/supabase/tests/044-required-sso.sql b/supabase/tests/044-required-sso.sql index db309ad7c80..1f8b7a775ed 100644 --- a/supabase/tests/044-required-sso.sql +++ b/supabase/tests/044-required-sso.sql @@ -30,6 +30,11 @@ select lives_ok( 'The owner creates a shared workspace' ); +select tests.enable_workspace_plan( + (select workspace_id from required_sso_test_state where name = 'hq'), + 'enterprise' +); + select throws_ok( $$ select * from public.set_workspace_policy( diff --git a/supabase/tests/045-workspace-logo.sql b/supabase/tests/045-workspace-logo.sql index a883a418bd8..224eee458bd 100644 --- a/supabase/tests/045-workspace-logo.sql +++ b/supabase/tests/045-workspace-logo.sql @@ -42,6 +42,10 @@ insert into workspace_logo_test_state (name, workspace_id) select 'owner', workspace_id from public.create_workspace('Fastrepl'); +select tests.enable_workspace_plan( + (select workspace_id from workspace_logo_test_state where name = 'owner') +); + select lives_ok( $$ select * from public.set_workspace_logo( @@ -132,6 +136,10 @@ insert into workspace_logo_test_state (name, workspace_id) select 'other', workspace_id from public.create_workspace('Other Company'); +select tests.enable_workspace_plan( + (select workspace_id from workspace_logo_test_state where name = 'other') +); + select throws_ok( $$ select * from public.set_workspace_logo( @@ -147,16 +155,14 @@ select throws_ok( select tests.clear_authentication(); select tests.authenticate_as('logo_owner'); -select throws_ok( +select lives_ok( $$ select * from public.set_workspace_logo( (select workspace_id from workspace_logo_test_state where name = 'owner'), NULL ) $$, - '42501', - 'hyprnote pro entitlement required', - 'A free account cannot change the workspace logo' + 'A free owner can manage a workspace paid by Team' ); select tests.clear_authentication(); diff --git a/supabase/tests/046-team-plan-split.sql b/supabase/tests/046-team-plan-split.sql index 2bcca512509..e1ed2f95dd7 100644 --- a/supabase/tests/046-team-plan-split.sql +++ b/supabase/tests/046-team-plan-split.sql @@ -33,7 +33,7 @@ select throws_ok( ) $$, '42501', - 'active Team subscription required', + 'workspace capability required: team.manage_workspace', 'An unbilled workspace cannot use Team management controls' ); @@ -48,7 +48,7 @@ select throws_ok( ) $$, '42501', - 'active Team subscription required', + 'workspace capability required: team.manage_workspace', 'Personal Pro does not unlock Team management controls' ); @@ -67,6 +67,10 @@ insert into stripe.subscriptions (id, customer, status) values ('sub_team_split', 'cus_team_split', 'active'::stripe.subscription_status) on conflict (id) do nothing; +insert into stripe.active_entitlements (id, customer, lookup_key) +values ('ent_team_split', 'cus_team_split', 'hyprnote_team') +on conflict (customer, lookup_key) do nothing; + select tests.authenticate_as('team_split_owner'); select results_eq( diff --git a/supabase/tests/047-workspace-plan-capabilities.sql b/supabase/tests/047-workspace-plan-capabilities.sql new file mode 100644 index 00000000000..ccfdd88e3df --- /dev/null +++ b/supabase/tests/047-workspace-plan-capabilities.sql @@ -0,0 +1,264 @@ +begin; +select plan(13); + +select tests.create_supabase_user( + 'capability_owner', + 'capability-owner@example.com' +); + +create temporary table workspace_capability_test_state ( + workspace_id uuid primary key +); + +grant all on workspace_capability_test_state to authenticated, service_role; + +reset role; + +update auth.users +set email_confirmed_at = now() +where id = tests.get_supabase_uid('capability_owner'); + +select tests.authenticate_as_hyprnote_pro('capability_owner'); + +insert into workspace_capability_test_state (workspace_id) +select workspace_id from public.create_workspace('Capability workspace'); + +select results_eq( + $$ + select workspace_tier + from public.get_workspace_access( + (select workspace_id from workspace_capability_test_state) + ) + $$, + array['free'::text], + 'Personal Pro does not change the workspace tier' +); + +select throws_ok( + $$ + select * from public.rename_workspace( + (select workspace_id from workspace_capability_test_state), + 'Personal Pro workspace' + ) + $$, + '42501', + 'workspace capability required: team.manage_workspace', + 'Personal Pro cannot manage an unpaid shared workspace' +); + +select throws_ok( + $$ + select * from public.create_session_share( + (select workspace_id from workspace_capability_test_state), + 'session-personal-pro' + ) + $$, + '42501', + 'workspace capability required: team.shared_notes', + 'Personal Pro cannot publish from an unpaid shared workspace' +); + +select tests.clear_authentication(); +reset role; + +insert into stripe.customers (id) +values ('cus_capability_workspace') +on conflict (id) do nothing; + +update public.workspaces +set + stripe_customer_id = 'cus_capability_workspace', + seat_limit = 3 +where id = (select workspace_id from workspace_capability_test_state); + +insert into stripe.subscriptions (id, customer, status) +values ( + 'sub_capability_workspace', + 'cus_capability_workspace', + 'active'::stripe.subscription_status +) +on conflict (id) do nothing; + +insert into stripe.active_entitlements (id, customer, lookup_key) +values ( + 'ent_capability_pro', + 'cus_capability_workspace', + 'hyprnote_pro' +) +on conflict (customer, lookup_key) do nothing; + +select tests.authenticate_as('capability_owner'); + +select results_eq( + $$ + select workspace_tier + from public.get_workspace_access( + (select workspace_id from workspace_capability_test_state) + ) + $$, + array['free'::text], + 'A generic Pro feature on the workspace is not a Team entitlement' +); + +select throws_ok( + $$ + select * from public.rename_workspace( + (select workspace_id from workspace_capability_test_state), + 'Generic Pro workspace' + ) + $$, + '42501', + 'workspace capability required: team.manage_workspace', + 'An active subscription without the Team feature stays locked' +); + +select tests.clear_authentication(); +reset role; + +insert into stripe.active_entitlements (id, customer, lookup_key) +values ( + 'ent_capability_team', + 'cus_capability_workspace', + 'hyprnote_team' +) +on conflict (customer, lookup_key) do nothing; + +select tests.authenticate_as('capability_owner'); + +select results_eq( + $$ + select + workspace_tier, + 'team.manage_members' = any (capabilities), + 'enterprise.sso' = any (capabilities), + seat_limit, + used_seats + from public.get_workspace_access( + (select workspace_id from workspace_capability_test_state) + ) + $$, + $$ values ('team'::text, true, false, 3, 1) $$, + 'Team returns workspace-scoped capabilities and seat usage' +); + +select results_eq( + $$ + select workspace_name from public.rename_workspace( + (select workspace_id from workspace_capability_test_state), + 'Paid Team workspace' + ) + $$, + array['Paid Team workspace'::text], + 'Team capability unlocks workspace management' +); + +select lives_ok( + $$ + select * from public.create_session_share( + (select workspace_id from workspace_capability_test_state), + 'session-team' + ) + $$, + 'Team capability unlocks shared note publication' +); + +select throws_ok( + $$ + select * from public.claim_workspace_domain( + (select workspace_id from workspace_capability_test_state), + 'example.com' + ) + $$, + '42501', + 'workspace capability required: enterprise.sso', + 'Team cannot use Enterprise SSO controls' +); + +select throws_ok( + $$ + select * from public.set_workspace_policy( + (select workspace_id from workspace_capability_test_state), + array['restricted', 'workspace'], + 'restricted', + 30, + true, + true, + false + ) + $$, + '42501', + 'workspace capability required: enterprise.retention', + 'Team cannot enable Enterprise retention' +); + +select tests.clear_authentication(); +reset role; + +insert into stripe.active_entitlements (id, customer, lookup_key) +values ( + 'ent_capability_enterprise', + 'cus_capability_workspace', + 'hyprnote_enterprise' +) +on conflict (customer, lookup_key) do nothing; + +select tests.authenticate_as('capability_owner'); + +select results_eq( + $$ + select + workspace_tier, + 'team.manage_workspace' = any (capabilities), + 'enterprise.capture' = any (capabilities), + 'enterprise.retention' = any (capabilities) + from public.get_workspace_access( + (select workspace_id from workspace_capability_test_state) + ) + $$, + $$ values ('enterprise'::text, true, true, true) $$, + 'Enterprise inherits Team and adds Enterprise capabilities' +); + +select lives_ok( + $$ + select * from public.claim_workspace_domain( + (select workspace_id from workspace_capability_test_state), + 'example.com' + ); + select * from public.set_workspace_policy( + (select workspace_id from workspace_capability_test_state), + array['restricted', 'workspace'], + 'restricted', + 30, + true, + true, + true + ) + $$, + 'Enterprise capability unlocks domain, SSO, and retention controls' +); + +select tests.clear_authentication(); +reset role; + +update stripe.subscriptions +set status = 'canceled'::stripe.subscription_status +where id = 'sub_capability_workspace'; + +select tests.authenticate_as('capability_owner'); + +select results_eq( + $$ + select + workspace_tier, + public.email_requires_sso('person@example.com') + from public.get_workspace_access( + (select workspace_id from workspace_capability_test_state) + ) + $$, + $$ values ('free'::text, false) $$, + 'Canceled billing removes capabilities and stops Enterprise SSO enforcement' +); + +select * from finish(); +rollback;