Skip to content

Commit 848771b

Browse files
feat(backend): username validation, search/suggestion endpoints and registry indices
Implements the username/auth slice across four issues. #560 — Sync src/db/schema.sql with the privy_did / privy_linked_at columns (the reference schema had drifted from the applied migrations) and add a migration that re-states the Privy identity schema idempotently, so it applies on a database where the columns were added out of band. Ships rollback scripts under migrations/rollback/. #541 — Add a unique index on LOWER(username) so the registry is case-insensitively unique at the database level, plus a text_pattern_ops index over the same expression to serve prefix lookups. #545 — Add validate_username / validate_username_prefix helpers (lowercase ASCII alphanumeric, 3-15 chars) with unit tests, and reject malformed input in the resolve and suggestion handlers before it reaches Redis or Postgres. #540 — Add GET /api/users/suggestions, a paginated username autocomplete returning profile avatars and a total match count. Harden GET /api/users/search: escape LIKE metacharacters (a `q` of `%` previously matched every row), match usernames case-insensitively via the new index, and clamp limit/offset. Its response stays a bare array so the dashboard and mobile app are unaffected. Closes #540 Closes #541 Closes #545 Closes #560
1 parent d207843 commit 848771b

9 files changed

Lines changed: 513 additions & 8 deletions

backend/docs/openapi.yaml

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,27 @@ components:
153153
type: string
154154
nullable: true
155155

