Joystick joins the catalog (Basic client auth, rotatesRefreshTokens), resolves
identity by channel id, and is a streampixels managed-connection provider only:
it is not an identity provider for the profile (ruling signin-waits-for-public-bot).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tsc has been red since 9ebf38b: builtInAppProfiles.repixelizer is
Partial-typed, so passing it to isAllowedReturnOrigin and spreading it
into an AppProfile literal both fail under exactOptionalPropertyTypes.
Clean at 827cdee, red at 9ebf38b and after -- the earlier "pre-existing"
reading was taken against a commit inside the same pass rather than
against the last green one.
Narrowed with a throw rather than a cast: a missing built-in should fail
the suite loudly, not be asserted out of existence.
tsc --noEmit -p tsconfig.json -> no output, exit 0
vitest run -> Tests 121 passed | 1 skipped (122)
The one failing file remains tests/odin-publication.test.ts
(Cannot find module 'cultcache-ts'), red on origin/main as well.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The runtime app-registration writer (POST /v1/apps) was deleted in an
earlier pass because it minted a client_secret nobody verified. This
pass kept resolveAppProfile's registered-app fallback and
listAppProfiles on the theory that AppSlug is an open string and
dynamic registration was a live extension point. Soul falsified that:
every HTTP route enumerates the four built-in slugs in its own JSON
schema (app.ts's `enum: [...appSlugs]` at every appSlug param/body
field), so a registered row could never be resolved from a request.
With the writer already gone, this was unreachable code behind a dead
writer -- delete it rather than keep authenticating a door nobody can
open.
Deleted: src/app-registry.ts entirely (resolveAppProfile,
listAppProfiles, isBuiltInApp, registeredAppToProfile,
returnOriginsFromRedirectUris); StoredRegisteredApp and the
findRegisteredApp/listRegisteredApps HeimdallStore methods
(store/types.ts); their InMemoryStore and PostgresStore
implementations, including seedRegisteredApp and mapRegisteredApp; the
registered_apps table (store/schema.ts now drops it on any store built
before this schema); tests/app-registry.test.ts.
Every call site (app.ts x5, claims.ts, private-command-plane.ts) now
calls the existing built-in-only getAppProfile (app-profiles.ts)
directly -- sync, no store needed. If dynamic app registration is
wanted later it returns as a designed, authenticated feature, not by
resurrecting this vestige.
Net -305 lines against the prior commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The symbol brand on AppCaller stopped a TypeScript importer from
writing the `{ appSlug }` literal, but nothing enforced it at
runtime: no handler inspected the branded field, so a cast
(`as unknown as AppCaller`) reached refreshAppSession/startOAuthFlow's
caller gate with a forged value carrying the right appSlug. A brand
that claims an authority it does not enforce is worse than none --
this closes that gap by making the brand real instead of documenting
what it does not do.
brandCaller (app-caller.ts) now registers every AppCaller it mints in
a module-private WeakSet; isAppCaller is the only way a consumer may
trust one. startOAuthFlow and refreshAppSession call isAppCaller(caller)
instead of trusting the AppCaller | null type alone at their two gate
checks.
Flips Soul's forged-caller it.fails to a passing it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The public /v1/oauth/:provider/start route read handoff.attemptId
straight out of the request body and folded it into the signed state
token unconditionally. Since the private command plane's beginAuth
mints an attempt handle server-side and later redeems by that handle
(consumeAuthCompletionByAttempt), anyone who learned a pending handle
could carry it through their own OAuth flow and bind their own
identity to it -- the app's completeAuth(H) would then authenticate
the victim's session as the attacker. Same family as the takeover
chain closed in the prior pass: a caller-chosen identifier trusted as
authority.
normalizeOAuthHandoff now always strips attemptId from a
browser_completion handoff; a public caller has no attempt to
correlate and gets its completion code directly. The only path an
attempt handle can reach the state token is startOAuthFlow's new
trustedBrowserAttemptId option, which is a TypeScript function
argument set by beginAuth from the handle it just minted -- never a
value parsed out of a request body.
Also adds the uniqueness constraint the by-attempt redemption needed:
a partial unique index on (app_slug, attempt_id) WHERE attempt_id IS
NOT NULL AND consumed_at IS NULL, mirrored in InMemoryStore. Without
it, consumeAuthCompletionByAttempt's UPDATE could match more than one
row and silently discard all but the one it returned.
Flips Soul's attempt-handle-binding it.fails to a passing it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>