Names are leaderboard identity; churn would make records unattributable. An effective change (including clears) stamps name_changed_at; further changes inside 7 days get a bad_input with the unlock time, same-value writes are no-ops, and the sharing toggle is unaffected. UserInfo exposes nameLockedUntil; the Account card shows the lock.
- commit
- 46dbdaf130b19acc3d476dc43869b1650c18af2f
- parent
- d42fe89fbd18d654acea8e85a04b6428af91b61b
- author
- Dami <dami.etoile@gmail.com>
| A | migrations/ | 4 | ++++ |
| M | packages/ | 2 | ++ |
| M | web/ | 8 | +++++++- |
| M | worker/ | 7 | ++++++- |
| M | worker/ | 1 | + |
| M | worker/ | 8 | ++++++++ |
| M | worker/ | 27 | +++++++++++++++++++++++++-- |
| M | worker/ | 2 | +- |
| M | worker/ | 20 | ++++++++++++++++++++ |
@@ -172,6 +172,8 @@ export interface UserInfo {
172172 name: string | null;173173 /** Master toggle: starred scripts are publicly listed while true. */174174 publicSharing: boolean;175+ /** ISO time until which the display name cannot change again (7-day cooldown), or null. */176+ nameLockedUntil: string | null;175177 }176178 177179 // ---------------------------------------------------------------------------@@ -102,7 +102,13 @@ function paintAccount(card: HTMLElement, ctx: AppCtx) {
102102 ),103103 nameMsg,104104 ),105- h('p', { class: 'muted' }, 'Your display name on Browse listings; blank shows your user id.'),105+ h(106+ 'p',107+ { class: 'muted' },108+ user.nameLockedUntil !== null109+ ? `Name changes are limited to once per 7 days - next change ${new Date(user.nameLockedUntil).toLocaleDateString()}.`110+ : 'Your display name on Browse listings; blank shows your user id. Changable once per 7 days.',111+ ),106112 h(107113 'label',108114 { class: 'row toggle-row' },@@ -24,6 +24,7 @@ export interface UserRow {
2424 name: string | null;2525 created_at: number;2626 public_sharing: number;27+ name_changed_at: number | null;2728 }2829 2930 export interface ScriptRow {@@ -191,8 +192,12 @@ export async function updateUserName(
191192 db: D1Database,192193 userId: string,193194 name: string | null,195+ now: number,194196 ): Promise<void> {195- await db.prepare(`UPDATE users SET name = ?2 WHERE id = ?1`).bind(userId, name).run();197+ await db198+ .prepare(`UPDATE users SET name = ?2, name_changed_at = ?3 WHERE id = ?1`)199+ .bind(userId, name, now)200+ .run();196201 }197202 198203 export async function upsertUser(@@ -78,6 +78,7 @@ function makeService(): TribesService {
7878 externalId: 'google:123',7979 name: null,8080 publicSharing: true,81+ nameLockedUntil: null,8182 })),8283 deleteAccount: vi.fn(async () => undefined),8384 heart: vi.fn(async () => undefined),@@ -399,6 +399,10 @@ export async function handleApi(
399399 externalId: row.external_id,400400 name: row.name,401401 publicSharing: row.public_sharing === 1,402+ nameLockedUntil:403+ row.name_changed_at !== null && row.name_changed_at + 7 * 86400 > nowSec()404+ ? isoOf(row.name_changed_at + 7 * 86400)405+ : null,402406 };403407 return json(info);404408 }@@ -577,6 +581,10 @@ async function loginAs(
577581 externalId: row.external_id,578582 name: row.name,579583 publicSharing: row.public_sharing === 1,584+ nameLockedUntil:585+ row.name_changed_at !== null && row.name_changed_at + 7 * 86400 > nowSec()586+ ? isoOf(row.name_changed_at + 7 * 86400)587+ : null,580588 },581589 };582590 }@@ -157,12 +157,21 @@ function toRef(r: db.SimScriptRow): SimScriptRef {
157157 };158158 }159159 160-function userInfoOf(row: db.UserRow): UserInfo {160+const NAME_COOLDOWN_SECONDS = 7 * 24 * 3600;161+162+function nameLockedUntilOf(row: db.UserRow, now: number): string | null {163+ if (row.name_changed_at === null) return null;164+ const until = row.name_changed_at + NAME_COOLDOWN_SECONDS;165+ return until > now ? isoOf(until) : null;166+}167+168+function userInfoOf(row: db.UserRow, now = nowSec()): UserInfo {161169 return {162170 id: row.id,163171 externalId: row.external_id,164172 name: row.name,165173 publicSharing: row.public_sharing === 1,174+ nameLockedUntil: nameLockedUntilOf(row, now),166175 };167176 }168177 @@ -431,8 +440,22 @@ export function createService(env: Env, deps: ServiceDeps): TribesService {
431440 await db.setUserSharing(D, user.userId, input.publicSharing);432441 }433442 if (input.name !== undefined) {443+ const now = nowSec();444+ const current = await db.getUserById(D, user.userId);445+ if (!current) throw new ServiceError('not_found', 'user no longer exists');434446 const name = input.name === null ? null : sanitizeMeta(input.name, 40);435- await db.updateUserName(D, user.userId, name === '' ? null : name);447+ const next = name === '' ? null : name;448+ // Same value = no-op; an effective change consumes the 7-day cooldown.449+ if (next !== current.name) {450+ const lockedUntil = nameLockedUntilOf(current, now);451+ if (lockedUntil !== null) {452+ throw new ServiceError(453+ 'bad_input',454+ `display name changes are limited to once per 7 days (next change ${lockedUntil})`,455+ );456+ }457+ await db.updateUserName(D, user.userId, next, now);458+ }436459 }437460 const row = await db.getUserById(D, user.userId);438461 if (!row) throw new ServiceError('not_found', 'user no longer exists');@@ -9,7 +9,7 @@ import { DatabaseSync } from 'node:sqlite';
99 import { readFileSync } from 'node:fs';1010 import { fileURLToPath } from 'node:url';1111 12-const MIGRATIONS = ['0001_init.sql', '0002_sim_metrics.sql', '0003_social.sql', '0004_r2_metrics.sql', '0005_keypair_auth.sql', '0006_link_codes.sql'];12+const MIGRATIONS = ['0001_init.sql', '0002_sim_metrics.sql', '0003_social.sql', '0004_r2_metrics.sql', '0005_keypair_auth.sql', '0006_link_codes.sql', '0007_name_cooldown.sql'];1313 1414 type Param = string | number | null | Uint8Array;1515 @@ -548,3 +548,23 @@ describe('sweep integration', () => {
548548 expect(await db.getSimById(h.D, publicSim.id, null)).toBeNull();549549 });550550 });551+552+describe('display-name cooldown', () => {553+ it('allows the first change, blocks a second within 7 days, no-ops same value', async () => {554+ const h = makeHarness();555+ const u = await h.user('renamer');556+ const first = await h.service.setSharing(u, { name: 'dami' });557+ expect(first.name).toBe('dami');558+ expect(first.nameLockedUntil).not.toBeNull();559+ // Same value: no-op, no error.560+ const same = await h.service.setSharing(u, { name: 'dami' });561+ expect(same.name).toBe('dami');562+ // Different value inside the window: blocked.563+ await expect(h.service.setSharing(u, { name: 'other' })).rejects.toMatchObject({564+ code: 'bad_input',565+ });566+ // Sharing toggle is unaffected by the name lock.567+ const toggled = await h.service.setSharing(u, { publicSharing: true });568+ expect(toggled.publicSharing).toBe(true);569+ });570+});