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
Ordinary public users cannot edit profiles.
Community submitters may populate only a narrow safe field set for unclaimed profiles through profiles:submitCommunityProfile:
displayNamealiasestagspersontype-specific fieldscommunitytype-specific fields
Community submitters must not set fields that imply verified authority, private contact details, billing state, ownership, custom slugs, or sensitive visibility choices. Profile creation can still generate an initial slug from submitted display text.
The current public mutation requires a Convex authenticated identity and stores source attribution. Freeform bios, about text, avatar URLs, banner URLs, private contact details, and custom slugs are intentionally outside the ordinary community-submission field set.
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.
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.
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