Profile Access And Claims
Status Note
This doc captures the permission and claim-state baseline for #12 and #13, plus later auth, ownership, field-visibility, Discord claim, and VRChat proof-code slices.
It intentionally does not add moderation UI, role delegation, ownership transfer, contested-claim resolution, or a hard-coded VRCLinking API integration.
Read Baseline
- public users can read
publishedprofiles draft_privateprofiles are not public- claimed owners can read their own profiles regardless of publication state once ownership is modeled
- moderators can read profiles regardless of publication state once moderator authority exists
Edit Baseline
Signed-out users cannot edit profiles.
What separates a community contributor from an owner is what a field describes, not a list of field names:
- Information about the person is community-editable on an unclaimed profile. Display name, aliases, tags, outbound links, headline, bio, region, timezone, role tags and pronouns. Facts a third party can know and correct, and the reason an unclaimed profile is worth visiting at all. A few of these carry one extra condition, described below, and it is about whether the value is on screen rather than about what it describes.
- The record itself is not.
slugis the profile's address, so changing it on someone else's behalf breaks every link already shared. Appearance -- avatar shape, border colour, section order -- is a presentation choice belonging to whoever owns the profile, and is governed byprofileAppearancerather than the editable-field union.
COMMUNITY_UNEDITABLE_FIELDS in convex/_profilePermissions.ts states that as
an exclusion. It replaced an allowlist, under which the default for any field
added later was "not editable" -- which is how outboundLinks came to be
excluded by omission rather than by decision.
Community contributors must not set fields implying verified authority, private contact details, billing state, ownership, custom slugs, or field-visibility choices.
A field the profile marks private is not community-editable either. Editing a
field means being shown its current value first, so the community may not edit
what it may not read -- otherwise the editor becomes a way to read a withheld
value by opening a form, and a blind save would overwrite one. unlisted is
usually not private: for most fields it renders on the profile page, so a
contributor looking at that page has already seen it — with the exceptions below.
profiles:editableProfile returns values only for fields the subject has
cleared, so the form shows exactly what it may change.
Visibility does not settle that on its own, because "may be shown" is not
"is shown". The public page puts role tags, category tags and free tags in a
single metadata line, and whether a given value reaches it depends on the
profile: a headline takes that row entirely, and without one the line renders
four values after deduplication. timezone has no place on the page at all;
only the lookup shows it, beside the region.
So those keys are treated as not reliably shown, and for them unlisted — the
state discovery excludes — means the value may be nowhere a contributor can
reach. They are withheld from community editing while unlisted, and editable
while public, because the lookup carries public values whatever the page does.
Owners keep them either way; it is their own record.
This is deliberately conservative rather than exact. Three review rounds each found another way that re-deriving the page's layout in the permission check was inexact — the headline, then grouped keys whose two halves render differently, then the four-item limit — and a rule that has to mirror a component's rendering will keep being wrong in a new way. The conservative version can cost a contributor an edit to an unlisted value that happened to be on screen. The exact one, whenever it drifts, lets them read a value the page never showed them, which is the thing the rule exists to prevent.
timezone was briefly excluded outright, on the claim that no public surface
renders it. That was wrong about the lookup. The true claim is the narrower one:
the profile page does not render it.
An existing link keeps the provenance it already had, and each row says which one it arrived with. The form posts the whole array back, so restamping on every save would downgrade an owner-authored link to community-submitted because somebody fixed a typo elsewhere — while matching by destination alone gets duplicates wrong in both directions, either handing one link's source to every row that shares its URL or promoting a community row when the owner row above it is deleted.
The claim is not authority. source is accepted on the input, stripped before
normalization so it can never be stored from writer input, and honoured only
against a stored link that genuinely carries it — each stored link claimed once.
A writer asking for owner_authored on a link nothing has gets their own stamp.
The editor sends the updatedAt it loaded. Because the form posts every group it
rendered, a second person saving a display-name fix would otherwise spread stale
values over links and tags somebody else changed meanwhile, and the diff would
read those as deliberate. A mismatched version is refused rather than silently
won.
"It loaded" is the contract, not "the query currently holds". The fields are
uncontrolled, so when somebody else saves while the page is open, Convex pushes a
newer updatedAt while every defaultValue keeps the values it mounted with.
Sending the live number would pass the check with a payload built from what the
other editor just replaced — the overwrite the check exists to refuse, arriving
through the check. The editor pins the version its inputs were filled from for
the life of the form, so that save is refused and the message says to reload.
The argument is required rather than optional. A check a caller can decline by omitting it is not a check: a cached page still running the previous deployment's bundle, or anything calling the mutation directly, would post the same whole-form payload and skip straight past it. Every browser save knows the version it loaded, because it had to read the profile to fill the form.
Media contributions are proposals, not profile edits. A verified-email user
may create one purpose-bound upload for a published, publicly surfaced,
unclaimed profile through profileMediaSubmissions:createUploadIntent. The MVP
accepts a person profile_image or community primary_logo only. It requires a
source URL and credit, with optional public metadata and a reviewer note. It
does not collect rights or likeness categories or an attestation checkbox.
Processing changes the private submission from upload_pending to submitted;
it does not create a profileAssets row. The ordinary asset consumer rejects a
community_proposal intent unless a matching approval transaction names the
submission. This keeps pending, rejected, and withdrawn media unreachable from
public asset projections.
An unclaimed target requires a super_admin reviewer. A browser reviewer moves
a submitted proposal to under_review before deciding it. If the target is claimed
while the proposal waits, the new active owner can review it and see rejected
target history. Approval rechecks
the current public profile state and revision, active-asset quota, upload purpose,
content hash, and target identity, then atomically creates a public asset with
source: "community_submitted". Claiming never rewrites that provenance.
Contributors can list only their own submissions and withdraw only open ones.
They see the target's submission-time public name if it is later hidden and
privately renamed, plus a short public disposition after rejection, never the
reviewer's identity or private reason. A claiming owner can see the source,
credit, reviewer note, and matching-proposal count but not the submitter identity
or confidential moderator notes; super_admin reviewers receive that additional evidence. The candidate-preview
route must repeat this review authorization and remain private/no-store rather
than reusing a public asset route.
Admission is bounded to three open rows per contributor, two per profile, six new rows per contributor per day, twenty new rows per profile per day, and a short contributor cooldown. Withdrawn rows still count toward rolling creation limits, so withdraw/resubmit cannot bypass spam controls.
profiles:updateProfileFromBrowser serves both subjects and resolves which one
applies from ownership: the profile's owner edits as claimed_owner, anyone
else editing an unclaimed profile edits as community_submitter, and a non-owner
editing a claimed profile is refused. Links carry the subject's provenance, so a
contributor's links are stored community_submitted rather than
owner_authored.
Edits apply directly rather than queueing for review, matching community
submissions, which publish immediately. An edit that changes a value writes a
profileAuditEvents row naming the actor and the fields that actually changed,
readable by the profile's owner and by an operator holding
view_private_seed_lookup through seedAccess:withheldProfileRecord.
"Actually changed" is the contract, not "was submitted". The editor posts every
field group it rendered on every save, so recording the payload's keys would
report aliases, tags, links and roles as updated because somebody fixed a typo in
the display name — and a save that changed nothing would still write a row. A
no-op save writes no patch, no reindex and no history. The public API's own write
ledger (apiWriteAuditEvents) is separate and records the request regardless: a
write that changed nothing is still a write that was made.
profiles:submitCommunityProfile creates a profile from the same field set,
requires an authenticated identity, and stores source attribution. Creation
generates an initial slug from submitted display text.
Claimed owners may edit normal profile fields after a claim attaches authority to the existing profile record. This baseline assumes claimed owners can edit identity, presentation, slug, tags, and type-specific profile fields, subject to future field-level visibility and abuse controls.
Moderators may override profile fields later for safety, corrections, and abuse handling. The moderation UI and detailed audit model are deferred.
Ownership Records
profileOwners records are the durable owner authority link between VRDex users and profiles. users is VRDex's own table, keyed to Clerk by clerkUserId.
Locked decision: ownership is attached to a profile record, not inferred from provider login alone.
roleKeyis currently the singleton literalowner- only one active owner may exist for a profile at a time
- repeated grants for the same active owner are idempotent
- grants to a different active owner must fail until a future transfer or moderation flow revokes the old owner
- claim approval must update the profile search document because trust rank and public trust labels can change
Claim States
claimState describes owner authority:
unclaimed: no owner authority is attached yetclaimed_unverified: a claimant controls the profile, but stronger verification is not completeclaimed_verified: owner control and stronger verification are both established
Claim transitions preserve the same profile record and slug. Claiming a profile should not create a duplicate identity record.
Allowed ordinary transitions are real state changes only:
unclaimed->claimed_unverifiedunclaimed->claimed_verifiedclaimed_unverified->claimed_verified
Downgrades, contested claims, transfer flows, and suppression flows require explicit moderation or ownership workflows later.
A weaker approval method must not downgrade an already verified profile. For example, a later Discord person claim leaves an existing claimed_verified profile verified instead of moving it back to claimed_unverified.
Claim Methods
Current claim-level actions require a signed-in user whose Clerk token asserts a verified email address. The check reads the token claim rather than the mirrored emailVerificationTime column — see auth-sessions.md.
Locked decision: claiming a suitable existing unclaimed profile attaches ownership to that existing profile record and preserves its _id, slug, source history, and related references.
Current recommendation: the no-match creation path is explicit. profileClaims:createClaimedDiscordPersonProfile and profileClaims:createClaimedDiscordCommunityProfile require the caller to confirm that no suitable unclaimed match exists before a new self-created profile is written.
- Discord person claims require a linked Discord provider account and grant
claimed_unverifiedowner control for an existing person profile. - Discord person no-match creation requires a linked Discord provider account, creates a
creationSource: "self"person profile, records an approveddiscord_personclaim request, and grantsclaimed_unverifiedowner control. - Discord community claims run through a purpose-scoped OAuth round-trip (
identify guilds) indiscordVerification, not through a bot token.startGuildVerificationsends the user to Discord,completeGuildVerificationreads every guild the token can see and records anexternalControlProofsrow for each guild the user owns or holds Administrator or Manage Server in, then revokes the token.profileConnections:claimCommunityWithVerifiedGuildgrants owner control against one of those proofs. It grantsclaimed_verifiedonly when the guild already backs the listing through an association somebody other than the claimant recorded; otherwise ownership is granted atclaimed_unverified, because controlling a server is not evidence that the server is this listing's. The bot-token path (profileClaims:verifyDiscordCommunityAdminClaim) remains for the legacydiscord_community_adminrequest flow. - Discord community no-match creation requires a linked Discord provider account, creates a
creationSource: "self"community profile, records an approveddiscord_communityclaim request, and grantsclaimed_unverifiedowner control. - Current recommendation: OAuth guild verification is the stronger server-authority path. A linked-account-only community creation is owner-controlled but not
claimed_verifiedunless a later guild-control, VRChat group, manual, or equivalent stronger verification flow succeeds. VRCLinking is not among them: it attests a person's VRChat identity, sostartVrchatProofacceptstargetType: "vrclinking"only for a person profile. - The same rule governs VRChat proofs and the legacy bot-token path: proving control of the target grants ownership, and
claimed_verifiedadditionally requires a pre-existing association recorded by somebody else.profileConnections:recordOperatorAssociationis that writer — an internal mutation run with the deployment key, deliberately with no self-service surface. - VRChat user proof requires a person profile and creates a proof-code attempt with
targetType: "vrchat_user". - VRChat group proof requires a community profile and creates a proof-code attempt with
targetType: "vrchat_group". - VRCLinking uses the same attempt table with
targetType: "vrclinking", but answers from a community's delegated API key rather than from a posted proof code. Person profiles only: it attests that a Discord identity is linked to a claimed VRChat account, and records avrchat_userasset. The delegated key belongs to a community; the claim it supports does not. - A claimant may hold at most
MAX_OPEN_PROOF_ATTEMPTSunexpired pending attempts per target type. Re-requesting an attempt that already exists returns the same code and is not subject to the cap.
New direct VRChat attempts use VRDEX plus five digits (including leading zeros).
A normalized target may receive at most 20 new codes per rolling 24 hours across
all profiles, claimants, and attempt states. Existing pending attempts bypass
this issuance limit and retain their code and expiry, including legacy codes.
Historical codes are never reused for the same target, even after cancellation
or expiry. No new account-wide cooldown or daily limit applies. VRCLinking keeps
its existing format and limits.
Proof reading has two paths, chosen by target type:
- VRChat user and group proofs are read by the collector fleet.
VRCHAT_PROOF_ADAPTER_URLis optional; with no adapter configured,profileClaims:verifyVrchatProofViaAdapterreturnsqueuedandcommunityTelemetry:claimPendingProofCheckshands the attempt to a collector, which reads the target's bio or group description with the service-account session and reports the verdict back. Seedocs/deployment/claim-verification-enablement.md. - VRCLinking proofs go to
VRCLINKING_PROOF_ADAPTER_URL(workers/vrclinking-adapter). Convex sends the claimant's Discord id plus secret-store references for up to five delegated guild credentials; the adapter resolves each reference through IAM and asks VRCLinking whether that Discord id is linked in the guild. Convex never holds a delegated token.
Both paths return whether control was proved plus an evidence summary, and record an externalControlProofs row on success.
Field Visibility
Profile field visibility supports three states:
public: visible on direct profile pages and eligible for discovery/search/card projectionsunlisted: visible on direct profile pages but omitted from discovery/search/card projectionsprivate: omitted from all public projections
displayName, slug, profileType, and trust labels remain public while the profile itself is public. avatarImageUrl governs an approved profile-image placement, bannerImageUrl governs a banner placement, and mediaKit governs primary/additional logos, gallery, and featured media. Missing visibility keys retain the existing public default.
Claimed owners update supported field visibility through profilePrivacy:updateFieldVisibility. The mutation requires a signed-in account with an active profileOwners owner record for the claimed profile, rejects unknown field keys or states, stores public defaults compactly, and refreshes the profile search document after the profile row changes.
Field visibility is separate from profile-level opt-out. Hiding a field controls which details appear on public surfaces; opt-out and suppression decide whether the profile should surface publicly at all.
Trust Labels
Initial trust labels map from claimState plus creationSource:
community_submitted:claimState: "unclaimed"andcreationSource: "community"unclaimed: no owner authority has been attachedclaimed_unverified: owner control exists, stronger verification is pendingclaimed_verified: owner control and verification are established
These labels are business-logic helpers only. Final UI copy and visual treatment belong to public profile and trust-label issues.
Mutation Contracts
Future claim mutations must:
- validate allowed claim-state transitions
- handle no-op writes outside the claim transition helper
- set
claimedAtwhenclaimStateleaves"unclaimed" - preserve the profile
_idand slug when authority changes - patch
updatedAton every profile write - use
profileOwnersfor durable owner authority instead of interpreting provider login as ownership by itself
Owner privacy mutations must:
- require active owner authority for the target profile
- reject unknown field visibility keys and states
- patch
updatedAton profile writes - refresh discovery/search projections when field visibility changes