9.6 KiB
GameCult Hosted Access Architecture
What this file is
This file is the shared architecture note for GameCult-hosted experiments that need the same basic promise:
- let people sign in with Discord and/or Patreon
- decide access from GameCult/community entitlements
- keep app runtimes tied to local sessions and local accounts, not provider ids
It is not a map of landed auth code yet.
Problem
If every experiment rebuilds:
- provider OAuth
- identity linking
- entitlement refresh
- session cookies
- queue/job ownership
- "Discord role OR Patreon tier" logic
then we get five near-identical auth pits and deserve what happens next.
The reusable move is to build one GameCult hosted-access layer and let each app bind its own policy and runtime seams onto it.
Design goals
- one reusable local account/session model across hosted experiments
- provider login and linking owned by one shared access layer
- per-app capability policies instead of hard-coded one-off gate checks
- host runtimes consume signed local session claims, not raw provider tokens
- keep app-domain data separate unless there is a deliberate reason to share it
- start as an embedded shared package before pretending we need a central auth service
Non-goals
- becoming a general public identity provider
- building multi-tenant SaaS auth machinery
- inventing global billing analytics because we got bored
- forcing cross-app single sign-on on day one before a second app exists
Core concepts
Accountone GameCult-local person record independent of external providersLinkedIdentityone external provider identity attached to exactly one local accountEntitlementSnapshotcached result from a provider or grant source saying whether some claim is currently trueCapabilityGranta reusable local grant such asglobal_member,app_access, oradmin_access, scoped either globally or to one appAppAccessProfilethe per-app binding that names capabilities, allowed provider sources, and policy rulesSessionsigned local app/browser session; the browser gets this, not provider tokensAuditEventdurable record for login, link, unlink, refresh, denial, and admin overrides
System shape
flowchart TD
A["Browser / app web UI"] --> B["Hosted experiment web surface"]
B --> C["GameCult access layer"]
C --> D["Discord OAuth + guild role checks"]
C --> E["Patreon OAuth + campaign/tier checks"]
C --> F["Local account/session/grant store"]
C --> G["Signed access claims"]
G --> H["Hosted experiment runtime"]
The important split:
- the access layer owns provider OAuth, identity linking, entitlement refresh, and signed local sessions
- the host experiment owns its workload, queue, editor, render path, or other product-specific machinery
Do not smear provider logic directly through every host runtime.
Backbone reuse is not data merging
Using the same auth backbone does not mean all hosted experiments should pour their user data into one communal bucket.
The reusable thing is:
- provider OAuth plumbing
- signed local session mechanics
- linked-identity primitives
- entitlement refresh
- capability evaluation
The non-reusable-by-default thing is:
- app-domain user data
- creator data
- audience data
- queue/job payloads
- inventories, scenes, outputs, or other product state
Recommended default:
- shared auth/access code
- separate app databases or at least separate app-owned schemas/tables for domain data
- if a shared access store exists at all, keep it limited to auth/control-plane truth such as identities, grants, entitlement snapshots, sessions, and audit events
Do not let "same auth backbone" quietly mutate into "same user-data swamp."
Reusable data model
Suggested durable core:
accountsidcreated_atlast_seen_atdisplay_nameprimary_emailnullable
linked_identitiesidaccount_idproviderprovider_user_idusernameaccess_token_encryptedrefresh_token_encryptedtoken_expires_atprofile_json- unique on
(provider, provider_user_id)
entitlement_snapshotsidaccount_idproviderscopeevaluated_atis_allowedreason_codereason_detailraw_summary_json
capability_grantsidaccount_idscope_typeenum:global,appscope_idnullable app slugcapabilitysourceenum:manual,invite,migration,operatorstatusexpires_atnullablenote
sessionsidaccount_idapp_slugcreated_atlast_seen_atexpires_atclaims_jsonaccess_revision
audit_eventsidaccount_idnullablesession_idnullableapp_slugnullableevent_typeevent_payload_jsoncreated_at
Important invariants:
- one provider identity belongs to one local account only
- provider tokens stay server-side only
- session cookies represent local sessions, not provider trust directly
- app-specific capability checks read local claims, not Discord or Patreon on every route
Provider adapters
Shared provider/gate adapters should be boring and reusable:
DiscordRoleEntitlementProvider- login via Discord OAuth
- entitlement check via guild membership and role ids, ideally through the GameCult bot token server-side
PatreonMembershipEntitlementProvider- login via Patreon OAuth
- entitlement check via campaign membership and tier ids
ManualGrantProvider- local override for staff, migrations, experiments, or emergency access
InviteGrantProvider- reusable invite-issued app or role grants where that pattern makes sense
Policy model
The reusable core should not hard-code one app's access rules. It should evaluate capabilities against an app profile.
Suggested app-profile shape:
AppAccessProfile {
appSlug: string
displayName: string
capabilities: string[]
policyRules: CapabilityRule[]
publicRoutes: string[]
explanationStrings: map[string]string
}
Example rule style:
app_access = discord.allowed_role || patreon.allowed_tier || grant.global_member || grant.app_access
queue_submit = app_access
admin_access = grant.operator || grant.admin_access
Auth and linking flow
The generic flow should be the same across hosted experiments:
- user starts OAuth from the host app UI
- shared access layer signs OAuth
state - provider callback resolves the provider identity
- shared layer finds or creates the local account
- if the user was already signed in locally, the identity may link onto the existing account instead of creating a new one
- shared layer refreshes entitlements and capability claims
- shared layer issues or updates the local session cookie
The browser should never be the custodian of provider tokens.
App integration contract
Every hosted experiment should integrate through the same seams:
- define an
app_slug - define the app capability profile
- mark which routes are public, authenticated, or capability-gated
- attach
account_id,session_id, andaccess_revisionto owned resources such as jobs, drafts, uploads, or queue entries - require resource-owner checks on private read/update/delete paths
If an app has a queue or job system:
- queue entries belong to local account/session ids, not provider ids
- entitlement refresh should block new submissions after access loss
- active-job cancellation policy is app-specific, but the recommended first cut is still "let the active job finish, block new work"
Deployment modes
Mode 1: Embedded shared package
Recommended first cut.
- one shared
gamecult_accesspackage/module - each hosted experiment embeds it
- each app keeps its own runtime and route integration
- sessions may still be app-local
- each app keeps its own domain-data store
Why this is the right first cut:
- reusable without forcing a separate always-on dependency
- small blast radius
- easy to evolve while only one or two apps use it
Mode 2: Same-host shared access service
Use this when multiple apps on the same server want one shared access store and session issuer, but full centralization still feels premature.
Mode 3: Dedicated GameCult access service
Only do this when multiple independently deployed apps genuinely need:
- shared cross-app sessions
- central grant administration
- one callback origin for all providers
- enough auth churn that duplicated embedded deployments become the bigger pain
Do not build this first because it sounds important.
Even in this mode, the dedicated service should own auth/control-plane data, not absorb every app's audience or product data.
Configuration split
Suggested generic env surface:
GC_ACCESS_ENABLED=1GC_ACCESS_SESSION_SECRET=...GC_ACCESS_BASE_URL=https://access.gamecult.orgor host-app local equivalentGC_ACCESS_DISCORD_CLIENT_ID=...GC_ACCESS_DISCORD_CLIENT_SECRET=...GC_ACCESS_DISCORD_BOT_TOKEN=...GC_ACCESS_DISCORD_GUILD_ID=...GC_ACCESS_PATREON_CLIENT_ID=...GC_ACCESS_PATREON_CLIENT_SECRET=...GC_ACCESS_PATREON_CAMPAIGN_ID=...GC_ACCESS_ENTITLEMENT_CACHE_TTL_SECONDS=900GC_ACCESS_PROVIDER_FAILURE_GRACE_SECONDS=3600
Per-app binding should live in code or profile config, not in cloned provider env namespaces.
Audit Result
The correct reusable seam is:
- shared provider/session/entitlement/capability machinery
- thin per-app access profiles and runtime bindings
The incorrect reusable seam is:
- cloning one app’s auth blob into every future experiment
- or turning shared auth into shared domain-data soup