Split hosted access architecture
This commit is contained in:
parent
e44de5793b
commit
532c189fe2
6 changed files with 522 additions and 444 deletions
10
AGENTS.md
10
AGENTS.md
|
|
@ -21,8 +21,11 @@ diagnostics good enough to catch when we are lying to ourselves.
|
|||
- Treat `docs/implementation-plan.md` as the forward plan.
|
||||
- Treat `docs/lean-optimizer-algorithm-map.md` as the source-grounded live
|
||||
algorithm map.
|
||||
- Treat `docs/hosted-auth-architecture.md` as a future architecture note for
|
||||
hosted auth work, not as proof that auth machinery already exists.
|
||||
- Treat `docs/gamecult-hosted-access-architecture.md` as the future shared
|
||||
access architecture for GameCult-hosted experiments, not as proof that auth
|
||||
machinery already exists here already.
|
||||
- Treat `docs/repixelizer-hosted-access-profile.md` as the Repixelizer-specific
|
||||
binding onto that future shared access architecture.
|
||||
- Update `state/map.yaml` when project understanding changes.
|
||||
- Add evidence after meaningful research, implementation, verification, or
|
||||
rejected paths, but keep it distilled. Routine "I just did this" proof belongs
|
||||
|
|
@ -34,7 +37,8 @@ diagnostics good enough to catch when we are lying to ourselves.
|
|||
- Project root: `E:\Projects\repixelizer`
|
||||
- Implementation plan: `E:\Projects\repixelizer\docs\implementation-plan.md`
|
||||
- Algorithm map: `E:\Projects\repixelizer\docs\lean-optimizer-algorithm-map.md`
|
||||
- Hosted auth architecture: `E:\Projects\repixelizer\docs\hosted-auth-architecture.md`
|
||||
- Shared hosted access architecture: `E:\Projects\repixelizer\docs\gamecult-hosted-access-architecture.md`
|
||||
- Repixelizer hosted access profile: `E:\Projects\repixelizer\docs\repixelizer-hosted-access-profile.md`
|
||||
- Handoff summary: `E:\Projects\repixelizer\notes\fresh-workspace-handoff.md`
|
||||
- State CLI: `E:\Projects\repixelizer\tools\repixelizer_state.py`
|
||||
- Pre-compaction helper: `E:\Projects\repixelizer\tools\repixelizer_prepare_compaction.py`
|
||||
|
|
|
|||
333
docs/gamecult-hosted-access-architecture.md
Normal file
333
docs/gamecult-hosted-access-architecture.md
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
# GameCult Hosted Access Architecture
|
||||
|
||||
## What this file is
|
||||
|
||||
This file is a future 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 currently landed auth code in this repo.
|
||||
|
||||
## Problem
|
||||
|
||||
Repixelizer is probably not the last hosted experiment that wants this shape.
|
||||
|
||||
If every experiment re-implements:
|
||||
|
||||
- provider OAuth
|
||||
- identity linking
|
||||
- entitlement refresh
|
||||
- session cookies
|
||||
- queue/job ownership
|
||||
- "Discord role OR Patreon tier" logic
|
||||
|
||||
then we will end up with five damp little auth pits that are all almost the
|
||||
same and all annoying in slightly different ways.
|
||||
|
||||
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 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
|
||||
- reusable enough as a shared package first, with room to graduate into a
|
||||
shared service later if that pain actually materializes
|
||||
|
||||
## 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
|
||||
|
||||
- `Account`
|
||||
one GameCult-local person record independent of external providers
|
||||
- `LinkedIdentity`
|
||||
one external provider identity attached to exactly one local account
|
||||
- `EntitlementSnapshot`
|
||||
cached result from a provider or grant source saying whether some claim is
|
||||
currently true
|
||||
- `CapabilityGrant`
|
||||
a reusable local grant such as `global_member`, `app_access`, or
|
||||
`admin_access`, scoped either globally or to one app
|
||||
- `AppAccessProfile`
|
||||
the per-app binding that names capabilities, allowed provider sources, and
|
||||
policy rules
|
||||
- `Session`
|
||||
signed local app/browser session; the browser gets this, not provider tokens
|
||||
- `AuditEvent`
|
||||
durable record for login, link, unlink, refresh, denial, and admin overrides
|
||||
|
||||
## System shape
|
||||
|
||||
```mermaid
|
||||
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 actual workload, queue, editor, render path, or
|
||||
whatever weird machine it is
|
||||
|
||||
Do not smear provider logic directly through every host runtime.
|
||||
|
||||
## Reusable data model
|
||||
|
||||
Suggested durable core:
|
||||
|
||||
- `accounts`
|
||||
- `id`
|
||||
- `created_at`
|
||||
- `last_seen_at`
|
||||
- `display_name`
|
||||
- `primary_email` nullable
|
||||
- `linked_identities`
|
||||
- `id`
|
||||
- `account_id`
|
||||
- `provider`
|
||||
- `provider_user_id`
|
||||
- `username`
|
||||
- `access_token_encrypted`
|
||||
- `refresh_token_encrypted`
|
||||
- `token_expires_at`
|
||||
- `profile_json`
|
||||
- unique on `(provider, provider_user_id)`
|
||||
- `entitlement_snapshots`
|
||||
- `id`
|
||||
- `account_id`
|
||||
- `provider`
|
||||
- `scope`
|
||||
- `evaluated_at`
|
||||
- `is_allowed`
|
||||
- `reason_code`
|
||||
- `reason_detail`
|
||||
- `raw_summary_json`
|
||||
- `capability_grants`
|
||||
- `id`
|
||||
- `account_id`
|
||||
- `scope_type` enum: `global`, `app`
|
||||
- `scope_id` nullable app slug
|
||||
- `capability`
|
||||
- `source` enum: `manual`, `invite`, `migration`, `operator`
|
||||
- `status`
|
||||
- `expires_at` nullable
|
||||
- `note`
|
||||
- `sessions`
|
||||
- `id`
|
||||
- `account_id`
|
||||
- `app_slug`
|
||||
- `created_at`
|
||||
- `last_seen_at`
|
||||
- `expires_at`
|
||||
- `claims_json`
|
||||
- `access_revision`
|
||||
- `audit_events`
|
||||
- `id`
|
||||
- `account_id` nullable
|
||||
- `session_id` nullable
|
||||
- `app_slug` nullable
|
||||
- `event_type`
|
||||
- `event_payload_json`
|
||||
- `created_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
|
||||
|
||||
This is the good kind of bureaucracy: one place to keep the provider sludge.
|
||||
|
||||
## Policy model
|
||||
|
||||
The reusable core should not hard-code "Repixelizer access". It should evaluate
|
||||
capabilities against an app profile.
|
||||
|
||||
Suggested app-profile shape:
|
||||
|
||||
```text
|
||||
AppAccessProfile {
|
||||
appSlug: string
|
||||
displayName: string
|
||||
capabilities: string[]
|
||||
policyRules: CapabilityRule[]
|
||||
publicRoutes: string[]
|
||||
explanationStrings: map[string]string
|
||||
}
|
||||
```
|
||||
|
||||
Example rule style:
|
||||
|
||||
```text
|
||||
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
|
||||
```
|
||||
|
||||
This gives reuse in the right place:
|
||||
|
||||
- the shared layer knows how to link identities, refresh entitlements, and
|
||||
evaluate capabilities
|
||||
- each experiment only defines which capabilities it wants and what qualifies
|
||||
|
||||
## Auth and linking flow
|
||||
|
||||
The generic flow should be the same across hosted experiments:
|
||||
|
||||
1. user starts OAuth from the host app UI
|
||||
2. shared access layer signs OAuth `state`
|
||||
3. provider callback resolves the provider identity
|
||||
4. shared layer finds or creates the local account
|
||||
5. if the user was already signed in locally, the identity may link onto the
|
||||
existing account instead of creating a new one
|
||||
6. shared layer refreshes entitlements and capability claims
|
||||
7. shared layer issues or updates the local session cookie
|
||||
|
||||
The browser should never be the custodian of provider tokens. It is a goblin
|
||||
with JavaScript.
|
||||
|
||||
## 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
|
||||
- capability-gated
|
||||
- attach `account_id`, `session_id`, and `access_revision` to 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
|
||||
|
||||
This is where we avoid building the cathedral first.
|
||||
|
||||
### Mode 1: Embedded shared package
|
||||
|
||||
Recommended first cut.
|
||||
|
||||
- one shared `gamecult_access` package/module
|
||||
- each hosted experiment embeds it
|
||||
- each app keeps its own runtime and route integration
|
||||
- sessions may still be app-local
|
||||
|
||||
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.
|
||||
|
||||
## Configuration split
|
||||
|
||||
Reusable shared config should stop pretending each app needs its own provider
|
||||
stack.
|
||||
|
||||
Suggested generic env surface:
|
||||
|
||||
- `GC_ACCESS_ENABLED=1`
|
||||
- `GC_ACCESS_SESSION_SECRET=...`
|
||||
- `GC_ACCESS_BASE_URL=https://access.gamecult.org` or host-app local equivalent
|
||||
- `GC_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=900`
|
||||
- `GC_ACCESS_PROVIDER_FAILURE_GRACE_SECONDS=3600`
|
||||
|
||||
Per-app binding should live in code or profile config, not in a hundred cloned
|
||||
provider env names:
|
||||
|
||||
- app slug
|
||||
- hostnames
|
||||
- allowed Discord role ids
|
||||
- allowed Patreon tier ids
|
||||
- capability rules
|
||||
- public queue-summary policy, if any
|
||||
|
||||
## Recommended extraction path
|
||||
|
||||
1. Keep the repixelizer-specific note as an app profile, not the whole world.
|
||||
2. Extract the shared ideas into a reusable `gamecult_access` package or sibling
|
||||
repo once the first real implementation starts.
|
||||
3. Move provider OAuth, signed session handling, identity linking, entitlement
|
||||
refresh, and capability evaluation into that shared layer.
|
||||
4. Leave app-specific route gating and runtime ownership fields in the host app.
|
||||
5. Only consider a dedicated shared access service after a second hosted
|
||||
experiment proves the need.
|
||||
|
||||
## Audit Result
|
||||
|
||||
Yes, this can be made reusable.
|
||||
|
||||
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 a Repixelizer-specific auth blob into every future experiment and
|
||||
changing a few strings with a kitchen knife.
|
||||
|
|
@ -1,441 +1,6 @@
|
|||
# Repixelizer Hosted Auth Architecture
|
||||
This file moved.
|
||||
|
||||
## What this file is
|
||||
Use:
|
||||
|
||||
This file is a future architecture note for hosted-demo authentication and
|
||||
entitlement work.
|
||||
|
||||
It is not a map of code that already exists in the repo. Current live hosted
|
||||
demo behavior is still just:
|
||||
|
||||
- hosted-demo runtime limits and UI flags in `src/repixelizer/gui.py`
|
||||
- the local single-process queue in `src/repixelizer/gui.py`
|
||||
- frontend consumption of `hostedDemo` runtime config in `frontend/src/app.ts`
|
||||
|
||||
If future auth work lands and this note drifts away from the code, trust the
|
||||
code first and either update this document or cut the stale parts instead of
|
||||
pretending the architecture already exists.
|
||||
|
||||
## Summary
|
||||
|
||||
The hosted Repixelizer demo should support both:
|
||||
|
||||
- Patreon-backed access for supporters who do not use Discord
|
||||
- Discord role-backed access for supporters or community members who do
|
||||
|
||||
Access should be granted if **either** entitlement source says yes.
|
||||
|
||||
That means we do **not** build two separate protected apps, two separate queue paths, or two separate user models. We build one local account system with multiple linked identities and one access policy.
|
||||
|
||||
## Goals
|
||||
|
||||
- Let a user sign in with either Discord or Patreon
|
||||
- Grant access if the user:
|
||||
- has one of the allowed Discord roles in the GameCult server, or
|
||||
- has an allowed Patreon entitlement
|
||||
- Allow optional account linking so one human can attach both providers to one local account
|
||||
- Keep queue ownership, job cancellation, and hosted limits tied to one local user/session model
|
||||
- Avoid duplicated entitlement logic spread across unrelated login handlers
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Turning Repixelizer into a general identity provider
|
||||
- Requiring every Patreon supporter to join Discord
|
||||
- Requiring every Discord member to have Patreon connected
|
||||
- Complex billing state history or subscription analytics
|
||||
- Multi-tenant auth machinery
|
||||
|
||||
## External providers
|
||||
|
||||
### Discord
|
||||
|
||||
Use Discord OAuth2 authorization code flow for login.
|
||||
|
||||
Minimum use:
|
||||
|
||||
- identify the user
|
||||
|
||||
Recommended access pattern:
|
||||
|
||||
- log the user in with Discord OAuth
|
||||
- use the existing GameCult bot token server-side to inspect that user's guild membership and role ids
|
||||
|
||||
Why this path:
|
||||
|
||||
- role truth already lives in Discord
|
||||
- the Patreon bot already manages supporter roles there
|
||||
- role checks stay anchored to the same operational source of truth
|
||||
- the web app does not need to ask the user for broader guild scopes unless we later decide it is necessary
|
||||
|
||||
If needed, we can still support the OAuth `guilds.members.read` path later. It is not the preferred first cut.
|
||||
|
||||
### Patreon
|
||||
|
||||
Use Patreon OAuth2 authorization code flow for login.
|
||||
|
||||
After login:
|
||||
|
||||
- fetch the logged-in user's identity and membership data
|
||||
- inspect membership to the GameCult campaign
|
||||
- inspect current entitled tiers or equivalent supporter status fields
|
||||
|
||||
This path exists specifically for the "I want access to Repixelizer and I do not care about Discord" crowd.
|
||||
|
||||
## Core model
|
||||
|
||||
### Local user
|
||||
|
||||
Repixelizer should have one internal user record that is independent of any provider.
|
||||
|
||||
Suggested shape:
|
||||
|
||||
- `users`
|
||||
- `id`
|
||||
- `created_at`
|
||||
- `last_seen_at`
|
||||
- `display_name`
|
||||
- `primary_email` nullable
|
||||
- `access_state` cached summary, optional
|
||||
|
||||
### Linked identities
|
||||
|
||||
Each local user may have zero or more linked external identities.
|
||||
|
||||
- `linked_identities`
|
||||
- `id`
|
||||
- `user_id`
|
||||
- `provider` enum: `discord`, `patreon`
|
||||
- `provider_user_id`
|
||||
- `username`
|
||||
- `access_token_encrypted`
|
||||
- `refresh_token_encrypted`
|
||||
- `token_expires_at`
|
||||
- `profile_json`
|
||||
- unique on `(provider, provider_user_id)`
|
||||
|
||||
Important invariant:
|
||||
|
||||
- one provider identity can belong to only one local user
|
||||
|
||||
### Entitlement snapshots
|
||||
|
||||
Do not make every page hit go out to Discord or Patreon. Cache the last evaluated entitlement state locally.
|
||||
|
||||
- `entitlement_snapshots`
|
||||
- `id`
|
||||
- `user_id`
|
||||
- `provider`
|
||||
- `evaluated_at`
|
||||
- `is_allowed`
|
||||
- `reason_code`
|
||||
- `reason_detail`
|
||||
- `raw_summary_json`
|
||||
|
||||
Examples:
|
||||
|
||||
- Discord provider says `is_allowed = true`, `reason_code = allowed_role`
|
||||
- Patreon provider says `is_allowed = false`, `reason_code = no_active_membership`
|
||||
|
||||
### Sessions
|
||||
|
||||
Hosted GUI sessions should be local application sessions, not raw provider tokens sprayed into the browser.
|
||||
|
||||
- `sessions`
|
||||
- `id`
|
||||
- `user_id`
|
||||
- `created_at`
|
||||
- `last_seen_at`
|
||||
- `expires_at`
|
||||
- `access_granted`
|
||||
- `access_reason`
|
||||
|
||||
Store the session id in an HttpOnly secure cookie.
|
||||
|
||||
## Access policy
|
||||
|
||||
This is the heart of it.
|
||||
|
||||
Authentication answers:
|
||||
|
||||
- who is this user
|
||||
|
||||
Entitlement answers:
|
||||
|
||||
- should this user get through the gate
|
||||
|
||||
Those are different questions and should stay different in code.
|
||||
|
||||
### Provider-level entitlement checks
|
||||
|
||||
Implement two providers:
|
||||
|
||||
- `DiscordRoleEntitlementProvider`
|
||||
- `PatreonEntitlementProvider`
|
||||
|
||||
Each provider returns something like:
|
||||
|
||||
```text
|
||||
EntitlementResult {
|
||||
provider: "discord" | "patreon"
|
||||
allowed: boolean
|
||||
reason_code: string
|
||||
reason_detail: string
|
||||
checked_at: datetime
|
||||
}
|
||||
```
|
||||
|
||||
### Unified policy
|
||||
|
||||
Implement one access policy:
|
||||
|
||||
```text
|
||||
access = discord_allowed || patreon_allowed
|
||||
```
|
||||
|
||||
That is the only rule that should matter to the hosted demo.
|
||||
|
||||
Everything else is explanation and caching.
|
||||
|
||||
### Allowed sources
|
||||
|
||||
#### Discord allowed
|
||||
|
||||
Grant access if the user has at least one configured allowed role in the configured guild.
|
||||
|
||||
Examples:
|
||||
|
||||
- Patreon supporter role
|
||||
- founder role
|
||||
- moderator role
|
||||
- special alpha-tester role
|
||||
|
||||
Keep the role list environment-configured, not hardcoded in some grimy branch.
|
||||
|
||||
#### Patreon allowed
|
||||
|
||||
Grant access if the user has at least one allowed Patreon entitlement.
|
||||
|
||||
Examples:
|
||||
|
||||
- any paid membership
|
||||
- one or more specific tiers
|
||||
|
||||
Keep this configurable too.
|
||||
|
||||
## Login and linking flows
|
||||
|
||||
### Flow A: Login with Discord
|
||||
|
||||
1. User clicks `Log in with Discord`
|
||||
2. Complete Discord OAuth
|
||||
3. Find or create linked identity `(discord, discord_user_id)`
|
||||
4. Attach to existing local user if already linked
|
||||
5. Otherwise create a new local user and attach the identity
|
||||
6. Evaluate entitlements
|
||||
7. Create local session with access granted or denied
|
||||
|
||||
### Flow B: Login with Patreon
|
||||
|
||||
1. User clicks `Log in with Patreon`
|
||||
2. Complete Patreon OAuth
|
||||
3. Find or create linked identity `(patreon, patreon_user_id)`
|
||||
4. Attach to existing local user if already linked
|
||||
5. Otherwise create a new local user and attach the identity
|
||||
6. Evaluate entitlements
|
||||
7. Create local session with access granted or denied
|
||||
|
||||
### Flow C: Link second provider
|
||||
|
||||
Once logged in, the user may choose `Link Discord` or `Link Patreon`.
|
||||
|
||||
Rules:
|
||||
|
||||
- linking requires an authenticated local session
|
||||
- linking a provider already attached to another local user is rejected
|
||||
- after linking, re-run unified entitlement evaluation
|
||||
|
||||
This turns "Patreon-only supporter later joins Discord" and "Discord member later subscribes on Patreon" into boring account maintenance instead of support tickets from hell.
|
||||
|
||||
## Queue and job integration
|
||||
|
||||
The hosted queue should attach jobs to local sessions and local users, not directly to provider ids.
|
||||
|
||||
Suggested additions:
|
||||
|
||||
- `jobs.user_id`
|
||||
- `jobs.session_id`
|
||||
- `jobs.entitlement_checked_at`
|
||||
|
||||
Rules:
|
||||
|
||||
- only authenticated sessions with `access_granted = true` may enqueue jobs
|
||||
- queue position and active job state are scoped to the local user session
|
||||
- heartbeat and cancel behavior stay exactly as already designed
|
||||
|
||||
If a user loses entitlement mid-session:
|
||||
|
||||
- do not allow new jobs
|
||||
- queued jobs may be canceled on next entitlement refresh
|
||||
- active job policy can be either:
|
||||
- let current job finish, block future jobs
|
||||
- or cancel active job immediately
|
||||
|
||||
Recommended first cut:
|
||||
|
||||
- let current active job finish
|
||||
- reject future submissions
|
||||
- cancel queued-but-not-started jobs after entitlement loss
|
||||
|
||||
That is less spiteful and avoids turning transient API issues into ruined runs.
|
||||
|
||||
## Entitlement refresh strategy
|
||||
|
||||
Do not ask Discord and Patreon on every request like a lunatic.
|
||||
|
||||
Use a cached snapshot with refresh triggers.
|
||||
|
||||
Refresh on:
|
||||
|
||||
- successful login
|
||||
- provider link
|
||||
- explicit `refresh access` action
|
||||
- first protected request after snapshot TTL expires
|
||||
|
||||
Recommended TTL:
|
||||
|
||||
- 10 to 15 minutes
|
||||
|
||||
### Discord refresh
|
||||
|
||||
Fetch guild membership and roles using the bot token and configured guild id.
|
||||
|
||||
If the user is not in the guild, the Discord entitlement is false.
|
||||
|
||||
### Patreon refresh
|
||||
|
||||
Refresh via Patreon API using the stored Patreon identity and tokens.
|
||||
|
||||
If the membership lookup fails because tokens are stale, attempt refresh.
|
||||
|
||||
If refresh fails, mark Patreon entitlement unknown or false depending on the failure mode.
|
||||
|
||||
Recommended behavior:
|
||||
|
||||
- provider outage should not instantly hard-lock already active users if a recent positive snapshot exists
|
||||
- use a short grace window for transient provider failures
|
||||
|
||||
## Configuration
|
||||
|
||||
Suggested environment variables:
|
||||
|
||||
- `REPIXELIZER_AUTH_ENABLED=1`
|
||||
- `REPIXELIZER_SESSION_SECRET=...`
|
||||
- `REPIXELIZER_BASE_URL=https://repixelizer.gamecult.org`
|
||||
|
||||
- `REPIXELIZER_DISCORD_CLIENT_ID=...`
|
||||
- `REPIXELIZER_DISCORD_CLIENT_SECRET=...`
|
||||
- `REPIXELIZER_DISCORD_BOT_TOKEN=...`
|
||||
- `REPIXELIZER_DISCORD_GUILD_ID=...`
|
||||
- `REPIXELIZER_DISCORD_ALLOWED_ROLE_IDS=role1,role2,role3`
|
||||
|
||||
- `REPIXELIZER_PATREON_CLIENT_ID=...`
|
||||
- `REPIXELIZER_PATREON_CLIENT_SECRET=...`
|
||||
- `REPIXELIZER_PATREON_CAMPAIGN_ID=...`
|
||||
- `REPIXELIZER_PATREON_ALLOWED_TIER_IDS=tier1,tier2`
|
||||
|
||||
- `REPIXELIZER_ENTITLEMENT_CACHE_TTL_SECONDS=900`
|
||||
- `REPIXELIZER_PROVIDER_FAILURE_GRACE_SECONDS=3600`
|
||||
|
||||
## UI behavior
|
||||
|
||||
The hosted landing state should show:
|
||||
|
||||
- `Log in with Discord`
|
||||
- `Log in with Patreon`
|
||||
|
||||
If access is denied after login:
|
||||
|
||||
- say why in plain English
|
||||
- show what qualifies
|
||||
- offer linking the other provider if the user is logged in locally
|
||||
|
||||
Examples:
|
||||
|
||||
- `Your Discord account is signed in, but you do not have one of the allowed GameCult roles.`
|
||||
- `Your Patreon account is signed in, but no active Repixelizer-eligible membership was found.`
|
||||
- `Already a supporter on the other platform? Link it here.`
|
||||
|
||||
Once allowed:
|
||||
|
||||
- the queue UI and hosted GUI behave normally
|
||||
|
||||
## Security notes
|
||||
|
||||
- Use authorization code flow for both providers
|
||||
- Use `state` properly for both OAuth flows
|
||||
- Store provider tokens server-side only
|
||||
- Encrypt stored refresh/access tokens at rest
|
||||
- Use secure, HttpOnly session cookies
|
||||
- Keep local session invalidation separate from provider logout
|
||||
- Audit log link/unlink, login, entitlement refresh, and submission denial events
|
||||
|
||||
## Recommended implementation order
|
||||
|
||||
### Phase 1: Local auth scaffolding
|
||||
|
||||
- add local user, linked identity, session, and entitlement snapshot models
|
||||
- add session cookie handling
|
||||
- add protected-session middleware/helpers
|
||||
|
||||
### Phase 2: Discord login and entitlement
|
||||
|
||||
- implement Discord OAuth login
|
||||
- implement bot-backed guild role entitlement check
|
||||
- gate the hosted queue behind Discord-based access
|
||||
|
||||
### Phase 3: Patreon login and entitlement
|
||||
|
||||
- implement Patreon OAuth login
|
||||
- implement Patreon membership/tier entitlement check
|
||||
- unify access policy across both providers
|
||||
|
||||
### Phase 4: Identity linking
|
||||
|
||||
- allow linking the second provider
|
||||
- re-run entitlement evaluation on link/unlink
|
||||
|
||||
### Phase 5: Operational hardening
|
||||
|
||||
- add entitlement refresh TTLs and provider-failure grace behavior
|
||||
- add UI for denied states and manual refresh
|
||||
- add audit logs and admin diagnostics
|
||||
|
||||
## Invariants
|
||||
|
||||
Keep these sacred:
|
||||
|
||||
- login provider is not the same thing as entitlement provider
|
||||
- the queue only knows local users and local sessions
|
||||
- access is granted if either provider says yes
|
||||
- provider-specific logic stays isolated behind entitlement providers
|
||||
- linking identities must never merge two existing local users implicitly
|
||||
|
||||
If we violate those rules, the auth system will become a damp little pit of edge cases almost immediately.
|
||||
|
||||
## Audit Result
|
||||
|
||||
This design is still compatible with the current Epiphany workflow after one
|
||||
important correction: it must be treated as future architecture, not current
|
||||
truth.
|
||||
|
||||
That boundary is now explicit. The note can keep its job if it stays focused on:
|
||||
|
||||
- the proposed local-user plus linked-identity model
|
||||
- the `discord_allowed || patreon_allowed` entitlement rule
|
||||
- the implementation phases and invariants for future auth work
|
||||
|
||||
It should not quietly become:
|
||||
|
||||
- a handoff note
|
||||
- a claim that auth is already landed
|
||||
- a duplicate of the live queue behavior already described by the code
|
||||
- `docs/gamecult-hosted-access-architecture.md` for the reusable shared design
|
||||
- `docs/repixelizer-hosted-access-profile.md` for the Repixelizer-specific binding
|
||||
|
|
|
|||
172
docs/repixelizer-hosted-access-profile.md
Normal file
172
docs/repixelizer-hosted-access-profile.md
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
# Repixelizer Hosted Access Profile
|
||||
|
||||
## What this file is
|
||||
|
||||
This file binds Repixelizer onto the shared future design in
|
||||
`docs/gamecult-hosted-access-architecture.md`.
|
||||
|
||||
It is still future design, not a claim that the hosted demo already has auth.
|
||||
|
||||
## Current repo boundary
|
||||
|
||||
Current live hosted-demo behavior is still just:
|
||||
|
||||
- hosted-demo runtime limits and UI flags in `src/repixelizer/gui.py`
|
||||
- the local single-process queue in `src/repixelizer/gui.py`
|
||||
- frontend runtime-config consumption in `frontend/src/app.ts`
|
||||
|
||||
No Discord login, Patreon login, linked identities, local sessions, or access
|
||||
gates are landed yet.
|
||||
|
||||
## App identity
|
||||
|
||||
- `app_slug`: `repixelizer`
|
||||
- primary hostname: `repixelizer.gamecult.org`
|
||||
- runtime shape: one hosted GUI plus one queue-backed worker loop inside the app
|
||||
|
||||
## Capabilities
|
||||
|
||||
Repixelizer does not need a baroque permission lattice.
|
||||
|
||||
The likely first-cut capabilities are:
|
||||
|
||||
- `app_access`
|
||||
- may load the protected hosted GUI
|
||||
- `queue_submit`
|
||||
- may create a repixelizer job
|
||||
- `job_read_own`
|
||||
- may read own job state, event stream, and final output
|
||||
- `job_cancel_own`
|
||||
- may cancel own queued or running job
|
||||
- `admin_access`
|
||||
- may inspect operational or grant/admin surfaces later
|
||||
|
||||
For the first cut:
|
||||
|
||||
```text
|
||||
queue_submit = app_access
|
||||
job_read_own = app_access + resource ownership check
|
||||
job_cancel_own = app_access + resource ownership check
|
||||
```
|
||||
|
||||
## Access policy
|
||||
|
||||
Repixelizer should inherit the generic GameCult access pattern:
|
||||
|
||||
```text
|
||||
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
|
||||
```
|
||||
|
||||
Likely Discord-allowed examples:
|
||||
|
||||
- Patreon supporter role
|
||||
- founder role
|
||||
- moderator role
|
||||
- explicit alpha-tester role
|
||||
|
||||
Likely Patreon-allowed examples:
|
||||
|
||||
- any paid GameCult membership
|
||||
- or one configured subset of tiers if the experiment needs a stricter gate
|
||||
|
||||
## Repixelizer runtime binding
|
||||
|
||||
When this lands, the shared access layer should integrate with the current queue
|
||||
shape rather than replace it.
|
||||
|
||||
Suggested queue/job additions:
|
||||
|
||||
- `jobs.account_id`
|
||||
- `jobs.session_id`
|
||||
- `jobs.access_revision`
|
||||
- `jobs.entitlement_checked_at`
|
||||
|
||||
Route policy:
|
||||
|
||||
- public:
|
||||
- `/api/health`
|
||||
- `/api/config`
|
||||
- public or capability-gated by policy choice:
|
||||
- `/api/queue`
|
||||
- requires `queue_submit`:
|
||||
- `POST /api/jobs`
|
||||
- requires ownership:
|
||||
- `GET /api/jobs/{job_id}`
|
||||
- `GET /api/jobs/{job_id}/events`
|
||||
- `POST /api/jobs/{job_id}/heartbeat`
|
||||
- `DELETE /api/jobs/{job_id}`
|
||||
|
||||
Important invariant:
|
||||
|
||||
- job ownership must resolve from the local session/account, not from raw
|
||||
Discord or Patreon identifiers
|
||||
|
||||
## UI binding
|
||||
|
||||
Once access exists, the hosted landing flow should become:
|
||||
|
||||
- anonymous visitor sees:
|
||||
- `Log in with Discord`
|
||||
- `Log in with Patreon`
|
||||
- signed-in but denied visitor sees:
|
||||
- plain-English denial reason
|
||||
- what qualifies
|
||||
- link-the-other-provider affordance
|
||||
- allowed visitor sees:
|
||||
- the normal hosted GUI and queue flow
|
||||
|
||||
The queue/progress UI itself should stay as close as possible to the current
|
||||
runtime. The auth layer should gate entry, not rewrite the machine for fun.
|
||||
|
||||
## Config mapping
|
||||
|
||||
Repixelizer-specific auth config should not grow a separate provider jungle.
|
||||
|
||||
Keep:
|
||||
|
||||
- existing runtime/queue/limit env vars under `REPIXELIZER_*`
|
||||
|
||||
Add shared access config under `GC_ACCESS_*`.
|
||||
|
||||
Repixelizer-specific app-profile config can live under either:
|
||||
|
||||
- code constants, or
|
||||
- a small profile/config surface such as:
|
||||
- `GC_ACCESS_REPIXELIZER_ALLOWED_DISCORD_ROLE_IDS=...`
|
||||
- `GC_ACCESS_REPIXELIZER_ALLOWED_PATREON_TIER_IDS=...`
|
||||
- `GC_ACCESS_REPIXELIZER_PUBLIC_QUEUE_SUMMARY=1`
|
||||
|
||||
The exact encoding can be decided later. The important thing is not cloning the
|
||||
provider credentials into a second app-specific namespace.
|
||||
|
||||
## Recommended implementation order
|
||||
|
||||
1. Extract or embed the shared `gamecult_access` layer described in
|
||||
`docs/gamecult-hosted-access-architecture.md`.
|
||||
2. Gate `POST /api/jobs` plus per-job read/cancel/event routes through local
|
||||
sessions and ownership checks.
|
||||
3. Add login/link/denied-state UI around the existing hosted GUI shell.
|
||||
4. Decide whether `/api/queue` stays public summary or moves behind `app_access`.
|
||||
5. Only after a second hosted experiment exists, decide whether the shared
|
||||
access layer should leave embedded-package mode and become a shared service.
|
||||
|
||||
## Invariants
|
||||
|
||||
- Repixelizer should consume the shared access layer; it should not become the
|
||||
template that future experiments copy by hand.
|
||||
- The queue remains owned by local session/account ids.
|
||||
- Provider tokens stay server-side only.
|
||||
- Capability checks belong at route boundaries and resource ownership seams, not
|
||||
scattered through queue internals.
|
||||
- Auth should gate the hosted demo, not mutate the solver model or pretend to be
|
||||
the product.
|
||||
|
|
@ -33,7 +33,9 @@ Do not trust this file for the exact live HEAD. Always check git.
|
|||
- The current tracked weakness is the widened dark contour near the badge sword tip on tapered shapes.
|
||||
- `source_structure` exists because lattice-only `source_fidelity` could call visibly better outputs worse.
|
||||
- The old tray optimizer is dead and should stay dead unless the entire machine map changes for a real reason.
|
||||
- `docs/hosted-auth-architecture.md` is a future design note for a not-yet-landed hosted auth system; current hosted-demo truth is still just the queue and limit machinery in `src/repixelizer/gui.py` plus the frontend runtime-config plumbing.
|
||||
- `docs/gamecult-hosted-access-architecture.md` is the future shared design for reusable GameCult-hosted access across experiments.
|
||||
- `docs/repixelizer-hosted-access-profile.md` is the Repixelizer-specific binding onto that future shared access layer.
|
||||
- current hosted-demo truth is still just the queue and limit machinery in `src/repixelizer/gui.py` plus the frontend runtime-config plumbing.
|
||||
|
||||
## Critical Doctrine
|
||||
|
||||
|
|
@ -90,7 +92,8 @@ Rules now in force:
|
|||
- this handoff is a compact re-entry packet.
|
||||
- `docs/implementation-plan.md` is the forward plan.
|
||||
- `docs/lean-optimizer-algorithm-map.md` is the source-grounded control-flow map.
|
||||
- `docs/hosted-auth-architecture.md` is future auth design, not live implementation truth.
|
||||
- `docs/gamecult-hosted-access-architecture.md` is future shared auth/access design, not live implementation truth.
|
||||
- `docs/repixelizer-hosted-access-profile.md` is the app-specific future binding, also not live implementation truth.
|
||||
|
||||
Do not let any one note become all of those things. That is how the tower grows
|
||||
sideways and starts calling itself architecture.
|
||||
|
|
|
|||
|
|
@ -2,3 +2,4 @@
|
|||
{"ts":"2026-04-26T00:00:00Z","type":"metric","status":"accepted","note":"source_structure is tracked alongside source_fidelity because lattice-only scoring could rank visibly better outputs as worse."}
|
||||
{"ts":"2026-04-26T00:00:00Z","type":"regression","status":"rejected","note":"The d9fa411 phase-field tuning pass was a real regression on the pinned badge case; reverting it restores the known-good badge result exactly."}
|
||||
{"ts": "2026-04-26T03:57:11+00:00", "type": "docs", "status": "ok", "note": "Audit pass clarified note boundaries: the algorithm map remains the live control-flow truth, the implementation plan remains the forward hypothesis ledger, and hosted-auth-architecture is explicitly future design rather than landed auth machinery."}
|
||||
{"ts": "2026-04-26T04:05:06+00:00", "type": "design", "status": "accepted", "note": "Hosted auth was split into a reusable GameCult hosted-access architecture plus a thin Repixelizer-specific access profile. Recommended extraction path is an embedded shared gamecult_access layer first, with dedicated service only after a second app proves the need."}
|
||||
|
|
|
|||
Loading…
Reference in a new issue