@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
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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;
|