@pixelume/sdk 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,78 @@ conter mudanças incompatíveis, sempre listadas em **Changed/Removed**.
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-10-14
11
+
12
+ ### Added
13
+
14
+ - `restoreAcrossSessions` (opt-in, padrão `false`): recupera a conta numa **aba nova ou após reabrir o
15
+ navegador** enquanto a sessão central da Pixelume for válida, sem pedir senha. Após um login, o SDK
16
+ guarda em `localStorage` só um indício não secreto (`pixelume.oauth.restore.<clientId>`: versão,
17
+ escopo concedido e data) e, num início sem sessão em memória, faz **no máximo uma** autorização
18
+ `prompt=none` (ida e volta de topo, sem tela). Requer a Pixelume API com `prompt=none`
19
+ (implantada em 14/10/2026). `true` usa 30 dias; `{ maxAgeDays }` aceita de 1 a 90.
20
+ - Estado `redirecting`/`exchanging` com `restoring: true` durante a tentativa silenciosa.
21
+ - Motivos anônimos `session_ended` (a API respondeu `login_required`/`consent_required`/
22
+ `interaction_required`: o indício é removido e nada é tentado de novo automaticamente) e `offline`
23
+ (sem conexão para tentar ou concluir a recuperação: o indício é mantido).
24
+ - `PixelumeAuthError` com `reason: 'restore_rejected'` quando a recuperação é recusada por outro
25
+ motivo (por exemplo, `saves` deixou de ser permitido: o indício passa a pedir só `profile`).
26
+ - `PixelumeBrowserAdapter.persistentStorage` e `isOnline()` opcionais; tipo
27
+ `PixelumeCrossSessionRestoreOptions`; `config.restoreAcrossSessions`.
28
+ - Widget: “Conectando à sua conta Pixelume…” durante a recuperação e textos para sessão encerrada e
29
+ falta de conexão.
30
+
31
+ ### Changed
32
+
33
+ - Com `restoreAcrossSessions`, o reload da mesma aba também usa `prompt=none` em vez de passar pelo
34
+ portal: se a sessão central tiver acabado, o jogador continua como visitante em vez de ver a tela de
35
+ login. Sem a opção, o comportamento é idêntico ao da 0.4.0 (um indício deixado por uma versão com a
36
+ opção ligada é apenas removido).
37
+ - Proteções contra loop: uma tentativa por início, marcador por aba que impede nova tentativa por
38
+ 10 minutos se a anterior não voltar, callback repetido recusado sem troca de código, retorno do
39
+ back/forward cache durante a tentativa vira visitante. Tokens continuam só em memória.
40
+
41
+ ## [0.4.0] - 2026-10-14
42
+
43
+ ### Added
44
+
45
+ - Renovação automática de sessão com os refresh tokens rotativos da Pixelume API: renovação proativa
46
+ (`expires_in − 60 s`, com jitter), verificação ao retomar a aba (`visibilitychange`, `pageshow`,
47
+ `online`), single-flight por instância, uma renovação + um único retry após `401`, retry único após
48
+ falha de rede/5xx, backoff para `429`/offline sem logout e encerramento explícito em `invalid_grant`
49
+ (`session_expired`) ou `invalid_client` (`game_unavailable`). Tokens permanecem só em memória.
50
+ - `pixelume.auth.session`, `auth.subscribeSession()` e `auth.refresh()` para interfaces próprias
51
+ (escopos, expiração, renovação em andamento e falha transitória, sem expor tokens).
52
+ - `pixelume.saves` (Cloud Saves): `list()`, `load()`, `save()` e `delete()` sobre
53
+ `/v1/games/:gameId/saves`, com validação local de slot/versão/revisão/JSON, `PixelumeSaveConflictError`
54
+ (`409`, com `current` e `expectedRevision`), exclusão idempotente (`'deleted' | 'not_found'`) e
55
+ atividade observável (`saves.activity`, `saves.subscribe()`) sem dados do save.
56
+ - Opção `scope` (`'profile' | 'profile saves'`) no cliente e em `auth.login()`; a restauração após
57
+ reload reutiliza o escopo concedido.
58
+ - `PixelumeClient.destroy()` para liberar timers e listeners.
59
+ - `PixelumeHttpError.details` com os detalhes não sensíveis do envelope de erro da API
60
+ (`oauthError`, `quota`, metadados de conflito).
61
+ - Widget: aviso de conexão durante falhas transitórias de renovação, mensagem para jogo indisponível e
62
+ status da nuvem somente após `saves.save()`/`saves.delete()` explícitos.
63
+ - Exemplo Vite com status da sessão, painel de Cloud Saves e modo `?headless`.
64
+
65
+ ### Changed
66
+
67
+ - `auth.logout()` revoga o refresh token (`token_type_hint=refresh_token`, com `client_id`), encerrando
68
+ toda a autorização do jogo; sem refresh token, revoga o access token como antes.
69
+ - `auth.getPlayer()` renova a sessão em vez de declará-la expirada quando o access token vence ou a API
70
+ responde `401`; quando a sessão realmente termina, o estado `anonymous/expired` agora traz
71
+ `PixelumeAuthError` explícito em `error` (antes `null`).
72
+ - O parser do token aceita `scope` `profile saves` e os campos opcionais `refresh_token` e
73
+ `refresh_token_expires_in`.
74
+ - Uniões públicas ampliadas (aditivo, mas afeta `switch` exaustivo): `PixelumeAuthErrorReason` ganhou
75
+ `not_authenticated`, `session_expired`, `game_unavailable` e `renewal_rejected`;
76
+ `PixelumeApiErrorCode` ganhou os códigos de Cloud Saves e `OAUTH_UNSUPPORTED_GRANT_TYPE`.
77
+ - A interface `PixelumeAuthApi` ganhou `session`, `subscribeSession()` e `refresh()`; implementações
78
+ próprias dessa interface (mocks) precisam acrescentá-los.
79
+ - O marcador não sensível em `sessionStorage` passou a guardar o escopo concedido (`profile` ou
80
+ `profile saves`); o valor antigo `1` continua aceito.
81
+
10
82
  ## [0.3.0] - 2026-10-13
11
83
 
12
84
  ### Added
@@ -65,7 +137,8 @@ conter mudanças incompatíveis, sempre listadas em **Changed/Removed**.
65
137
  `invalid_response`) e `isPixelumeError()`.
66
138
  - Build ESM + `.d.ts`, CI e workflow de publicação via npm Trusted Publishing.
67
139
 
68
- [Unreleased]: https://github.com/ericksonnascimento/pixelume-sdk/compare/v0.3.0...HEAD
140
+ [Unreleased]: https://github.com/ericksonnascimento/pixelume-sdk/compare/v0.4.0...HEAD
141
+ [0.4.0]: https://github.com/ericksonnascimento/pixelume-sdk/compare/v0.3.0...v0.4.0
69
142
  [0.3.0]: https://github.com/ericksonnascimento/pixelume-sdk/compare/v0.2.0...v0.3.0
70
143
  [0.2.0]: https://github.com/ericksonnascimento/pixelume-sdk/compare/v0.1.1...v0.2.0
71
144
  [0.1.1]: https://github.com/ericksonnascimento/pixelume-sdk/compare/v0.1.0...v0.1.1
package/README.md CHANGED
@@ -1,9 +1,10 @@
1
1
  # @pixelume/sdk
2
2
 
3
- SDK TypeScript para jogos web da Pixelume. O core headless continua sendo o padrão; a versão 0.3 adiciona
4
- um widget oficial opt-in em `@pixelume/sdk/ui`. Os dois modos usam a mesma autenticação centralizada com
5
- OAuth 2.0 Authorization Code + PKCE S256. Não há dependências de runtime, senha no jogo nem
6
- `client_secret`.
3
+ SDK TypeScript para jogos web da Pixelume. O core headless continua sendo o padrão e há um widget
4
+ oficial opt-in em `@pixelume/sdk/ui`. Os dois modos usam a mesma autenticação centralizada com OAuth 2.0
5
+ Authorization Code + PKCE S256. A versão 0.4 adiciona **renovação automática de sessão** (refresh tokens
6
+ rotativos, só em memória) e **Cloud Saves** (`pixelume.saves`). Não há dependências de runtime, senha
7
+ no jogo nem `client_secret`.
7
8
 