156+
UserSuggestionsResponse:
157+
type: object
158+
properties:
159+
query:
160+
type: string
161+
description: The normalised (lowercased, trimmed) prefix that was matched.
162+
results:
163+
type: array
164+
items:
165+
$ref: '#/components/schemas/SearchUser'
166+
limit:
167+
type: integer
168+
offset:
169+
type: integer
170+
total:
171+
type: integer
172+
format: int64
173+
description: Total matches for the prefix, ignoring limit/offset.
174+
has_more:
175+
type: boolean
176+
156177
FriendUser:
157178
type: object
158179
properties:
@@ -695,9 +716,31 @@ paths:
695716
- name: q
696717
in: query
697718
required: true
698-
description: Search term matched against username and address (ILIKE)
719+
description: >-
720+
Prefix matched case-insensitively against username, and
721+
case-sensitively against address. LIKE metacharacters are escaped
722+
and matched literally.
699723
schema:
700724
type: string
725+
minLength: 1
726+
maxLength: 64
727+
- name: limit
728+
in: query
729+
required: false
730+
description: Page size, clamped to 1-50.
731+
schema:
732+
type: integer
733+
default: 20
734+
minimum: 1
735+
maximum: 50
736+
- name: offset
737+
in: query
738+
required: false
739+
description: Rows to skip; negative values are clamped to 0.
740+
schema:
741+
type: integer
742+
default: 0
743+
minimum: 0
701744
responses:
702745
'200':
703746
description: Matching users
@@ -707,6 +750,74 @@ paths:
707750
type: array
708751
items:
709752
$ref: '#/components/schemas/SearchUser'
753+
'400':
754+
$ref: '#/components/responses/BadRequest'
755+
'401':
756+
description: Missing or invalid JWT
757+
content:
758+
application/json:
759+
schema:
760+
$ref: '#/components/schemas/ErrorResponse'
761+
'429':
762+
$ref: '#/components/responses/TooManyRequests'
763+
'500':
764+
description: Internal server error
765+
content:
766+
application/json:
767+
schema:
768+
$ref: '#/components/schemas/ErrorResponse'
769+
770+
/api/users/suggestions:
771+
get:
772+
tags:
773+
- Users
774+
summary: Username autocomplete suggestions (paginated)
775+
description: >-
776+
Username-only prefix search for autocomplete. Unlike /api/users/search
777+
this validates `q` as a username prefix (lowercase alphanumeric, at most
778+
15 characters) and returns a paginated envelope with the total number of
779+
matches alongside each profile's avatar.
780+
operationId: suggestUsernames
781+
security:
782+
- BearerAuth: []
783+
parameters:
784+
- name: q
785+
in: query
786+
required: true
787+
description: >-
788+
Username prefix. Lowercased before matching; rejected with 400 if it
789+
is empty, longer than 15 characters, or contains anything other than
790+
lowercase letters and digits.
791+
schema:
792+
type: string
793+
minLength: 1
794+
maxLength: 15
795+
- name: limit
796+
in: query
797+
required: false
798+
description: Page size, clamped to 1-25.
799+
schema:
800+
type: integer
801+
default: 10
802+
minimum: 1
803+
maximum: 25
804+
- name: offset
805+
in: query
806+
required: false
807+
description: Rows to skip; negative values are clamped to 0.
808+
schema:
809+
type: integer
810+
default: 0
811+
minimum: 0
812+
responses:
813+
'200':
814+
description: Paginated username suggestions
815+
content:
816+
application/json:
817+
schema:
818+
$ref: '#/components/schemas/UserSuggestionsResponse'
819+
'400':
820+
$ref: '#/components/responses/BadRequest'
710821
'401':
711822
description: Missing or invalid JWT
712823
content:
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
-- #560: store the Privy DID and auth metadata on user profile records.
2+
--
3+
-- 20260726000001_privy_identity.sql introduced privy_did / privy_linked_at
4+
-- with a bare `ALTER TABLE ... ADD COLUMN`, which aborts if either column is
5+
-- already present. This migration re-states the same end state idempotently so
6+
-- it applies cleanly on a database where the columns were added out of band,
7+
-- and ships a matching rollback script under migrations/rollback/.
8+
9+
ALTER TABLE users ADD COLUMN IF NOT EXISTS privy_did VARCHAR(255);
10+
ALTER TABLE users ADD COLUMN IF NOT EXISTS privy_linked_at TIMESTAMP;
11+
12+
-- Nullable UNIQUE: a Privy DID maps to at most one account, but an account may
13+
-- exist without a linked DID. `ADD CONSTRAINT` has no IF NOT EXISTS form, so
14+
-- the catalog is checked first.
15+
DO $$
16+
BEGIN
17+
IF NOT EXISTS (
18+
SELECT 1
19+
FROM pg_constraint
20+
WHERE conrelid = 'users'::regclass
21+
AND contype = 'u'
22+
AND conname = 'users_privy_did_key'
23+
) THEN
24+
ALTER TABLE users ADD CONSTRAINT users_privy_did_key UNIQUE (privy_did);
25+
END IF;
26+
END
27+
$$;
28+
29+
-- Lookup path for `SELECT address FROM users WHERE privy_did = $1` in
30+
-- api::auth::privy_auth. Partial, so the rows with no linked DID stay out of it.
31+
CREATE INDEX IF NOT EXISTS idx_users_privy_did
32+
ON users (privy_did)
33+
WHERE privy_did IS NOT NULL;
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
-- #541: index username mappings and enforce DB-level registration uniqueness.
2+
3+
-- users.username already carries a plain UNIQUE constraint, but that constraint
4+
-- is case-sensitive: `Ebube` and `ebube` can both register and then resolve to
5+
-- different accounts. This makes the registry case-insensitively unique at the
6+
-- database level, so a racing pair of registrations cannot both commit.
7+
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username_lower
8+
ON users (LOWER(username));
9+
10+
-- Prefix lookup for the search/suggestion endpoints (`LIKE 'ebu%'`). A btree in
11+
-- the default collation cannot serve LIKE, so the prefix path gets its own
12+
-- text_pattern_ops index over the same lowercased expression.
13+
CREATE INDEX IF NOT EXISTS idx_users_username_lower_pattern
14+
ON users (LOWER(username) text_pattern_ops);
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
-- Rollback for 20260729000000_privy_did_auth_metadata.sql (#560).
2+
--
3+
-- Dropping the columns is destructive: every linked Privy identity is lost and
4+
-- users have to re-link. Only the index and constraint are dropped here, which
5+
-- returns the schema to its pre-#560 shape without discarding data.
6+
--
7+
-- To also remove the columns (and the identity data with them), uncomment the
8+
-- ALTER TABLE at the bottom.
9+
10+
DROP INDEX IF EXISTS idx_users_privy_did;
11+
12+
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_privy_did_key;
13+
14+
-- ALTER TABLE users
15+
-- DROP COLUMN IF EXISTS privy_did,
16+
-- DROP COLUMN IF EXISTS privy_linked_at;
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
-- Rollback for 20260729000001_username_indices.sql (#541).
2+
--
3+
-- Non-destructive: these are indices only, no user data is touched. Dropping
4+
-- idx_users_username_lower gives up case-insensitive registration uniqueness,
5+
-- so `Ebube` and `ebube` become registerable as separate accounts again.
6+
7+
DROP INDEX IF EXISTS idx_users_username_lower_pattern;
8+
DROP INDEX IF EXISTS idx_users_username_lower;
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Rollback scripts
2+
3+
One `<version>_<name>.down.sql` per reversible migration in `backend/migrations/`.
4+
5+
These are **manual**. sqlx's migrator only walks files at the top level of
6+
`migrations/`, so this directory is invisible to `sqlx migrate run` and to the
7+
`sqlx::migrate!()` bootstrap in `src/db/mod.rs` — adding a script here cannot
8+
change what a deploy applies.
9+
10+
To roll a migration back, apply its script and then delete the bookkeeping row
11+
so the migrator will re-apply the migration on the next run:
12+
13+
```sh
14+
psql "$DATABASE_URL" -f migrations/rollback/<version>_<name>.down.sql
15+
psql "$DATABASE_URL" -c "DELETE FROM _sqlx_migrations WHERE version = <version>;"
16+
```
17+
18+
Roll back newest-first; the scripts are not written to be applied out of order.

backend/src/api/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ pub fn user_routes_with_state(state: user::UserState) -> Router {
7171
get(user::get_profile).post(user::update_profile),
7272
)
7373
.route("/search", get(user::search_users))
74+
.route("/suggestions", get(user::suggest_usernames))
7475
.route("/friends", get(user::list_friends))
7576
.route("/friends/request", post(user::send_friend_request))
7677
.route("/friends/:id/accept", post(user::accept_friend_request))

0 commit comments

Comments
 (0)