Commit graph Heimdall/docs
Author SHA1 Message Date
4fe367ac8d Identity mover: POST /v1/apps/:slug/identity-moves, proof of both accounts, strict-passkey coverage, alias row, credential strip (#16)
Soul s3 clean at 2f1b733. Needs identity_moves DDL + GRANT SELECT, INSERT before the build starts.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 01:59:11 +00:00
Heimdall
6ed6d8fa0d Step-up assertion: pin per-app not-permitted budget and the 10.5 min prune horizon, name the tripped budget in the audit row, correct validation-order and audit docs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 20:19:41 +00:00
Heimdall
1259911632 Step-up assertion: audit a flood once per window, budget the not-permitted branch, base64url nonce, honest nonce retention and precondition docs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 20:13:13 +00:00
Heimdall
2f93809a29 Docs: app-asserted step-up route, step_up_method claim, Bifrost profile grant, architecture note, evidence line
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 20:01:46 +00:00
Heimdall
46e5d5364f Docs: a Twitch step-up proof is weaker; point the unlink section at the step-up note
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 19:21:45 +00:00
Heimdall
d8b8d25e81 Docs: identity list and unlink routes, step-up proof window, linked_identities single-writer rule; evidence ledger line
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 18:59:21 +00:00
Heimdall
673713a015 Step-up: build the authorize URL before claiming the launch (a refused step-up no longer burns the state); pin the strict step_up parse with a signed forgery; document backend_callback and mixed-version limits
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 23:21:12 +00:00
Heimdall
7a69d7567b Opt-in sign_in step-up: app-authenticated, signed-state flag, provider re-auth parameters, typed refusal
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 23:10:27 +00:00
afa1b74318 Merge pull request 'Docs: answer collector record and consent' (#6) from heimdall/answer-collector-consent into main 2026-11-10 17:12:26 +00:00
Heimdall
570e930043 Note the two-clock comparison in the training rule as an open gap
accepted_at is origin_server_ts, received_at is the collector's clock; a collector clock behind the homeserver's excludes a first answer. Fails closed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 17:12:15 +00:00
5fc626c7ec Connect start: pin the app-secret gate on every managed provider, refuse blank targets (#9)
The target is stored and compared raw by design (no trim).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 12:18:26 +00:00
Heimdall
d4cd112193 Blank the refresh token in the fallback write only when it is spent
A provider that keeps its refresh token valid (Twitch) keeps it when the
rotation cannot be committed: the marker is left, the token stays, and the
next caller after the backoff presents it. Blanking stays for a revoke and
for a provider that rotates (the rotate decision now carries
consumedRefreshToken). Live-Postgres test for Twitch written first and seen
failing; the contract states the rule inline.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 10:22:14 +00:00
Heimdall
f2f1dabc06 Blank the consumed refresh token in the fallback marker write
When the rotation or revocation the provider's answer called for cannot be
committed, the fallback write now clears refresh_token_encrypted as well as
stamping the marker, so the end of the 60 s backoff cannot replay the consumed
token. Tests (live Postgres, trigger-refused revoke) assert no replayable token
and no second provider call. Docs: the marker is a best-effort write on a
second connection after rollback, the skew cap, the blanking write's residual.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 10:07:24 +00:00
Heimdall
9c7d980165 Leave the failed-refresh marker when the revoke a rotating provider's answer called for cannot be written
A rotating provider that answered without a new refresh token has consumed the old one; the revoke
that follows is rolled back if its UPDATE fails, and no marker remained. The fallback marker now
covers revoke as well as rotate. The residual-risk paragraph says the window opens when the request
is sent, cites the steward ruling, and ends its sentences.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 21:56:40 +00:00
Heimdall
1118f235b0 Docs: name the process-death residual in the refresh contract
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 21:53:12 +00:00
Heimdall
f5f4b2830f Record a failed refresh on the linked identity row so another process does not re-present the token
A failed or timed-out refresh attempt now writes refresh_failed_at under the row lock; a process
that takes the lock inside a 60 s backoff answers managed_credential_refresh_failed without calling
the provider. Rotation, revocation and re-link clear the marker. Closes the cross-process residual
that refresh-once-per-token left to a follow-up, and pins it on the two-pool rig.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 21:35:13 +00:00
Heimdall
0ea6691cd3 Refusal return contract: pin the fragment, carry the attempt id, document it
The consent_refused page's return link is tested for heimdall_status,
heimdall_error, heimdall_refused_by and no completion code, for both a decline
and a provider access_denied. A browser_completion refusal now carries
heimdall_attempt_id. The contract for app authors is in the consent doc.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:50:58 +00:00
Heimdall
b6372e5a4f Named revocation-conflict tests on pg-mem, first-create doc line, npm fallback in test-live-pg
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:45:57 +00:00
Heimdall
f7a38a7d2a Doc the FK, the cookie and scope bounds, consent_text_missing
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:39:34 +00:00
Heimdall
5ce8f1c14c Consent doc: ask cookie, carried wording, unique revocation, fixed link expiry
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:32:21 +00:00
Heimdall
48207268d0 Consent flow doc: steward rulings and the built design; pin repeat-revoke
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:24:48 +00:00
Heimdall
aaeb175dc6 State the two encodings of the version rule; note the checkbox version gap
Room notice version is a JSON number (a string, including "01", is malformed);
the answer key keeps the version as encoded text under the leading-zero
refusal. Same rule, two encodings. The registration checkbox records no
version: recorded as an open gap Void is raising.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 02:34:48 +00:00
Heimdall
d6a8d4238c Rule every consent version as a positive u32 with no leading zeros
Fold the leading-zero text into one version rule under Representation:
sample, notice, notice acceptance and terms versions, and the room notice,
all positive decimal integers, no leading zeros, at most 4294967295.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 00:30:00 +00:00
Heimdall
cafdc52e5f Rule version spelling and the room notice event
Leading-zero versions are refused, not normalised; one version, one key.
A room's notice is the org.gamecult.notice state event {version, scope}
under the room's power levels; a pinned message is not accepted. Absent
or malformed means no train line and the narrowest scope.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 23:28:40 +00:00
Heimdall
4e99bfa628 Rule empty text and received_at ties from CultLib #12 follow-up
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 19:40:44 +00:00
Heimdall
2364979f0d Rule the open points from the CultLib consent-records review
Scopeless replies are stored and excluded; tombstone is HMAC-SHA-256 with a
member-side salt; representation, training-rule scope match, scope partition
and MemberTerms ownership fixed. Docs only.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 17:24:39 +00:00
Heimdall
d836f29e3a Align answer-collector consent with #swarm rulings of 2026-11-06
Room notice declares scope (train included); first reply accepts a notice
version; terms version for newcomers only; training needs an acceptance at
answer time and a recorded scope; redaction is exit, past use not undone;
salted hash-only tombstone; no reward sound on consent controls; personal
details out of scope; typed field tables for the CultCache records.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 14:31:45 +00:00
Heimdall
e8edd88efc Specify answer collector record and consent
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 13:30:45 +00:00
Heimdall
a7cdf0fea7 Draft: Joystick consent flow design and skipped test sketch
Documents what a viewer sees today on the Joystick path and what consent
is missing, then drafts a visible ask, typed refusal and auditable receipt.
Design only; the test file is a skipped sketch and was not run.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 12:17:14 +00:00
Heimdall
b541e094eb Merge heimdall-binds-completion (r2 state binding) into main's link and Google work; gate connect starts that carry a connection
Launch hop, signed per-flow cookie and one-use claims are merged with main's
link-initiator binding and Google OIDC (secrets now derived at launch).
A connect start carrying a connection target now needs the app's own secret.
Google, link and managed-credential tests go through the launch helper.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 12:46:05 +00:00
heimdall
9123f2def0 Merge main (link-initiator binding) and reconcile the Google link with it
One initiator field (initiator_account_id) replaces link_account_id: a bearer
names it for requires_link providers, the authenticated app for the rest. One
refusal class (LinkRefusal, 403) replaces AccountLinkRefused. Provider count in
the verse test is 8.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 10:58:02 +00:00
Heimdall
c829429cd5 Google link is two-phase; callback evaluates all linked identities
The callback records a typed pending link (short TTL, single use) and
writes no linked identity; POST /v1/apps/:appSlug/links/confirm with the
Heimdall access token of the initiating account attaches it. A victim who
completes an attacker's link URL attaches nothing. The sign-in callback
also evaluates the account's other linked identities with the start-time
policy, so a Discord-origin member returning via Google keeps app_access.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 17:15:33 +00:00
Heimdall
8e870d20a0 Google OIDC provider: link-only, sub-keyed, PKCE and nonce
Google is a Heimdall provider behind Bifrost, with no direct upstream.
Scopes openid email profile; the callback verifies the id_token
(RS256 against Google's keys, iss, aud, exp, iat, nonce) and requires
a verified email. The identity key is sub, never the email.

PKCE verifier and nonce are derived from the state jti and Heimdall's
signing key, so nothing is stored between start and callback.

Google never mints an account. mode=link needs a Heimdall access token
for the app and attaches to that account only; sign_in signs in an
identity already linked; an unknown sub is refused, and a sub held by
another account is never moved. The six existing providers still mint
on first login.

Admitted to the bifrost profile only. The Idunn recipe declares the
Google client id and secret file.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 16:10:23 +00:00
Heimdall
9f5a0e6938 Bind link mode to its initiator and redeem to its target
Link start needs an authenticated app and an initiatingAccountId, carried in
the signed state. The callback refuses an identity held by another account
(link_initiator_mismatch) and attaches an unlinked one to the initiator.
auth_completions stores target_id; redeem compares it and answers
completion_target_mismatch without consuming the code. Link completions are
redeemed only by the authenticated app.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 13:38:51 +00:00
Meta Vens
5076e04950 Docs: the launch hop, the binding cookie and the callback refusals
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 20:13:18 +02:00
Meta Vens
ed964201d4 Docs: startRequiresAppCaller, and Heimdall authenticates while Bifrost issues identity
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 18:14:37 +02:00
Meta Vens
778eabaedc Docs: map managed credential custody, the callback mint and the open follow-ups
architecture.md gains a Managed credential custody section: the single
refresh owner, the store-port lock, per-process single flight, typed
revoked and refresh-failed answers, the provider descriptor token fields
and the one token request, the mode x role gates, custody-only
completions, verification layer and follow-ups by id. The spotify finding
now matches the removed spiverse profile; JOYSTICK joins the provider env
lists. implementation-plan and service-contract lose two stale lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:49:34 +02:00
Meta Vens
e1ac84fb70 Joystick custody needs a creator binding, at start and at the callback
callbackMint now takes the connection: custody_only is a connect for a
managed-connection-only provider bound to connection.kind creator with a
targetId. Anything else is refused at start with creator_connection_required
before any redirect, and at the callback from the signed state before the
code exchange. The streampixels profile note says what Heimdall guarantees
and records the S1 rule that SP's redeem treats a non-creator Joystick
completion as an error, never a sign-in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:34:24 +02:00
Meta Vens
a32be41878 Token answers: a default lifetime replaces an unknown expiry; a malformed refresh_token is typed
A token answer without a usable expires_in (absent, non-numeric, a string that
is not a positive integer) now takes the descriptor's
defaultAccessTokenLifetimeSeconds (3600 for every provider), so no issued
token has a NULL expiry that dueForRefresh would never refresh. A 2xx whose
refresh_token is present but not a string is the typed
provider_body_not_token_set (502 managed_credential_refresh_failed, tokens
kept); an empty string stays a missing token, so a rotating provider still
fails closed. The expiry rule is now pinned on memory and both live Postgres
rigs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:24:21 +02:00
Meta Vens
79ea0007d3 OAuth callback: mode and provider role decide what a completion mints
The start gate only refused early. The callback ignored the signed state's
mode, so a Joystick connect for StreamPixels redeemed into a viewer_access
session. callbackMint(profile, provider, mode) now decides at the callback:
identity providers keep a viewer session (Bifrost discord connect unchanged);
a connect for a managed-connection-only provider stores custody and mints a
claim with no facts, no capabilities and no refresh token (the refresh route
would recompute viewer_access from the account); anything else is refused.
StreamPixels' redeem reads only account, provider, mode, connection and
claimSet.identities, so it accepts the capability-free claim unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:23:53 +02:00
Meta Vens
c1fbc1261a OAuth start: connect keeps admitting identity providers, as Bifrost uses it
The first gate refused connect for a provider outside managedConnectionProviders,
which broke Bifrost's existing Discord connect. sign_in and link still take
identityProviders only, so a managed-only provider (Joystick) can never sign
anyone in or link; connect takes either list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:38:13 +02:00
Meta Vens
b15bf33b21 Refresh: one attempt per identity per process; waiters share its outcome
Callers that arrive while a refresh of one linked identity is in flight in
this process join it and receive its success or failure, instead of queuing
on the row lock and presenting the same refresh token again behind a failed
holder. The entry lives as long as the attempt, so a later request retries.
The store lock stays the cross-process guard; the residual risk (one
presentation per process per burst) is written into the service contract.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:34:39 +02:00
Meta Vens
d989860163 Refresh classification: revoke only on a 400 invalid_grant; any other answer keeps the tokens
A grant verdict is read from a 400 body by exact code, never from a 401, a
5xx or a 2xx. A 2xx that is not an object with a non-empty string
access_token and no error is refused as provider_body_not_token_set, so a
malformed success keeps the tokens and surfaces as the typed 502 for every
provider, rotating or not. A refresh answer without expires_in stores the
expiry as unknown rather than keeping the already-due one; an unknown expiry
is never due, so the next resolve does not refresh again.

Pins Soul's three surviving mutants: substring code match, the dropped
expiry rule, and the snapshot refresh token on a non-rotating provider.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:34:17 +02:00
Meta Vens
c554c00697 OAuth start: the profile's role lists decide which provider a mode may use
sign_in and link now take only identityProviders; connect takes only
managedConnectionProviders. supportsProvider(), which unioned the role lists
with entitlementSources and never looked at the mode, is deleted: a Joystick
sign_in for streampixels no longer starts, so a Deru sign-in cannot rewrite
his managed credential row. No built-in profile had a provider reachable
only through the union.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:31:59 +02:00
Meta Vens
3eaa9ef265 Declare Joystick in the Idunn recipe and document the locked refresh
Recipe optional_environment gains the Joystick client id and secret file;
service-contract documents the row lock, rotation fail-closed and the 410/502
answers; the StreamPixels profile names Heimdall as sole holder of provider
refresh tokens (F5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:15:42 +02:00
Meta Vens
83d2f48256 Docs: secrets are _FILE names; drop the unread Spotiverse secret
Soul S4: the Bifrost handoff notes, the Bifrost profile and the service
contract still named plaintext secret variables; each now names the _FILE
credential path. .env.example and the Spotiverse profile taught
GC_ACCESS_APP_SPOTIVERSE_SHARED_SECRET_FILE, which nothing reads: spotiverse
is not in appSlugs. Both lines are removed rather than adding the slug.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:40:21 +02:00
Meta Vens
fe26208f05 Docs and .env.example teach secrets as NAME_FILE credential files
Soul F4: the architecture configuration list, the Spotiverse restore steps and
.env.example still taught plaintext secret variables. The architecture list now
names only what src/config.ts reads, plain settings apart from secret files,
and drops the names nothing reads (GC_ACCESS_SESSION_SECRET, GC_ACCESS_ENABLED,
GC_ACCESS_INTERNAL_URL, the Discord bot token, the Patreon campaign id and
two TTLs). .env.example comments out the _FILE lines, because an empty
NAME_FILE= is refused at startup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:52:11 +02:00
Meta Vens
d2d0778847 Delete the inline signing-key PEM input
GC_ACCESS_SIGNING_PRIVATE_KEY_PEM put the signing key's bytes into the
process environment, and so into any binding, unit or plan that carries
environment. The recipe never declared it and GC_ACCESS_SIGNING_PRIVATE_KEY_PATH
already covers the key as a file, which a binding binds through
[workload.secret_files]. The signing key is now always a file.

Idunn secret-files cut, Cut 1 (delete first).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:26:56 +02:00
Meta Vens
8476c0290b R21.4: document StreamPixels' public origin
docs/app-profiles/streampixels.md carried no public origin, so the
allowlist entry in src/app-profiles.ts (https://streampixels.gamecult.org)
had only ops config as a corroborating source, not a doc owner. Add the
line, corroborated by gamecult-ops/nginx/streampixels.gamecult.org.conf:14
and StreamPixels' own .env example, so all four allowlist entries have
a doc owner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:51:45 +02:00
Meta Vens
827cdee761 Tests/docs: promote Soul's two it.fails, update the tripwire and fixtures
Both caller-identity-soul.test.ts holes are now closed and promoted
from it.fails to a plain it (both green): the caller-chosen
completionCode is never redeemable, and an arbitrary returnTo origin
is refused. The AppCaller-literal tripwire's expected forger list moves
from ["private-command-plane.ts"] to [] now that the private plane
constructs its caller through callerFromOpenedEnvelope instead of the
literal; the "refuses to mint on refresh" test's wrongApp caller is
built the same way, since a raw { appSlug } literal no longer
satisfies the now-branded AppCaller type.

access-plugin.test.ts's ghostlight completion-code assertion is
updated: `completion.code` is minted independently of the attempt
handle now, so the test documents that the code differs from the
handle instead of asserting they match.

app-registry.test.ts drops validateAppRegistration coverage (deleted)
and seeds registered-app fixtures through InMemoryStore.seedRegisteredApp
instead of the deleted store.registerApp.

docs/service-contract.md documents the new 400 return_to_not_allowed
failure mode. state/evidence.jsonl records this pass's decisions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:23:04 +02:00