8
9
  ## Instalação
9
10
 
@@ -19,12 +20,14 @@ CommonJS, use `await import('@pixelume/sdk')`.
19
20
  Antes de integrar um jogo, registre na Pixelume API:
20
21
 
21
22
  - um `client_id` público, normalmente igual ao `gameId`;
22
- - o escopo `profile`;
23
- - cada callback completo por igualdade exata de scheme, host, porta, path e query.
23
+ - os escopos: `profile` (obrigatório) e `saves` (somente se o jogo usar Cloud Saves);
24
+ - cada callback completo por igualdade exata de scheme, host, porta, path e query, e as origens do jogo.
24
25
 
25
26
  Não são aceitos curingas, prefixos ou callbacks enviados dinamicamente. O cliente local já cadastrado é
26
27
  `pixelume-local-dev`, exclusivamente com `http://localhost:4173/oauth/callback` e
27
- `http://127.0.0.1:4173/oauth/callback`. Jogos publicados exigem migration/configuração revisada na API.
28
+ `http://127.0.0.1:4173/oauth/callback`, apenas com `profile`. Jogos são cadastrados pelo Game Registry
29
+ da API (`npm run admin:games` no repositório `pixelume-api`; veja `docs/GAME_REGISTRY.md` de lá). Em
30
+ 14/10/2026 nenhum cliente público tinha `saves` habilitado em produção.
28
31
 
29
32
  ## Uso headless
30
33
 
