Users
The Users resource is the caller's own account plus every read-only lookup against other users. The literal /@me segment is always the calling user; :userId is anyone else.
@me routes are mounted before parameterized ones so the router matches the literal first. Self-actions never need a permission check — acting on yourself is always allowed. Acting on :userId always gates on users.read or users.write.
The User object
The full user row, as returned by GET /v1/users/@me. Public-facing lookups return a narrower PublicUser shape.
- Name
id- Type
- string
- Description
Snowflake — 64-bit decimal string, never an integer.
- Name
email- Type
- string
- Description
Lowercase. Unique. Source of identity.
- Name
username- Type
- string | null
- Description
2–32 chars,
[a-zA-Z0-9._], consecutive dots rejected. Unique case-insensitively. Changeable once per 30 days.
- Name
display_name- Type
- string
- Description
1–32 chars, any Unicode. Defaults to the email local-part at signup. Subject to the impersonation blocklist.
- Name
role- Type
- UserRole
- Description
0MEMBER,1ADMIN,2OWNER.
- Name
bio- Type
- string | null
- Description
Up to 1024 chars.
- Name
avatar- Type
- string | null
- Description
sha256[0:16] content hash. URL is
${CDN_BASE_URL}/avatars/${user.id}/${avatar}.png.
- Name
verified_at- Type
- string | null
- Description
Timestamp the user completed email verification. Null until then.
- Name
suspended_until- Type
- string | null
- Description
If set and in the future, the user is suspended.
- Name
disabled_at- Type
- string | null
- Description
Hard disable — only reactivation flow reachable.
- Name
deleted_at- Type
- string | null
- Description
Soft delete. Row preserved so message authorship doesn't dangle.
- Name
created_at- Type
- string
- Description
ISO-8601.
- Name
updated_at- Type
- string
- Description
ISO-8601, maintained by a DB trigger.
- Name
premium_type- Type
- integer
- Description
0none. Higher tiers gate badges + cosmetics.
- Name
oid- Type
- string | null
- Description
Turnkey sub-organization id. Null for the wormhole service principal.
- Name
wallet_solana, wallet_ethereum, wallet_bitcoin, wallet_zcash- Type
- string | null
- Description
Multi-chain addresses provisioned at signup.
The derived "active" check is:
const isActive = (u: User) =>
u.verified_at !== null
&& u.deleted_at === null
&& u.disabled_at === null
&& (u.suspended_until === null || new Date(u.suspended_until) < new Date());
Most gates that should refuse banned or locked users route through this so the rule stays in one place.
Get the current user
Returns the full User shape for the bearer token holder. The most common single endpoint in the entire API — clients hit this on every cold start to hydrate state.
Request
curl https://api.localuniverse.io/v1/users/@me \
-H "authorization: <token>"
Update the current user
PATCH semantics: omitted = leave alone, value = set, null = clear (only on nullable columns).
color_item_id is a transient onboarding-only field — when present, the server grants that color item to the user and equips it to the SKIN slot inside the same transaction as the profile update. It is not a column.
- Name
username- Type
- string
- Description
Subject to the once-per-30-days update cap.
- Name
display_name- Type
- string
- Description
1–32 chars Unicode.
- Name
bio- Type
- ?string
- Description
Pass
nullto clear.
- Name
avatar- Type
- ?string
- Description
sha256[0:16] hash from
/users/@me/avatar.nullto clear.
- Name
color_item_id- Type
- string
- Description
Onboarding-only: grant + equip this color item to
SKIN.
Request
curl -X PATCH https://api.localuniverse.io/v1/users/@me \
-H "authorization: <token>" \
-H "content-type: application/json" \
-d '{ "display_name": "Tino", "bio": "Building" }'
Upload an avatar
Multipart upload of a PNG. Server validates magic-bytes, hashes (sha256[0:16]), stores at avatars/<user_id>/<hash>.png in CDN_BUCKET, stamps the new hash onto users.avatar, and returns the refreshed user row.
PNG only. 2 MB cap. Identical bytes for the same user dedupe naturally — same hash → same key.
Request
curl -X POST https://api.localuniverse.io/v1/users/@me/avatar \
-H "authorization: <token>" \
-F "file=@./avatar.png"
Settings
Client-defined JSON blob (up to 64 KB) the API stores opaquely. The server never parses it — it's an arbitrary key-value bag the app uses for UI prefs.
| Method | Path | Notes |
|---|---|---|
GET | /v1/users/@me/settings | Returns the latest blob + updated_at. |
PATCH | /v1/users/@me/settings | Replaces the whole blob. Body shape: { "settings": "<stringified JSON>" }. |
Channels
Channels under /@me are the caller's inbox view — DMs they're in, group DMs, and HUBs they have membership in.
| Method | Path | Notes |
|---|---|---|
GET | /v1/users/@me/channels | List the caller's channels. |
POST | /v1/users/@me/channels | Open a DM or group DM. Body discriminates: { recipient_id } for DM, { recipient_ids: [...] } for group DM. |
HUB / HOME creation lives on POST /v1/channels instead — see Channels.
Inventory
Owned ItemInstance rows.
| Method | Path | Notes |
|---|---|---|
GET | /v1/users/@me/inventory | List the caller's instances. |
GET | /v1/users/@me/inventory/:instanceId | Fetch a single instance. |
DELETE | /v1/users/@me/inventory/:instanceId | Discard an instance the caller owns. |
POST | /v1/users/:userId/inventory | Admin grant. Requires items.write. |
Gameplay drops (fish caught, NPC reward) call itemInstanceService.grant() directly from their own service — they don't round-trip through this HTTP surface.
Equipment
| Method | Path | Notes |
|---|---|---|
GET | /v1/users/@me/equipment | Current equipped instances by slot. |
PUT | /v1/users/@me/equipment/:slot | Equip an instance into a slot. Body: { instance_id }. |
DELETE | /v1/users/@me/equipment/:slot | Clear a slot. |
PATCH | /v1/users/:userId/equipment | Bulk update on someone else's behalf. Requires equipment.write. |
The slot is an integer in the URL — see EquipmentSlot in @localuniverse/common/types for the enum.
Sessions
See Authentication → Sessions — every active session, plus revoke endpoints.
Relationships
Friends, friend requests, and blocks live under /@me/relationships.
| Method | Path | Notes |
|---|---|---|
GET | /v1/users/@me/relationships | List every relationship row for the caller. |
POST | /v1/users/@me/relationships | Send a friend request. Body: { target_id } or { username }. |
PATCH | /v1/users/@me/relationships/:targetId | Accept / change relationship type. |
DELETE | /v1/users/@me/relationships/:targetId | Remove the relationship (unfriend or cancel a pending request). |
PUT | /v1/users/@me/blocks/:targetId | Block. |
DELETE | /v1/users/@me/blocks/:targetId | Unblock. |
The type field follows RelationshipType in @localuniverse/common/types:
1 FRIEND, 2 OUTGOING_FRIEND_REQUEST, 3 INCOMING_FRIEND_REQUEST, 4 OUTGOING_BLOCK, 5 INCOMING_BLOCK.
Other users
PublicUser
Returned by GET /v1/users/:userId, GET /v1/users?q=..., and embedded inside channel recipient lists. A strict subset of the full user row — no email, no lifecycle timestamps.
- Name
id- Type
- string
- Description
- Name
username- Type
- string | null
- Description
- Name
display_name- Type
- string
- Description
- Name
role- Type
- UserRole
- Description
- Name
bio- Type
- string | null
- Description
- Name
avatar- Type
- string | null
- Description
- Name
created_at- Type
- string
- Description
- Name
public_flags- Type
- integer
- Description
- Name
premium_type- Type
- integer
- Description
- Name
bot- Type
- boolean
- Description
Endpoints
| Method | Path | Permission | Notes |
|---|---|---|---|
GET | /v1/users | users.read | Search by username prefix. ?q=<prefix>&limit=<n> (max 50). Only rows with a non-null username appear. |
GET | /v1/users/:userId | users.read | Full row, but with private fields elided to PublicUser when the caller isn't an admin. |
GET | /v1/users/:userId/events | analytics.read | Per-user event feed for moderation. Keyset-paginated by event id: ?before=<id>&limit=<n>. |