Fluffer by MotorbunnyDevelopers

Web Launcher SDK

Accounts

Consent-first identity for Fluffer apps: user.profile, account.identity scopes, game.auth, signed tokens and verifying them on your server.

Players can use your app without telling it who they are. When your app genuinely needs the player's Fluffer account (to show their name, save a public high score, or let them post, like or follow), it asks, and the player decides.

Fluffer offers two levels:

CapabilityGives youUse it when
user.profileA display name and an app-specific id, after consentYou only need a name on screen
account.identityA consented session plus short-lived signed tokens your backend can verify, with scopesYour app acts on the account: posting, reacting, anything your server must trust

Both capabilities follow the same rules:

  1. Declaring isn't consent. Listing user.profile or account.identity in your manifest only lets your app ask. Until the player agrees, your app receives nothing about the account: no id, no name, no picture, no token.
  2. Ask from a tap, on a screen that explains why. Show your own screen that says what you'll use the account for, with a button. Call the consent method from that button. Never ask at launch or from a timer.
  3. Fluffer draws the consent sheet. It names your app and lists exactly what would be shared, in plain words. The player taps Allow or Don't allow.
  4. "No" is a normal answer. If the player declines, your app keeps working as a guest. Don't block play behind sign-in.
  5. Ids are app-specific. The id your app sees is derived from the player and your app, so two apps can't match up their records about the same person.
  6. Players can disconnect any time, from your app or from their Fluffer account settings.

#Display name only: user.profile

{ "capabilities": ["device.vibrate", "user.profile"] }
import { createGame, user } from '@fluffer/game-kit';

const game = await createGame({ capabilities: ['device.vibrate', 'user.profile'] });

// On your own "Show my name on the scoreboard?" screen:
useNameButton.onclick = async () => {
  const me = await user.get();          // the first call shows Fluffer's consent sheet
  if (!me || me.isGuest) {
    showName('Guest');                  // declined or not signed in: keep going
  } else {
    showName(me.displayName ?? 'Player');
  }
};

user.get() resolves with { id, displayName?, isGuest }, or null. A declined ask resolves { isGuest: true }.

#Verifiable identity: account.identity

Declare the capability and every scope you might ever ask for:

{
  "capabilities": ["device.vibrate", "account.identity"],
  "auth": { "scopes": ["profile.read", "content.react", "content.post"] }
}
ScopeThe player sees "Your app wants to ..."When to ask
profile.readsee your username and picture, and whether you are age-verifiedFirst. Required for every app.
content.reactlike, follow and report as youWhen the player first reacts
content.postpost and share under your accountAt the moment they post, not at launch

There's also a moderation scope reserved for staff tools; it's never shown to players. See Manifest for the validation rules.

#The session

interface AuthSession {
  signedIn: boolean;
  isGuest: boolean;
  consented: boolean;          // false → none of the optional fields below
  userId?: string;             // app-specific id
  displayName?: string;
  avatarUrl?: string;          // https picture; absent for private profiles
  grantedScopes: AuthScope[];
  adultVerified: boolean;      // the account passed Fluffer's age verification
  role?: 'admin' | 'moderator';
}

Before consent, the session is just { signedIn, isGuest, consented: false, grantedScopes: [], adultVerified: false }. Your app knows whether an account exists, and nothing else.

#With game-kit: game.auth

import { createGame } from '@fluffer/game-kit';

const game = await createGame({ capabilities: ['device.vibrate', 'account.identity'] });

// 1. Read the session at startup. This never prompts.
if (!game.auth.isAvailable()) {
  hideAccountFeatures();               // capability not granted, or an older Fluffer
} else if (!game.auth.session().consented) {
  showConnectScreen();                 // your own screen explaining why
}

// 2. Ask from a tap.
connectButton.onclick = async () => {
  const session = await game.auth.requestConsent(['profile.read', 'content.react']);
  if (!session.consented) return;      // declined: carry on as a guest
  showProfile(session.displayName, session.avatarUrl);
};

// 3. React to changes: consent, sign-out, scope changes, disconnect from settings.
game.auth.onChange((s) => renderAccountUi(s));

// 4. Let the player disconnect.
disconnectButton.onclick = () => game.auth.revoke();
MethodReturnsNotes
isAvailable()booleanfalse when account.identity wasn't granted or Fluffer is too old to support it
session()AuthSessionCached snapshot. Never prompts, never creates a token.
requestConsent(scopes)Promise<AuthSession>The only call that shows the consent sheet on its own. Resolves either way; check consented.
getToken(scopes)Promise<AuthToken>A signed token for your backend. See below.
current()string or nullThe cached, unexpired token string, for building headers synchronously
revoke()Promise<void>Disconnects your app: its tokens stop working and consent is cleared
onChange(handler)unsubscribe functionFires on every session change

requestConsent, getToken and revoke reject with PERMISSION_DENIED when the capability is missing or the player is a guest. getToken also rejects before consent. Asking for a scope your manifest doesn't declare fails with INVALID_ARGUMENT.

#Tokens for your backend

When your app calls your own server on the player's behalf, send a token instead of a user id. Ask for the token at the moment of the action:

postButton.onclick = async () => {
  const { token, scopes } = await game.auth.getToken(['content.post']);
  if (!scopes.includes('content.post')) {
    showMessage('Posting needs permission to post under your account.');
    return;                                   // the player declined this scope
  }
  await fetch('https://api.example.com/posts', {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ text }),
  });
};
interface AuthToken {
  token: string;        // compact JWS
  expiresAt: number;    // ms since epoch, at most 15 minutes ahead
  scopes: AuthScope[];  // what was actually granted; may be fewer than you asked for
}

#Verifying a token on your server

The token is a signed JWT. Its claims:

ClaimMeaning
subThe app-specific user id (the same as AuthSession.userId)
audYour app's slug. Reject tokens minted for other apps.
scopeSpace-separated granted scopes
issThe issuer: Fluffer
sidThe Fluffer session the token belongs to
nameDisplay name when the token was created
pictureApproved avatar URL; absent for private profiles
adultWhether the account passed age verification
roleadmin or moderator, only when the account has one
iat, expIssued-at and expiry, in seconds; exp is at most iat + 900

On every write, your server should:

  1. Verify the signature.
  2. Check iss, check that aud is your app's slug, and check that exp hasn't passed.
  3. Check that scope contains the scope the action needs (for example content.post to post). Don't treat role as a substitute for a scope.
  4. Take the user's identity only from the token, never from a user id in the request body.

Production tokens are signed with ES256 and carry a kid header. To get the verification keys for production, contact us at developers@fluffer.app.

#Testing locally

The mock launcher supports both capabilities. By default it plays a signed-in account (mock-user-1) that hasn't consented yet, and it taps Allow automatically. Its tokens are signed with HS256 and the secret mock-secret, so your local backend can verify them while you develop.

import { installMockLauncher } from '@fluffer/launcher-sdk';

const mock = installMockLauncher({
  slug: 'my-game',                       // becomes the token's aud
  auth: {
    consent: true,                       // false = the player taps "Don't allow" everywhere
    session: { displayName: 'Test Player', adultVerified: true },
  },
});

mock.auth.setConsent(false);             // the player declines or disconnects
mock.auth.setSession({ isGuest: true }); // switch to a guest
mock.auth.setSession({ grantedScopes: ['profile.read'] });   // already consented
console.log(mock.auth.session(), mock.auth.tokens());

@fluffer/launcher-sdk also exports decodeJwt() and verifyHs256() for inspecting and checking mock tokens in tests.

With game-kit's automatic mock, pass your slug to createGame({ slug: 'my-game' }) so mock tokens carry the right aud.