Skip to content

krew.io on Yumina: integration notes for the krew team

This is the practical companion to the design spec (docs/superpowers/specs/2026-09-12-krew-yumina-identity-handoff-design.md). It lists exactly what Yumina exposes and what krew has to do with it.

What Yumina gives you

ThingWhere
The page players land onhttps://yumina.io/krew (optionally ?path=<encoded krew path+query>)
Your game, framedhttps://play.krew.io/<path>?host=yumina inside that page
Public configGET https://yumina.io/api/partners/krew/config
Public signing keys (JWKS)GET https://yumina.io/api/auth/jwks
Identity token (page fetches it, you never call this)POST https://yumina.io/api/partners/krew/token

Dev: replace https://yumina.io with the Yumina dev API origin we give you, and https://play.krew.io with whatever origin your dev client runs on. Yumina's dev config points at your origin through its KREW_CLIENT_ORIGIN variable.

Yumina is not in the game's network path

The frame at play.krew.io keeps talking to game.krew.io directly, HTTP and WebSocket, exactly as today. Yumina never proxies or redirects that traffic and never sees it. The whole integration is three calls:

  1. The Yumina page fetches an identity token from Yumina's own API.
  2. The page hands the token to your frame with postMessage; your frame POSTs it to your own game.krew.io/auth/yumina/session and gets a krew session back.
  3. Your server fetches Yumina's public keys from the JWKS URL once and caches them, the way it does with Google's keys.

So game.krew.io needs to allow the play.krew.io origin (it most likely does already), and nothing for yumina.io. Heads-up: your server's env validation requires the API host to equal the site host or be a subdomain of it, for the cookie domain. play.krew.io + game.krew.io fails that check; a site URL of https://krew.io passes and its cookie domain covers both subdomains. With bearer tokens the cookie hardly matters, so relaxing the check is fine too.

The redirect on krew.io

Every krew.io URL except the CrazyGames embed and play.krew.io should 301 to:

https://yumina.io/krew?path=<URL-encoded original path + query>

Example: https://krew.io/clan/abc?tab=membershttps://yumina.io/krew?path=%2Fclan%2Fabc%3Ftab%3Dmembers.

Yumina validates path (must start with /, not //, max 512 chars, no control characters or backslashes) and frames https://play.krew.io/clan/abc?tab=members&host=yumina. Anything invalid falls back to /.

Detecting that you are inside Yumina

The frame URL always carries host=yumina. Use it to select the Yumina platform provider in the client SDK (alongside crazygames, adinplay, local). A referrer check on yumina.io is a fine fallback.

The postMessage bridge

Your frame talks to the Yumina page with window.parent.postMessage(msg, "https://yumina.io"). Always pass the explicit target origin. Only accept messages where event.origin === "https://yumina.io" (or the dev origin).

Frame → page:

json
{ "type": "yumina:auth:request", "requestId": "<fresh opaque id>" }
{ "type": "yumina:auth:login" }
{ "type": "yumina:auth:logout" }
{ "type": "yumina:game:ready" }
  • yumina:auth:request: send on boot, on every renewal (about once a minute while playing is fine), after yumina:auth:changed, and whenever you need fresh proof. Every auth reply echoes your requestId so you can drop stale answers. If the Yumina session is still loading, the answer waits for it rather than saying "nobody". Only the newest pending request is answered.
  • yumina:auth:login: send when the player presses your "Sign in with Yumina" button. The page navigates the top window to Yumina's login and returns to the same /krew?path=... afterwards, then your frame boots again and requests.
  • yumina:auth:logout: send from your sign-out control in Yumina mode. The page ends the Yumina login session; the confirmation is the yumina:auth:changed that follows.
  • yumina:game:ready: optional, hides Yumina's loading overlay early (it also hides on the iframe load event).

Page → frame:

json
{ "type": "yumina:auth:token", "requestId": "<echo>", "token": "<jwt>", "expiresAt": "2026-09-12T10:00:00.000Z" }
{ "type": "yumina:auth:unauthenticated", "requestId": "<echo>" }
{ "type": "yumina:auth:error", "requestId": "<echo>", "code": "temporarily_unavailable" }
{ "type": "yumina:auth:changed" }
  • yumina:auth:token: exchange it immediately (see below). Do not store it. expiresAt is informational; the signed exp is the truth.
  • yumina:auth:unauthenticated: Yumina confirmed nobody is signed in. Guests may play; show your sign-in button whenever you want.
  • yumina:auth:error: identity could not be determined right now (network, rate limit, outage). This is not a logout. Retry with backoff and never create a replacement account from it.
  • yumina:auth:changed: the signed-in user changed (login, logout, user switch, or a logout you requested). Invalidate outstanding requests and send a new yumina:auth:request. The page revalidates its session when it regains focus, so cross-tab changes are announced within a few seconds; a token fetch that was in flight when the user changed is dropped rather than delivered.

The identity token

A JWT, alg: RS256, kid in the header. Verify against the JWKS URL above with key caching (same as your Google id_token code). Reject if iss, aud, exp or the signature fail.

ClaimValue
isshttps://yumina.io
audkrew.io
subYumina user id. Your permanent key for this player. Random, never reused.
sidYumina login-session id. Stable across renewals for one login; scope your authorization lease to (iss, sub, sid).
iat, expLifetime is 90 seconds.
jtiRandom id, in case you want replay protection.
emailAccount email
email_verifiedboolean
usernameYumina handle, unique, may be null on very old accounts
display_nameWhat Yumina shows for the player. Never empty.
pictureAbsolute https:// avatar URL, or null
identities{ "google": "...", "discord": "...", "twitter": "..." }, only present providers; {} for a password-only account

identities.google is Google's OpenID sub and identities.discord is the Discord user id, the same values your auth.googleId / auth.discordId already hold. Google sub, Discord ids and X ids are global per user, not per OAuth app, which is why they match across the two products. Every id comes from a completed OAuth login on Yumina; nothing in the token is derived from an editable profile field or an email match. Ids are strings; never convert them to numbers. The signing key is stable; if Yumina ever rotates it, the old key stays published in the JWKS while tokens signed with it can still be valid.

The public config (GET /api/partners/krew/config) returns enabled, clientOrigin, apiOrigin, audience, issuer, jwksUrl and tokenTtlSeconds. The token endpoint answers 401 only when there is no Yumina session, 429 with Retry-After when rate limited, 403 for a cross-site browser origin, and 503 for a temporary signing failure; the page maps everything except 401 to yumina:auth:error.

Suggested krew-side flow (your call)

  1. POST /auth/yumina/session { token } on your API, a sibling of the CrazyGames session route: verify → find or create → signSessionJwt → return { token } for the bearer header.
  2. Add auth.yuminaId to the user model, indexed like the other providers.
  3. Match order: auth.yuminaId == sub wins. Otherwise look up identities.google / identities.discord against legacy accounts that have no yuminaId yet. 0 → new account. 1 → attach yuminaId. 2 → merge, log, snapshot, attach.
  4. Sync display_name and picture on each session start, the way you sync OAuth avatars today.
  5. Inside the frame, accept postMessages only from https://yumina.io (and the dev origin). That is the only place the Yumina origin appears on your side.

Testing together

  • Yumina dev exposes the same routes; we hand you the dev origin and set KREW_CLIENT_ORIGIN to your dev client.
  • Use a Yumina account with both Google and Discord linked (Settings → Connected accounts) against a krew dev database seeded with one Google-made and one Discord-made legacy account to exercise the 0 / 1 / 2 match cases.
  • GET /api/partners/krew/config returning enabled: false means Yumina has the feature switched off; the page shows "not available yet".