@rocapine/community 0.11.0 → 0.12.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rocapine/community",
3
- "version": "0.11.0",
3
+ "version": "0.12.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": {
@@ -0,0 +1,53 @@
1
+ // Display name / handle rules shared by update-profile and the daily sweep:
2
+ // the app's own name is reserved (no "Unchaind Team" impersonating the app),
3
+ // a few staff-sounding handles too, and a taken handle gets a free suggestion.
4
+
5
+ const APP_NAME = Deno.env.get("COMMUNITY_APP_NAME") ?? "";
6
+
7
+ /** Lowercase, accents stripped, letters and digits only. */
8
+ function collapse(s: string): string {
9
+ return s
10
+ .normalize("NFD")
11
+ .replace(/\p{Diacritic}/gu, "")
12
+ .toLowerCase()
13
+ .replace(/[^a-z0-9]/g, "");
14
+ }
15
+
16
+ /** True when `value` contains the app's name. Short names (< 4 chars, "Eve") only match a whole word so "Steve" stays allowed. */
17
+ export function containsAppName(value: string, appName: string = APP_NAME): boolean {
18
+ const app = collapse(appName);
19
+ if (app === "") return false;
20
+ if (app.length >= 4) return collapse(value).includes(app);
21
+ return value
22
+ .normalize("NFD")
23
+ .replace(/\p{Diacritic}/gu, "")
24
+ .toLowerCase()
25
+ .split(/[^a-z0-9]+/)
26
+ .includes(app);
27
+ }
28
+
29
+ const RESERVED_HANDLES = ["admin", "support", "official", "team", "moderator"];
30
+
31
+ export function isReservedHandle(handle: string, appName: string = APP_NAME): boolean {
32
+ return RESERVED_HANDLES.includes(handle) || containsAppName(handle, appName);
33
+ }
34
+
35
+ /**
36
+ * A free handle on `handle`'s base: "superuser" -> "superuser-4821". Same
37
+ * digit widening as generate_handle (core/009). `isFree` checks the table.
38
+ */
39
+ export async function suggestHandle(
40
+ handle: string,
41
+ isFree: (candidate: string) => Promise<boolean>,
42
+ ): Promise<string | null> {
43
+ const base = handle.replace(/-[0-9]+$/, "").slice(0, 15) || "user";
44
+ for (let tries = 0; tries < 10; tries++) {
45
+ const digits =
46
+ tries < 5
47
+ ? Math.floor(Math.random() * 9000) + 1000
48
+ : Math.floor(Math.random() * 900000) + 100000;
49
+ const candidate = `${base}-${digits}`;
50
+ if (await isFree(candidate)) return candidate;
51
+ }
52
+ return null;
53
+ }
@@ -3,7 +3,8 @@
3
3
  // 'pending' -> promote to 'visible' (catches items whose synchronous
4
4
  // moderate-one call failed), clean 'visible' -> just stamp. Also sweeps
5
5
  // not-yet-checked usernames (profiles.username is client-writable, see
6
- // core/007_username_moderation.sql): flagged -> blanked. Posts a Slack
6
+ // core/007_username_moderation.sql): flagged or containing the app's name ->
7
+ // blanked, i.e. back to the default name (core/009). Posts a Slack
7
8
  // summary when there was something to check (no-op if SLACK_WEBHOOK_URL is
8
9
  // unset — see _shared/slack.ts). Never deletes rows. App criticism is not a
9
10
  // category (stays visible).
@@ -20,6 +21,7 @@ import {
20
21
  type ModerationResult,
21
22
  } from "../_shared/moderation.ts";
22
23
  import { fetchImagePaths, POST_IMAGES_BUCKET, postImageUrl } from "../_shared/images.ts";
24
+ import { containsAppName } from "../_shared/names.ts";
23
25
  import { postToSlack } from "../_shared/slack.ts";
24
26
 
25
27
  assertModerationConfigured();
@@ -109,6 +111,7 @@ async function sweepUsernames(): Promise<{
109
111
  }
110
112
  results.forEach((r, idx) => {
111
113
  const cats = flaggedCategories(r);
114
+ if (containsAppName(batch[idx].username)) cats.push("app_name");
112
115
  if (cats.length > 0) blanked.push({ profile: batch[idx], categories: cats });
113
116
  });
114
117
  processed.push(...batch);
@@ -11,6 +11,7 @@
11
11
  import { createClient } from "jsr:@supabase/supabase-js@2";
12
12
  import { adminClient, json, SUPABASE_URL } from "../_shared/client.ts";
13
13
  import { assertModerationConfigured, moderateInput } from "../_shared/moderation.ts";
14
+ import { containsAppName, isReservedHandle, suggestHandle } from "../_shared/names.ts";
14
15
 
15
16
  assertModerationConfigured();
16
17
 
@@ -54,12 +55,17 @@ Deno.serve(async (req) => {
54
55
  if (u.length < 2 || u.length > 24) {
55
56
  return json({ status: "error", error: "invalid_username" }, 400);
56
57
  }
58
+ if (containsAppName(u)) return json({ status: "error", error: "reserved_name" }, 400);
57
59
  const verdict = await moderateInput(u);
58
60
  if (!verdict) return json({ status: "error", error: "moderation unavailable" }, 503);
59
61
  if (verdict.flagged) {
60
62
  return json({ status: "rejected", field: "username", reason: verdict.reason });
61
63
  }
62
- const { error } = await admin.from("profiles").update({ username: u }).eq("id", uid);
64
+ // The user's own name: the host app's sync no longer overwrites it (core/009).
65
+ const { error } = await admin
66
+ .from("profiles")
67
+ .update({ username: u, username_set_by_user: true })
68
+ .eq("id", uid);
63
69
  if (error) return json({ status: "error", error: error.message }, 500);
64
70
  }
65
71
 
@@ -69,15 +75,26 @@ Deno.serve(async (req) => {
69
75
  if (!/^[a-z0-9-]{3,20}$/.test(h)) {
70
76
  return json({ status: "error", error: "invalid_handle" }, 400);
71
77
  }
78
+ if (isReservedHandle(h)) return json({ status: "error", error: "reserved_handle" }, 400);
72
79
  const verdict = await moderateInput(h);
73
80
  if (!verdict) return json({ status: "error", error: "moderation unavailable" }, 503);
74
81
  if (verdict.flagged) {
75
82
  return json({ status: "rejected", field: "handle", reason: verdict.reason });
76
83
  }
77
- const { error } = await admin.from("profiles").update({ handle: h }).eq("id", uid);
84
+ const { error } = await admin
85
+ .from("profiles")
86
+ .update({ handle: h, handle_set_by_user: true })
87
+ .eq("id", uid);
78
88
  if (error) {
79
89
  if (error.code === "23505") {
80
- return json({ status: "error", code: "handle_taken" }, 409);
90
+ const suggestion = await suggestHandle(h, async (candidate) => {
91
+ const { count } = await admin
92
+ .from("profiles")
93
+ .select("id", { count: "exact", head: true })
94
+ .eq("handle", candidate);
95
+ return count === 0;
96
+ });
97
+ return json({ status: "error", code: "handle_taken", suggestion }, 409);
81
98
  }
82
99
  return json({ status: "error", error: error.message }, 500);
83
100
  }
@@ -0,0 +1,199 @@
1
+ -- community-sdk core/009_default_names.sql
2
+ -- Default names: every profile gets "<label> <digits>" / "<prefix>-<digits>"
3
+ -- (assign_default_profile, called by the client once per launch). The host
4
+ -- app's name seeds the profile until the user edits it through update-profile;
5
+ -- from then on a client write of `username` is ignored (the user wins). See
6
+ -- docs/superpowers/specs/2026-10-26-default-names-design.md.
7
+
8
+ alter table public.profiles
9
+ add column if not exists default_username text,
10
+ add column if not exists default_handle text,
11
+ add column if not exists username_set_by_user boolean not null default false,
12
+ add column if not exists handle_set_by_user boolean not null default false;
13
+
14
+ -- generate_handle with the slug to use when `base` has no usable letters
15
+ -- (a Korean or emoji-only name): the app's handle prefix instead of "member".
16
+ create or replace function public.generate_handle(base text, fallback text)
17
+ returns text
18
+ language plpgsql volatile security definer
19
+ set search_path = public, extensions
20
+ as $$
21
+ declare
22
+ slug text;
23
+ candidate text;
24
+ tries int := 0;
25
+ begin
26
+ slug := left(regexp_replace(lower(unaccent(coalesce(base, ''))), '[^a-z0-9]', '', 'g'), 12);
27
+ if slug = '' then
28
+ slug := left(regexp_replace(lower(coalesce(fallback, '')), '[^a-z0-9]', '', 'g'), 12);
29
+ end if;
30
+ if slug = '' then
31
+ slug := 'member';
32
+ end if;
33
+ loop
34
+ if tries < 5 then
35
+ candidate := slug || '-' || (floor(random() * 9000) + 1000)::int;
36
+ else
37
+ candidate := slug || '-' || (floor(random() * 900000) + 100000)::int;
38
+ end if;
39
+ exit when not exists (select 1 from public.profiles where handle = candidate);
40
+ tries := tries + 1;
41
+ end loop;
42
+ return candidate;
43
+ end;
44
+ $$;
45
+
46
+ create or replace function public.generate_handle(base text)
47
+ returns text
48
+ language sql volatile security definer
49
+ set search_path = public, extensions
50
+ as $$ select public.generate_handle(base, 'member') $$;
51
+
52
+ -- The default handle when it is still free, else a fresh one on its prefix.
53
+ create or replace function public.default_handle_for(p_id uuid, p_default text)
54
+ returns text
55
+ language plpgsql volatile security definer
56
+ set search_path = public, extensions
57
+ as $$
58
+ begin
59
+ if p_default is null then
60
+ return null;
61
+ end if;
62
+ if not exists (select 1 from public.profiles where handle = p_default and id <> p_id) then
63
+ return p_default;
64
+ end if;
65
+ return public.generate_handle('', regexp_replace(p_default, '-[0-9]+$', ''));
66
+ end;
67
+ $$;
68
+
69
+ -- One trigger owns username + handle consistency (replaces core/001's
70
+ -- assign_handle and core/007's profiles_username_changed, whose relative order
71
+ -- mattered). "Trusted" writes are update-profile (service role) and this
72
+ -- file's security definer functions (they set community.trusted).
73
+ create or replace function public.profiles_names()
74
+ returns trigger
75
+ language plpgsql security definer
76
+ set search_path = public, extensions
77
+ as $$
78
+ declare
79
+ trusted boolean := coalesce(auth.role(), '') = 'service_role'
80
+ or coalesce(current_setting('community.trusted', true), '') = 'on';
81
+ prefix text;
82
+ begin
83
+ if tg_op = 'UPDATE' and new.username is distinct from old.username then
84
+ if not trusted and old.username_set_by_user then
85
+ -- Host sync after the user picked their own name: the user wins.
86
+ new.username := old.username;
87
+ elsif new.username is null
88
+ or (new.username = old.username_rejected and not trusted) then
89
+ -- Blanked (moderation sweep, old clients, account scrub) or a re-sync of
90
+ -- a name the sweep already blanked: back to the default name.
91
+ new.username := old.default_username;
92
+ new.username_checked_at := now();
93
+ elsif new.username is not distinct from new.default_username then
94
+ new.username_checked_at := now();
95
+ else
96
+ -- A genuinely new name: re-check it on the next sweep.
97
+ new.username_checked_at := null;
98
+ end if;
99
+ end if;
100
+
101
+ if new.username is not null and new.handle is null then
102
+ new.handle := public.generate_handle(new.username,
103
+ regexp_replace(coalesce(new.default_handle, ''), '-[0-9]+$', ''));
104
+ elsif tg_op = 'UPDATE'
105
+ and not new.handle_set_by_user
106
+ and not new.username_set_by_user
107
+ and new.username is distinct from old.username
108
+ and new.handle is not distinct from old.handle then
109
+ -- The host's name changed (or the sweep blanked it) and the user never
110
+ -- picked a handle: follow it.
111
+ if new.username is not distinct from new.default_username then
112
+ new.handle := coalesce(public.default_handle_for(new.id, new.default_handle), new.handle);
113
+ else
114
+ prefix := regexp_replace(coalesce(new.default_handle, ''), '-[0-9]+$', '');
115
+ new.handle := public.generate_handle(new.username, prefix);
116
+ end if;
117
+ end if;
118
+ return new;
119
+ end;
120
+ $$;
121
+
122
+ drop trigger if exists profiles_assign_handle on public.profiles;
123
+ drop trigger if exists profiles_username_changed_trg on public.profiles;
124
+ drop function if exists public.assign_handle();
125
+ drop function if exists public.profiles_username_changed();
126
+
127
+ create trigger profiles_names_trg
128
+ before insert or update of username, handle on public.profiles
129
+ for each row execute function public.profiles_names();
130
+
131
+ -- Called by the client once per launch; idempotent. `p_label` is the app's
132
+ -- default name in the user's language ("Frère"), `p_handle` its ASCII handle
133
+ -- prefix ("brother"). Fills username / handle only when still null.
134
+ create or replace function public.assign_default_profile(p_label text, p_handle text)
135
+ returns void
136
+ language plpgsql volatile security definer
137
+ set search_path = public, extensions
138
+ as $$
139
+ declare
140
+ uid uuid := auth.uid();
141
+ label text := left(btrim(coalesce(p_label, '')), 17);
142
+ h text;
143
+ name text;
144
+ begin
145
+ if uid is null then
146
+ return;
147
+ end if;
148
+ if exists (select 1 from public.profiles where id = uid and default_username is not null) then
149
+ return;
150
+ end if;
151
+ if label = '' then
152
+ label := 'User';
153
+ end if;
154
+ h := public.generate_handle('', coalesce(nullif(p_handle, ''), 'user'));
155
+ name := label || ' ' || substring(h from '-([0-9]+)$');
156
+ perform set_config('community.trusted', 'on', true);
157
+ update public.profiles
158
+ set default_username = name,
159
+ default_handle = h,
160
+ username = coalesce(username, name),
161
+ handle = coalesce(handle, h)
162
+ where id = uid;
163
+ perform set_config('community.trusted', '', true);
164
+ end;
165
+ $$;
166
+
167
+ -- Account deletion: default name and handle back, bio / avatar / edits cleared.
168
+ create or replace function public.reset_profile_identity()
169
+ returns void
170
+ language plpgsql volatile security definer
171
+ set search_path = public, extensions
172
+ as $$
173
+ declare
174
+ uid uuid := auth.uid();
175
+ begin
176
+ if uid is null then
177
+ return;
178
+ end if;
179
+ perform set_config('community.trusted', 'on', true);
180
+ update public.profiles
181
+ set username = default_username,
182
+ handle = coalesce(public.default_handle_for(id, default_handle), handle),
183
+ username_set_by_user = false,
184
+ handle_set_by_user = false,
185
+ bio = null,
186
+ avatar_url = null
187
+ where id = uid;
188
+ perform set_config('community.trusted', '', true);
189
+ end;
190
+ $$;
191
+
192
+ revoke execute on function public.generate_handle(text, text) from public, anon, authenticated;
193
+ revoke execute on function public.generate_handle(text) from public, anon, authenticated;
194
+ revoke execute on function public.default_handle_for(uuid, text) from public, anon, authenticated;
195
+ revoke execute on function public.profiles_names() from public, anon, authenticated;
196
+ revoke execute on function public.assign_default_profile(text, text) from public, anon;
197
+ revoke execute on function public.reset_profile_identity() from public, anon;
198
+ grant execute on function public.assign_default_profile(text, text) to authenticated;
199
+ grant execute on function public.reset_profile_identity() to authenticated;