diff --git a/skills/rudder-web-sdk/SKILL.md b/skills/rudder-web-sdk/SKILL.md index e13b3fe..1e61112 100644 --- a/skills/rudder-web-sdk/SKILL.md +++ b/skills/rudder-web-sdk/SKILL.md @@ -1,6 +1,6 @@ --- name: rudder-web-sdk -description: Use when working with the Rudder TypeScript/JavaScript SDK (@rudder/js-sdk) — the browser/player-facing SDK for the Rudder LiveOps platform. Covers client setup, device/custom auth, observable domains (player, inventory, catalog, stores, remote config, storage), leaderboards, quests, battle pass, scenario effects, and error handling. Load this whenever code imports from @rudder/js-sdk or RudderClient appears. +description: Use when working with the Rudder TypeScript/JavaScript SDK (@rudder/js-sdk) — the browser/player-facing SDK for the Rudder LiveOps platform. Covers client setup, device/google/apple/custom auth, identity linking, observable domains (player, inventory, catalog, stores, remote config, storage), leaderboards, quests, battle pass, scenario effects, and error handling. Load this whenever code imports from @rudder/js-sdk or RudderClient appears. --- # Rudder Web SDK (@rudder/js-sdk) @@ -51,12 +51,22 @@ drop cached state. ```ts await client.auth.loginWithDevice({ region, language, nickname }); // device id auto-generated/persisted -await client.auth.loginWithCustom({ customData, region, language, nickname }); +await client.auth.loginWithGoogle({ idToken, region, language, nickname }); +await client.auth.loginWithApple({ idToken, region, language, nickname }); +await client.auth.loginWithCustom({ customData, provider?, region, language, nickname }); +// identity linking (requires an active session): +await client.auth.linkWithGoogle(idToken); +await client.auth.linkWithApple(idToken); +await client.auth.linkWithCustom({ customData, provider? }); +await client.auth.unlinkIdentity(provider); // 'device' | 'google' | 'apple' | 'custom' | 'custom:' client.auth.logout(); client.auth.isAuthenticated; // boolean client.auth.onAuthStateChange(cb); // fires immediately, returns unsubscribe ``` +Linked identities are listed on the profile: `client.player.data.identities` +(`PlayerProfileIdentity { provider, subject, createdAt }`). + Tokens are saved to the `TokenStore` automatically. The transport injects `Authorization: Bearer `, retries GETs, and single-flight refreshes on 401; a failed refresh clears tokens and emits `signed-out`. Details: diff --git a/skills/rudder-web-sdk/reference/auth.md b/skills/rudder-web-sdk/reference/auth.md index ee3213b..1f41386 100644 --- a/skills/rudder-web-sdk/reference/auth.md +++ b/skills/rudder-web-sdk/reference/auth.md @@ -1,14 +1,20 @@ # Auth — `client.auth` `AuthService` (source: `src/auth/AuthService.ts`). Player authentication: -device ID login (primary for game clients), custom webhook login, logout, and -auth state observation. +device ID login (primary for game clients), Google / Apple sign-in, custom +webhook login, identity linking/unlinking, logout, and auth state observation. ## Methods ```ts loginWithDevice(options?: LoginWithDeviceOptions): Promise +loginWithGoogle(options: LoginWithGoogleOptions): Promise +loginWithApple(options: LoginWithAppleOptions): Promise loginWithCustom(options: LoginWithCustomOptions): Promise +linkWithGoogle(idToken: string): Promise +linkWithApple(idToken: string): Promise +linkWithCustom(options: LinkWithCustomOptions): Promise +unlinkIdentity(provider: string): Promise logout(): void get isAuthenticated(): boolean onAuthStateChange(listener: AuthStateListener): () => void @@ -28,11 +34,27 @@ The device ID is auto-generated on first call and persisted in localStorage (`src/device/DeviceId.ts`). The request also carries the client's `projectKey`. +### `LoginWithGoogleOptions` / `LoginWithAppleOptions` + +```ts +{ + idToken: string; // required — Google ID token / Apple identity token + region?: string; // default 'global' + language?: string; // default 'en' + nickname?: string; // omitted from the request when not set +} +``` + +The client obtains the `idToken` from the provider's own sign-in flow +(Google Identity Services, Sign in with Apple) and forwards it to +`POST /sdk/v1/authorization/google` or `/sdk/v1/authorization/apple`. + ### `LoginWithCustomOptions` ```ts { customData: Record; // required — forwarded to the project's custom auth webhook + provider?: string; // custom provider name; omitted = the default provider region?: string; // default 'global' language?: string; // default 'en' nickname?: string; @@ -43,6 +65,8 @@ The device ID is auto-generated on first call and persisted in localStorage ```ts interface LoginViaDeviceResponse { accessToken?: string; refreshToken?: string } +interface LoginViaGoogleResponse { accessToken?: string; refreshToken?: string } +interface LoginViaAppleResponse { accessToken?: string; refreshToken?: string } interface LoginViaCustomResponse { accessToken?: string; refreshToken?: string } ``` @@ -50,6 +74,36 @@ On success both tokens are saved to the client's `TokenStore`, the runtime starts (domains warmed, scenario engine restored, `player_login` event fired), and the state flips to `'signed-in'`. +## Identity linking + +`linkWithGoogle` / `linkWithApple` / `linkWithCustom` attach an additional +identity to the currently signed-in player; `unlinkIdentity` detaches one. +All four require an active session — the access token is sent automatically +(transport level). They return nothing (HTTP 204). + +```ts +await client.auth.linkWithGoogle(idToken); +await client.auth.linkWithApple(idToken); +await client.auth.linkWithCustom({ customData, provider }); // provider optional +await client.auth.unlinkIdentity('custom:my-backend'); +``` + +### `LinkWithCustomOptions` + +```ts +{ + customData: Record; // required + provider?: string; // custom provider name; omitted = the default provider +} +``` + +`unlinkIdentity(provider)` takes the full provider string: `device`, +`google`, `apple`, `custom`, or `custom:`. + +Linked identities appear on the player profile: +`client.player.data.identities: PlayerProfileIdentity[]` with +`{ provider: string; subject: string; createdAt: string }`. + ## Environments A project has two environments, `staging` and `prod`. The SDK key you pass as diff --git a/skills/rudder-web-sdk/reference/player.md b/skills/rudder-web-sdk/reference/player.md index afff3d7..57df4cf 100644 --- a/skills/rudder-web-sdk/reference/player.md +++ b/skills/rudder-web-sdk/reference/player.md @@ -23,10 +23,17 @@ await client.player.reload(); // force refetch ```ts interface PlayerProfile { + identities?: PlayerProfileIdentity[]; player?: Player; wallets?: Wallet[]; } +interface PlayerProfileIdentity { + createdAt: string; + provider: string; // 'device' | 'google' | 'apple' | 'custom' | 'custom:' + subject: string; +} + interface Player { createdAt?: string; id?: string; diff --git a/src/auth/AuthService.ts b/src/auth/AuthService.ts index 41127d5..0b23213 100644 --- a/src/auth/AuthService.ts +++ b/src/auth/AuthService.ts @@ -1,15 +1,21 @@ /** * AuthService — player authentication. * - * Handles login via device ID (the primary auth flow for game clients) or a - * custom backend webhook, logout (token clearing), and auth state observation. + * Handles login via device ID (the primary auth flow for game clients), Google, + * Apple, or a custom backend webhook, identity linking/unlinking, logout + * (token clearing), and auth state observation. */ import type { RudderContext } from '../core/context.js'; import type { RemoteConfigShape } from '../state/RemoteConfigState.js'; import { getOrCreateDeviceId } from '../device/DeviceId.js'; import { api } from '../generated/api.js'; -import type { LoginViaCustomResponse, LoginViaDeviceResponse } from '../generated/auth.js'; +import type { + LoginViaAppleResponse, + LoginViaCustomResponse, + LoginViaDeviceResponse, + LoginViaGoogleResponse, +} from '../generated/auth.js'; export type AuthState = 'signed-in' | 'signed-out'; @@ -27,6 +33,8 @@ export interface LoginWithDeviceOptions { export interface LoginWithCustomOptions { /** Arbitrary payload forwarded to the developer's custom auth webhook. */ customData: Record; + /** Name of the custom auth provider; omitted means the default provider. */ + provider?: string; /** Player's region code (default: "global"). */ region?: string; /** Player's language code (default: "en"). */ @@ -35,6 +43,35 @@ export interface LoginWithCustomOptions { nickname?: string; } +export interface LoginWithGoogleOptions { + /** Google ID token obtained from the Google sign-in flow. */ + idToken: string; + /** Player's region code (default: "global"). */ + region?: string; + /** Player's language code (default: "en"). */ + language?: string; + /** Optional player nickname; omitted from the request when not set. */ + nickname?: string; +} + +export interface LoginWithAppleOptions { + /** Apple ID token (identity token) obtained from Sign in with Apple. */ + idToken: string; + /** Player's region code (default: "global"). */ + region?: string; + /** Player's language code (default: "en"). */ + language?: string; + /** Optional player nickname; omitted from the request when not set. */ + nickname?: string; +} + +export interface LinkWithCustomOptions { + /** Arbitrary payload forwarded to the developer's custom auth webhook. */ + customData: Record; + /** Name of the custom auth provider; omitted means the default provider. */ + provider?: string; +} + export class AuthService { private readonly listeners = new Set(); private state: AuthState; @@ -100,10 +137,11 @@ export class AuthService * saved to the client's TokenStore. */ async loginWithCustom(options: LoginWithCustomOptions): Promise { - const { customData, region = 'global', language = 'en', nickname } = options; + const { customData, provider, region = 'global', language = 'en', nickname } = options; const response = await api.loginViaCustom(this.ctx, { key: this.ctx.options.projectKey, customData, + provider, region, language, nickname, @@ -118,6 +156,90 @@ export class AuthService return response; } + /** + * Authenticates with a Google ID token and returns access + refresh tokens. + * + * On success, tokens are saved to the client's TokenStore. + */ + async loginWithGoogle(options: LoginWithGoogleOptions): Promise { + const { idToken, region = 'global', language = 'en', nickname } = options; + const response = await api.loginViaGoogle(this.ctx, { + key: this.ctx.options.projectKey, + idToken, + region, + language, + nickname, + }); + + this.ctx.options.tokenStore.saveTokens( + response.accessToken ?? '', + response.refreshToken ?? '', + ); + await this.startRuntime(); + this.setState('signed-in'); + return response; + } + + /** + * Authenticates with an Apple ID token and returns access + refresh tokens. + * + * On success, tokens are saved to the client's TokenStore. + */ + async loginWithApple(options: LoginWithAppleOptions): Promise { + const { idToken, region = 'global', language = 'en', nickname } = options; + const response = await api.loginViaApple(this.ctx, { + key: this.ctx.options.projectKey, + idToken, + region, + language, + nickname, + }); + + this.ctx.options.tokenStore.saveTokens( + response.accessToken ?? '', + response.refreshToken ?? '', + ); + await this.startRuntime(); + this.setState('signed-in'); + return response; + } + + /** + * Links a Google identity to the currently signed-in player. Requires an + * active session (the access token is sent automatically). + */ + async linkWithGoogle(idToken: string): Promise { + await api.linkViaGoogle(this.ctx, { idToken }); + } + + /** + * Links an Apple identity to the currently signed-in player. Requires an + * active session (the access token is sent automatically). + */ + async linkWithApple(idToken: string): Promise { + await api.linkViaApple(this.ctx, { idToken }); + } + + /** + * Links a custom identity to the currently signed-in player via the project's + * custom authorization webhook. Requires an active session. + */ + async linkWithCustom(options: LinkWithCustomOptions): Promise { + await api.linkViaCustom(this.ctx, { + customData: options.customData, + provider: options.provider, + }); + } + + /** + * Unlinks an identity from the currently signed-in player. The provider is + * the full provider string: `device`, `google`, `apple`, `custom`, or + * `custom:`. + */ + async unlinkIdentity(provider: string): Promise { + await api.unlinkIdentity(this.ctx, { provider }); + } + /** Clears all stored tokens (logout). */ logout(): void { this.ctx.options.tokenStore.clear(); diff --git a/src/generated/api.ts b/src/generated/api.ts index 6afb89f..8e9e094 100644 --- a/src/generated/api.ts +++ b/src/generated/api.ts @@ -1,6 +1,6 @@ // Code generated by apigen. DO NOT EDIT. -import type { LoginViaCustomRequest, LoginViaCustomResponse, LoginViaDeviceRequest, LoginViaDeviceResponse, RefreshAccessTokenRequest, RefreshAccessTokenResponse } from './auth.js'; +import type { LinkViaAppleRequest, LinkViaCustomRequest, LinkViaGoogleRequest, LoginViaAppleRequest, LoginViaAppleResponse, LoginViaCustomRequest, LoginViaCustomResponse, LoginViaDeviceRequest, LoginViaDeviceResponse, LoginViaGoogleRequest, LoginViaGoogleResponse, RefreshAccessTokenRequest, RefreshAccessTokenResponse, UnlinkIdentityRequest } from './auth.js'; import type { AddBattlePassXpRequest, AddBattlePassXpResponse, ClaimBattlePassRewardRequest, ClaimBattlePassRewardResponse, GetBattlePassProgressRequest, GetBattlePassProgressResponse, PurchaseBattlePassPremiumRequest, PurchaseBattlePassPremiumResponse } from './battlepass.js'; import type { ListCatalogItemsResponse } from './catalog.js'; import type { GetInventoryResponse } from './inventory.js'; @@ -118,6 +118,24 @@ export const api = { ): Promise => t.request('POST', '/sdk/v1/scenarios/callback', body), + linkViaApple: ( + t: Transport, + body: LinkViaAppleRequest, + ): Promise => + t.request('POST', '/sdk/v1/authorization/apple/link', body), + + linkViaCustom: ( + t: Transport, + body: LinkViaCustomRequest, + ): Promise => + t.request('POST', '/sdk/v1/authorization/custom/link', body), + + linkViaGoogle: ( + t: Transport, + body: LinkViaGoogleRequest, + ): Promise => + t.request('POST', '/sdk/v1/authorization/google/link', body), + listCatalogItems: ( t: Transport, ): Promise => @@ -143,6 +161,12 @@ export const api = { ): Promise => t.request('GET', '/sdk/v1/stores'), + loginViaApple: ( + t: Transport, + body: LoginViaAppleRequest, + ): Promise => + t.request('POST', '/sdk/v1/authorization/apple', body), + loginViaCustom: ( t: Transport, body: LoginViaCustomRequest, @@ -155,6 +179,12 @@ export const api = { ): Promise => t.request('POST', '/sdk/v1/authorization/device', body), + loginViaGoogle: ( + t: Transport, + body: LoginViaGoogleRequest, + ): Promise => + t.request('POST', '/sdk/v1/authorization/google', body), + purchaseBattlePassPremium: ( t: Transport, body: PurchaseBattlePassPremiumRequest, @@ -193,6 +223,12 @@ export const api = { ): Promise => t.request('POST', '/sdk/v1/scenarios/trigger', body), + unlinkIdentity: ( + t: Transport, + body: UnlinkIdentityRequest, + ): Promise => + t.request('POST', '/sdk/v1/authorization/unlink', body), + updateProjectStorage: ( t: Transport, body: UpdateProjectStorageRequest, diff --git a/src/generated/auth.ts b/src/generated/auth.ts index ab2414a..bcb3bc9 100644 --- a/src/generated/auth.ts +++ b/src/generated/auth.ts @@ -1,10 +1,37 @@ // Code generated by apigen. DO NOT EDIT. +export interface LinkViaAppleRequest { + "idToken": string; +} + +export interface LinkViaCustomRequest { + "customData": { [key: string]: unknown }; + "provider"?: string; +} + +export interface LinkViaGoogleRequest { + "idToken": string; +} + +export interface LoginViaAppleRequest { + "idToken": string; + "key": string; + "language": string; + "nickname"?: string; + "region": string; +} + +export interface LoginViaAppleResponse { + "accessToken"?: string; + "refreshToken"?: string; +} + export interface LoginViaCustomRequest { "customData"?: { [key: string]: unknown }; "key"?: string; "language"?: string; "nickname"?: string; + "provider"?: string; "region"?: string; } @@ -26,6 +53,19 @@ export interface LoginViaDeviceResponse { "refreshToken"?: string; } +export interface LoginViaGoogleRequest { + "idToken": string; + "key": string; + "language": string; + "nickname"?: string; + "region": string; +} + +export interface LoginViaGoogleResponse { + "accessToken"?: string; + "refreshToken"?: string; +} + export interface RefreshAccessTokenRequest { "refreshToken"?: string; } @@ -35,3 +75,7 @@ export interface RefreshAccessTokenResponse { "refreshToken"?: string; } +export interface UnlinkIdentityRequest { + "provider": string; +} + diff --git a/src/generated/player.ts b/src/generated/player.ts index abe2ce7..fc2bbe1 100644 --- a/src/generated/player.ts +++ b/src/generated/player.ts @@ -11,10 +11,17 @@ export interface Player { } export interface PlayerProfile { + "identities"?: PlayerProfileIdentity[]; "player"?: Player; "wallets"?: Wallet[]; } +export interface PlayerProfileIdentity { + "createdAt": string; + "provider": string; + "subject": string; +} + export interface Wallet { "balance"?: number; "currency"?: string; diff --git a/src/index.ts b/src/index.ts index 917f12e..4b8654b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,8 +22,11 @@ export type { AuthService, AuthState, AuthStateListener, + LinkWithCustomOptions, + LoginWithAppleOptions, LoginWithCustomOptions, LoginWithDeviceOptions, + LoginWithGoogleOptions, } from './auth/AuthService.js'; export type { LeaderboardsService, LeaderboardHandle } from './leaderboards/LeaderboardsService.js'; export type { BattlePassService } from './battlepass/BattlePassService.js'; @@ -71,7 +74,7 @@ export type { // Generated wire types — the shapes returned by the observable state and // handles, so consumers can name them instead of re-declaring mirrors. -export type { Player, PlayerProfile, Wallet } from './generated/player.js'; +export type { Player, PlayerProfile, PlayerProfileIdentity, Wallet } from './generated/player.js'; export type { CatalogItem } from './generated/catalog.js'; export type { PlayerInventoryItem } from './generated/inventory.js'; export type {