diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..1bb3c83 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,7 @@ + + + + true + + + diff --git a/Dockerfile b/Dockerfile index 4f607e6..45072e5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,15 @@ FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR /src -COPY src/Bifrost.Web/Bifrost.Web.csproj src/Bifrost.Web/ -RUN dotnet restore src/Bifrost.Web/Bifrost.Web.csproj +COPY Directory.Build.props . +COPY src/Bifrost.Web/Bifrost.Web.csproj src/Bifrost.Web/packages.lock.json src/Bifrost.Web/ +RUN dotnet restore src/Bifrost.Web/Bifrost.Web.csproj --locked-mode COPY . . RUN dotnet publish src/Bifrost.Web/Bifrost.Web.csproj \ --configuration Release \ --output /app/publish \ - /p:UseAppHost=false + /p:UseAppHost=false /p:RestoreLockedMode=true FROM mcr.microsoft.com/dotnet/aspnet:8.0-bookworm-slim AS runtime WORKDIR /app diff --git a/docs/bridge.md b/docs/bridge.md index 683d7d2..d971d0c 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -196,15 +196,7 @@ The command is a dry-run shape probe for public surfaces that are real enough to Bifrost's native Discord gateway is the conversation observer. A direct mention of the Bifrost bot, or a reply to one of its messages, becomes an immutable typed `bifrost.discord.persona_conversation_event.v0` only when the guild and channel have one exact Persona target binding. Bot-authored, ambient, empty, over-1200-character, unbound, and ambiguously bound messages do not enter the conversation path. The 1200-character cap is the shared signed alpha boundary with Epiphany. The observation store is Bifrost-private working state, never an actuator queue, and insert collisions must contain exactly equal content. VoidBot does not observe, schedule, or answer for the target Persona. -Persona replies cross back through two explicit CultMesh surfaces. Epiphany is -the sole writer of `epiphany.persona_discord_delivery_request.v0`; Bifrost -reads that surface, authenticates the dedicated Persona-mouth signer, journals -external execution before touching Discord, and is the sole writer of -`bifrost.persona_discord_delivery_receipt.v0`. Epiphany reads receipts but -never receives the Discord token or write access to Bifrost private state. -`unknown` is terminal for automatic transport retry: an external post may have -happened, so restart recovery must not manufacture a duplicate. Failed and -unknown outcomes remain Persona pressure for explicit resolution. +Persona speech goes out through Matrix, not Discord. `tools/persona-matrix-mouth.mjs` is the one process for every Persona (`PERSONAS`, a list of slugs): it holds each Persona's Matrix access token as its own systemd credential (`matrix-token-`) and nobody else holds any. Per Persona it keeps one `/sync` loop over the configured rooms and the account's direct rooms (a DM invitation from an account on `MATRIX_SERVER_NAME` is accepted while that Persona's brake is released, and one from any other server is declined), one cursor and one delivery sequence. A delivery carries the sender's Matrix id, the event's `m.mentions` user ids and whether the room is a DM. Reading wakes nothing and acks nothing: the heartbeat polls with the non-consuming `gamecult.persona.chat_pending_request.v1`, and only `chat_ack` advances a position. The mouth speaks on a `gamecult.persona.chat_send_request.v1` and joins rooms only while that Persona's brake (`/etc/gamecult/minds//control.cc`) is released. One loopback CultNet RUDP endpoint serves all of them; a request naming an unlisted Persona gets a typed `chat_refusal` (`UnknownPersona`). Document shapes: `tools/persona-chat-documents.mjs`; golden bytes: `tests/fixtures/persona-chat-v1/`. The Discord posting actuator, its permit and its RUDP listener are removed. Bifrost admits an observation only when guild, channel, target runtime, canonical repository, Persona, producer, and producer runtime match a preconfigured `bifrost.persona_feedback.target_binding.v0` document in Bifrost's private provider store. That binding also owns source visibility, data classification, and the delivery-store target. Bifrost never infers public visibility from Discord identifiers. It classifies the author using its own `bifrost.discord.actor_link.v0` state: a match yields `linked_governance_feedback`; absence yields `unlinked_social_feedback`. Neither class grants adoption, work, release, or deployment authority. diff --git a/docs/context.md b/docs/context.md index fdcef97..542ff8d 100644 --- a/docs/context.md +++ b/docs/context.md @@ -18,8 +18,10 @@ The product center is the Labor Platform loop: eligible participants express pol - source of deployment and infrastructure conventions - documents Yggdrasil, nginx, systemd, and local-Postgres patterns - `E:\Projects\Heimdall` - - owns OAuth, linked accounts, token custody, signed claims, grants, consent, and revocation - - Bifrost consumes Heimdall identity/capability facts instead of becoming the credential vault + - authenticates: owns OAuth, linked accounts, token custody, signed claims, grants, consent, and revocation + - does not issue GameCult identity: its account id (`HeimdallAccountId`) is an authenticator handle, never who a person is on GameCult + - Bifrost consumes Heimdall authentication/capability facts instead of becoming the credential vault + - remaining scar: Heimdall still mints its own account id in code (`src/app.ts` `createAccount`); chat does not need that to move, so it has not - `E:\Projects\VoidBot` - observes Discord, packages consensus, and runs repo Persona heartbeats - should call Bifrost for governed GitHub/Discord/Reddit/intake crossings instead of becoming hidden transport authority @@ -55,7 +57,7 @@ After bootstrap, the next session should start implementing the first vertical s - member alpha first - invite-only onboarding -- GitHub as the only auth provider in v1 +- Bifrost is the GameCult identity issuer: `UserAccount.Id` is the subject, and Bifrost is the OIDC provider for chat (`docs/oidc-issuer.md`); GitHub, native, and Heimdall-linked sign-in only authenticate into it - GitHub issues and internal tasks both supported - app-native governance and voting - priority and reward pressure should be transparent and auditable diff --git a/docs/jurisdiction-map.md b/docs/jurisdiction-map.md index c0a2754..a251b00 100644 --- a/docs/jurisdiction-map.md +++ b/docs/jurisdiction-map.md @@ -103,6 +103,8 @@ This keeps Reddit useful without making subreddit mechanics the secret constitut - signed identity and capability claims - grants, consent, revocation, and capability evaluation +Heimdall authenticates; it does not issue GameCult identity. Bifrost owns the identity (`UserAccount.Id`, `NormalizedBifrostIdentity`) and issues it to chat as an OIDC provider (`docs/oidc-issuer.md`). `HeimdallAccountId` is an authenticator handle that links a Heimdall account to a Bifrost identity. + Bifrost consumes Heimdall claims. It does not become the key vault. The split is bridge versus gate: Heimdall decides who may cross under which grant; Bifrost moves authorized work across public protocols and records where it landed. ### Patron Collection Crossing @@ -153,7 +155,7 @@ Repo Personas may generate bridge requests. They do not own the bridge itself. | Concern | Owner | Reason | | --- | --- | --- | -| Who is this actor? | Heimdall | Identity and account custody must stay with the auth authority. | +| Who is this actor? | Bifrost (identity) with Heimdall (authentication) | Bifrost issues the identity; account custody and credential proof stay with the auth authority. | | Is this actor allowed to request this crossing? | Heimdall plus Bifrost | Heimdall issues grants; Bifrost applies bridge-domain policy. | | What work is being requested? | Bifrost | The request shape, priority, provenance, and lifecycle are governance concerns. | | How does interest become reward pressure? | Bifrost | Priority, bounty growth, vote weight, and reward allocation are Labor Platform concerns. | @@ -205,6 +207,7 @@ This is not the final hosted control plane. It is the current bridge actuator an - Do not add Bifrost-scoped Discord transport or dispatch receipts to arbitrary repo Persona scripts. Call Bifrost. - Do not add Bifrost-scoped Reddit posting or Persona flair handling to arbitrary repo Persona scripts. Call Bifrost. - Do not put OAuth credentials or account custody into Bifrost helper scripts as the long-term plan. Use Heimdall claims. +- Do not hand Heimdall ids, GitHub ids, or display names out as the GameCult subject. The OIDC `sub` is `UserAccount.Id`, and the localpart is `NormalizedBifrostIdentity`, minted once. - Do not make CultCache documents decide policy. They store packets; Bifrost gives those packets meaning. - Do not let a repo Persona turn a proposal into endless Aquarium pressure once it has enough shape. Route it to Bifrost as a reviewable artifact. - Do not expose bridge debug noise to public channels. Public receipts should say what happened, which Bifrost topic or work item owns it, where it landed, and what comes next. diff --git a/docs/oidc-issuer.md b/docs/oidc-issuer.md new file mode 100644 index 0000000..a7f59a4 --- /dev/null +++ b/docs/oidc-issuer.md @@ -0,0 +1,113 @@ +# Bifrost OIDC issuer + +Bifrost is the GameCult identity issuer. It runs an OpenID Connect provider on its public host so that Matrix +Authentication Service (MAS) can sign chat users in with their Bifrost account. Heimdall authenticates people into +Bifrost; it does not issue the identity chat sees. + +The provider is OpenIddict (`OpenIddict.AspNetCore` and `OpenIddict.EntityFrameworkCore`, 7.7.1). Bifrost owns two +decisions at the authorize endpoint: who the chat user is, and whether they may get a session. + +## Endpoints + +The issuer is `Host:PublicBaseUrl`, normalized by .NET into an absolute URI. For a bare host that adds a trailing +slash, so for `https://bifrost.gamecult.org` the issuer string is `https://bifrost.gamecult.org/`. Configure MAS with +that exact string. + +| Endpoint | Path | +| --- | --- | +| Discovery | `/.well-known/openid-configuration` | +| JWKS | `/.well-known/jwks` | +| Authorization | `/connect/authorize` | +| Token | `/connect/token` | +| Userinfo | `/connect/userinfo` | + +Only the authorization code flow exists, and PKCE (S256) is required. There is no implicit, hybrid, password, +client-credentials or device flow, no refresh token, and no dynamic client registration. The token endpoint takes +`client_secret_basic` only. Scopes are `openid` and `profile`. + +## The one client + +Exactly one client is registered, from configuration, and seeded or updated at startup; every other application +row (with its authorizations and tokens) is deleted at startup. Any other `client_id`, an unregistered `redirect_uri`, or a request without PKCE is refused by the +provider before any sign-in redirect. + +## Configuration + +The section is `Bifrost:Oidc`. Absent, the provider surface is not mapped at all and discovery answers 404. Present, +it must be complete: an unreadable or wrong-shaped file stops startup with a message that names the setting and never +echoes its value. + +| Setting | Meaning | +| --- | --- | +| `Bifrost:Oidc:SigningKeyPath` | PEM file with an EC P-256 private key. Tokens and id_tokens are signed ES256; JWKS publishes exactly that key. A non-P-256 or public-only key stops startup. | +| `Bifrost:Oidc:EncryptionKeyPath` | File holding 32 random bytes as base64 (`openssl rand -base64 32`). Encrypts the tokens OpenIddict issues, the access token and the parked authorize request among them. | +| `Bifrost:Oidc:Clients:Mas:ClientId` | The client id MAS presents. | +| `Bifrost:Oidc:Clients:Mas:ClientSecretPath` | File holding the client secret. | +| `Bifrost:Oidc:Clients:Mas:RedirectUri` | MAS's callback URL, registered exactly. | +| `Bifrost:Oidc:Clients:Mas:BackchannelLogoutUri` | Optional. MAS's back-channel logout URL, `https:///upstream/backchannel-logout/`; absolute http(s) or startup fails. Unset: no logout is sent and no authorization is revoked. | + +There are no development or ephemeral keys in the host. Key files are provisioned like the other +`[workload.secret_files]` paths. + +## Claims + +| Claim | Value | +| --- | --- | +| `sub` | `UserAccount.Id`, formatted `D` | +| `preferred_username` | `NormalizedBifrostIdentity` (minted once; there is no rename path) | +| `name` | `DisplayName`, else `BifrostIdentity` | + +No e-mail, no Heimdall id, no GitHub id, and no membership detail leaves Bifrost. + +## Who may sign in + +The authorize endpoint reads the cookie session and the same actor facts the `/App` policy reads. + +- No session: challenge to `/auth/sign-in`, which resumes the original request after sign-in. OpenIddict parks the + full request (its request token, in the OpenIddict token table, expiring after ten minutes, spent when used) and + the return path carries only a short `request_uri`, so `LocalReturnPath.MaxLength` stays 256. +- Signed in, membership not `Active`: redirect to the client's `redirect_uri` with `error=access_denied` and + `error_description=The account is not an active GameCult member.` +- Signed in, `Active`, but `NormalizedBifrostIdentity` empty: `error=access_denied` with + `error_description=The account has no Bifrost identity.` +- Signed in and `Active`: a code is issued. +- Token endpoint: membership is read again; an account no longer `Active` gets `invalid_grant` and no tokens. + +## Back-channel logout + +When `BackchannelLogoutUri` is set, a hosted reconciler (`OidcLogoutReconciler`) runs every 30 seconds. It lists the +subjects holding valid authorizations of the MAS client; for each subject whose membership is not `Active` (the same +reading the token and userinfo endpoints use; a deleted account or a subject that is not an account id counts as not +`Active`) it POSTs one OIDC back-channel logout token to MAS, and only after a 2xx revokes that subject's authorizations, +which also invalidates their tokens. Membership is never written by this path: any way of leaving `Active` (admin +action, sync, a database edit, a future suspend) is covered because the work is derived from membership state. + +The token is an ES256 JWT signed with the issuer key, header `typ` `logout+jwt`, with `iss` as the id_token carries it, +`aud` the MAS client id, `sub` the account id, `iat`, `exp` two minutes later, a fresh `jti` and the +`http://schemas.openid.net/event/backchannel-logout` event. It carries no `nonce` and no `sid`; MAS finds the sessions +by `sub`. It is sent as `application/x-www-form-urlencoded` field `logout_token` with a 10 second timeout, and the +response body is never logged. + +Failure handling is per subject, in memory: a 5xx, a timeout or a network fault backs off exponentially from 30 seconds +to 15 minutes; a 400 (token refused) or 404 (unknown provider ULID) is a configuration fault, logged by status only, +and retried at the 15 minute cap. Nothing is revoked until MAS answers 2xx. A restart loses the backoff and nothing +else: the authorizations table is the queue, so a later-set URI also catches up on earlier demotions. + +Tradeoff: delivery latency is up to one tick, and a member demoted and re-promoted inside one tick keeps their sessions. + +MAS verified at v1.28.0 (`crates/handlers/src/upstream_oauth2/backchannel_logout.rs`): the token is checked like an +id_token (`iss`, `aud`, `alg`, JWKS signature), `exp` and `iat` are required, `sub` or `sid` is required, `nonce` must be +absent. Owed on the MAS side (gamecult-chat): the provider's `backchannel_logout_uri` and +`on_backchannel_logout: logout_all`. Re-check against the MAS version pinned there. + +## MAS configuration + +Configure MAS's upstream provider with the issuer above, the client id and secret, the token endpoint auth method +`client_secret_basic`, the id_token signing algorithm `ES256`, scope `openid profile`, and a localpart claim import +from `preferred_username`. MAS mints the Matrix localpart on first sign-in, so the claim must never change for an +account. + +## Remaining scar + +Heimdall still mints its own account id in code (`src/app.ts` `createAccount`). That id is an authenticator handle +and chat does not need it to move. diff --git a/docs/research/2026-10-29-estate-tally-game-theory/README.md b/docs/research/2026-10-29-estate-tally-game-theory/README.md new file mode 100644 index 0000000..f67a9a5 --- /dev/null +++ b/docs/research/2026-10-29-estate-tally-game-theory/README.md @@ -0,0 +1,330 @@ +# Eyes: estate internal tally, game-theory probes (2026-10-29) + +Fork: Fork Desk question 39, `eureka-body:question:estate-internal-tally` (InForce; options proportional | winner-take-all; Self's recommended: proportional). Context ruling `eureka-body:ruling:governance-estate-blocks`. Session self-2026-10-29-eureka. Facts and measured results only, no recommendation. +Operator additions folded in (her words relayed by the Self): within-bloc vote-power curves ("Patrons vote power scales with donation total, but it's logarithmic... Member vote should scale with contribution... via a lower exponent"); present case ("I'm the only active member"); renormalization ruling ("normalize it by who actually voted"). + +## Code, outputs, model +Dir `sims/` (numpy 2.5.4, Python 3.13, `python -I