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:
| Capability | Gives you | Use it when |
|---|---|---|
user.profile | A display name and an app-specific id, after consent | You only need a name on screen |
account.identity | A consented session plus short-lived signed tokens your backend can verify, with scopes | Your app acts on the account: posting, reacting, anything your server must trust |
#Consent first
Both capabilities follow the same rules:
- Declaring isn't consent. Listing
user.profileoraccount.identityin 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. - 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.
- 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.
- "No" is a normal answer. If the player declines, your app keeps working as a guest. Don't block play behind sign-in.
- 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.
- 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"] }
}
| Scope | The player sees "Your app wants to ..." | When to ask |
|---|---|---|
profile.read | see your username and picture, and whether you are age-verified | First. Required for every app. |
content.react | like, follow and report as you | When the player first reacts |
content.post | post and share under your account | At 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();
| Method | Returns | Notes |
|---|---|---|
isAvailable() | boolean | false when account.identity wasn't granted or Fluffer is too old to support it |
session() | AuthSession | Cached 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 null | The 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 function | Fires 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
}
- Tokens last at most 15 minutes. game-kit caches the token and gets a fresh one when less than 2 minutes remain.
- Asking for a scope the player hasn't granted yet may show the consent sheet once more. Scopes they decline are simply missing from
scopes, so handle that rather than failing. - Tokens only travel through
getToken(). Fluffer never puts them in your app's URL, and you shouldn't either: don't log them or put them in query strings.
#Verifying a token on your server
The token is a signed JWT. Its claims:
| Claim | Meaning |
|---|---|
sub | The app-specific user id (the same as AuthSession.userId) |
aud | Your app's slug. Reject tokens minted for other apps. |
scope | Space-separated granted scopes |
iss | The issuer: Fluffer |
sid | The Fluffer session the token belongs to |
name | Display name when the token was created |
picture | Approved avatar URL; absent for private profiles |
adult | Whether the account passed age verification |
role | admin or moderator, only when the account has one |
iat, exp | Issued-at and expiry, in seconds; exp is at most iat + 900 |
On every write, your server should:
- Verify the signature.
- Check
iss, check thataudis your app's slug, and check thatexphasn't passed. - Check that
scopecontains the scope the action needs (for examplecontent.postto post). Don't treatroleas a substitute for a scope. - 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.