@rocapine/community 0.1.0 → 0.2.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.
@@ -146,7 +146,7 @@ function printNextSteps(log, modules) {
146
146
  log(" 2. Set Edge Function secrets: supabase secrets set OPENAI_API_KEY=...");
147
147
  log(" optional: SLACK_WEBHOOK_URL, COMMUNITY_APP_NAME, COMMUNITY_FALLBACK_NAME");
148
148
  if (modules.includes("push")) {
149
- log(" push module: EXPO_ACCESS_TOKEN (required for notify-like/notify-comment)");
149
+ log(" push module: EXPO_ACCESS_TOKEN (only if your Expo project enforces Enhanced Security for push); COMMUNITY_PUSH_COPY (optional per-locale copy)");
150
150
  }
151
151
  log(" 3. Deploy the Edge Functions: supabase functions deploy");
152
152
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rocapine/community",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "CLI to install, upgrade and adopt the Rocapine Community SDK's Supabase backend (migrations, Edge Functions) into a project.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,15 +1,48 @@
1
- // Shared Supabase admin client + JSON response helper.
1
+ // Shared Supabase admin client, caller identity helpers + JSON response helper.
2
2
 
3
3
  import { createClient } from "jsr:@supabase/supabase-js@2";
4
4
 
5
5
  export const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
6
6
 
7
7
  // Service-role client: bypasses RLS. Every function does privileged work
8
- // through this; callers only ever pass the public anon JWT (verify_jwt).
8
+ // through this; callers only ever pass the public anon JWT (verify_jwt) or a
9
+ // user JWT.
9
10
  export function adminClient() {
10
11
  return createClient(SUPABASE_URL, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!);
11
12
  }
12
13
 
14
+ // The gateway (verify_jwt) has already validated the bearer's signature, so
15
+ // only the role claim needs checking here. An exact string match against the
16
+ // injected SUPABASE_SERVICE_ROLE_KEY broke when a project migrated to the new
17
+ // API keys: the runtime then receives the sb_secret_* value while callers
18
+ // still hold the legacy service_role JWT. Accept either proof.
19
+ export function isServiceCaller(req: Request): boolean {
20
+ const bearer = (req.headers.get("authorization") ?? "").replace(/^Bearer\s+/i, "");
21
+ if (bearer && bearer === Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")) return true;
22
+ try {
23
+ const payload = JSON.parse(
24
+ atob(bearer.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")),
25
+ ) as { role?: string };
26
+ return payload.role === "service_role";
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ // The user behind the request's bearer, or null for an anon-key-only call
33
+ // (or an invalid/expired user JWT). Resolved through Auth so a forged `sub`
34
+ // claim can't be smuggled in with a plain anon token.
35
+ export async function callerUserId(req: Request): Promise<string | null> {
36
+ const authorization = req.headers.get("authorization");
37
+ if (!authorization) return null;
38
+ const client = createClient(SUPABASE_URL, Deno.env.get("SUPABASE_ANON_KEY")!, {
39
+ global: { headers: { Authorization: authorization } },
40
+ });
41
+ const { data, error } = await client.auth.getUser();
42
+ if (error || !data.user) return null;
43
+ return data.user.id;
44
+ }
45
+
13
46
  // The client consumes responses via supabase.functions.invoke, which only
14
47
  // parses the body as JSON when the Content-Type says so. Without this header
15
48
  // invoke returns a raw string and verdict.status is undefined.
@@ -1,23 +1,7 @@
1
- // Per-app copy used inside notifications, injected as function secrets so the
2
- // same function code deploys unchanged to every app:
3
- //
4
- // supabase secrets set COMMUNITY_FALLBACK_NAME="A member"
5
- // supabase secrets set COMMUNITY_APP_NAME="My App"
6
- // supabase secrets set COMMUNITY_REACTION_PUSH_TEXT="{name} sent you support"
7
- //
8
- // Defaults are deliberately neutral.
9
-
10
- // Display name used when the acting user has no username yet
11
- // ("<name>: ..." in comment pushes, "<name> liked your post").
12
- export const FALLBACK_ACTOR_NAME = Deno.env.get("COMMUNITY_FALLBACK_NAME") ?? "Someone";
1
+ // Per-app copy injected as function secrets so the same function code
2
+ // deploys unchanged to every app. Push copy (actor fallback name, like /
3
+ // comment / reaction titles) lives in _shared/copy.ts (COMMUNITY_PUSH_COPY).
13
4
 
14
5
  // Push title fallback for official broadcast posts whose author has no
15
6
  // username (normally the official account's display name).
16
7
  export const BROADCAST_FALLBACK_TITLE = Deno.env.get("COMMUNITY_APP_NAME") ?? "Community";
17
-
18
- // notify-reaction's single-reactor push title template. `{name}` is
19
- // substituted with the reactor's display name (or FALLBACK_ACTOR_NAME).
20
- // Neutral default; an app with its own reaction semantics overrides this
21
- // secret rather than the function code.
22
- export const REACTION_PUSH_TEXT =
23
- Deno.env.get("COMMUNITY_REACTION_PUSH_TEXT") ?? "{name} is thinking of you";
@@ -0,0 +1,247 @@
1
+ // Locale-aware push copy for notify-comment / notify-like / notify-reaction.
2
+ //
3
+ // Every string below is a neutral built-in for the 9 locales the UI package
4
+ // ships. An app with its own voice overrides ANY of them — per locale, per
5
+ // key — through ONE JSON secret instead of forking the function bodies (the
6
+ // way one host app had to before this existed):
7
+ //
8
+ // supabase secrets set COMMUNITY_PUSH_COPY='{
9
+ // "fallbackName": { "en": "A sister", "pt-PT": "Uma irmã" },
10
+ // "comment.title": { "pl": "Otrzymałaś komentarz" },
11
+ // "like.one": { "pl": "{name} polubiła twój post" },
12
+ // "reaction.one": { "en": "{name} prayed for you 🙏" },
13
+ // "reaction.many": { "en": "{name} and {count} others prayed for you 🙏",
14
+ // "pl": { "one": "…", "few": "…", "many": "…" } }
15
+ // }'
16
+ //
17
+ // Placeholders: `{name}` (actor display name) and `{count}` (the number of
18
+ // OTHER people in a coalesced push). A `*.many` value is either a string or a
19
+ // `{ one, few, many, other }` object picked by the CLDR plural category of
20
+ // `{count}` for the recipient's locale (Polish needs all three; French treats
21
+ // 0 like 1; everything else is one/other).
22
+ //
23
+ // Legacy single-string secrets still apply, below the JSON and above the
24
+ // built-ins: COMMUNITY_FALLBACK_NAME (every locale's fallbackName) and
25
+ // COMMUNITY_REACTION_PUSH_TEXT (every locale's reaction.one).
26
+
27
+ type Forms = { one?: string; few?: string; many?: string; other?: string };
28
+ type CopyValue = string | Forms;
29
+ type LocaleCopy = Record<string, CopyValue>;
30
+
31
+ export type PushCopyKey =
32
+ "fallbackName" | "comment.title" | "like.one" | "like.many" | "reaction.one" | "reaction.many";
33
+
34
+ const BUILTIN: Record<string, LocaleCopy> = {
35
+ en: {
36
+ fallbackName: "Someone",
37
+ "comment.title": "You received a comment",
38
+ "like.one": "{name} liked your post",
39
+ "like.many": {
40
+ one: "{name} and {count} other liked your post",
41
+ other: "{name} and {count} others liked your post",
42
+ },
43
+ "reaction.one": "{name} is thinking of you",
44
+ "reaction.many": {
45
+ one: "{name} and {count} other are thinking of you",
46
+ other: "{name} and {count} others are thinking of you",
47
+ },
48
+ },
49
+ "pt-PT": {
50
+ fallbackName: "Alguém",
51
+ "comment.title": "Recebeste um comentário",
52
+ "like.one": "{name} gostou da tua publicação",
53
+ "like.many": {
54
+ one: "{name} e mais {count} pessoa gostou da tua publicação",
55
+ other: "{name} e mais {count} pessoas gostaram da tua publicação",
56
+ },
57
+ "reaction.one": "{name} está a pensar em ti",
58
+ "reaction.many": {
59
+ one: "{name} e mais {count} pessoa está a pensar em ti",
60
+ other: "{name} e mais {count} pessoas estão a pensar em ti",
61
+ },
62
+ },
63
+ "pt-BR": {
64
+ fallbackName: "Alguém",
65
+ "comment.title": "Você recebeu um comentário",
66
+ "like.one": "{name} curtiu seu post",
67
+ "like.many": {
68
+ one: "{name} e mais {count} pessoa curtiu seu post",
69
+ other: "{name} e mais {count} pessoas curtiram seu post",
70
+ },
71
+ "reaction.one": "{name} está pensando em você",
72
+ "reaction.many": {
73
+ one: "{name} e mais {count} pessoa está pensando em você",
74
+ other: "{name} e mais {count} pessoas estão pensando em você",
75
+ },
76
+ },
77
+ "es-ES": {
78
+ fallbackName: "Alguien",
79
+ "comment.title": "Has recibido un comentario",
80
+ "like.one": "A {name} le ha gustado tu publicación",
81
+ "like.many": {
82
+ one: "A {name} y a {count} persona más les ha gustado tu publicación",
83
+ other: "A {name} y a {count} personas más les ha gustado tu publicación",
84
+ },
85
+ "reaction.one": "{name} está pensando en ti",
86
+ "reaction.many": {
87
+ one: "{name} y {count} persona más está pensando en ti",
88
+ other: "{name} y {count} personas más están pensando en ti",
89
+ },
90
+ },
91
+ "es-419": {
92
+ fallbackName: "Alguien",
93
+ "comment.title": "Recibiste un comentario",
94
+ "like.one": "A {name} le gustó tu publicación",
95
+ "like.many": {
96
+ one: "A {name} y a {count} persona más les gustó tu publicación",
97
+ other: "A {name} y a {count} personas más les gustó tu publicación",
98
+ },
99
+ "reaction.one": "{name} está pensando en ti",
100
+ "reaction.many": {
101
+ one: "{name} y {count} persona más está pensando en ti",
102
+ other: "{name} y {count} personas más están pensando en ti",
103
+ },
104
+ },
105
+ it: {
106
+ fallbackName: "Qualcuno",
107
+ "comment.title": "Hai ricevuto un commento",
108
+ "like.one": "A {name} piace il tuo post",
109
+ "like.many": {
110
+ one: "A {name} e ad altra {count} persona piace il tuo post",
111
+ other: "A {name} e ad altre {count} persone piace il tuo post",
112
+ },
113
+ "reaction.one": "{name} sta pensando a te",
114
+ "reaction.many": {
115
+ one: "{name} e altra {count} persona sta pensando a te",
116
+ other: "{name} e altre {count} persone stanno pensando a te",
117
+ },
118
+ },
119
+ pl: {
120
+ fallbackName: "Ktoś",
121
+ "comment.title": "Masz nowy komentarz",
122
+ "like.one": "{name} polubił(a) twój post",
123
+ "like.many": {
124
+ one: "{name} i {count} inna osoba polubiły twój post",
125
+ few: "{name} i {count} inne osoby polubiły twój post",
126
+ many: "{name} i {count} innych osób polubiło twój post",
127
+ },
128
+ "reaction.one": "{name} myśli o tobie",
129
+ "reaction.many": {
130
+ one: "{name} i {count} inna osoba myśli o tobie",
131
+ few: "{name} i {count} inne osoby myślą o tobie",
132
+ many: "{name} i {count} innych osób myśli o tobie",
133
+ },
134
+ },
135
+ fr: {
136
+ fallbackName: "Quelqu'un",
137
+ "comment.title": "Tu as reçu un commentaire",
138
+ "like.one": "{name} a aimé ta publication",
139
+ "like.many": {
140
+ one: "{name} et {count} autre personne ont aimé ta publication",
141
+ other: "{name} et {count} autres personnes ont aimé ta publication",
142
+ },
143
+ "reaction.one": "{name} pense à toi",
144
+ "reaction.many": {
145
+ one: "{name} et {count} autre personne pensent à toi",
146
+ other: "{name} et {count} autres personnes pensent à toi",
147
+ },
148
+ },
149
+ de: {
150
+ fallbackName: "Jemand",
151
+ "comment.title": "Du hast einen Kommentar erhalten",
152
+ "like.one": "{name} gefällt dein Beitrag",
153
+ "like.many": {
154
+ one: "{name} und {count} weiterer Person gefällt dein Beitrag",
155
+ other: "{name} und {count} weiteren Personen gefällt dein Beitrag",
156
+ },
157
+ "reaction.one": "{name} denkt an dich",
158
+ "reaction.many": {
159
+ one: "{name} und {count} weitere Person denken an dich",
160
+ other: "{name} und {count} weitere Personen denken an dich",
161
+ },
162
+ },
163
+ };
164
+
165
+ // Same rule set as the UI package's `pluralCategory` (packages/ui/src/i18n.ts).
166
+ export function pluralCategory(locale: string, n: number): keyof Forms {
167
+ const lang = locale.split("-")[0]?.toLowerCase();
168
+ if (lang === "pl") {
169
+ if (n === 1) return "one";
170
+ const m10 = n % 10;
171
+ const m100 = n % 100;
172
+ if (m10 >= 2 && m10 <= 4 && !(m100 >= 12 && m100 <= 14)) return "few";
173
+ return "many";
174
+ }
175
+ if (lang === "fr") return n === 0 || n === 1 ? "one" : "other";
176
+ return n === 1 ? "one" : "other";
177
+ }
178
+
179
+ function parseOverrides(): Record<string, Record<string, CopyValue>> {
180
+ const raw = Deno.env.get("COMMUNITY_PUSH_COPY");
181
+ if (!raw) return {};
182
+ try {
183
+ const parsed = JSON.parse(raw);
184
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
185
+ } catch (e) {
186
+ console.error("community-sdk: COMMUNITY_PUSH_COPY is not valid JSON, ignoring it", e);
187
+ return {};
188
+ }
189
+ }
190
+
191
+ const OVERRIDES = parseOverrides();
192
+ const LEGACY_FALLBACK_NAME = Deno.env.get("COMMUNITY_FALLBACK_NAME");
193
+ const LEGACY_REACTION_ONE = Deno.env.get("COMMUNITY_REACTION_PUSH_TEXT");
194
+
195
+ /** `es-MX` → `es-ES`, `pt-AO` → `pt-PT`, `fr-CA` → `fr`; unknown → `en`. */
196
+ function resolveLocale(locale: string | null | undefined): string {
197
+ if (!locale) return "en";
198
+ if (BUILTIN[locale]) return locale;
199
+ const lang = locale.split("-")[0].toLowerCase();
200
+ if (BUILTIN[lang]) return lang;
201
+ if (lang === "es") return "es-ES";
202
+ if (lang === "pt") return "pt-PT";
203
+ return "en";
204
+ }
205
+
206
+ function pick(key: PushCopyKey, locale: string, exact: string | null | undefined): CopyValue {
207
+ const overrideByLocale = OVERRIDES[key] ?? {};
208
+ // Override lookup: exact locale as sent by the client, then the resolved one.
209
+ const override =
210
+ (exact ? overrideByLocale[exact] : undefined) ??
211
+ overrideByLocale[locale] ??
212
+ overrideByLocale[locale.split("-")[0]];
213
+ if (override !== undefined) return override;
214
+ if (key === "fallbackName" && LEGACY_FALLBACK_NAME) return LEGACY_FALLBACK_NAME;
215
+ if (key === "reaction.one" && LEGACY_REACTION_ONE) return LEGACY_REACTION_ONE;
216
+ return BUILTIN[locale][key] ?? BUILTIN.en[key];
217
+ }
218
+
219
+ function fill(template: string, params: { name?: string; count?: number }): string {
220
+ return template
221
+ .replace(/\{name\}/g, params.name ?? "")
222
+ .replace(/\{count\}/g, params.count === undefined ? "" : String(params.count));
223
+ }
224
+
225
+ /** Copy resolver for one recipient (`profiles.locale`). */
226
+ export function pushCopy(recipientLocale: string | null | undefined) {
227
+ const locale = resolveLocale(recipientLocale);
228
+ const text = (key: PushCopyKey, params: { name?: string; count?: number } = {}): string => {
229
+ const value = pick(key, locale, recipientLocale);
230
+ if (typeof value === "string") return fill(value, params);
231
+ const category = pluralCategory(locale, params.count ?? 0);
232
+ const form =
233
+ value[category] ??
234
+ value.other ??
235
+ value.many ??
236
+ value.one ??
237
+ // A partial override object: fall through to the built-in forms.
238
+ (() => {
239
+ const builtin = BUILTIN[locale][key] ?? BUILTIN.en[key];
240
+ return typeof builtin === "string"
241
+ ? builtin
242
+ : (builtin[category] ?? builtin.other ?? builtin.many ?? builtin.one ?? "");
243
+ })();
244
+ return fill(form, params);
245
+ };
246
+ return { locale, fallbackName: text("fallbackName"), text };
247
+ }
@@ -4,30 +4,12 @@
4
4
  // Guarded by a service-role check — the public anon key passes the platform's
5
5
  // verify_jwt but must NOT be able to trigger a broadcast.
6
6
 
7
- import { adminClient } from "../_shared/client.ts";
7
+ import { adminClient, isServiceCaller } from "../_shared/client.ts";
8
8
  import { sendExpoPushBatch } from "../_shared/push.ts";
9
9
  import { BROADCAST_FALLBACK_TITLE } from "../_shared/config.ts";
10
10
 
11
11
  const supabase = adminClient();
12
12
 
13
- // The gateway (verify_jwt) has already validated the bearer's signature, so
14
- // only the role claim needs checking here. An exact string match against the
15
- // injected SUPABASE_SERVICE_ROLE_KEY broke when the project migrated to the
16
- // new API keys: the runtime now receives the sb_secret_* value while callers
17
- // still hold the legacy service_role JWT. Accept either proof.
18
- function isServiceCaller(req: Request): boolean {
19
- const bearer = (req.headers.get("authorization") ?? "").replace(/^Bearer\s+/i, "");
20
- if (bearer && bearer === Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")) return true;
21
- try {
22
- const payload = JSON.parse(
23
- atob(bearer.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")),
24
- ) as { role?: string };
25
- return payload.role === "service_role";
26
- } catch {
27
- return false;
28
- }
29
- }
30
-
31
13
  Deno.serve(async (req) => {
32
14
  if (!isServiceCaller(req)) {
33
15
  return new Response("unauthorized", { status: 401 });
@@ -41,7 +41,7 @@ Deno.serve(async () => {
41
41
  const { data: posts, error: postsError } = await supabase
42
42
  .from("posts")
43
43
  .select(
44
- "id, content, author_id, status, poll_options(idx, label), profiles!posts_author_id_fkey(username, amplitude_id, revenuecat_id)",
44
+ "id, content, author_id, status, profiles!posts_author_id_fkey(username, amplitude_id, revenuecat_id)",
45
45
  )
46
46
  .is("moderated_at", null)
47
47
  .in("status", ["pending", "visible"]);
@@ -57,16 +57,34 @@ Deno.serve(async () => {
57
57
  }
58
58
 
59
59
  // Poll option labels are user content: fold them into the post's moderated
60
- // text (same treatment as the synchronous moderate-one function).
60
+ // text (same treatment as the synchronous moderate-one function). The polls
61
+ // module is optional: without it the table doesn't exist, the query errors,
62
+ // and posts are moderated on their text alone (a separate query instead of
63
+ // an embed so a core-only install doesn't 400 the whole sweep).
64
+ const optionsByPost = new Map<string, { idx: number; label: string }[]>();
65
+ if (posts && posts.length > 0) {
66
+ const { data: options, error } = await supabase
67
+ .from("poll_options")
68
+ .select("post_id, idx, label")
69
+ .in(
70
+ "post_id",
71
+ posts.map((p) => p.id),
72
+ );
73
+ if (!error) {
74
+ for (const o of options ?? []) {
75
+ const list = optionsByPost.get(o.post_id) ?? [];
76
+ list.push({ idx: o.idx, label: o.label });
77
+ optionsByPost.set(o.post_id, list);
78
+ }
79
+ }
80
+ }
61
81
  const items: Item[] = [
62
82
  ...(posts ?? []).map((p) => ({
63
83
  ...p,
64
84
  profiles: toOne(p.profiles),
65
85
  content: [
66
86
  p.content,
67
- ...(p.poll_options ?? [])
68
- .sort((a: { idx: number }, b: { idx: number }) => a.idx - b.idx)
69
- .map((o: { label: string }) => o.label),
87
+ ...(optionsByPost.get(p.id) ?? []).sort((a, b) => a.idx - b.idx).map((o) => o.label),
70
88
  ].join("\n"),
71
89
  table: "posts" as const,
72
90
  })),
@@ -116,11 +134,15 @@ Deno.serve(async () => {
116
134
  const totalItems = items.length;
117
135
 
118
136
  const flaggedIds = new Set(flagged.map((f) => f.item.id));
137
+ // Status guards: a row the author soft-deleted while the sweep ran must not
138
+ // be resurrected as hidden/visible, so every write re-checks the status it
139
+ // was selected with.
119
140
  for (const { item, categories } of flagged) {
120
141
  await supabase
121
142
  .from(item.table)
122
143
  .update({ status: "hidden", moderation_reason: categories.join(",") })
123
- .eq("id", item.id);
144
+ .eq("id", item.id)
145
+ .in("status", ["pending", "visible"]);
124
146
  }
125
147
  // Backstop: promote clean 'pending' items to 'visible' (their synchronous
126
148
  // moderate-one call must have failed).
@@ -128,10 +150,18 @@ Deno.serve(async () => {
128
150
  const promotePostIds = promote.filter((i) => i.table === "posts").map((i) => i.id);
129
151
  const promoteCommentIds = promote.filter((i) => i.table === "comments").map((i) => i.id);
130
152
  if (promotePostIds.length > 0) {
131
- await supabase.from("posts").update({ status: "visible" }).in("id", promotePostIds);
153
+ await supabase
154
+ .from("posts")
155
+ .update({ status: "visible" })
156
+ .in("id", promotePostIds)
157
+ .eq("status", "pending");
132
158
  }
133
159
  if (promoteCommentIds.length > 0) {
134
- await supabase.from("comments").update({ status: "visible" }).in("id", promoteCommentIds);
160
+ await supabase
161
+ .from("comments")
162
+ .update({ status: "visible" })
163
+ .in("id", promoteCommentIds)
164
+ .eq("status", "pending");
135
165
  }
136
166
  const now = new Date().toISOString();
137
167
  const processedPostIds = processed.filter((i) => i.table === "posts").map((i) => i.id);
@@ -3,8 +3,14 @@
3
3
  // promoted to 'visible' (published); flagged -> 'hidden' (rejected) with the
4
4
  // reason. On an OpenAI error we fail closed (leave it pending); the daily
5
5
  // backstop re-checks stale pending items so nothing is silently lost.
6
+ //
7
+ // Security: the caller must be the row's author (resolved from their user
8
+ // JWT through Auth) or the service role — an anon-key-only call can't
9
+ // moderate someone else's pending item. Every status write is guarded with
10
+ // `status = 'pending'` so a row the author deleted mid-flight is never
11
+ // resurrected.
6
12
 
7
- import { adminClient, json } from "../_shared/client.ts";
13
+ import { adminClient, callerUserId, isServiceCaller, json } from "../_shared/client.ts";
8
14
  import { assertModerationConfigured, moderateInput } from "../_shared/moderation.ts";
9
15
 
10
16
  assertModerationConfigured();
@@ -12,7 +18,10 @@ assertModerationConfigured();
12
18
  const supabase = adminClient();
13
19
 
14
20
  Deno.serve(async (req) => {
15
- const { kind, id } = (await req.json()) as { kind: "post" | "comment"; id: string };
21
+ const { kind, id } = (await req.json().catch(() => ({}))) as {
22
+ kind?: "post" | "comment";
23
+ id?: string;
24
+ };
16
25
  if ((kind !== "post" && kind !== "comment") || !id) {
17
26
  return json({ status: "error", error: "bad request" }, 400);
18
27
  }
@@ -20,25 +29,33 @@ Deno.serve(async (req) => {
20
29
 
21
30
  const { data: row } = await supabase
22
31
  .from(table)
23
- .select("id, content, status")
32
+ .select("id, author_id, content, status")
24
33
  .eq("id", id)
25
34
  .single();
26
35
  if (!row) return json({ status: "error", error: "not found" });
36
+
37
+ if (!isServiceCaller(req)) {
38
+ const uid = await callerUserId(req);
39
+ if (!uid || uid !== row.author_id) return json({ status: "error", error: "forbidden" }, 403);
40
+ }
41
+
27
42
  // Idempotent: only pending rows are moderated here.
28
43
  if (row.status !== "pending") {
29
44
  return json({ status: row.status === "visible" ? "published" : row.status });
30
45
  }
31
46
 
32
47
  // A poll post's option labels are user content too: moderate them together
33
- // with the question, one verdict for the whole post.
48
+ // with the question, one verdict for the whole post. The polls module is
49
+ // optional — on an install without it the table doesn't exist and the
50
+ // query errors, which simply means "no options".
34
51
  let input = row.content;
35
52
  if (kind === "post") {
36
- const { data: options } = await supabase
53
+ const { data: options, error } = await supabase
37
54
  .from("poll_options")
38
55
  .select("label")
39
56
  .eq("post_id", id)
40
57
  .order("idx");
41
- if (options && options.length > 0) {
58
+ if (!error && options && options.length > 0) {
42
59
  input = [row.content, ...options.map((o) => o.label)].join("\n");
43
60
  }
44
61
  }
@@ -54,9 +71,17 @@ Deno.serve(async (req) => {
54
71
  await supabase
55
72
  .from(table)
56
73
  .update({ status: "hidden", moderation_reason: verdict.reason, moderated_at: now })
57
- .eq("id", id);
74
+ .eq("id", id)
75
+ .eq("status", "pending");
58
76
  return json({ status: "rejected", reason: verdict.reason });
59
77
  }
60
- await supabase.from(table).update({ status: "visible", moderated_at: now }).eq("id", id);
78
+ const { data: updated } = await supabase
79
+ .from(table)
80
+ .update({ status: "visible", moderated_at: now })
81
+ .eq("id", id)
82
+ .eq("status", "pending")
83
+ .select("id");
84
+ // Nothing updated: the author deleted (or a sweep moderated) it meanwhile.
85
+ if (!updated || updated.length === 0) return json({ status: "pending" });
61
86
  return json({ status: "published" });
62
87
  });
@@ -1,29 +1,35 @@
1
- // Real-time comment notification: a pg_net trigger on comments posts the new
2
- // row here. We push to the post author (unless self, opted out, no token, or
3
- // the author has blocked the commenter).
1
+ // Real-time comment notification: a pg_net trigger fires here when a comment
2
+ // becomes visible (published). We push to the post author (unless self, opted
3
+ // out, no token, or the author has blocked the commenter).
4
+ //
5
+ // Security: this function is reachable with the public anon key (the trigger
6
+ // calls it that way), so the body is UNTRUSTED. Only `record.id` is used; the
7
+ // comment is re-read from the database and must be visible — nothing in the
8
+ // push comes from the request itself.
4
9
 
5
10
  import { adminClient } from "../_shared/client.ts";
6
11
  import { sendExpoPush } from "../_shared/push.ts";
7
- import { FALLBACK_ACTOR_NAME } from "../_shared/config.ts";
12
+ import { pushCopy } from "../_shared/copy.ts";
8
13
 
9
14
  const supabase = adminClient();
10
15
 
11
- interface CommentRecord {
12
- id: string;
13
- post_id: string;
14
- author_id: string;
15
- content: string;
16
- }
17
-
18
16
  Deno.serve(async (req) => {
19
- const { record } = (await req.json()) as { record: CommentRecord };
17
+ const { record } = (await req.json().catch(() => ({}))) as { record?: { id?: string } };
18
+ if (!record?.id) return new Response("skip: no id", { status: 400 });
19
+
20
+ const { data: comment } = await supabase
21
+ .from("comments")
22
+ .select("id, post_id, author_id, content, status")
23
+ .eq("id", record.id)
24
+ .single();
25
+ if (!comment || comment.status !== "visible") return new Response("skip: not visible");
20
26
 
21
27
  const { data: post } = await supabase
22
28
  .from("posts")
23
29
  .select("author_id")
24
- .eq("id", record.post_id)
30
+ .eq("id", comment.post_id)
25
31
  .single();
26
- if (!post || post.author_id === record.author_id) return new Response("skip: self/none");
32
+ if (!post || post.author_id === comment.author_id) return new Response("skip: self/none");
27
33
 
28
34
  const { data: pref } = await supabase
29
35
  .from("push_tokens")
@@ -36,25 +42,32 @@ Deno.serve(async (req) => {
36
42
  .from("blocks")
37
43
  .select("blocker_id")
38
44
  .eq("blocker_id", post.author_id)
39
- .eq("blocked_id", record.author_id)
45
+ .eq("blocked_id", comment.author_id)
40
46
  .maybeSingle();
41
47
  if (blocked) return new Response("skip: blocked");
42
48
 
49
+ const { data: recipient } = await supabase
50
+ .from("profiles")
51
+ .select("locale")
52
+ .eq("id", post.author_id)
53
+ .single();
54
+ const copy = pushCopy(recipient?.locale);
55
+
43
56
  const { data: actor } = await supabase
44
57
  .from("profiles")
45
58
  .select("username")
46
- .eq("id", record.author_id)
59
+ .eq("id", comment.author_id)
47
60
  .single();
48
- const name = actor?.username?.trim() || FALLBACK_ACTOR_NAME;
61
+ const name = actor?.username?.trim() || copy.fallbackName;
49
62
  const excerpt =
50
- record.content.length > 140 ? `${record.content.slice(0, 137)}...` : record.content;
63
+ comment.content.length > 140 ? `${comment.content.slice(0, 137)}...` : comment.content;
51
64
  // Title states the event; body is the commenter's name then the comment in
52
65
  // quotes, e.g. Marie: "So happy for you!".
53
66
  const body = `${name}: "${excerpt}"`;
54
67
 
55
68
  await sendExpoPush({
56
69
  to: pref.expo_push_token,
57
- title: "You received a comment",
70
+ title: copy.text("comment.title"),
58
71
  body,
59
72
  data: { route: "/community", kind: "community_comment" },
60
73
  badge: 1,
@@ -1,11 +1,18 @@
1
1
  // Coalesced like notifications. Trigger path (body has record): apply a 10-min
2
2
  // per-post cooldown so bursts do not spam. Digest path (no record, from cron):
3
3
  // sweep posts with unnotified likes older than the cooldown. Either way, group
4
- // as "<name> liked your post" (1) or "<name> and N others liked your post".
4
+ // as "<name> liked your post" (1) or "<name> and N others liked your post",
5
+ // in the recipient's locale (profiles.locale) — see _shared/copy.ts for the
6
+ // built-in copy and the COMMUNITY_PUSH_COPY override.
7
+ //
8
+ // Security: the trigger path only takes `record.post_id` from the (anon-key
9
+ // reachable, hence untrusted) body and re-derives everything from the likes
10
+ // table, so a forged body can at most flush a real post's pending like
11
+ // notifications early.
5
12
 
6
13
  import { adminClient } from "../_shared/client.ts";
7
14
  import { sendExpoPush } from "../_shared/push.ts";
8
- import { FALLBACK_ACTOR_NAME } from "../_shared/config.ts";
15
+ import { pushCopy } from "../_shared/copy.ts";
9
16
 
10
17
  const supabase = adminClient();
11
18
  const COOLDOWN_MS = 10 * 60 * 1000;
@@ -52,16 +59,23 @@ async function flushPost(postId: string) {
52
59
  );
53
60
  if (contributors.length === 0) return;
54
61
 
62
+ const { data: recipient } = await supabase
63
+ .from("profiles")
64
+ .select("locale")
65
+ .eq("id", post.author_id)
66
+ .single();
67
+ const copy = pushCopy(recipient?.locale);
68
+
55
69
  const { data: actor } = await supabase
56
70
  .from("profiles")
57
71
  .select("username")
58
72
  .eq("id", contributors[0].user_id)
59
73
  .single();
60
- const name = actor?.username?.trim() || FALLBACK_ACTOR_NAME;
74
+ const name = actor?.username?.trim() || copy.fallbackName;
61
75
  const title =
62
76
  contributors.length === 1
63
- ? `${name} liked your post`
64
- : `${name} and ${contributors.length - 1} others liked your post`;
77
+ ? copy.text("like.one", { name })
78
+ : copy.text("like.many", { name, count: contributors.length - 1 });
65
79
 
66
80
  await sendExpoPush({
67
81
  to: pref.expo_push_token,
@@ -5,90 +5,22 @@
5
5
  // sweep posts with unnotified reactions older than the cooldown. Never
6
6
  // includes any reaction content — the reaction table carries no text.
7
7
  //
8
- // Push copy: the recipient's profiles.locale selects a built-in neutral
9
- // template for the common single-reactor case (COMMUNITY_REACTION_PUSH_TEXT
10
- // overrides that template for every locale, `{name}` substituted with the
11
- // reactor's display name — an app with its own reaction semantics sets its
12
- // own copy there). The multi-reactor case always uses the locale's own
13
- // "and N others" phrasing; there is no secret for it, only the built-in
14
- // translations below.
8
+ // Push copy: the recipient's profiles.locale selects the built-in neutral
9
+ // copy ("{name} is thinking of you" / "… and N others …") — an app with its
10
+ // own reaction semantics overrides it per locale with the COMMUNITY_PUSH_COPY
11
+ // secret (see _shared/copy.ts; the older COMMUNITY_REACTION_PUSH_TEXT single
12
+ // string still works for the single-reactor case).
15
13
  //
16
- // NOTE: profiles.locale is not part of the SDK's core schema yet (task 16) —
17
- // it must exist before this function can run. See the internal port notes
18
- // for a reference migration adding it.
14
+ // Security: same as notify-like — only `record.post_id` is read from the
15
+ // untrusted body; everything else comes from post_reactions.
19
16
 
20
17
  import { adminClient } from "../_shared/client.ts";
21
18
  import { sendExpoPush } from "../_shared/push.ts";
22
- import { FALLBACK_ACTOR_NAME, REACTION_PUSH_TEXT } from "../_shared/config.ts";
19
+ import { pushCopy } from "../_shared/copy.ts";
23
20
 
24
21
  const supabase = adminClient();
25
22
  const COOLDOWN_MS = 10 * 60 * 1000;
26
23
 
27
- /**
28
- * CLDR plural form for a count: 1 -> `one`, 2-4 -> `few`, 5+ -> `many` (the
29
- * Polish rule, minus the fractional `other` case a push count never hits).
30
- * Every other locale below needs only a singular/plural pair.
31
- */
32
- function plForm(n: number, one: string, few: string, many: string) {
33
- if (n === 1) return one;
34
- const lastTwo = n % 100;
35
- const inTeens = lastTwo >= 10 && lastTwo < 20;
36
- return !inTeens && n % 10 >= 2 && n % 10 <= 4 ? few : many;
37
- }
38
-
39
- // Built-in neutral push copy per recipient locale (profiles.locale). Keys
40
- // mirror SupportedLanguage in host apps; unknown locales fall back to en.
41
- // `one` is only used when COMMUNITY_REACTION_PUSH_TEXT is unset (its default
42
- // already matches the `en` entry below).
43
- const COPY = {
44
- en: {
45
- fallbackName: "Someone",
46
- one: (name: string) => `${name} is thinking of you`,
47
- many: (name: string, extra: number) =>
48
- `${name} and ${extra} ${extra === 1 ? "other" : "others"} are thinking of you`,
49
- },
50
- "pt-PT": {
51
- fallbackName: "Alguém",
52
- one: (name: string) => `${name} está a pensar em ti`,
53
- many: (name: string, extra: number) =>
54
- `${name} e mais ${extra} ${extra === 1 ? "pessoa está" : "pessoas estão"} a pensar em ti`,
55
- },
56
- "pt-BR": {
57
- fallbackName: "Alguém",
58
- one: (name: string) => `${name} está pensando em você`,
59
- many: (name: string, extra: number) =>
60
- `${name} e mais ${extra} ${extra === 1 ? "pessoa está" : "pessoas estão"} pensando em você`,
61
- },
62
- "es-ES": {
63
- fallbackName: "Alguien",
64
- one: (name: string) => `${name} está pensando en ti`,
65
- many: (name: string, extra: number) =>
66
- `${name} y ${extra} ${extra === 1 ? "persona más está" : "personas más están"} pensando en ti`,
67
- },
68
- "es-419": {
69
- fallbackName: "Alguien",
70
- one: (name: string) => `${name} está pensando en ti`,
71
- many: (name: string, extra: number) =>
72
- `${name} y ${extra} ${extra === 1 ? "persona más está" : "personas más están"} pensando en ti`,
73
- },
74
- it: {
75
- fallbackName: "Qualcuno",
76
- one: (name: string) => `${name} sta pensando a te`,
77
- many: (name: string, extra: number) =>
78
- `${name} e altre ${extra} ${extra === 1 ? "persona sta" : "persone stanno"} pensando a te`,
79
- },
80
- pl: {
81
- fallbackName: "Ktoś",
82
- one: (name: string) => `${name} myśli o tobie`,
83
- many: (name: string, extra: number) =>
84
- `${name} i ${plForm(extra, `${extra} inna osoba myśli`, `${extra} inne osoby myślą`, `${extra} innych osób myśli`)} o tobie`,
85
- },
86
- } as const;
87
-
88
- function copyFor(locale: string | null | undefined) {
89
- return COPY[(locale ?? "en") as keyof typeof COPY] ?? COPY.en;
90
- }
91
-
92
24
  async function flushPost(postId: string) {
93
25
  const { data: post } = await supabase.from("posts").select("author_id").eq("id", postId).single();
94
26
  if (!post) return;
@@ -98,7 +30,7 @@ async function flushPost(postId: string) {
98
30
  .select("locale")
99
31
  .eq("id", post.author_id)
100
32
  .single();
101
- const copy = copyFor(recipient?.locale);
33
+ const copy = pushCopy(recipient?.locale);
102
34
 
103
35
  const { data: pref } = await supabase
104
36
  .from("push_tokens")
@@ -138,15 +70,11 @@ async function flushPost(postId: string) {
138
70
  .select("username")
139
71
  .eq("id", contributors[0].user_id)
140
72
  .single();
141
- // COMMUNITY_FALLBACK_NAME (the deployer-set env var, shared with
142
- // notify-like/notify-comment and meant to match the client's
143
- // anonymousAuthorFallback — see docs/backend-runbook.md) takes precedence
144
- // over the locale table's own fallbackName default.
145
- const name = actor?.username?.trim() || FALLBACK_ACTOR_NAME || copy.fallbackName;
73
+ const name = actor?.username?.trim() || copy.fallbackName;
146
74
  const title =
147
75
  contributors.length === 1
148
- ? REACTION_PUSH_TEXT.replace("{name}", name)
149
- : copy.many(name, contributors.length - 1);
76
+ ? copy.text("reaction.one", { name })
77
+ : copy.text("reaction.many", { name, count: contributors.length - 1 });
150
78
 
151
79
  await sendExpoPush({
152
80
  to: pref.expo_push_token,
@@ -2,35 +2,41 @@
2
2
  // record here; we enrich it with the reported account's identity and its
3
3
  // cumulative report count, then alert Slack (no-op if SLACK_WEBHOOK_URL is
4
4
  // unset — see _shared/slack.ts). Bans stay manual (dashboard).
5
+ //
6
+ // Security: reachable with the anon key, so only `record.id` is trusted; the
7
+ // report row is re-read from the database (a forged body can't inject text
8
+ // into the ops channel or probe another user's report count).
5
9
 
6
10
  import { adminClient } from "../_shared/client.ts";
7
11
  import { postToSlack } from "../_shared/slack.ts";
8
12
 
9
13
  const supabase = adminClient();
10
14
 
11
- interface ReportRecord {
12
- reported_user_id: string;
13
- reason: string;
14
- details: string | null;
15
- }
16
-
17
15
  Deno.serve(async (req) => {
18
- const { record } = (await req.json()) as { record: ReportRecord };
16
+ const { record } = (await req.json().catch(() => ({}))) as { record?: { id?: string } };
17
+ if (!record?.id) return new Response("skip: no id", { status: 400 });
18
+
19
+ const { data: report } = await supabase
20
+ .from("reports")
21
+ .select("id, reported_user_id, reason, details")
22
+ .eq("id", record.id)
23
+ .single();
24
+ if (!report) return new Response("skip: unknown report");
19
25
 
20
26
  const { data: reported } = await supabase
21
27
  .from("profiles")
22
28
  .select("username, amplitude_id, revenuecat_id")
23
- .eq("id", record.reported_user_id)
29
+ .eq("id", report.reported_user_id)
24
30
  .single();
25
31
  const { count } = await supabase
26
32
  .from("reports")
27
33
  .select("*", { count: "exact", head: true })
28
- .eq("reported_user_id", record.reported_user_id);
34
+ .eq("reported_user_id", report.reported_user_id);
29
35
 
30
36
  await postToSlack({
31
37
  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` : ""}` +
38
+ `:rotating_light: Report (*${report.reason}*) against *${reported?.username ?? report.reported_user_id}*, ${count ?? "?"} report(s) total\n` +
39
+ `${report.details ? `> ${report.details}\n` : ""}` +
34
40
  `amplitude: \`${reported?.amplitude_id ?? "?"}\` | revenuecat: \`${reported?.revenuecat_id ?? "?"}\``,
35
41
  });
36
42
  return new Response("ok");