@@ -6,7 +6,7 @@ const DB_VISIBILITIES: DbVisibility[] = ["private", "public_read", "public_write
66const ROLES : ResourceRole [ ] = [ "viewer" , "editor" , "admin" ] ;
77
88let schemaReady : Promise < void > | null = null ;
9- const SCHEMA_VERSION = "2026-04-07a " ;
9+ const SCHEMA_VERSION = "2026-05-03a " ;
1010type AccountState = "pending" | "approved" | "revoked" ;
1111
1212const dbVisibilityFromVisibility = ( value : Visibility ) : DbVisibility => {
@@ -69,7 +69,7 @@ const slugifyName = (value: string): string =>
6969 . replace ( / ^ - + | - + $ / g, "" )
7070 . replace ( / - { 2 , } / g, "-" ) ;
7171
72- const DELIMITER_CHARS = / [ + < > ~ \ /] / g;
72+ const DELIMITER_CHARS = / [ + < > ~ / ] / g;
7373const VARIATION_SELECTORS = / [ \uFE0E \uFE0F ] / g;
7474
7575export const canonicalizeSimulationLookupKey = ( value : string ) : string =>
@@ -172,16 +172,6 @@ const sanitizeDefaultFrequencyPresetId = (value: unknown): string | null | undef
172172 return trimmed ;
173173} ;
174174
175- const deriveDefaultName = ( userId : string , tokenPayload ?: Record < string , unknown > ) : string => {
176- const fromName = sanitizeName ( tokenPayload ?. name ) ;
177- if ( fromName ) return fromName ;
178- const fromEmail = sanitizeEmail ( tokenPayload ?. email ) ;
179- if ( fromEmail ) return fromEmail . split ( "@" ) [ 0 ] ;
180- const prefix = userId . includes ( "@" ) ? userId . split ( "@" ) [ 0 ] : userId ;
181- const compact = prefix . replace ( / [ _ - ] + / g, " " ) . trim ( ) ;
182- return sanitizeName ( compact ) ?? `User ${ userId . slice ( 0 , 6 ) } ` ;
183- } ;
184-
185175const deriveDefaultEmail = ( userId : string , tokenPayload ?: Record < string , unknown > ) : string => {
186176 const fromEmail = sanitizeEmail ( tokenPayload ?. email ) ;
187177 if ( fromEmail ) return fromEmail ;
@@ -216,16 +206,12 @@ const parseAdminUserIds = (env: Env): Set<string> => {
216206 ) ;
217207} ;
218208
219- const registrationMode = ( env : Env ) : "open" | "approval_required" => {
220- const value = ( env . REGISTRATION_MODE ?? "approval_required" ) . trim ( ) . toLowerCase ( ) ;
221- return value === "open" ? "open" : "approval_required" ;
222- } ;
223-
224209const REQUIRED_COLUMNS : Record < string , string [ ] > = {
225210 users : [
226211 "id" ,
227212 "username" ,
228213 "email" ,
214+ "username_set_at" ,
229215 "bio" ,
230216 "access_request_note" ,
231217 "idp_email" ,
@@ -320,6 +306,7 @@ const ensureSchema = async (env: Env): Promise<void> => {
320306 id TEXT PRIMARY KEY,
321307 username TEXT,
322308 email TEXT,
309+ username_set_at TEXT,
323310 bio TEXT,
324311 access_request_note TEXT,
325312 idp_email TEXT,
@@ -441,6 +428,32 @@ const ensureSchema = async (env: Env): Promise<void> => {
441428 if ( ! userColumns . has ( "default_frequency_preset_id" ) ) {
442429 await env . DB . prepare ( "ALTER TABLE users ADD COLUMN default_frequency_preset_id TEXT" ) . run ( ) ;
443430 }
431+ if ( ! userColumns . has ( "username_set_at" ) ) {
432+ await env . DB . prepare ( "ALTER TABLE users ADD COLUMN username_set_at TEXT" ) . run ( ) ;
433+ await env . DB
434+ . prepare (
435+ `UPDATE users
436+ SET username_set_at = COALESCE(updated_at, created_at)
437+ WHERE COALESCE(TRIM(username), '') != ''` ,
438+ )
439+ . run ( ) ;
440+ }
441+
442+ const now = new Date ( ) . toISOString ( ) ;
443+ await env . DB
444+ . prepare (
445+ `UPDATE users
446+ SET is_approved = 1,
447+ approved_at = COALESCE(approved_at, ?),
448+ approved_by_user_id = COALESCE(approved_by_user_id, 'system:open-registration'),
449+ updated_at = ?
450+ WHERE is_admin = 0
451+ AND is_moderator = 0
452+ AND is_approved = 0
453+ AND (approved_by_user_id IS NULL OR approved_by_user_id NOT LIKE 'revoked:%')` ,
454+ )
455+ . bind ( now , now )
456+ . run ( ) ;
444457
445458 const diagnostics = await getSchemaDiagnostics ( env ) ;
446459 if ( ! diagnostics . ok ) {
@@ -462,6 +475,7 @@ type UserRow = {
462475 id : string ;
463476 username : string | null ;
464477 email : string | null ;
478+ username_set_at : string | null ;
465479 bio : string | null ;
466480 access_request_note : string | null ;
467481 idp_email : string | null ;
@@ -513,7 +527,8 @@ export const chooseIdentityReconcileCandidate = (
513527
514528const toUserProfile = ( row : UserRow ) => ( {
515529 id : row . id ,
516- username : sanitizeName ( row . username ) ?? "User" ,
530+ username : sanitizeName ( row . username ) ?? "" ,
531+ needsUsername : ! row . username_set_at ,
517532 email : sanitizeEmail ( row . email ) ?? "unknown@users.linksim.local" ,
518533 bio : row . bio ?? "" ,
519534 accessRequestNote : row . access_request_note ?? "" ,
@@ -554,7 +569,7 @@ const readUserRow = async (env: Env, userId: string): Promise<UserRow | null> =>
554569 await ensureSchema ( env ) ;
555570 return env . DB
556571 . prepare (
557- "SELECT id, username, email, bio, access_request_note, idp_email, idp_email_verified, avatar_url, email_public, default_frequency_preset_id, avatar_object_key, avatar_thumb_key, avatar_hash, avatar_bytes, avatar_content_type, is_admin, is_moderator, is_approved, approved_at, approved_by_user_id, created_at, updated_at FROM users WHERE id = ?" ,
572+ "SELECT id, username, email, username_set_at, bio, access_request_note, idp_email, idp_email_verified, avatar_url, email_public, default_frequency_preset_id, avatar_object_key, avatar_thumb_key, avatar_hash, avatar_bytes, avatar_content_type, is_admin, is_moderator, is_approved, approved_at, approved_by_user_id, created_at, updated_at FROM users WHERE id = ?" ,
558573 )
559574 . bind ( userId )
560575 . first < UserRow > ( ) ;
@@ -570,7 +585,7 @@ const reconcileUserIdentityByIdpEmail = async (
570585
571586 const rows = await env . DB
572587 . prepare (
573- `SELECT id, username, email, bio, access_request_note, idp_email, idp_email_verified, avatar_url, email_public, default_frequency_preset_id, avatar_object_key, avatar_thumb_key, avatar_hash, avatar_bytes, avatar_content_type, is_admin, is_moderator, is_approved, approved_at, approved_by_user_id, created_at, updated_at,
588+ `SELECT id, username, email, username_set_at, bio, access_request_note, idp_email, idp_email_verified, avatar_url, email_public, default_frequency_preset_id, avatar_object_key, avatar_thumb_key, avatar_hash, avatar_bytes, avatar_content_type, is_admin, is_moderator, is_approved, approved_at, approved_by_user_id, created_at, updated_at,
574589 CASE
575590 WHEN lower(idp_email) = lower(?) AND idp_email_verified = 1 THEN 'verified_idp_email'
576591 WHEN lower(email) = lower(?) THEN 'legacy_email'
@@ -684,61 +699,57 @@ export const ensureUser = async (
684699 await env . DB . prepare ( "DELETE FROM deleted_users WHERE id = ?" ) . bind ( userId ) . run ( ) ;
685700 }
686701 const now = new Date ( ) . toISOString ( ) ;
687- const username = deriveDefaultName ( userId , tokenPayload ) ;
688702 const email = deriveDefaultEmail ( userId , tokenPayload ) ;
689703 const idpEmail = deriveVerifiedIdpEmail ( tokenPayload ) ;
690704 const idpEmailVerified = idpEmail ? 1 : 0 ;
691705 const isBootstrapAdmin = parseAdminUserIds ( env ) . has ( userId . toLowerCase ( ) ) ? 1 : 0 ;
692- const autoApprove = isBootstrapAdmin === 1 || registrationMode ( env ) === "open" ;
706+ const autoApprove = 1 ;
693707
694708 await env . DB . prepare (
695709 `INSERT OR IGNORE INTO users
696- (id, username, email, bio, access_request_note, idp_email, idp_email_verified, avatar_url, email_public, avatar_object_key, avatar_thumb_key, avatar_hash, avatar_bytes, avatar_content_type, is_admin, is_moderator, is_approved, approved_at, approved_by_user_id, created_at, updated_at)
697- VALUES (?, ? , ?, '', '', ?, ?, '', 1, NULL, NULL, NULL, NULL, NULL, ?, ?, ?, ?, ?, ?, ?)` ,
710+ (id, username, email, username_set_at, bio, access_request_note, idp_email, idp_email_verified, avatar_url, email_public, avatar_object_key, avatar_thumb_key, avatar_hash, avatar_bytes, avatar_content_type, is_admin, is_moderator, is_approved, approved_at, approved_by_user_id, created_at, updated_at)
711+ VALUES (?, '' , ?, NULL , '', '', ?, ?, '', 1, NULL, NULL, NULL, NULL, NULL, ?, ?, ?, ?, ?, ?, ?)` ,
698712 )
699713 . bind (
700714 userId ,
701- username ,
702715 email ,
703716 idpEmail || null ,
704717 idpEmailVerified ,
705718 isBootstrapAdmin ,
706719 0 ,
707- autoApprove ? 1 : 0 ,
708- autoApprove ? now : null ,
709- autoApprove ? userId : null ,
720+ autoApprove ,
721+ now ,
722+ "system:open-registration" ,
710723 now ,
711724 now ,
712725 )
713726 . run ( ) ;
714727
715728 await env . DB . prepare (
716729 `UPDATE users
717- SET username = COALESCE(NULLIF(TRIM(username), ''), ?),
718- email = COALESCE(NULLIF(TRIM(email), ''), ?),
730+ SET email = COALESCE(NULLIF(TRIM(email), ''), ?),
719731 idp_email = CASE WHEN ? = 1 THEN COALESCE(NULLIF(TRIM(idp_email), ''), ?) ELSE idp_email END,
720732 idp_email_verified = CASE WHEN ? = 1 THEN 1 ELSE idp_email_verified END,
721733 is_admin = CASE WHEN ? = 1 THEN 1 ELSE is_admin END,
722734 is_moderator = CASE WHEN ? = 1 THEN 1 ELSE is_moderator END,
723- is_approved = CASE WHEN ? = 1 THEN 1 ELSE is_approved END,
735+ is_approved = CASE WHEN ? = 1 AND (approved_by_user_id IS NULL OR approved_by_user_id NOT LIKE 'revoked:%') THEN 1 ELSE is_approved END,
724736 approved_at = CASE WHEN ? = 1 AND approved_at IS NULL THEN ? ELSE approved_at END,
725737 approved_by_user_id = CASE WHEN ? = 1 AND approved_by_user_id IS NULL THEN ? ELSE approved_by_user_id END,
726738 updated_at = ?
727739 WHERE id = ?` ,
728740 )
729741 . bind (
730- username ,
731742 email ,
732743 idpEmailVerified ,
733744 idpEmail || null ,
734745 idpEmailVerified ,
735746 isBootstrapAdmin ,
736747 0 ,
737- autoApprove ? 1 : 0 ,
738- autoApprove ? 1 : 0 ,
748+ autoApprove ,
749+ autoApprove ,
739750 now ,
740- autoApprove ? 1 : 0 ,
741- userId ,
751+ autoApprove ,
752+ "system:open-registration" ,
742753 now ,
743754 userId ,
744755 )
@@ -812,6 +823,7 @@ export const updateUserProfile = async (
812823 await env . DB . prepare (
813824 `UPDATE users
814825 SET username = ?,
826+ username_set_at = CASE WHEN ? = 1 THEN COALESCE(username_set_at, ?) ELSE username_set_at END,
815827 email = ?,
816828 bio = ?,
817829 access_request_note = ?,
@@ -828,6 +840,8 @@ export const updateUserProfile = async (
828840 )
829841 . bind (
830842 nextName ,
843+ patch . username === undefined ? 0 : 1 ,
844+ new Date ( ) . toISOString ( ) ,
831845 nextEmail ,
832846 nextBio ,
833847 nextAccessRequestNote ,
@@ -910,7 +924,7 @@ export const listUsers = async (env: Env) => {
910924 await ensureSchema ( env ) ;
911925 const rows = await env . DB
912926 . prepare (
913- "SELECT id, username, email, bio, access_request_note, idp_email, idp_email_verified, avatar_url, email_public, default_frequency_preset_id, avatar_object_key, avatar_thumb_key, avatar_hash, avatar_bytes, avatar_content_type, is_admin, is_moderator, is_approved, approved_at, approved_by_user_id, created_at, updated_at FROM users ORDER BY created_at DESC LIMIT 2000" ,
927+ "SELECT id, username, email, username_set_at, bio, access_request_note, idp_email, idp_email_verified, avatar_url, email_public, default_frequency_preset_id, avatar_object_key, avatar_thumb_key, avatar_hash, avatar_bytes, avatar_content_type, is_admin, is_moderator, is_approved, approved_at, approved_by_user_id, created_at, updated_at FROM users ORDER BY created_at DESC LIMIT 2000" ,
914928 )
915929 . all < UserRow > ( ) ;
916930 return rows . results . map ( toUserProfile ) ;
0 commit comments