All blocks
Collaboration
Share links
Hashed share links, view counts and per-user state. The token is returned once. Resolving a link hashes the token the caller presents, so a stored plaintext token keeps working after it is hashed.
npx better-supabase sql add share-linksimport { defineConfig } from "better-supabase/config";
export default defineConfig({
sql: {
modules: {
// Required by share-links; sql add installs them too.
"updated-at": {},
tenant: {},
access: {},
// Modes: "managed", "adopt", "custom". The default is "managed".
"share-links": { mode: "managed" },
},
},
});npx better-supabase sql add share-links writes these files into your declarative schema with the default layout. See SQL modules for the migration step and the layout options.
supabase/schemas/900_better_supabase_01_updated_at.sqlschemaupdated-at100 linessupabase/better-supabase-data/900_better_supabase_01_updated_at.sqldataupdated-at12 linessupabase/schemas/900_better_supabase_04_tenant.sqlschematenant156 linessupabase/better-supabase-data/900_better_supabase_04_tenant.sqldatatenant12 linessupabase/schemas/900_better_supabase_19_access.sqlschemaaccess375 linessupabase/better-supabase-data/900_better_supabase_19_access.sqldataaccess12 linessupabase/schemas/900_better_supabase_62_share_links.sqlschema232 linessupabase/better-supabase-data/900_better_supabase_62_share_links.sqldata12 lines
supabase/schemas/900_better_supabase_62_share_links.sql
-- better-supabase module: share-links
-- @bs-module share-links@1 managed
-- Hashed share links, view counts and per-user state. The token is returned once. Resolving a link hashes the token the caller presents, so a stored plaintext token keeps working after it is hashed.
-- Managed by `better-supabase sql add`; re-running it overwrites this file.
-- Change it through `sql.modules` in better-supabase.config.ts and the module's SQL hooks.
create or replace function better_supabase.actor_may_write()
returns boolean
language sql
stable
security invoker
set search_path = ''
as $$
select case
when auth.jwt() is null then true
when coalesce(auth.jwt() -> 'act' ->> 'read_only', '') = 'false' then true
when auth.jwt() -> 'act' ->> 'kind' = 'agent'
then coalesce(auth.jwt() -> 'act' ->> 'read_only', '') <> 'true'
when auth.jwt() -> 'act' ->> 'kind' in ('support', 'impersonation') then false
when auth.jwt() -> 'act' ->> 'session_id' is not null
and auth.jwt() -> 'act' ->> 'kind' is null then false
else true
end
$$;
revoke execute on function better_supabase.actor_may_write() from public;
grant execute on function better_supabase.actor_may_write() to anon, authenticated, service_role;
create or replace function better_supabase.raise_actor_readonly()
returns void
language plpgsql
volatile
set search_path = ''
as $$
begin
raise exception 'This session is read-only' using errcode = '42501', hint = 'ACTOR_READ_ONLY';
end;
$$;
revoke execute on function better_supabase.raise_actor_readonly() from public;
grant execute on function better_supabase.raise_actor_readonly() to anon, authenticated, service_role;
create schema if not exists better_supabase;
grant usage on schema better_supabase to anon, authenticated, service_role;
create table if not exists "better_supabase"."share_links" (
"id" uuid primary key default gen_random_uuid(),
"tenant_id" uuid not null,
"subject_type" text not null,
"subject_id" text not null,
"token_hash" text not null unique,
"role" text not null default 'view',
"version" integer not null default 1,
"expires_at" timestamptz,
"revoked_at" timestamptz,
"max_uses" integer,
"created_by" uuid,
"leaf_id" text
);
alter table "better_supabase"."share_links" add column if not exists "version_id" uuid;
alter table "better_supabase"."share_links" enable row level security;
revoke all on "better_supabase"."share_links" from anon, authenticated;
grant select on "better_supabase"."share_links" to authenticated;
grant all on "better_supabase"."share_links" to service_role;
create table if not exists "better_supabase"."subject_views" (
"id" uuid primary key default gen_random_uuid(),
"tenant_id" uuid not null,
"subject_type" text not null,
"subject_id" text not null,
"version" integer not null default 1,
"viewer_kind" text not null,
"viewer" text,
"first_viewed_at" timestamptz not null default now(),
"last_viewed_at" timestamptz not null default now(),
"view_count" integer not null default 1,
unique ("tenant_id", "subject_type", "subject_id", "viewer_kind", "viewer")
);
alter table "better_supabase"."subject_views" enable row level security;
revoke all on "better_supabase"."subject_views" from anon, authenticated;
grant select on "better_supabase"."subject_views" to authenticated;
grant all on "better_supabase"."subject_views" to service_role;
create table if not exists "better_supabase"."subject_user_state" (
"subject_type" text not null,
"subject_id" text not null,
"user_id" uuid not null,
"starred_at" timestamptz,
"last_opened_at" timestamptz,
primary key ("subject_type", "subject_id", "user_id")
);
alter table "better_supabase"."subject_user_state" enable row level security;
revoke all on "better_supabase"."subject_user_state" from anon, authenticated;
grant select on "better_supabase"."subject_user_state" to authenticated;
grant all on "better_supabase"."subject_user_state" to service_role;
drop function if exists "better_supabase"."create_share_link"(uuid, text, text, text);
create or replace function "better_supabase"."create_share_link"(tenant uuid, subject_type text, subject_id text, role text, version_id uuid default null)
returns jsonb
language plpgsql
security definer
set search_path = ''
as $$
declare
v_token text := encode(extensions.gen_random_bytes(24), 'hex');
v_id uuid;
begin
if not (better_supabase.actor_may_write()) then
raise exception 'This session is read-only' using errcode = '42501', hint = 'ACTOR_READ_ONLY';
end if;
if not (auth.role() = 'service_role' or coalesce(better_supabase.can('tenant', create_share_link.tenant, 'share_links.create'), false)) then
raise exception 'not allowed to share' using errcode = '42501', hint = 'SHARE_FORBIDDEN';
end if;
if create_share_link.version_id is not null then
raise exception 'install the versions module to share a version' using errcode = '22023', hint = 'SHARE_VERSION_INVALID';
end if;
insert into "better_supabase"."share_links" ("tenant_id", "subject_type", "subject_id", "token_hash", "role", "created_by", "version_id")
values (create_share_link.tenant, create_share_link.subject_type, create_share_link.subject_id, encode(extensions.digest(convert_to(v_token, 'utf8'), 'sha256'), 'hex'), coalesce(create_share_link.role, 'view'), auth.uid(), create_share_link.version_id)
returning "id" into v_id;
null;
return jsonb_build_object('id', v_id, 'token', v_token);
end;
$$;
revoke execute on function "better_supabase"."create_share_link"(uuid, text, text, text, uuid) from public, anon;
grant execute on function "better_supabase"."create_share_link"(uuid, text, text, text, uuid) to authenticated, service_role;
create or replace function "better_supabase"."revoke_share_link"(id uuid)
returns boolean
language plpgsql
security definer
set search_path = ''
as $$
#variable_conflict use_column
declare
v_tenant uuid;
begin
if not (better_supabase.actor_may_write()) then
raise exception 'This session is read-only' using errcode = '42501', hint = 'ACTOR_READ_ONLY';
end if;
update "better_supabase"."share_links" set "revoked_at" = now()
where "id" = revoke_share_link.id and "revoked_at" is null
returning "tenant_id" into v_tenant;
if not found then
raise exception 'share link not found' using errcode = 'P0002', hint = 'SHARE_NOT_FOUND';
end if;
null;
return true;
end;
$$;
revoke execute on function "better_supabase"."revoke_share_link"(uuid) from public, anon;
grant execute on function "better_supabase"."revoke_share_link"(uuid) to authenticated, service_role;
create or replace function "better_supabase"."resolve_share_link"(token text)
returns jsonb
language sql
stable
security definer
set search_path = ''
as $$
select jsonb_build_object('id', "id", 'subjectType', "subject_type", 'subjectId', "subject_id", 'role', "role", 'versionId', "version_id")
from "better_supabase"."share_links"
where "token_hash" = encode(extensions.digest(convert_to(resolve_share_link.token, 'utf8'), 'sha256'), 'hex')
and "revoked_at" is null
and ("expires_at" is null or "expires_at" > now())
$$;
revoke all on function "better_supabase"."resolve_share_link"(text) from public, anon, authenticated;
grant execute on function "better_supabase"."resolve_share_link"(text) to anon, authenticated, service_role;
create or replace function "better_supabase"."record_view"(tenant uuid, subject_type text, subject_id text, viewer_kind text, viewer text)
returns boolean
language plpgsql
security definer
set search_path = ''
as $$
begin
if not (better_supabase.actor_may_write()) then
raise exception 'This session is read-only' using errcode = '42501', hint = 'ACTOR_READ_ONLY';
end if;
insert into "better_supabase"."subject_views" ("tenant_id", "subject_type", "subject_id", "viewer_kind", "viewer")
values (record_view.tenant, record_view.subject_type, record_view.subject_id, record_view.viewer_kind, record_view.viewer)
on conflict ("tenant_id", "subject_type", "subject_id", "viewer_kind", "viewer")
do update set "last_viewed_at" = now(), "view_count" = "better_supabase"."subject_views"."view_count" + 1;
null;
return true;
end;
$$;
revoke execute on function "better_supabase"."record_view"(uuid, text, text, text, text) from public, anon;
grant execute on function "better_supabase"."record_view"(uuid, text, text, text, text) to authenticated, service_role;
create or replace function "better_supabase"."list_share_links"(subject_type text, subject_id text)
returns jsonb
language sql
stable
security definer
set search_path = ''
as $$
select coalesce(jsonb_agg(jsonb_build_object('id', "id", 'role', "role", 'revokedAt', "revoked_at")), '[]'::jsonb)
from "better_supabase"."share_links"
where "subject_type" = list_share_links.subject_type
and "subject_id" = list_share_links.subject_id
$$;
revoke execute on function "better_supabase"."list_share_links"(text, text) from public, anon;
grant execute on function "better_supabase"."list_share_links"(text, text) to authenticated, service_role;
create or replace function "better_supabase"."set_user_state"(subject_type text, subject_id text, starred boolean)
returns boolean
language plpgsql
security definer
set search_path = ''
as $$
begin
if not (better_supabase.actor_may_write()) then
raise exception 'This session is read-only' using errcode = '42501', hint = 'ACTOR_READ_ONLY';
end if;
insert into "better_supabase"."subject_user_state" ("subject_type", "subject_id", "user_id", "starred_at", "last_opened_at")
values (set_user_state.subject_type, set_user_state.subject_id, auth.uid(), case when set_user_state.starred then now() else null end, now())
on conflict ("subject_type", "subject_id", "user_id")
do update set "starred_at" = excluded."starred_at", "last_opened_at" = now();
return true;
end;
$$;
revoke execute on function "better_supabase"."set_user_state"(text, text, boolean) from public, anon;
grant execute on function "better_supabase"."set_user_state"(text, text, boolean) to authenticated, service_role;
create schema if not exists better_supabase;
create table if not exists better_supabase.modules (
name text primary key,
version integer not null,
mode text not null,
installed_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
alter table better_supabase.modules enable row level security;
revoke all on better_supabase.modules from anon, authenticated;
grant select on better_supabase.modules to service_role;
supabase/better-supabase-data/900_better_supabase_62_share_links.sql
-- better-supabase module: share-links
-- @bs-module-data share-links
-- Rows and settings a schema diff doesn't capture. Run `better-supabase sql data`
-- after the schema migration to put them in a migration.
-- Managed by `better-supabase sql add`; re-running it overwrites this file.
-- Change it through `sql.modules` in better-supabase.config.ts and the module's SQL hooks.
insert into better_supabase.modules (name, version, mode)
values ('share-links', 1, 'managed')
on conflict (name) do update
set version = excluded.version, mode = excluded.mode, updated_at = now();
This block has no React entry yet. Its TypeScript side is in better-supabase/blocks/share-links, which runs on the server.
With the outbox installed, the module writes these events in the same transaction as the change.
- share_link.created
- share_link.revoked
- subject.viewed