@rocapine/community 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +118 -0
- package/lib/commands/adopt.d.ts +38 -0
- package/lib/commands/adopt.js +105 -0
- package/lib/commands/init.d.ts +29 -0
- package/lib/commands/init.js +152 -0
- package/lib/commands/upgrade.d.ts +33 -0
- package/lib/commands/upgrade.js +280 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.js +123 -0
- package/lib/install-shared.d.ts +72 -0
- package/lib/install-shared.js +256 -0
- package/lib/manifest.d.ts +53 -0
- package/lib/manifest.js +74 -0
- package/lib/substitute.d.ts +15 -0
- package/lib/substitute.js +25 -0
- package/package.json +32 -0
- package/templates/functions/_shared/client.ts +20 -0
- package/templates/functions/_shared/config.ts +23 -0
- package/templates/functions/_shared/moderation.ts +102 -0
- package/templates/functions/_shared/push.ts +33 -0
- package/templates/functions/_shared/slack.ts +12 -0
- package/templates/functions/broadcast-post/index.ts +73 -0
- package/templates/functions/daily-moderation/index.ts +175 -0
- package/templates/functions/moderate-one/index.ts +62 -0
- package/templates/functions/notify-comment/index.ts +63 -0
- package/templates/functions/notify-like/index.ts +103 -0
- package/templates/functions/notify-reaction/index.ts +186 -0
- package/templates/functions/report-to-slack/index.ts +37 -0
- package/templates/functions/update-profile/index.ts +131 -0
- package/templates/migrations/core/001_tables.sql +177 -0
- package/templates/migrations/core/002_rls.sql +154 -0
- package/templates/migrations/core/003_moderation.sql +68 -0
- package/templates/migrations/core/004_dashboard.sql +102 -0
- package/templates/migrations/core/005_avatars.sql +19 -0
- package/templates/migrations/core/006_meta.sql +11 -0
- package/templates/migrations/inbox/001_inbox.sql +237 -0
- package/templates/migrations/polls/001_polls.sql +120 -0
- package/templates/migrations/push/001_push.sql +42 -0
- package/templates/migrations/push/002_triggers.sql +89 -0
- package/templates/migrations/reaction/001_reactions.sql +139 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Real-time report alert: a pg_net trigger on INSERT INTO reports posts the
|
|
2
|
+
// record here; we enrich it with the reported account's identity and its
|
|
3
|
+
// cumulative report count, then alert Slack (no-op if SLACK_WEBHOOK_URL is
|
|
4
|
+
// unset — see _shared/slack.ts). Bans stay manual (dashboard).
|
|
5
|
+
|
|
6
|
+
import { adminClient } from "../_shared/client.ts";
|
|
7
|
+
import { postToSlack } from "../_shared/slack.ts";
|
|
8
|
+
|
|
9
|
+
const supabase = adminClient();
|
|
10
|
+
|
|
11
|
+
interface ReportRecord {
|
|
12
|
+
reported_user_id: string;
|
|
13
|
+
reason: string;
|
|
14
|
+
details: string | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
Deno.serve(async (req) => {
|
|
18
|
+
const { record } = (await req.json()) as { record: ReportRecord };
|
|
19
|
+
|
|
20
|
+
const { data: reported } = await supabase
|
|
21
|
+
.from("profiles")
|
|
22
|
+
.select("username, amplitude_id, revenuecat_id")
|
|
23
|
+
.eq("id", record.reported_user_id)
|
|
24
|
+
.single();
|
|
25
|
+
const { count } = await supabase
|
|
26
|
+
.from("reports")
|
|
27
|
+
.select("*", { count: "exact", head: true })
|
|
28
|
+
.eq("reported_user_id", record.reported_user_id);
|
|
29
|
+
|
|
30
|
+
await postToSlack({
|
|
31
|
+
text:
|
|
32
|
+
`:rotating_light: Report (*${record.reason}*) against *${reported?.username ?? record.reported_user_id}*, ${count ?? "?"} report(s) total\n` +
|
|
33
|
+
`${record.details ? `> ${record.details}\n` : ""}` +
|
|
34
|
+
`amplitude: \`${reported?.amplitude_id ?? "?"}\` | revenuecat: \`${reported?.revenuecat_id ?? "?"}\``,
|
|
35
|
+
});
|
|
36
|
+
return new Response("ok");
|
|
37
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// update-profile: the ONLY write path for profiles.handle / bio / avatar_url.
|
|
2
|
+
// JWT-authenticated: uid comes from the caller's anon session token, never the
|
|
3
|
+
// body. Text fields run through OpenAI omni-moderation (same category logic
|
|
4
|
+
// as moderate-one); avatarPath runs image moderation on the object's public
|
|
5
|
+
// URL. Fail closed: if moderation is unreachable, nothing is written.
|
|
6
|
+
//
|
|
7
|
+
// Fields are applied sequentially (handle, then bio, then avatar); the first
|
|
8
|
+
// failure aborts and reports its field. The app's editors send one field at a
|
|
9
|
+
// time, so partial application is not observable in practice.
|
|
10
|
+
|
|
11
|
+
import { createClient } from "jsr:@supabase/supabase-js@2";
|
|
12
|
+
import { adminClient, json, SUPABASE_URL } from "../_shared/client.ts";
|
|
13
|
+
import { assertModerationConfigured, moderateInput } from "../_shared/moderation.ts";
|
|
14
|
+
|
|
15
|
+
assertModerationConfigured();
|
|
16
|
+
|
|
17
|
+
const admin = adminClient();
|
|
18
|
+
|
|
19
|
+
Deno.serve(async (req) => {
|
|
20
|
+
const caller = createClient(SUPABASE_URL, Deno.env.get("SUPABASE_ANON_KEY")!, {
|
|
21
|
+
global: { headers: { Authorization: req.headers.get("Authorization") ?? "" } },
|
|
22
|
+
});
|
|
23
|
+
const { data: userData } = await caller.auth.getUser();
|
|
24
|
+
const uid = userData.user?.id;
|
|
25
|
+
if (!uid) return json({ status: "error", error: "unauthorized" }, 401);
|
|
26
|
+
|
|
27
|
+
let body: { handle?: unknown; bio?: unknown; avatarPath?: unknown };
|
|
28
|
+
try {
|
|
29
|
+
body = await req.json();
|
|
30
|
+
} catch {
|
|
31
|
+
return json({ status: "error", error: "bad request" }, 400);
|
|
32
|
+
}
|
|
33
|
+
const { handle, bio, avatarPath } = body;
|
|
34
|
+
if (handle === undefined && bio === undefined && avatarPath === undefined) {
|
|
35
|
+
return json({ status: "error", error: "bad request" }, 400);
|
|
36
|
+
}
|
|
37
|
+
if (
|
|
38
|
+
(handle !== undefined && typeof handle !== "string") ||
|
|
39
|
+
(bio !== undefined && typeof bio !== "string") ||
|
|
40
|
+
(avatarPath !== undefined && typeof avatarPath !== "string")
|
|
41
|
+
) {
|
|
42
|
+
return json({ status: "error", error: "bad request" }, 400);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ============ HANDLE ============
|
|
46
|
+
if (handle !== undefined) {
|
|
47
|
+
const h = (handle as string).trim().toLowerCase();
|
|
48
|
+
if (!/^[a-z0-9-]{3,20}$/.test(h)) {
|
|
49
|
+
return json({ status: "error", error: "invalid_handle" }, 400);
|
|
50
|
+
}
|
|
51
|
+
const verdict = await moderateInput(h);
|
|
52
|
+
if (!verdict) return json({ status: "error", error: "moderation unavailable" }, 503);
|
|
53
|
+
if (verdict.flagged) {
|
|
54
|
+
return json({ status: "rejected", field: "handle", reason: verdict.reason });
|
|
55
|
+
}
|
|
56
|
+
const { error } = await admin.from("profiles").update({ handle: h }).eq("id", uid);
|
|
57
|
+
if (error) {
|
|
58
|
+
if (error.code === "23505") {
|
|
59
|
+
return json({ status: "error", code: "handle_taken" }, 409);
|
|
60
|
+
}
|
|
61
|
+
return json({ status: "error", error: error.message }, 500);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ============ BIO ============
|
|
66
|
+
if (bio !== undefined) {
|
|
67
|
+
const b = (bio as string).trim();
|
|
68
|
+
if (b.length > 300) {
|
|
69
|
+
return json({ status: "error", error: "bio_too_long" }, 400);
|
|
70
|
+
}
|
|
71
|
+
if (b !== "") {
|
|
72
|
+
const verdict = await moderateInput(b);
|
|
73
|
+
if (!verdict) return json({ status: "error", error: "moderation unavailable" }, 503);
|
|
74
|
+
if (verdict.flagged) {
|
|
75
|
+
return json({ status: "rejected", field: "bio", reason: verdict.reason });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// Clearing the bio (empty string -> null) needs no moderation.
|
|
79
|
+
const { error } = await admin
|
|
80
|
+
.from("profiles")
|
|
81
|
+
.update({ bio: b === "" ? null : b })
|
|
82
|
+
.eq("id", uid);
|
|
83
|
+
if (error) return json({ status: "error", error: error.message }, 500);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ============ AVATAR ============
|
|
87
|
+
if (avatarPath !== undefined) {
|
|
88
|
+
const path = avatarPath as string;
|
|
89
|
+
if (!path.startsWith(`${uid}/`) || path.includes("..")) {
|
|
90
|
+
return json({ status: "error", error: "forbidden_path" }, 403);
|
|
91
|
+
}
|
|
92
|
+
const publicUrl = `${SUPABASE_URL}/storage/v1/object/public/avatars/${path}`;
|
|
93
|
+
const verdict = await moderateInput([{ type: "image_url", image_url: { url: publicUrl } }]);
|
|
94
|
+
if (!verdict) return json({ status: "error", error: "moderation unavailable" }, 503);
|
|
95
|
+
if (verdict.flagged) {
|
|
96
|
+
await admin.storage.from("avatars").remove([path]);
|
|
97
|
+
return json({ status: "rejected", field: "avatar", reason: verdict.reason });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const { data: prevProfile } = await admin
|
|
101
|
+
.from("profiles")
|
|
102
|
+
.select("avatar_url")
|
|
103
|
+
.eq("id", uid)
|
|
104
|
+
.single();
|
|
105
|
+
const { error } = await admin.from("profiles").update({ avatar_url: publicUrl }).eq("id", uid);
|
|
106
|
+
if (error) return json({ status: "error", error: error.message }, 500);
|
|
107
|
+
|
|
108
|
+
const previousUrl = prevProfile?.avatar_url as string | null | undefined;
|
|
109
|
+
if (previousUrl && previousUrl !== publicUrl) {
|
|
110
|
+
const marker = "/object/public/avatars/";
|
|
111
|
+
const idx = previousUrl.indexOf(marker);
|
|
112
|
+
if (idx !== -1) {
|
|
113
|
+
// Best-effort: replacement cleanup should never block the response.
|
|
114
|
+
await admin.storage.from("avatars").remove([previousUrl.slice(idx + marker.length)]);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ============ SUCCESS ============
|
|
120
|
+
const { data: profile } = await admin
|
|
121
|
+
.from("profiles")
|
|
122
|
+
.select("handle, bio, avatar_url")
|
|
123
|
+
.eq("id", uid)
|
|
124
|
+
.single();
|
|
125
|
+
return json({
|
|
126
|
+
status: "ok",
|
|
127
|
+
handle: profile?.handle ?? null,
|
|
128
|
+
bio: profile?.bio ?? null,
|
|
129
|
+
avatarUrl: profile?.avatar_url ?? null,
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
-- community-sdk core/001_tables.sql (schema v1)
|
|
2
|
+
-- Community SDK — 01 core schema.
|
|
3
|
+
-- Fresh-install consolidation of the community tables in their FINAL shape.
|
|
4
|
+
-- Extracted from Eve's Rhythm (supabase/migrations 20260708090000 → 20260729120000
|
|
5
|
+
-- on main); the resulting schema is identical, without the historical churn.
|
|
6
|
+
|
|
7
|
+
create extension if not exists unaccent;
|
|
8
|
+
|
|
9
|
+
-- ============ PROFILES ============
|
|
10
|
+
-- One row per auth user (anonymous ones included), created by trigger.
|
|
11
|
+
create table public.profiles (
|
|
12
|
+
id uuid primary key references auth.users(id) on delete cascade,
|
|
13
|
+
-- Display name. NOT unique: apps may publish under real first names.
|
|
14
|
+
username text,
|
|
15
|
+
-- Stable public identity (slug-1234), assigned once by trigger when
|
|
16
|
+
-- username first becomes non-null. Never client-writable.
|
|
17
|
+
handle text unique,
|
|
18
|
+
-- Moderated fields: the update-profile Edge Function is the ONLY write path.
|
|
19
|
+
bio text check (char_length(bio) <= 300),
|
|
20
|
+
avatar_url text,
|
|
21
|
+
-- Optional links to the host app's analytics / IAP identities.
|
|
22
|
+
amplitude_id text,
|
|
23
|
+
revenuecat_id text,
|
|
24
|
+
is_banned boolean not null default false,
|
|
25
|
+
-- Team-run account: excluded from the dashboard's adoption metrics.
|
|
26
|
+
is_house boolean not null default false,
|
|
27
|
+
-- Verified seal in the app + right to post in the restricted news topic.
|
|
28
|
+
is_official boolean not null default false,
|
|
29
|
+
created_at timestamptz not null default now()
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
-- Auto-create a profile for every new auth user (incl. anonymous ones).
|
|
33
|
+
create or replace function public.handle_new_user()
|
|
34
|
+
returns trigger
|
|
35
|
+
language plpgsql
|
|
36
|
+
security definer set search_path = public
|
|
37
|
+
as $$
|
|
38
|
+
begin
|
|
39
|
+
insert into public.profiles (id) values (new.id);
|
|
40
|
+
return new;
|
|
41
|
+
end;
|
|
42
|
+
$$;
|
|
43
|
+
|
|
44
|
+
create trigger on_auth_user_created
|
|
45
|
+
after insert on auth.users
|
|
46
|
+
for each row execute function public.handle_new_user();
|
|
47
|
+
|
|
48
|
+
-- slug(base) + '-' + random 4-digit suffix; retries on collision and
|
|
49
|
+
-- widens to 6 digits after 5 collisions (guaranteed exit). VOLATILE so
|
|
50
|
+
-- the existence check sees rows already updated by the calling statement.
|
|
51
|
+
create or replace function public.generate_handle(base text)
|
|
52
|
+
returns text
|
|
53
|
+
language plpgsql volatile security definer
|
|
54
|
+
set search_path = public, extensions
|
|
55
|
+
as $$
|
|
56
|
+
declare
|
|
57
|
+
slug text;
|
|
58
|
+
candidate text;
|
|
59
|
+
tries int := 0;
|
|
60
|
+
begin
|
|
61
|
+
slug := lower(unaccent(coalesce(base, '')));
|
|
62
|
+
slug := regexp_replace(slug, '[^a-z0-9]', '', 'g');
|
|
63
|
+
slug := left(slug, 12);
|
|
64
|
+
if slug = '' then
|
|
65
|
+
slug := 'member';
|
|
66
|
+
end if;
|
|
67
|
+
loop
|
|
68
|
+
if tries < 5 then
|
|
69
|
+
candidate := slug || '-' || (floor(random() * 9000) + 1000)::int;
|
|
70
|
+
else
|
|
71
|
+
candidate := slug || '-' || (floor(random() * 900000) + 100000)::int;
|
|
72
|
+
end if;
|
|
73
|
+
exit when not exists (select 1 from public.profiles where handle = candidate);
|
|
74
|
+
tries := tries + 1;
|
|
75
|
+
end loop;
|
|
76
|
+
return candidate;
|
|
77
|
+
end;
|
|
78
|
+
$$;
|
|
79
|
+
|
|
80
|
+
-- Assign once, the first time username becomes non-null. A later name change
|
|
81
|
+
-- never re-assigns the handle (stable identity).
|
|
82
|
+
create or replace function public.assign_handle()
|
|
83
|
+
returns trigger
|
|
84
|
+
language plpgsql security definer
|
|
85
|
+
set search_path = public, extensions
|
|
86
|
+
as $$
|
|
87
|
+
begin
|
|
88
|
+
if new.handle is null and new.username is not null then
|
|
89
|
+
new.handle := public.generate_handle(new.username);
|
|
90
|
+
end if;
|
|
91
|
+
return new;
|
|
92
|
+
end;
|
|
93
|
+
$$;
|
|
94
|
+
|
|
95
|
+
create trigger profiles_assign_handle
|
|
96
|
+
before insert or update of username on public.profiles
|
|
97
|
+
for each row execute function public.assign_handle();
|
|
98
|
+
|
|
99
|
+
-- ============ POSTS ============
|
|
100
|
+
create table public.posts (
|
|
101
|
+
id uuid primary key default gen_random_uuid(),
|
|
102
|
+
author_id uuid not null references public.profiles(id) on delete cascade,
|
|
103
|
+
content text not null check (char_length(content) between 1 and 2000),
|
|
104
|
+
-- Optional app-defined category (client-side vocabulary, no check).
|
|
105
|
+
-- Convention: 'news' is reserved for official accounts (RLS-enforced).
|
|
106
|
+
topic text,
|
|
107
|
+
-- pending: awaiting moderation | visible: published |
|
|
108
|
+
-- hidden: masked by moderation | deleted: by author (soft delete)
|
|
109
|
+
status text not null default 'pending'
|
|
110
|
+
check (status in ('pending', 'visible', 'hidden', 'deleted')),
|
|
111
|
+
moderated_at timestamptz, -- null = not yet seen by the AI batch
|
|
112
|
+
moderation_reason text, -- set by the AI when hidden
|
|
113
|
+
-- Pinning is a dashboard (service role) operation; the feed orders by
|
|
114
|
+
-- pinned_at desc nulls last.
|
|
115
|
+
pinned_at timestamptz,
|
|
116
|
+
-- Human review trail (dashboard).
|
|
117
|
+
reviewed_at timestamptz,
|
|
118
|
+
reviewed_by text,
|
|
119
|
+
created_at timestamptz not null default now()
|
|
120
|
+
);
|
|
121
|
+
create index posts_feed_idx on public.posts (status, created_at desc);
|
|
122
|
+
create index posts_moderation_idx on public.posts (created_at) where moderated_at is null;
|
|
123
|
+
create index posts_pinned_idx on public.posts (pinned_at desc) where pinned_at is not null;
|
|
124
|
+
|
|
125
|
+
-- ============ COMMENTS ============
|
|
126
|
+
create table public.comments (
|
|
127
|
+
id uuid primary key default gen_random_uuid(),
|
|
128
|
+
post_id uuid not null references public.posts(id) on delete cascade,
|
|
129
|
+
author_id uuid not null references public.profiles(id) on delete cascade,
|
|
130
|
+
content text not null check (char_length(content) between 1 and 1000),
|
|
131
|
+
status text not null default 'pending'
|
|
132
|
+
check (status in ('pending', 'visible', 'hidden', 'deleted')),
|
|
133
|
+
moderated_at timestamptz,
|
|
134
|
+
moderation_reason text,
|
|
135
|
+
reviewed_at timestamptz,
|
|
136
|
+
reviewed_by text,
|
|
137
|
+
created_at timestamptz not null default now()
|
|
138
|
+
);
|
|
139
|
+
create index comments_post_idx on public.comments (post_id, created_at);
|
|
140
|
+
create index comments_moderation_idx on public.comments (created_at) where moderated_at is null;
|
|
141
|
+
|
|
142
|
+
-- ============ LIKES ============
|
|
143
|
+
create table public.likes (
|
|
144
|
+
post_id uuid not null references public.posts(id) on delete cascade,
|
|
145
|
+
user_id uuid not null references public.profiles(id) on delete cascade,
|
|
146
|
+
created_at timestamptz not null default now(),
|
|
147
|
+
-- Push-module coalescing marker (harmless if the push module is skipped).
|
|
148
|
+
notified_at timestamptz,
|
|
149
|
+
primary key (post_id, user_id)
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
-- ============ BLOCKS ============
|
|
153
|
+
create table public.blocks (
|
|
154
|
+
blocker_id uuid not null references public.profiles(id) on delete cascade,
|
|
155
|
+
blocked_id uuid not null references public.profiles(id) on delete cascade,
|
|
156
|
+
created_at timestamptz not null default now(),
|
|
157
|
+
primary key (blocker_id, blocked_id),
|
|
158
|
+
check (blocker_id <> blocked_id)
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
-- ============ REPORTS ============
|
|
162
|
+
create table public.reports (
|
|
163
|
+
id uuid primary key default gen_random_uuid(),
|
|
164
|
+
reporter_id uuid not null references public.profiles(id) on delete cascade,
|
|
165
|
+
reported_user_id uuid not null references public.profiles(id) on delete cascade,
|
|
166
|
+
post_id uuid references public.posts(id) on delete set null,
|
|
167
|
+
comment_id uuid references public.comments(id) on delete set null,
|
|
168
|
+
reason text not null check (reason in ('spam','harassment','hate','inappropriate','other')),
|
|
169
|
+
details text,
|
|
170
|
+
status text not null default 'open' check (status in ('open','resolved')),
|
|
171
|
+
-- Dashboard resolution trail.
|
|
172
|
+
resolved_at timestamptz,
|
|
173
|
+
resolved_by text,
|
|
174
|
+
resolution text check (resolution in ('content_hidden','rejected','user_banned')),
|
|
175
|
+
created_at timestamptz not null default now(),
|
|
176
|
+
check (post_id is not null or comment_id is not null)
|
|
177
|
+
);
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
-- community-sdk core/002_rls.sql (schema v1)
|
|
2
|
+
-- Community SDK — 02 RLS + grants (final state).
|
|
3
|
+
-- The client never filters blocked/hidden content itself; these policies are
|
|
4
|
+
-- the single enforcement point. Publication is gated server-side: clients can
|
|
5
|
+
-- ONLY insert 'pending' content, promotion to 'visible' is done by the
|
|
6
|
+
-- moderation Edge Functions (service role).
|
|
7
|
+
|
|
8
|
+
alter table public.profiles enable row level security;
|
|
9
|
+
alter table public.posts enable row level security;
|
|
10
|
+
alter table public.comments enable row level security;
|
|
11
|
+
alter table public.likes enable row level security;
|
|
12
|
+
alter table public.blocks enable row level security;
|
|
13
|
+
alter table public.reports enable row level security;
|
|
14
|
+
|
|
15
|
+
-- Helper: is the current user banned? Missing profile counts as banned.
|
|
16
|
+
create or replace function public.current_user_banned()
|
|
17
|
+
returns boolean
|
|
18
|
+
language sql stable security definer set search_path = public
|
|
19
|
+
as $$
|
|
20
|
+
select coalesce((select is_banned from profiles where id = auth.uid()), true);
|
|
21
|
+
$$;
|
|
22
|
+
|
|
23
|
+
create or replace function public.current_user_official()
|
|
24
|
+
returns boolean
|
|
25
|
+
language sql stable security definer set search_path = public
|
|
26
|
+
as $$
|
|
27
|
+
select coalesce((select is_official from profiles where id = auth.uid()), false);
|
|
28
|
+
$$;
|
|
29
|
+
|
|
30
|
+
-- ============ PROFILES ============
|
|
31
|
+
create policy "profiles readable by authenticated"
|
|
32
|
+
on public.profiles for select to authenticated using (true);
|
|
33
|
+
|
|
34
|
+
create policy "update own profile"
|
|
35
|
+
on public.profiles for update to authenticated
|
|
36
|
+
using (id = auth.uid())
|
|
37
|
+
with check (id = auth.uid() and is_banned = false); -- no self-unban
|
|
38
|
+
|
|
39
|
+
-- Users may only write display/identity fields. is_banned is not updatable
|
|
40
|
+
-- by users at all; handle/bio/avatar_url are written exclusively by the
|
|
41
|
+
-- update-profile Edge Function (service role) so they are always moderated.
|
|
42
|
+
revoke update on table public.profiles from authenticated;
|
|
43
|
+
grant update (username, amplitude_id, revenuecat_id) on table public.profiles to authenticated;
|
|
44
|
+
|
|
45
|
+
-- Column-scoped select: the RLS policy above only decides which ROWS are
|
|
46
|
+
-- visible, not which COLUMNS — PostgREST otherwise happily returns every
|
|
47
|
+
-- column, including amplitude_id/revenuecat_id/is_banned/is_house, to any
|
|
48
|
+
-- authenticated (or anon, before the policy's `to authenticated` even
|
|
49
|
+
-- applies at the grant level) caller. The client only ever reads the public
|
|
50
|
+
-- identity surface (service.ts FEED_SELECT/PROFILE_SELECT, plus the
|
|
51
|
+
-- update-profile Edge Function payloads) — is_official stays readable, it's
|
|
52
|
+
-- the UI's "official" seal.
|
|
53
|
+
revoke select on public.profiles from anon, authenticated;
|
|
54
|
+
grant select (id, username, handle, bio, avatar_url, is_official, created_at)
|
|
55
|
+
on public.profiles to anon, authenticated;
|
|
56
|
+
|
|
57
|
+
-- ============ POSTS ============
|
|
58
|
+
-- The heart of blocking: blocked authors' posts vanish from every query.
|
|
59
|
+
create policy "read visible posts, minus blocked authors"
|
|
60
|
+
on public.posts for select to authenticated
|
|
61
|
+
using (
|
|
62
|
+
author_id = auth.uid() -- authors always see their own posts (even hidden)
|
|
63
|
+
or (
|
|
64
|
+
status = 'visible'
|
|
65
|
+
and not exists (
|
|
66
|
+
select 1 from public.blocks b
|
|
67
|
+
where b.blocker_id = auth.uid() and b.blocked_id = posts.author_id
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
-- pending-only insert + the news guard: a tampered client can neither
|
|
73
|
+
-- self-publish nor post under the brand channel.
|
|
74
|
+
create policy "insert own posts if not banned"
|
|
75
|
+
on public.posts for insert to authenticated
|
|
76
|
+
with check (
|
|
77
|
+
author_id = auth.uid()
|
|
78
|
+
and not public.current_user_banned()
|
|
79
|
+
and status = 'pending'
|
|
80
|
+
and (topic is distinct from 'news' or public.current_user_official())
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
create policy "author can soft-delete own post"
|
|
84
|
+
on public.posts for update to authenticated
|
|
85
|
+
using (author_id = auth.uid())
|
|
86
|
+
with check (author_id = auth.uid() and status = 'deleted'); -- only allowed change
|
|
87
|
+
|
|
88
|
+
-- Column-scoped grants: the app only ever inserts (author_id, topic, content);
|
|
89
|
+
-- id/status/created_at come from defaults. Without this, a tampered client
|
|
90
|
+
-- could pre-set pinned_at or stamp the moderation trail at insert time.
|
|
91
|
+
revoke insert on table public.posts from authenticated;
|
|
92
|
+
grant insert (author_id, topic, content) on table public.posts to authenticated;
|
|
93
|
+
-- Authors may only write status (soft delete); content and the moderation
|
|
94
|
+
-- trail stay immutable from the client.
|
|
95
|
+
revoke update on table public.posts from authenticated;
|
|
96
|
+
grant update (status) on table public.posts to authenticated;
|
|
97
|
+
|
|
98
|
+
-- ============ COMMENTS ============ (same rules)
|
|
99
|
+
create policy "read visible comments, minus blocked authors"
|
|
100
|
+
on public.comments for select to authenticated
|
|
101
|
+
using (
|
|
102
|
+
author_id = auth.uid()
|
|
103
|
+
or (
|
|
104
|
+
status = 'visible'
|
|
105
|
+
and not exists (
|
|
106
|
+
select 1 from public.blocks b
|
|
107
|
+
where b.blocker_id = auth.uid() and b.blocked_id = comments.author_id
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
create policy "insert own comments if not banned"
|
|
113
|
+
on public.comments for insert to authenticated
|
|
114
|
+
with check (
|
|
115
|
+
author_id = auth.uid() and not public.current_user_banned() and status = 'pending'
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
create policy "author can soft-delete own comment"
|
|
119
|
+
on public.comments for update to authenticated
|
|
120
|
+
using (author_id = auth.uid())
|
|
121
|
+
with check (author_id = auth.uid() and status = 'deleted');
|
|
122
|
+
|
|
123
|
+
revoke insert on table public.comments from authenticated;
|
|
124
|
+
grant insert (post_id, author_id, content) on table public.comments to authenticated;
|
|
125
|
+
revoke update on table public.comments from authenticated;
|
|
126
|
+
grant update (status) on table public.comments to authenticated;
|
|
127
|
+
|
|
128
|
+
-- ============ LIKES ============
|
|
129
|
+
-- Only readable where the underlying post is readable, so the like graph of
|
|
130
|
+
-- hidden/deleted/blocked posts does not leak. The subquery on posts runs
|
|
131
|
+
-- under the posts RLS of the querying user.
|
|
132
|
+
create policy "likes readable where post readable"
|
|
133
|
+
on public.likes for select to authenticated
|
|
134
|
+
using (exists (select 1 from public.posts p where p.id = likes.post_id));
|
|
135
|
+
|
|
136
|
+
create policy "like as self" on public.likes for insert to authenticated
|
|
137
|
+
with check (user_id = auth.uid() and not public.current_user_banned());
|
|
138
|
+
|
|
139
|
+
create policy "unlike as self" on public.likes for delete to authenticated
|
|
140
|
+
using (user_id = auth.uid());
|
|
141
|
+
|
|
142
|
+
-- ============ BLOCKS ============
|
|
143
|
+
create policy "manage own blocks" on public.blocks for all to authenticated
|
|
144
|
+
using (blocker_id = auth.uid()) with check (blocker_id = auth.uid());
|
|
145
|
+
|
|
146
|
+
-- ============ REPORTS ============
|
|
147
|
+
create policy "report as self" on public.reports for insert to authenticated
|
|
148
|
+
with check (reporter_id = auth.uid());
|
|
149
|
+
-- No SELECT for users: reports are read via service role / dashboard only.
|
|
150
|
+
|
|
151
|
+
-- ============ RPC SURFACE HYGIENE ============
|
|
152
|
+
-- generate_handle is internal to the assign trigger (security definer, owner
|
|
153
|
+
-- keeps execute); no client needs to call it.
|
|
154
|
+
revoke execute on function public.generate_handle(text) from public, anon, authenticated;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
-- community-sdk core/003_moderation.sql (schema v1)
|
|
2
|
+
-- Community SDK — 03 moderation plumbing: daily cron batch + real-time report
|
|
3
|
+
-- webhook.
|
|
4
|
+
--
|
|
5
|
+
-- ⚠️ PLACEHOLDERS: before pushing, replace __SUPABASE_PROJECT_URL__ with the
|
|
6
|
+
-- app's project URL (https://<ref>.supabase.co) and __SUPABASE_ANON_KEY__ with
|
|
7
|
+
-- its anon key. The anon key is public by design (shipped inside the app
|
|
8
|
+
-- binary): the Edge Functions do privileged work through their own
|
|
9
|
+
-- service-role env, the JWT only passes verify_jwt.
|
|
10
|
+
--
|
|
11
|
+
-- The DO block below fails the migration loudly if the placeholders were not
|
|
12
|
+
-- replaced.
|
|
13
|
+
do $$ begin
|
|
14
|
+
if '__SUPABASE_PROJECT_URL__' like '\_\_SUPABASE%' escape '\' then
|
|
15
|
+
raise exception 'community-sdk: placeholders not substituted. Run: npx @rocapine/community init';
|
|
16
|
+
end if;
|
|
17
|
+
end $$;
|
|
18
|
+
|
|
19
|
+
create extension if not exists pg_cron;
|
|
20
|
+
create extension if not exists pg_net;
|
|
21
|
+
|
|
22
|
+
-- Real-time: forward each new report to the report-to-slack Edge Function.
|
|
23
|
+
create or replace function public.notify_report_webhook()
|
|
24
|
+
returns trigger
|
|
25
|
+
language plpgsql
|
|
26
|
+
security definer set search_path = public
|
|
27
|
+
as $$
|
|
28
|
+
begin
|
|
29
|
+
perform net.http_post(
|
|
30
|
+
url := '__SUPABASE_PROJECT_URL__/functions/v1/report-to-slack',
|
|
31
|
+
headers := jsonb_build_object(
|
|
32
|
+
'Content-Type', 'application/json',
|
|
33
|
+
'Authorization', 'Bearer __SUPABASE_ANON_KEY__'
|
|
34
|
+
),
|
|
35
|
+
body := jsonb_build_object('record', to_jsonb(new))
|
|
36
|
+
);
|
|
37
|
+
return new;
|
|
38
|
+
end;
|
|
39
|
+
$$;
|
|
40
|
+
|
|
41
|
+
drop trigger if exists on_report_created on public.reports;
|
|
42
|
+
create trigger on_report_created
|
|
43
|
+
after insert on public.reports
|
|
44
|
+
for each row execute function public.notify_report_webhook();
|
|
45
|
+
|
|
46
|
+
-- Daily 08:00 UTC moderation batch. Wrapped defensively: cron.schedule
|
|
47
|
+
-- upserts by job name, but if a prior manual run already created the job
|
|
48
|
+
-- and this raises anyway, do not fail the whole migration on a rerun.
|
|
49
|
+
do $$
|
|
50
|
+
begin
|
|
51
|
+
perform cron.schedule(
|
|
52
|
+
'daily-moderation',
|
|
53
|
+
'0 8 * * *',
|
|
54
|
+
$cron$
|
|
55
|
+
select net.http_post(
|
|
56
|
+
url := '__SUPABASE_PROJECT_URL__/functions/v1/daily-moderation',
|
|
57
|
+
headers := jsonb_build_object('Authorization', 'Bearer __SUPABASE_ANON_KEY__')
|
|
58
|
+
);
|
|
59
|
+
$cron$
|
|
60
|
+
);
|
|
61
|
+
exception when others then
|
|
62
|
+
if sqlerrm like '%already exists%' then
|
|
63
|
+
raise notice 'cron job daily-moderation already scheduled, skipping';
|
|
64
|
+
else
|
|
65
|
+
raise;
|
|
66
|
+
end if;
|
|
67
|
+
end;
|
|
68
|
+
$$;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
-- community-sdk core/004_dashboard.sql (schema v1)
|
|
2
|
+
-- Community SDK — 04 dashboard support: what the Rocactopus dashboard needs
|
|
3
|
+
-- to moderate this app (admin allow-list, audit log, aggregation view,
|
|
4
|
+
-- metrics RPC). The review/resolution columns live in core/001_tables.sql.
|
|
5
|
+
--
|
|
6
|
+
-- admin_user_stats and admin_daily_metrics are service-role/dashboard only:
|
|
7
|
+
-- the view runs with security_invoker=off (so it can see every row) and both
|
|
8
|
+
-- expose PII (amplitude_id, revenuecat_id, is_banned) — client roles must
|
|
9
|
+
-- never be able to select/execute them.
|
|
10
|
+
|
|
11
|
+
create table public.admin_users (
|
|
12
|
+
email text primary key,
|
|
13
|
+
created_at timestamptz not null default now()
|
|
14
|
+
);
|
|
15
|
+
alter table public.admin_users enable row level security;
|
|
16
|
+
-- No policy: readable only by the service role.
|
|
17
|
+
|
|
18
|
+
create table public.moderation_actions (
|
|
19
|
+
id uuid primary key default gen_random_uuid(),
|
|
20
|
+
actor_email text not null,
|
|
21
|
+
action text not null check (action in
|
|
22
|
+
('hide','restore','confirm_hide','reject_report','ban','unban')),
|
|
23
|
+
target_post_id uuid references public.posts(id) on delete set null,
|
|
24
|
+
target_comment_id uuid references public.comments(id) on delete set null,
|
|
25
|
+
target_user_id uuid references public.profiles(id) on delete set null,
|
|
26
|
+
note text,
|
|
27
|
+
created_at timestamptz not null default now()
|
|
28
|
+
);
|
|
29
|
+
alter table public.moderation_actions enable row level security;
|
|
30
|
+
create index moderation_actions_user_idx on public.moderation_actions (target_user_id, created_at desc);
|
|
31
|
+
|
|
32
|
+
create view public.admin_user_stats
|
|
33
|
+
with (security_invoker = off) as
|
|
34
|
+
select
|
|
35
|
+
p.id, p.username, p.is_banned, p.is_house, p.created_at,
|
|
36
|
+
p.amplitude_id, p.revenuecat_id,
|
|
37
|
+
(select count(*) from posts where author_id = p.id and status <> 'deleted') as post_count,
|
|
38
|
+
(select count(*) from comments where author_id = p.id and status <> 'deleted') as comment_count,
|
|
39
|
+
(select count(*) from posts where author_id = p.id and status = 'hidden')
|
|
40
|
+
+ (select count(*) from comments where author_id = p.id and status = 'hidden') as hidden_count,
|
|
41
|
+
(select count(*) from reports where reported_user_id = p.id) as reports_received,
|
|
42
|
+
(select count(*) from blocks where blocked_id = p.id) as blocked_by_count
|
|
43
|
+
from profiles p;
|
|
44
|
+
|
|
45
|
+
revoke select on public.admin_user_stats from public, anon, authenticated;
|
|
46
|
+
|
|
47
|
+
-- Daily series for /overview in one call. Adoption metrics exclude house accounts.
|
|
48
|
+
create or replace function public.admin_daily_metrics(from_date date, to_date date)
|
|
49
|
+
returns jsonb
|
|
50
|
+
language sql
|
|
51
|
+
stable
|
|
52
|
+
as $$
|
|
53
|
+
with days as (
|
|
54
|
+
select generate_series(from_date, to_date, interval '1 day')::date as day
|
|
55
|
+
),
|
|
56
|
+
human as (select id from profiles where is_house = false),
|
|
57
|
+
daily_posts as (
|
|
58
|
+
select p.created_at::date as day, count(*) as n
|
|
59
|
+
from posts p
|
|
60
|
+
where p.status <> 'deleted' and p.created_at::date between from_date and to_date
|
|
61
|
+
group by 1),
|
|
62
|
+
daily_comments as (
|
|
63
|
+
select c.created_at::date as day, count(*) as n
|
|
64
|
+
from comments c
|
|
65
|
+
where c.status <> 'deleted' and c.created_at::date between from_date and to_date
|
|
66
|
+
group by 1),
|
|
67
|
+
first_post as (
|
|
68
|
+
select author_id, min(created_at)::date as first_day
|
|
69
|
+
from posts where author_id in (select id from human) group by author_id),
|
|
70
|
+
new_posters as (
|
|
71
|
+
select first_day as day, count(*) as n from first_post
|
|
72
|
+
where first_day between from_date and to_date group by 1),
|
|
73
|
+
daily_hidden as (
|
|
74
|
+
select moderated_at::date as day, count(*) as n
|
|
75
|
+
from posts where status='hidden' and moderated_at::date between from_date and to_date
|
|
76
|
+
group by 1),
|
|
77
|
+
topics as (
|
|
78
|
+
select coalesce(topic,'(sans)') as topic, count(*) as n
|
|
79
|
+
from posts where status <> 'deleted' and created_at::date between from_date and to_date
|
|
80
|
+
group by 1),
|
|
81
|
+
reply_rate as (
|
|
82
|
+
select count(*) filter (where exists (
|
|
83
|
+
select 1 from comments c
|
|
84
|
+
where c.post_id = p.id and c.created_at <= p.created_at + interval '24 hours'))::float
|
|
85
|
+
/ nullif(count(*),0) as rate
|
|
86
|
+
from posts p
|
|
87
|
+
where p.status <> 'deleted' and p.author_id in (select id from human)
|
|
88
|
+
and p.created_at::date between from_date and to_date)
|
|
89
|
+
select jsonb_build_object(
|
|
90
|
+
'daily', (select coalesce(jsonb_agg(jsonb_build_object(
|
|
91
|
+
'day', d.day, 'posts', coalesce(dp.n,0), 'comments', coalesce(dc.n,0),
|
|
92
|
+
'new_posters', coalesce(np.n,0), 'hidden', coalesce(dh.n,0)) order by d.day), '[]'::jsonb)
|
|
93
|
+
from days d
|
|
94
|
+
left join daily_posts dp on dp.day=d.day
|
|
95
|
+
left join daily_comments dc on dc.day=d.day
|
|
96
|
+
left join new_posters np on np.day=d.day
|
|
97
|
+
left join daily_hidden dh on dh.day=d.day),
|
|
98
|
+
'topics', (select coalesce(jsonb_agg(jsonb_build_object('topic',topic,'count',n) order by n desc),'[]'::jsonb) from topics),
|
|
99
|
+
'reply_rate', (select coalesce(rate,0) from reply_rate));
|
|
100
|
+
$$;
|
|
101
|
+
|
|
102
|
+
revoke execute on function public.admin_daily_metrics(date, date) from public, anon, authenticated;
|