@@ -54,17 +57,52 @@ loginButton.addEventListener('click', () => {
54
57
 
55
58
  logoutButton.addEventListener('click', async () => {
56
59
  try {
57
- await pixelume.auth.logout(); // revoga somente o token deste jogo
60
+ await pixelume.auth.logout(); // revoga somente a autorização deste jogo
58
61
  } catch (error) {
59
62
  if (!isPixelumeError(error)) throw error;
60
63
  showRecoverableError(error.kind);
61
64
  }
62
65
  });
63
66
 
64
- // Ao desmontar a tela:
67
+ // Ao desmontar a tela/jogo:
65
68
  unsubscribe();
69
+ pixelume.destroy(); // cancela timers de renovação e listeners (não revoga)
66
70
  ```
67
71
 
72
+ ### Sessões longas
73
+
74
+ Nada a fazer: o SDK renova o access token (10 min) antes de expirar, ao retomar a aba e após um `401`,
75
+ com uma única renovação por vez e um único retry da requisição. Falhas de rede mantêm o jogador
76
+ conectado (`pixelume.auth.session.error`); sessão revogada/expirada vira `anonymous/expired` com erro
77
+ explícito. Detalhes: [renovação de sessão](docs/SESSION_RENEWAL.md).
78
+
79
+ ```ts
80
+ pixelume.auth.subscribeSession((session) => {
81
+ connectionBanner.hidden = session.error === null; // recuperável, sem logout
82
+ });
83
+ ```
84
+
85
+ ## Cloud Saves
86
+
87
+ ```ts
88
+ const pixelume = new Pixelume({ gameId: 'rail-puzzle', scope: 'profile saves' });
89
+ await pixelume.initialize();
90
+
91
+ const current = await pixelume.saves.load<{ level: number }>('main'); // ou null
92
+ await pixelume.saves.save('main', {
93
+ schemaVersion: 1,
94
+ expectedRevision: current?.revision ?? 0, // 0 cria; n atualiza só a revisão n
95
+ data: { level: 12 },
96
+ });
97
+ const { saves, limits, usage } = await pixelume.saves.list();
98
+ await pixelume.saves.delete('old-slot', { expectedRevision: 3 }); // 'deleted' | 'not_found'
99
+ ```
100
+
101
+ Conflitos (`409`) rejeitam com `PixelumeSaveConflictError` (revisão remota em `error.current`); o SDK
102
+ nunca sobrescreve, mescla ou reenvia sozinho, e não há fila offline. Saves são dados pessoais não
103
+ verificados: não servem como prova de conquista, ranking ou prêmio. Guia completo:
104
+ [Cloud Saves](docs/CLOUD_SAVES.md).
105
+
68
106
  Importar ou instanciar o core não cria elementos, não injeta estilos e não ativa UI. O health check
69
107
  também é totalmente independente: chamar `/health` nunca monta o widget.
70
108
 
@@ -98,39 +136,87 @@ widget.destroy();
98
136
  O painel usa Shadow DOM aberto, não aplica CSS global e chama somente `pixelume.auth.login()` e
99
137
  `pixelume.auth.logout()`. Entrar sempre redireciona ao portal Pixelume Auth; o widget não exibe campos de
100
138
  e-mail ou senha. Para overlays próprios ou canvas fullscreen, passe `container`, ajuste `position`,
101
- `offset` e `zIndex`. Veja o [guia completo do widget](docs/phase-03-widget.md).
139
+ `offset` e `zIndex`. Veja o [guia completo do widget](docs/phase-03-widget.md). O widget também mostra
140
+ avisos de conexão durante a renovação e, apenas depois de `saves.save()`/`saves.delete()` explícitos, o
141
+ status da nuvem.
102
142
 
103
143
  `pixelume.auth.state.status` é `uninitialized`, `redirecting`, `exchanging`, `anonymous` ou
104
- `authenticated`. O estado anônimo informa `reason` (`visitor`, `cancelled`, `expired`, `logged_out` ou
105
- `error`) e, em falha, um `PixelumeError`. Cancelar no portal resulta em visitante, não em exceção.
144
+ `authenticated`. `redirecting`/`exchanging` trazem `restoring: true` durante a recuperação silenciosa
145
+ entre sessões. O estado anônimo informa `reason` (`visitor`, `cancelled`, `expired`, `logged_out`,
146
+ `session_ended`, `offline` ou `error`) e, em falha, um `PixelumeError`. Cancelar no portal resulta em
147
+ visitante, não em exceção. `visitor` nunca significa “a sessão central acabou”: só que não havia o que
148
+ recuperar nesta aba/navegador.
106
149
 
107
150
  ### Visitante, recarga e expiração
108
151
 
109
152
  - Sem login ou callback, `initialize()` termina como visitante e não faz requisição.
110
- - O access token opaco dura 10 minutos e fica **somente em memória**.
153
+ - O access token opaco dura 10 minutos e é renovado automaticamente pelo refresh token rotativo; os dois
154
+ ficam **somente em memória**.
111
155
  - Após autenticar, o SDK guarda em `sessionStorage` apenas o marcador não secreto de que aquela aba
112
- tinha uma sessão. Em uma recarga, inicia uma nova autorização; enquanto a sessão central da API
113
- continuar válida, o portal retorna sem pedir senha. `restoreSession: false` desliga esse comportamento.
156
+ tinha uma sessão (e o escopo concedido). Em uma recarga, inicia uma nova autorização com o mesmo
157
+ escopo; enquanto a sessão central da API continuar válida, o portal retorna sem pedir senha.
158
+ `restoreSession: false` desliga esse comportamento.
159
+ - Aba nova ou navegador reaberto: veja [Recuperação entre sessões](#recuperação-entre-sessões-do-navegador-opt-in).
114
160
  - PKCE `state`/`code_verifier` ficam na sessão por no máximo 10 minutos e são removidos antes da troca.
115
161
  - Código expirado/repetido, `state` divergente, callback adulterado e 401 deixam o jogo utilizável como
116
162
  visitante. O jogo pode oferecer o login novamente.
117
- - `logout()` limpa o estado local imediatamente e chama `/v1/oauth/revoke`. A sessão central do portal
163
+ - `logout()` limpa o estado local imediatamente, cancela a renovação e revoga o refresh token em
164
+ `/v1/oauth/revoke` (o que encerra todos os tokens daquela autorização). A sessão central do portal
118
165
  continua existente por desenho; entrar novamente pode ser imediato.
119
166
 
167
+ ### Recuperação entre sessões do navegador (opt-in)
168
+
169
+ Sem a opção, uma aba nova ou o navegador reaberto começam como visitante, mesmo com a sessão central
170
+ da Pixelume ainda válida (30 dias). Com `restoreAcrossSessions: true`:
171
+
172
+ ```ts
173
+ const pixelume = new Pixelume({
174
+ gameId: 'meu-jogo',
175
+ scope: 'profile saves',
176
+ restoreAcrossSessions: true, // ou { maxAgeDays: 7 }
177
+ });
178
+ ```
179
+
180
+ 1. Depois de um login, o SDK grava em `localStorage` somente `pixelume.oauth.restore.<clientId>` =
181
+ `{ version, scope, at }` — nenhum token, código, verifier, ID ou e-mail.
182
+ 2. Num início sem sessão em memória (aba nova, navegador reaberto ou reload), se esse indício existir
183
+ e tiver menos de `maxAgeDays` (padrão 30), `initialize()` publica
184
+ `{ status: 'redirecting', restoring: true }` e faz uma navegação de topo para
185
+ `/v1/oauth/authorize?…&prompt=none`. A API **não mostra tela**: devolve o `code` (PKCE S256 + `state`)
186
+ se a sessão central for válida, ou `error=login_required`.
187
+ 3. No retorno: sucesso → `authenticated` (o escopo pedido nunca passa do anterior nem do configurado), e o
188
+ SDK devolve a URL original (caminho, query e hash) com `history.replaceState`. `login_required` →
189
+ `anonymous/session_ended`, o indício é apagado e **nenhuma** tentativa automática se repete até um novo
190
+ login manual.
191
+ 4. Sem conexão no início → `anonymous/offline`, sem navegação, indício mantido (o próximo início tenta).
192
+
193
+ **UX:** a tentativa é uma ida e volta de topo de poucas centenas de milissegundos; o jogo carrega duas
194
+ vezes. Mostre “Conectando…” enquanto `status` for `uninitialized`/`redirecting`/`exchanging` e, se o jogo
195
+ roteia pelo endereço, leia a rota depois de `initialize()` (ou guarde-a ao ver `redirecting`). Estado em
196
+ memória do jogo antes de `initialize()` se perde na navegação.
197
+
198
+ **Proteções:** uma tentativa por início; um marcador por aba (`sessionStorage`) impede nova tentativa por
199
+ 10 minutos se a anterior não voltar (erro de rede, botão Voltar, back/forward cache → `visitor`); callback
200
+ repetido é recusado sem troca de código; cada aba usa seu próprio `state`/verifier; `logout()` em qualquer
201
+ aba apaga o indício. Sem `localStorage` disponível (bloqueado, adaptador injetado sem
202
+ `persistentStorage`), o comportamento é o da 0.4. Com a opção desligada, um indício antigo é removido.
203
+
120
204
  ## Configuração
121
205
 
122
- | Opção | Padrão | Observação |
123
- | ---------------- | ------------------------------------ | ----------------------------------------------------------------------------------------- |
124
- | `apiUrl` | `https://api.pixelume.com.br` | HTTPS; HTTP só em loopback ou com `allowInsecureHttp` explícito |
125
- | `authUrl` | `https://auth.pixelume.com.br` | Portal esperado; a API continua sendo a autoridade que escolhe o redirect |
126
- | `gameId` | — | slug público do jogo; também é o `clientId` padrão |
127
- | `clientId` | `gameId` | identificador OAuth público cadastrado; nunca é credencial |
128
- | `redirectUri` | `<origin>/oauth/callback` no browser | deve coincidir exatamente com o cadastro e não pode conter fragmento nem parâmetros OAuth |
129
- | `restoreSession` | `true` | reautoriza após reload da mesma aba sem persistir token |
130
- | `timeoutMs` | `10000` | cobre resposta e leitura do corpo |
131
- | `credentials` | `omit` | política dos módulos genéricos; Auth força `omit` para token/userinfo/revoke |
132
- | `fetch` | `globalThis.fetch` | injetável para testes/instrumentação |
133
- | `browser` | globals do browser | adaptador avançado para webviews e testes |
206
+ | Opção | Padrão | Observação |
207
+ | ----------------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
208
+ | `apiUrl` | `https://api.pixelume.com.br` | HTTPS; HTTP só em loopback ou com `allowInsecureHttp` explícito |
209
+ | `authUrl` | `https://auth.pixelume.com.br` | Portal esperado; a API continua sendo a autoridade que escolhe o redirect |
210
+ | `gameId` | — | slug público do jogo; também é o `clientId` padrão |
211
+ | `clientId` | `gameId` | identificador OAuth público cadastrado; nunca é credencial |
212
+ | `redirectUri` | `<origin>/oauth/callback` no browser | deve coincidir exatamente com o cadastro e não pode conter fragmento nem parâmetros OAuth |
213
+ | `scope` | `profile` | `profile saves` para Cloud Saves (o cadastro do jogo precisa permitir `saves`) |
214
+ | `restoreSession` | `true` | reautoriza após reload da mesma aba sem persistir token |
215
+ | `restoreAcrossSessions` | `false` | opt-in: recupera a conta em aba nova/navegador reaberto via `prompt=none` (indício não secreto em `localStorage`) |
216
+ | `timeoutMs` | `10000` | cobre resposta e leitura do corpo |
217
+ | `credentials` | `omit` | política dos módulos genéricos; Auth força `omit` para token/userinfo/revoke |
218
+ | `fetch` | `globalThis.fetch` | injetável para testes/instrumentação |
219
+ | `browser` | globals do browser | adaptador avançado para webviews e testes |
134
220
 
135
221
  `authUrl` é informativo/configurável para ambientes, mas o SDK nunca concatena nem escolhe um retorno
136
222
  via portal: `/v1/oauth/authorize` valida o cliente/callback e redireciona para o portal configurado na
@@ -150,16 +236,21 @@ uma ferramenta de teste, não UI oficial.
150
236
 
151
237
  ## Segurança e confiança
152
238
 
153
- - O fluxo não é OIDC: não há ID Token, discovery, refresh token nem `client_secret`.
239
+ - O fluxo não é OIDC: não há ID Token, discovery nem `client_secret`. O refresh token é rotativo, de uso
240
+ único, ligado ao jogo e à sessão central, e fica só em memória.
154
241
  - A sessão central é cookie `HttpOnly`, `Secure`, `SameSite=Lax` e host-only da API. O jogo nunca a lê.
155
242
  - Requests OAuth do jogo usam Bearer curto e `credentials: 'omit'`; `credentials: 'include'` pertence
156
243
  apenas ao portal ao consultar/concluir a sessão central.
157
244
  - `crypto.getRandomValues` e Web Crypto SHA-256 geram `state`, verifier e challenge; não há
158
245
  `Math.random`.
159
- - Access token, código e verifier não são registrados. Tokens não vão para `localStorage`, cookies ou URL.
246
+ - Access/refresh token, código e verifier não são registrados. Tokens não vão para `localStorage`,
247
+ `sessionStorage`, cookies, URL, estado observável ou mensagens de erro. Com `restoreAcrossSessions`,
248
+ `localStorage` recebe apenas o indício `{ version, scope, at }`.
160
249
  - CORS/Origin não são considerados autenticação. PKCE, URI exata, state e vínculos persistidos pela API
161
250
  são os controles de segurança.
162
251
  - XSS ainda pode ler um token que esteja em memória; jogos devem manter CSP e dependências sob controle.
252
+ - Cloud Saves sempre passam pela autorização do backend (token do próprio jogo + escopo `saves`); o SDK
253
+ nunca envia `playerId`, e a UI nunca prova legitimidade de progresso.
163
254
 
164
255
  ## Erros
165
256
 
@@ -168,8 +259,11 @@ Todo erro lançado diretamente pelo SDK é `PixelumeError`. Os `kind` estáveis
168
259
  o modo visitante; consulte `pixelume.auth.state.error`. Métodos chamados explicitamente (`login`,
169
260
  `getPlayer`, `logout`) rejeitam normalmente.
170
261
 
171
- O SDK não faz retry automático. Para 400/401/403/429/5xx, use `PixelumeHttpError.status`, `code` e
172
- `requestId`; para cancelamento do chamador, use `AbortSignal`.
262
+ O único retry automático é o da renovação de sessão: após `401` em uma rota autenticada, o SDK renova uma
263
+ vez e repete aquela requisição uma vez. Nada mais é repetido. Para 400/403/409/413/429/5xx, use
264
+ `PixelumeHttpError.status`, `code`, `details` e `requestId`; conflitos de save são
265
+ `PixelumeSaveConflictError`; sessões encerradas são `PixelumeAuthError` (`session_expired`,
266
+ `game_unavailable`, `renewal_rejected`, `not_authenticated`). Para cancelamento, use `AbortSignal`.
173
267
 
174
268
  ## Health check
175
269
 
@@ -184,6 +278,7 @@ local, mas não diretamente de outro domínio no browser.
184
278
 
185
279
  | SDK | Pixelume API | Pixelume Auth | Recursos |
186
280
  | ------- | ---------------------------------------------------- | ------------------------------ | ----------------------------------------------- |
281
+ | `0.4.x` | OpenAPI `0.6.0`, release `10752b8` ou compatível | Fase 2 `3a1ded4` ou compatível | Renovação de sessão, Cloud Saves, widget, SSO |
187
282
  | `0.3.x` | OpenAPI `0.4.0`, release SSO `3846479` ou compatível | Fase 2 `3a1ded4` ou compatível | Widget opt-in, SSO PKCE, perfil, logout, health |
188
283
  | `0.2.x` | OpenAPI `0.4.0`, release SSO `3846479` ou compatível | Fase 2 `3a1ded4` ou compatível | SSO PKCE, perfil, logout, health |
189
284
  | `0.1.x` | API anterior/atual para `/health` | não consumido | fundação e health apenas |
@@ -201,7 +296,9 @@ npm run verify
201
296
  npm run example:smoke # leitura opcional da API real
202
297
  ```
203
298
 
204
- Mais detalhes: [widget oficial](docs/phase-03-widget.md), [integração SSO](docs/SSO_INTEGRATION.md),
299
+ Mais detalhes: [renovação de sessão](docs/SESSION_RENEWAL.md), [Cloud Saves](docs/CLOUD_SAVES.md),
300
+ [auditoria da Fase 4](docs/PHASE_04_CONTRACT_AUDIT.md), [widget oficial](docs/phase-03-widget.md),
301
+ [integração SSO](docs/SSO_INTEGRATION.md),
205
302
  [auditoria do contrato](docs/SSO_CONTRACT_AUDIT.md), [arquitetura](docs/architecture.md) e
206
303
  [publicação](docs/publishing.md).
207
304
 
@@ -25,11 +25,17 @@ export interface PlayerEnvelopeWire {
25
25
  player: PlayerWire;
26
26
  }
27
27
  export declare const parsePlayerEnvelope: BodyParser<PlayerEnvelopeWire>;
28
+ /** Scopes the API can grant. `profile` is always present; `saves` is opt-in per game. */
29
+ export type OAuthScopeWire = 'profile' | 'saves';
28
30
  export interface OAuthTokenResponseWire {
29
31
  access_token: string;
30
32
  token_type: 'Bearer';
31
33
  expires_in: number;
32
- scope: 'profile';
34
+ /** Granted scopes, parsed from the space-separated `scope` field. */
35
+ scopes: readonly OAuthScopeWire[];
36
+ /** Rotating refresh token; absent for legacy codes or when the central session already ended. */
37
+ refresh_token?: string;
38
+ refresh_token_expires_in?: number;
33
39
  }
34
40
  export declare const parseOAuthTokenResponse: BodyParser<OAuthTokenResponseWire>;
35
41
  export declare const parseEmptyResponse: BodyParser<void>;
@@ -40,13 +46,47 @@ export interface HealthResponseWire {
40
46
  release?: string;
41
47
  }
42
48
  export declare const parseHealthResponse: BodyParser<HealthResponseWire>;
43
- /** Error envelope returned by every API error: `{ error: { code, message, requestId } }`. */
49
+ /** Error envelope returned by every API error: `{ error: { code, message, requestId, details? } }`. */
44
50
  export interface ErrorEnvelopeWire {
45
51
  error: {
46
52
  code: string;
47
53
  message: string;
48
54
  requestId: string;
55
+ details: Readonly<Record<string, unknown>> | null;
49
56
  };
50
57
  }
51
58
  /** Returns the envelope when `body` matches it, `null` otherwise (e.g. an HTML 502 from a proxy). */
52
59
  export declare function parseErrorEnvelope(body: unknown): ErrorEnvelopeWire | null;
60
+ export interface SaveMetadataWire {
61
+ gameId: string;
62
+ slotId: string;
63
+ schemaVersion: number;
64
+ revision: number;
65
+ sizeBytes: number;
66
+ createdAt: string;
67
+ updatedAt: string;
68
+ }
69
+ export interface CloudSaveWire extends SaveMetadataWire {
70
+ data: Record<string, unknown>;
71
+ }
72
+ export interface SaveListWire {
73
+ gameId: string;
74
+ saves: SaveMetadataWire[];
75
+ limits: {
76
+ maxSlotsPerGame: number;
77
+ maxDataBytes: number;
78
+ maxBytesPerPlayer: number;
79
+ };
80
+ usage: {
81
+ slots: number;
82
+ playerBytes: number;
83
+ };
84
+ }
85
+ /** Validates one `SaveMetadata` object; `null` when it does not match. */
86
+ export declare function parseSaveMetadata(value: unknown): SaveMetadataWire | null;
87
+ /** `PUT` → `{ save: SaveMetadata }`. */
88
+ export declare const parseSaveMetadataEnvelope: BodyParser<SaveMetadataWire>;
89
+ /** `GET /saves/:slotId` → `{ save: SaveMetadata & { data } }`. */
90
+ export declare const parseCloudSaveEnvelope: BodyParser<CloudSaveWire>;
91
+ /** `GET /saves` → `{ gameId, saves, limits, usage }`. */
92
+ export declare const parseSaveList: BodyParser<SaveListWire>;
@@ -27,11 +27,12 @@ export const parsePlayerEnvelope = (body) => {
27
27
  }
28
28
  return ok({ player: { id, displayName, avatarUrl } });
29
29
  };
30
+ const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43,128}$/u;
31
+ const KNOWN_SCOPES = ['profile', 'saves'];
30
32
  export const parseOAuthTokenResponse = (body) => {
31
33
  if (!isRecord(body))
32
34
  return fail('expected a JSON object');
33
- if (typeof body.access_token !== 'string' ||
34
- !/^[A-Za-z0-9_-]{43,128}$/u.test(body.access_token)) {
35
+ if (typeof body.access_token !== 'string' || !TOKEN_PATTERN.test(body.access_token)) {
35
36
  return fail('expected "access_token" to be a base64url token');
36
37
  }
37
38
  if (body.token_type !== 'Bearer')
@@ -39,13 +40,34 @@ export const parseOAuthTokenResponse = (body) => {
39
40
  if (!Number.isInteger(body.expires_in) || body.expires_in <= 0) {
40
41
  return fail('expected "expires_in" to be a positive integer');
41
42
  }
42
- if (body.scope !== 'profile')
43
- return fail('expected "scope" to be "profile"');
44
- return ok({
43
+ const scopes = typeof body.scope === 'string' ? body.scope.split(' ') : [];
44
+ if (!scopes.includes('profile') ||
45
+ new Set(scopes).size !== scopes.length ||
46
+ !scopes.every((scope) => KNOWN_SCOPES.includes(scope))) {
47
+ return fail('expected "scope" to be "profile" or "profile saves"');
48
+ }
49
+ const result = {
45
50
  access_token: body.access_token,
46
51
  token_type: 'Bearer',
47
52
  expires_in: body.expires_in,
48
- scope: 'profile',
53
+ scopes: scopes,
54
+ };
55
+ if (body.refresh_token === undefined)
56
+ return ok(result);
57
+ if (typeof body.refresh_token !== 'string' || !TOKEN_PATTERN.test(body.refresh_token)) {
58
+ return fail('expected "refresh_token" to be a base64url token when present');
59
+ }
60
+ const refreshExpiresIn = body.refresh_token_expires_in;
61
+ if (refreshExpiresIn !== undefined &&
62
+ (!Number.isInteger(refreshExpiresIn) || refreshExpiresIn < 0)) {
63
+ return fail('expected "refresh_token_expires_in" to be a non-negative integer when present');
64
+ }
65
+ return ok({
66
+ ...result,
67
+ refresh_token: body.refresh_token,
68
+ ...(refreshExpiresIn === undefined
69
+ ? {}
70
+ : { refresh_token_expires_in: refreshExpiresIn }),
49
71
  });
50
72
  };
51
73
  export const parseEmptyResponse = (body) => body === undefined ? ok(undefined) : fail('expected an empty response body');
@@ -69,10 +91,87 @@ export const parseHealthResponse = (body) => {
69
91
  export function parseErrorEnvelope(body) {
70
92
  if (!isRecord(body) || !isRecord(body.error))
71
93
  return null;
72
- const { code, message, requestId } = body.error;
94
+ const { code, message, requestId, details } = body.error;
73
95
  if (typeof code !== 'string' || typeof message !== 'string' || typeof requestId !== 'string') {
74
96
  return null;
75
97
  }
76
- return { error: { code, message, requestId } };
98
+ return { error: { code, message, requestId, details: isRecord(details) ? details : null } };
99
+ }
100
+ function isNonNegativeInteger(value) {
101
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
102
+ }
103
+ function isTimestamp(value) {
104
+ return typeof value === 'string' && !Number.isNaN(Date.parse(value));
105
+ }
106
+ /** Validates one `SaveMetadata` object; `null` when it does not match. */
107
+ export function parseSaveMetadata(value) {
108
+ if (!isRecord(value))
109
+ return null;
110
+ const { gameId, slotId, schemaVersion, revision, sizeBytes, createdAt, updatedAt } = value;
111
+ if (typeof gameId !== 'string' ||
112
+ typeof slotId !== 'string' ||
113
+ !isNonNegativeInteger(schemaVersion) ||
114
+ schemaVersion < 1 ||
115
+ !isNonNegativeInteger(revision) ||
116
+ revision < 1 ||
117
+ !isNonNegativeInteger(sizeBytes) ||
118
+ !isTimestamp(createdAt) ||
119
+ !isTimestamp(updatedAt)) {
120
+ return null;
121
+ }
122
+ return { gameId, slotId, schemaVersion, revision, sizeBytes, createdAt, updatedAt };
77
123
  }
124
+ /** `PUT` → `{ save: SaveMetadata }`. */
125
+ export const parseSaveMetadataEnvelope = (body) => {
126
+ const save = isRecord(body) ? parseSaveMetadata(body.save) : null;
127
+ return save ? ok(save) : fail('expected a JSON object containing "save" metadata');
128
+ };
129
+ /** `GET /saves/:slotId` → `{ save: SaveMetadata & { data } }`. */
130
+ export const parseCloudSaveEnvelope = (body) => {
131
+ if (!isRecord(body) || !isRecord(body.save)) {
132
+ return fail('expected a JSON object containing "save"');
133
+ }
134
+ const metadata = parseSaveMetadata(body.save);
135
+ if (!metadata)
136
+ return fail('expected "save" to contain valid save metadata');
137
+ const data = body.save.data;
138
+ if (!isRecord(data))
139
+ return fail('expected "save.data" to be a JSON object');
140
+ return ok({ ...metadata, data });
141
+ };
142
+ /** `GET /saves` → `{ gameId, saves, limits, usage }`. */
143
+ export const parseSaveList = (body) => {
144
+ if (!isRecord(body) || typeof body.gameId !== 'string' || !Array.isArray(body.saves)) {
145
+ return fail('expected a JSON object containing "gameId" and "saves"');
146
+ }
147
+ const saves = [];
148
+ for (const entry of body.saves) {
149
+ const save = parseSaveMetadata(entry);
150
+ if (!save)
151
+ return fail('expected every entry of "saves" to be save metadata');
152
+ saves.push(save);
153
+ }
154
+ const { limits, usage } = body;
155
+ if (!isRecord(limits) ||
156
+ !isNonNegativeInteger(limits.maxSlotsPerGame) ||
157
+ !isNonNegativeInteger(limits.maxDataBytes) ||
158
+ !isNonNegativeInteger(limits.maxBytesPerPlayer)) {
159
+ return fail('expected "limits" to contain non-negative integers');
160
+ }
161
+ if (!isRecord(usage) ||
162
+ !isNonNegativeInteger(usage.slots) ||
163
+ !isNonNegativeInteger(usage.playerBytes)) {
164
+ return fail('expected "usage" to contain non-negative integers');
165
+ }
166
+ return ok({
167
+ gameId: body.gameId,
168
+ saves,
169
+ limits: {
170
+ maxSlotsPerGame: limits.maxSlotsPerGame,
171
+ maxDataBytes: limits.maxDataBytes,
172
+ maxBytesPerPlayer: limits.maxBytesPerPlayer,
173
+ },
174
+ usage: { slots: usage.slots, playerBytes: usage.playerBytes },
175
+ });
176
+ };
78
177
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/contracts/index.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Wire contracts of the Pixelume API used by the SDK, with hand-written runtime guards.\n *\n * Source of truth: `pixelume-api` (`docs/openapi.yaml` and `src/interfaces/http/app.ts`).\n * See `docs/api-contract-audit.md`. These types describe JSON on the wire and are internal;\n * public modules map them to SDK types so the API can evolve without breaking consumers.\n *\n * Validation is intentionally tolerant to *additive* changes (unknown fields are ignored,\n * unknown enum values are accepted as strings) and strict about the fields the SDK relies on.\n */\n\nexport type ParseResult<T> =\n { readonly ok: true; readonly value: T } | { readonly ok: false; readonly reason: string };\n\nexport type BodyParser<T> = (body: unknown) => ParseResult<T>;\n\nconst ok = <T>(value: T): ParseResult<T> => ({ ok: true, value });\nconst fail = (reason: string): ParseResult<never> => ({ ok: false, reason });\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\nexport interface PlayerWire {\n id: string;\n displayName: string;\n avatarUrl: string | null;\n}\n\nexport interface PlayerEnvelopeWire {\n player: PlayerWire;\n}\n\nexport const parsePlayerEnvelope: BodyParser<PlayerEnvelopeWire> = (body) => {\n if (!isRecord(body) || !isRecord(body.player)) {\n return fail('expected a JSON object containing \"player\"');\n }\n const { id, displayName, avatarUrl } = body.player;\n if (typeof id !== 'string' || id.length === 0) return fail('expected \"player.id\" to be a string');\n if (typeof displayName !== 'string') return fail('expected \"player.displayName\" to be a string');\n if (avatarUrl !== null && typeof avatarUrl !== 'string') {\n return fail('expected \"player.avatarUrl\" to be a string or null');\n }\n return ok({ player: { id, displayName, avatarUrl } });\n};\n\nexport interface OAuthTokenResponseWire {\n access_token: string;\n token_type: 'Bearer';\n expires_in: number;\n scope: 'profile';\n}\n\nexport const parseOAuthTokenResponse: BodyParser<OAuthTokenResponseWire> = (body) => {\n if (!isRecord(body)) return fail('expected a JSON object');\n if (\n typeof body.access_token !== 'string' ||\n !/^[A-Za-z0-9_-]{43,128}$/u.test(body.access_token)\n ) {\n return fail('expected \"access_token\" to be a base64url token');\n }\n if (body.token_type !== 'Bearer') return fail('expected \"token_type\" to be \"Bearer\"');\n if (!Number.isInteger(body.expires_in) || (body.expires_in as number) <= 0) {\n return fail('expected \"expires_in\" to be a positive integer');\n }\n if (body.scope !== 'profile') return fail('expected \"scope\" to be \"profile\"');\n return ok({\n access_token: body.access_token,\n token_type: 'Bearer',\n expires_in: body.expires_in as number,\n scope: 'profile',\n });\n};\n\nexport const parseEmptyResponse: BodyParser<void> = (body) =>\n body === undefined ? ok(undefined) : fail('expected an empty response body');\n\n/** `GET /health` → 200. OpenAPI: `{ status: 'ok', registration: enum, release?: /^[a-f0-9]{7,40}$/ }`. */\nexport interface HealthResponseWire {\n status: 'ok';\n registration: string;\n release?: string;\n}\n\nexport const parseHealthResponse: BodyParser<HealthResponseWire> = (body) => {\n if (!isRecord(body)) return fail('expected a JSON object');\n if (body.status !== 'ok') return fail('expected \"status\" to be \"ok\"');\n if (typeof body.registration !== 'string') return fail('expected \"registration\" to be a string');\n if (body.release !== undefined && typeof body.release !== 'string') {\n return fail('expected \"release\" to be a string when present');\n }\n return ok({\n status: 'ok',\n registration: body.registration,\n ...(body.release === undefined ? {} : { release: body.release }),\n });\n};\n\n/** Error envelope returned by every API error: `{ error: { code, message, requestId } }`. */\nexport interface ErrorEnvelopeWire {\n error: { code: string; message: string; requestId: string };\n}\n\n/** Returns the envelope when `body` matches it, `null` otherwise (e.g. an HTML 502 from a proxy). */\nexport function parseErrorEnvelope(body: unknown): ErrorEnvelopeWire | null {\n if (!isRecord(body) || !isRecord(body.error)) return null;\n const { code, message, requestId } = body.error;\n if (typeof code !== 'string' || typeof message !== 'string' || typeof requestId !== 'string') {\n return null;\n }\n return { error: { code, message, requestId } };\n}\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/contracts/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAOH,MAAM,EAAE,GAAG,CAAI,KAAQ,EAAkB,EAAE,CAAC,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;AAClE,MAAM,IAAI,GAAG,CAAC,MAAc,EAAsB,EAAE,CAAC,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC,CAAC;AAE7E,SAAS,QAAQ,CAAC,KAAc;IAC9B,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;AAC9E,CAAC;AAYD,MAAM,CAAC,MAAM,mBAAmB,GAAmC,CAAC,IAAI,EAAE,EAAE;IAC1E,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC;QAC9C,OAAO,IAAI,CAAC,4CAA4C,CAAC,CAAC;IAC5D,CAAC;IACD,MAAM,EAAE,EAAE,EAAE,WAAW,EAAE,SAAS,EAAE,GAAG,IAAI,CAAC,MAAM,CAAC;IACnD,IAAI,OAAO,EAAE,KAAK,QAAQ,IAAI,EAAE,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC,qCAAqC,CAAC,CAAC;IAClG,IAAI,OAAO,WAAW,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC,8CAA8C,CAAC,CAAC;IACjG,IAAI,SAAS,KAAK,IAAI,IAAI,OAAO,SAAS,KAAK,QAAQ,EAAE,CAAC;QACxD,OAAO,IAAI,CAAC,oDAAoD,CAAC,CAAC;IACpE,CAAC;IACD,OAAO,EAAE,CAAC,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,WAAW,EAAE,SAAS,EAAE,EAAE,CAAC,CAAC;AACxD,CAAC,CAAC;AAgBF,MAAM,aAAa,GAAG,0BAA0B,CAAC;AACjD,MAAM,YAAY,GAAsB,CAAC,SAAS,EAAE,OAAO,CAAC,CAAC;AAE7D,MAAM,CAAC,MAAM,uBAAuB,GAAuC,CAAC,IAAI,EAAE,EAAE;IAClF,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC,wBAAwB,CAAC,CAAC;IAC3D,IAAI,OAAO,IAAI,CAAC,YAAY,KAAK,QAAQ,IAAI,CAAC,aAAa,CAAC,IAAI,CAAC,IAAI,CAAC,YAAY,CAAC,EAAE,CAAC;QACpF,OAAO,IAAI,CAAC,iDAAiD,CAAC,CAAC;IACjE,CAAC;IACD,IAAI,IAAI,CAAC,UAAU,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC,sCAAsC,CAAC,CAAC;IACtF,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,IAAI,CAAC,UAAU,CAAC,IAAK,IAAI,CAAC,UAAqB,IAAI,CAAC,EAAE,CAAC;QAC3E,OAAO,IAAI,CAAC,gDAAgD,CAAC,CAAC;IAChE,CAAC;IACD,MAAM,MAAM,GAAG,OAAO,IAAI,CAAC,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;IAC3E,IACE,CAAC,MAAM,CAAC,QAAQ,CAAC,SAAS,CAAC;QAC3B,IAAI,GAAG,CAAC,MAAM,CAAC,CAAC,IAAI,KAAK,MAAM,CAAC,MAAM;QACtC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,YAAY,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,EACtD,CAAC;QACD,OAAO,IAAI,CAAC,qDAAqD,CAAC,CAAC;IACrE,CAAC;IACD,MAAM,MAAM,GAA2B;QACrC,YAAY,EAAE,IAAI,CAAC,YAAY;QAC/B,UAAU,EAAE,QAAQ;QACpB,UAAU,EAAE,IAAI,CAAC,UAAoB;QACrC,MAAM,EAAE,MAA0B;KACnC,CAAC;IACF,IAAI,IAAI,CAAC,aAAa,KAAK,SAAS;QAAE,OAAO,EAAE,CAAC,MAAM,CAAC,CAAC;IACxD,IAAI,OAAO,IAAI,CAAC,aAAa,KAAK,QAAQ,IAAI,CAAC,aAAa,CAAC,IAAI,CAAC,IAAI,CAAC,aAAa,CAAC,EAAE,CAAC;QACtF,OAAO,IAAI,CAAC,+DAA+D,CAAC,CAAC;IAC/E,CAAC;IACD,MAAM,gBAAgB,GAAG,IAAI,CAAC,wBAAwB,CAAC;IACvD,IACE,gBAAgB,KAAK,SAAS;QAC9B,CAAC,CAAC,MAAM,CAAC,SAAS,CAAC,gBAAgB,CAAC,IAAK,gBAA2B,GAAG,CAAC,CAAC,EACzE,CAAC;QACD,OAAO,IAAI,CAAC,+EAA+E,CAAC,CAAC;IAC/F,CAAC;IACD,OAAO,EAAE,CAAC;QACR,GAAG,MAAM;QACT,aAAa,EAAE,IAAI,CAAC,aAAa;QACjC,GAAG,CAAC,gBAAgB,KAAK,SAAS;YAChC,CAAC,CAAC,EAAE;YACJ,CAAC,CAAC,EAAE,wBAAwB,EAAE,gBAA0B,EAAE,CAAC;KAC9D,CAAC,CAAC;AACL,CAAC,CAAC;AAEF,MAAM,CAAC,MAAM,kBAAkB,GAAqB,CAAC,IAAI,EAAE,EAAE,CAC3D,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,iCAAiC,CAAC,CAAC;AAS/E,MAAM,CAAC,MAAM,mBAAmB,GAAmC,CAAC,IAAI,EAAE,EAAE;IAC1E,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC,wBAAwB,CAAC,CAAC;IAC3D,IAAI,IAAI,CAAC,MAAM,KAAK,IAAI;QAAE,OAAO,IAAI,CAAC,8BAA8B,CAAC,CAAC;IACtE,IAAI,OAAO,IAAI,CAAC,YAAY,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC,wCAAwC,CAAC,CAAC;IACjG,IAAI,IAAI,CAAC,OAAO,KAAK,SAAS,IAAI,OAAO,IAAI,CAAC,OAAO,KAAK,QAAQ,EAAE,CAAC;QACnE,OAAO,IAAI,CAAC,gDAAgD,CAAC,CAAC;IAChE,CAAC;IACD,OAAO,EAAE,CAAC;QACR,MAAM,EAAE,IAAI;QACZ,YAAY,EAAE,IAAI,CAAC,YAAY;QAC/B,GAAG,CAAC,IAAI,CAAC,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,IAAI,CAAC,OAAO,EAAE,CAAC;KACjE,CAAC,CAAC;AACL,CAAC,CAAC;AAYF,qGAAqG;AACrG,MAAM,UAAU,kBAAkB,CAAC,IAAa;IAC9C,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IAC1D,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,SAAS,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC;IACzD,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,OAAO,OAAO,KAAK,QAAQ,IAAI,OAAO,SAAS,KAAK,QAAQ,EAAE,CAAC;QAC7F,OAAO,IAAI,CAAC;IACd,CAAC;IACD,OAAO,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,SAAS,EAAE,OAAO,EAAE,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC;AAC9F,CAAC;AA2BD,SAAS,oBAAoB,CAAC,KAAc;IAC1C,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,MAAM,CAAC,aAAa,CAAC,KAAK,CAAC,IAAI,KAAK,IAAI,CAAC,CAAC;AAChF,CAAC;AAED,SAAS,WAAW,CAAC,KAAc;IACjC,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC;AACvE,CAAC;AAED,0EAA0E;AAC1E,MAAM,UAAU,iBAAiB,CAAC,KAAc;IAC9C,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IAClC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,aAAa,EAAE,QAAQ,EAAE,SAAS,EAAE,SAAS,EAAE,SAAS,EAAE,GAAG,KAAK,CAAC;IAC3F,IACE,OAAO,MAAM,KAAK,QAAQ;QAC1B,OAAO,MAAM,KAAK,QAAQ;QAC1B,CAAC,oBAAoB,CAAC,aAAa,CAAC;QACpC,aAAa,GAAG,CAAC;QACjB,CAAC,oBAAoB,CAAC,QAAQ,CAAC;QAC/B,QAAQ,GAAG,CAAC;QACZ,CAAC,oBAAoB,CAAC,SAAS,CAAC;QAChC,CAAC,WAAW,CAAC,SAAS,CAAC;QACvB,CAAC,WAAW,CAAC,SAAS,CAAC,EACvB,CAAC;QACD,OAAO,IAAI,CAAC;IACd,CAAC;IACD,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,aAAa,EAAE,QAAQ,EAAE,SAAS,EAAE,SAAS,EAAE,SAAS,EAAE,CAAC;AACtF,CAAC;AAED,wCAAwC;AACxC,MAAM,CAAC,MAAM,yBAAyB,GAAiC,CAAC,IAAI,EAAE,EAAE;IAC9E,MAAM,IAAI,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,iBAAiB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IAClE,OAAO,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,mDAAmD,CAAC,CAAC;AACrF,CAAC,CAAC;AAEF,kEAAkE;AAClE,MAAM,CAAC,MAAM,sBAAsB,GAA8B,CAAC,IAAI,EAAE,EAAE;IACxE,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;QAC5C,OAAO,IAAI,CAAC,0CAA0C,CAAC,CAAC;IAC1D,CAAC;IACD,MAAM,QAAQ,GAAG,iBAAiB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC9C,IAAI,CAAC,QAAQ;QAAE,OAAO,IAAI,CAAC,gDAAgD,CAAC,CAAC;IAC7E,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC;IAC5B,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC,0CAA0C,CAAC,CAAC;IAC7E,OAAO,EAAE,CAAC,EAAE,GAAG,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC;AACnC,CAAC,CAAC;AAEF,yDAAyD;AACzD,MAAM,CAAC,MAAM,aAAa,GAA6B,CAAC,IAAI,EAAE,EAAE;IAC9D,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,OAAO,IAAI,CAAC,MAAM,KAAK,QAAQ,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;QACrF,OAAO,IAAI,CAAC,wDAAwD,CAAC,CAAC;IACxE,CAAC;IACD,MAAM,KAAK,GAAuB,EAAE,CAAC;IACrC,KAAK,MAAM,KAAK,IAAI,IAAI,CAAC,KAAK,EAAE,CAAC;QAC/B,MAAM,IAAI,GAAG,iBAAiB,CAAC,KAAK,CAAC,CAAC;QACtC,IAAI,CAAC,IAAI;YAAE,OAAO,IAAI,CAAC,qDAAqD,CAAC,CAAC;QAC9E,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACnB,CAAC;IACD,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,GAAG,IAAI,CAAC;IAC/B,IACE,CAAC,QAAQ,CAAC,MAAM,CAAC;QACjB,CAAC,oBAAoB,CAAC,MAAM,CAAC,eAAe,CAAC;QAC7C,CAAC,oBAAoB,CAAC,MAAM,CAAC,YAAY,CAAC;QAC1C,CAAC,oBAAoB,CAAC,MAAM,CAAC,iBAAiB,CAAC,EAC/C,CAAC;QACD,OAAO,IAAI,CAAC,oDAAoD,CAAC,CAAC;IACpE,CAAC;IACD,IACE,CAAC,QAAQ,CAAC,KAAK,CAAC;QAChB,CAAC,oBAAoB,CAAC,KAAK,CAAC,KAAK,CAAC;QAClC,CAAC,oBAAoB,CAAC,KAAK,CAAC,WAAW,CAAC,EACxC,CAAC;QACD,OAAO,IAAI,CAAC,mDAAmD,CAAC,CAAC;IACnE,CAAC;IACD,OAAO,EAAE,CAAC;QACR,MAAM,EAAE,IAAI,CAAC,MAAM;QACnB,KAAK;QACL,MAAM,EAAE;YACN,eAAe,EAAE,MAAM,CAAC,eAAe;YACvC,YAAY,EAAE,MAAM,CAAC,YAAY;YACjC,iBAAiB,EAAE,MAAM,CAAC,iBAAiB;SAC5C;QACD,KAAK,EAAE,EAAE,KAAK,EAAE,KAAK,CAAC,KAAK,EAAE,WAAW,EAAE,KAAK,CAAC,WAAW,EAAE;KAC9D,CAAC,CAAC;AACL,CAAC,CAAC","sourcesContent":["/**\n * Wire contracts of the Pixelume API used by the SDK, with hand-written runtime guards.\n *\n * Source of truth: `pixelume-api` (`docs/openapi.yaml` and `src/interfaces/http/app.ts`).\n * See `docs/api-contract-audit.md`. These types describe JSON on the wire and are internal;\n * public modules map them to SDK types so the API can evolve without breaking consumers.\n *\n * Validation is intentionally tolerant to *additive* changes (unknown fields are ignored,\n * unknown enum values are accepted as strings) and strict about the fields the SDK relies on.\n */\n\nexport type ParseResult<T> =\n { readonly ok: true; readonly value: T } | { readonly ok: false; readonly reason: string };\n\nexport type BodyParser<T> = (body: unknown) => ParseResult<T>;\n\nconst ok = <T>(value: T): ParseResult<T> => ({ ok: true, value });\nconst fail = (reason: string): ParseResult<never> => ({ ok: false, reason });\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\nexport interface PlayerWire {\n id: string;\n displayName: string;\n avatarUrl: string | null;\n}\n\nexport interface PlayerEnvelopeWire {\n player: PlayerWire;\n}\n\nexport const parsePlayerEnvelope: BodyParser<PlayerEnvelopeWire> = (body) => {\n if (!isRecord(body) || !isRecord(body.player)) {\n return fail('expected a JSON object containing \"player\"');\n }\n const { id, displayName, avatarUrl } = body.player;\n if (typeof id !== 'string' || id.length === 0) return fail('expected \"player.id\" to be a string');\n if (typeof displayName !== 'string') return fail('expected \"player.displayName\" to be a string');\n if (avatarUrl !== null && typeof avatarUrl !== 'string') {\n return fail('expected \"player.avatarUrl\" to be a string or null');\n }\n return ok({ player: { id, displayName, avatarUrl } });\n};\n\n/** Scopes the API can grant. `profile` is always present; `saves` is opt-in per game. */\nexport type OAuthScopeWire = 'profile' | 'saves';\n\nexport interface OAuthTokenResponseWire {\n access_token: string;\n token_type: 'Bearer';\n expires_in: number;\n /** Granted scopes, parsed from the space-separated `scope` field. */\n scopes: readonly OAuthScopeWire[];\n /** Rotating refresh token; absent for legacy codes or when the central session already ended. */\n refresh_token?: string;\n refresh_token_expires_in?: number;\n}\n\nconst TOKEN_PATTERN = /^[A-Za-z0-9_-]{43,128}$/u;\nconst KNOWN_SCOPES: readonly string[] = ['profile', 'saves'];\n\nexport const parseOAuthTokenResponse: BodyParser<OAuthTokenResponseWire> = (body) => {\n if (!isRecord(body)) return fail('expected a JSON object');\n if (typeof body.access_token !== 'string' || !TOKEN_PATTERN.test(body.access_token)) {\n return fail('expected \"access_token\" to be a base64url token');\n }\n if (body.token_type !== 'Bearer') return fail('expected \"token_type\" to be \"Bearer\"');\n if (!Number.isInteger(body.expires_in) || (body.expires_in as number) <= 0) {\n return fail('expected \"expires_in\" to be a positive integer');\n }\n const scopes = typeof body.scope === 'string' ? body.scope.split(' ') : [];\n if (\n !scopes.includes('profile') ||\n new Set(scopes).size !== scopes.length ||\n !scopes.every((scope) => KNOWN_SCOPES.includes(scope))\n ) {\n return fail('expected \"scope\" to be \"profile\" or \"profile saves\"');\n }\n const result: OAuthTokenResponseWire = {\n access_token: body.access_token,\n token_type: 'Bearer',\n expires_in: body.expires_in as number,\n scopes: scopes as OAuthScopeWire[],\n };\n if (body.refresh_token === undefined) return ok(result);\n if (typeof body.refresh_token !== 'string' || !TOKEN_PATTERN.test(body.refresh_token)) {\n return fail('expected \"refresh_token\" to be a base64url token when present');\n }\n const refreshExpiresIn = body.refresh_token_expires_in;\n if (\n refreshExpiresIn !== undefined &&\n (!Number.isInteger(refreshExpiresIn) || (refreshExpiresIn as number) < 0)\n ) {\n return fail('expected \"refresh_token_expires_in\" to be a non-negative integer when present');\n }\n return ok({\n ...result,\n refresh_token: body.refresh_token,\n ...(refreshExpiresIn === undefined\n ? {}\n : { refresh_token_expires_in: refreshExpiresIn as number }),\n });\n};\n\nexport const parseEmptyResponse: BodyParser<void> = (body) =>\n body === undefined ? ok(undefined) : fail('expected an empty response body');\n\n/** `GET /health` → 200. OpenAPI: `{ status: 'ok', registration: enum, release?: /^[a-f0-9]{7,40}$/ }`. */\nexport interface HealthResponseWire {\n status: 'ok';\n registration: string;\n release?: string;\n}\n\nexport const parseHealthResponse: BodyParser<HealthResponseWire> = (body) => {\n if (!isRecord(body)) return fail('expected a JSON object');\n if (body.status !== 'ok') return fail('expected \"status\" to be \"ok\"');\n if (typeof body.registration !== 'string') return fail('expected \"registration\" to be a string');\n if (body.release !== undefined && typeof body.release !== 'string') {\n return fail('expected \"release\" to be a string when present');\n }\n return ok({\n status: 'ok',\n registration: body.registration,\n ...(body.release === undefined ? {} : { release: body.release }),\n });\n};\n\n/** Error envelope returned by every API error: `{ error: { code, message, requestId, details? } }`. */\nexport interface ErrorEnvelopeWire {\n error: {\n code: string;\n message: string;\n requestId: string;\n details: Readonly<Record<string, unknown>> | null;\n };\n}\n\n/** Returns the envelope when `body` matches it, `null` otherwise (e.g. an HTML 502 from a proxy). */\nexport function parseErrorEnvelope(body: unknown): ErrorEnvelopeWire | null {\n if (!isRecord(body) || !isRecord(body.error)) return null;\n const { code, message, requestId, details } = body.error;\n if (typeof code !== 'string' || typeof message !== 'string' || typeof requestId !== 'string') {\n return null;\n }\n return { error: { code, message, requestId, details: isRecord(details) ? details : null } };\n}\n\n// ---------------------------------------------------------------------------------------------\n// Cloud Saves (`/v1/games/:gameId/saves`). OpenAPI: SaveMetadata, CloudSave, SaveListResponse.\n// ---------------------------------------------------------------------------------------------\n\nexport interface SaveMetadataWire {\n gameId: string;\n slotId: string;\n schemaVersion: number;\n revision: number;\n sizeBytes: number;\n createdAt: string;\n updatedAt: string;\n}\n\nexport interface CloudSaveWire extends SaveMetadataWire {\n data: Record<string, unknown>;\n}\n\nexport interface SaveListWire {\n gameId: string;\n saves: SaveMetadataWire[];\n limits: { maxSlotsPerGame: number; maxDataBytes: number; maxBytesPerPlayer: number };\n usage: { slots: number; playerBytes: number };\n}\n\nfunction isNonNegativeInteger(value: unknown): value is number {\n return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;\n}\n\nfunction isTimestamp(value: unknown): value is string {\n return typeof value === 'string' && !Number.isNaN(Date.parse(value));\n}\n\n/** Validates one `SaveMetadata` object; `null` when it does not match. */\nexport function parseSaveMetadata(value: unknown): SaveMetadataWire | null {\n if (!isRecord(value)) return null;\n const { gameId, slotId, schemaVersion, revision, sizeBytes, createdAt, updatedAt } = value;\n if (\n typeof gameId !== 'string' ||\n typeof slotId !== 'string' ||\n !isNonNegativeInteger(schemaVersion) ||\n schemaVersion < 1 ||\n !isNonNegativeInteger(revision) ||\n revision < 1 ||\n !isNonNegativeInteger(sizeBytes) ||\n !isTimestamp(createdAt) ||\n !isTimestamp(updatedAt)\n ) {\n return null;\n }\n return { gameId, slotId, schemaVersion, revision, sizeBytes, createdAt, updatedAt };\n}\n\n/** `PUT` → `{ save: SaveMetadata }`. */\nexport const parseSaveMetadataEnvelope: BodyParser<SaveMetadataWire> = (body) => {\n const save = isRecord(body) ? parseSaveMetadata(body.save) : null;\n return save ? ok(save) : fail('expected a JSON object containing \"save\" metadata');\n};\n\n/** `GET /saves/:slotId` → `{ save: SaveMetadata & { data } }`. */\nexport const parseCloudSaveEnvelope: BodyParser<CloudSaveWire> = (body) => {\n if (!isRecord(body) || !isRecord(body.save)) {\n return fail('expected a JSON object containing \"save\"');\n }\n const metadata = parseSaveMetadata(body.save);\n if (!metadata) return fail('expected \"save\" to contain valid save metadata');\n const data = body.save.data;\n if (!isRecord(data)) return fail('expected \"save.data\" to be a JSON object');\n return ok({ ...metadata, data });\n};\n\n/** `GET /saves` → `{ gameId, saves, limits, usage }`. */\nexport const parseSaveList: BodyParser<SaveListWire> = (body) => {\n if (!isRecord(body) || typeof body.gameId !== 'string' || !Array.isArray(body.saves)) {\n return fail('expected a JSON object containing \"gameId\" and \"saves\"');\n }\n const saves: SaveMetadataWire[] = [];\n for (const entry of body.saves) {\n const save = parseSaveMetadata(entry);\n if (!save) return fail('expected every entry of \"saves\" to be save metadata');\n saves.push(save);\n }\n const { limits, usage } = body;\n if (\n !isRecord(limits) ||\n !isNonNegativeInteger(limits.maxSlotsPerGame) ||\n !isNonNegativeInteger(limits.maxDataBytes) ||\n !isNonNegativeInteger(limits.maxBytesPerPlayer)\n ) {\n return fail('expected \"limits\" to contain non-negative integers');\n }\n if (\n !isRecord(usage) ||\n !isNonNegativeInteger(usage.slots) ||\n !isNonNegativeInteger(usage.playerBytes)\n ) {\n return fail('expected \"usage\" to contain non-negative integers');\n }\n return ok({\n gameId: body.gameId,\n saves,\n limits: {\n maxSlotsPerGame: limits.maxSlotsPerGame,\n maxDataBytes: limits.maxDataBytes,\n maxBytesPerPlayer: limits.maxBytesPerPlayer,\n },\n usage: { slots: usage.slots, playerBytes: usage.playerBytes },\n });\n};\n"]}