From 8d4c7b850cd548c85ad42ddd6da8c046937f9164 Mon Sep 17 00:00:00 2001 From: Rodrigo Castro Date: Wed, 4 Nov 2026 21:47:49 -0300 Subject: [PATCH 1/5] =?UTF-8?q?Documenta=20ADR=200005:=20token=20Bearer=20?= =?UTF-8?q?no=20client,=20n=C3=A3o=20cookie=20httpOnly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/adr/0005-token-bearer-no-client.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 docs/adr/0005-token-bearer-no-client.md diff --git a/docs/adr/0005-token-bearer-no-client.md b/docs/adr/0005-token-bearer-no-client.md new file mode 100644 index 0000000..6fb7ef8 --- /dev/null +++ b/docs/adr/0005-token-bearer-no-client.md @@ -0,0 +1,24 @@ +# 0005 · Token Bearer no client, não cookie httpOnly + +**Status:** aceito + +## Contexto + +A issue A04 pedia guardar o token de sessão em cookie `httpOnly` via rota de servidor do Nuxt, nunca acessível a JavaScript do navegador — o jeito mais seguro contra roubo de token por XSS. + +## Decisão + +O `front-admin` continua com o padrão do template `base_nuxt_modular`: token Bearer guardado em `authStore` (Pinia) e persistido em `localStorage`, enviado pelo `HttpClient` como `Authorization: Bearer `. + +## Por quê + +O app do participante (Flutter, `base_clean_arch_bloc`) vai consumir a mesma API com o mesmo token Sanctum. Cookie `httpOnly` só existe no navegador — um app mobile não tem como usá-lo. Ter dois mecanismos de autenticação (cookie pro admin web, Bearer pro app) duplicaria a lógica de emissão/revogação de sessão no backend e a superfície de teste, sem benefício pro mobile. Unificar em Bearer mantém web e app no mesmo fluxo de login/logout/expiração. + +## Risco aceito e mitigação + +Token em `localStorage` fica exposto a um XSS bem-sucedido no admin. Mitigações até agora: CSP e sanitização de entrada estão no escopo de B05 (segurança base, ainda não feita); o token tem TTL configurável no Sanctum; `EnsureIdempotencyKey`/rate limiting (B04) reduzem o dano de um token roubado em endpoints sensíveis. Revisitar esta decisão se o admin passar a lidar com dado de cartão ou algo de risco equivalente. + +## Consequências + +- `HttpClient`/`authStore` do `front-admin` não mudam de mecanismo nesta issue. +- Todo endpoint de autenticação do backend (`Identity`, quando existir) deve devolver `{user, token}` — formato que `useAuthSubmit` já espera. From e769b1c053b0e9d8637bacceb2b4f7ed546d2413 Mon Sep 17 00:00:00 2001 From: Rodrigo Castro Date: Wed, 4 Nov 2026 21:47:50 -0300 Subject: [PATCH 2/5] Adiciona openapi-typescript e gera api-types.d.ts a partir do Scramble --- .../shared/services/pingService.test.ts | 19 ++ .../shared/services/pingService.ts | 12 + .../app/infrastructure/http/api-types.d.ts | 57 +++++ front-admin/package-lock.json | 210 ++++++++++++++++++ front-admin/package.json | 2 + 5 files changed, 300 insertions(+) create mode 100644 front-admin/app/application/shared/services/pingService.test.ts create mode 100644 front-admin/app/application/shared/services/pingService.ts create mode 100644 front-admin/app/infrastructure/http/api-types.d.ts diff --git a/front-admin/app/application/shared/services/pingService.test.ts b/front-admin/app/application/shared/services/pingService.test.ts new file mode 100644 index 0000000..1a820e9 --- /dev/null +++ b/front-admin/app/application/shared/services/pingService.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it, vi } from 'vitest' +import { httpClient } from '@/infrastructure/http/HttpClient' +import pingService from './pingService' + +vi.mock('@/infrastructure/http/HttpClient', () => ({ + httpClient: { get: vi.fn() }, +})) + +describe('pingService.ping', () => { + it('chama GET /ping e devolve o payload tipado pelo contrato OpenAPI', async () => { + const payload = { status: 'ok', request_id: 'abc-123' } + vi.mocked(httpClient.get).mockResolvedValueOnce(payload) + + const result = await pingService.ping() + + expect(httpClient.get).toHaveBeenCalledWith('/ping') + expect(result).toEqual(payload) + }) +}) diff --git a/front-admin/app/application/shared/services/pingService.ts b/front-admin/app/application/shared/services/pingService.ts new file mode 100644 index 0000000..5c38b58 --- /dev/null +++ b/front-admin/app/application/shared/services/pingService.ts @@ -0,0 +1,12 @@ +import { httpClient } from '@/infrastructure/http/HttpClient' +import type { operations } from '@/infrastructure/http/api-types' + +const PING_URL = '/ping' + +export type PingPayload = operations['ping']['responses'][200]['content']['application/json'] + +export default { + async ping(): Promise { + return httpClient.get(PING_URL) + }, +} diff --git a/front-admin/app/infrastructure/http/api-types.d.ts b/front-admin/app/infrastructure/http/api-types.d.ts new file mode 100644 index 0000000..6226d05 --- /dev/null +++ b/front-admin/app/infrastructure/http/api-types.d.ts @@ -0,0 +1,57 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/v1/ping": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["ping"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: never; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + ping: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + status: string; + request_id: string | null; + }; + }; + }; + }; + }; +} diff --git a/front-admin/package-lock.json b/front-admin/package-lock.json index d4cd6d0..31b1da6 100644 --- a/front-admin/package-lock.json +++ b/front-admin/package-lock.json @@ -23,6 +23,7 @@ "daisyui": "^5.7.46", "eslint": "^10.14.0", "happy-dom": "^20.14.5", + "openapi-typescript": "^7.13.0", "tailwindcss": "^4.3.3", "typescript": "^5.9.3", "vitest": "^5.0.2", @@ -2481,6 +2482,89 @@ "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", "license": "MIT" }, + "node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/ajv/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/config": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", + "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.23", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.23.tgz", + "integrity": "sha512-si5RAEgXylHljgfvUr5k3b+doRmLcWaF8X5RLtPmZSBt3paskSXauqJ6DDx7IChInlUnXz0SHQSXI/5xff61oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "8.11.2", + "@redocly/config": "0.22.0", + "colorette": "1.4.0", + "https-proxy-agent": "7.0.6", + "js-levenshtein": "1.1.6", + "js-yaml": "4.3.2", + "minimatch": "5.1.9", + "pluralize": "8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@rolldown/binding-android-arm-eabi": { "version": "1.2.11", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", @@ -5154,6 +5238,16 @@ "devOptional": true, "license": "MIT" }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/ansi-regex": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", @@ -5936,6 +6030,13 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, "node_modules/comment-parser": { "version": "1.4.9", "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.9.tgz", @@ -8295,6 +8396,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -8647,6 +8761,16 @@ "dev": true, "license": "MIT" }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/js-tokens": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", @@ -10014,6 +10138,37 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openapi-typescript": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", + "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^1.34.6", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.3.0", + "supports-color": "^10.2.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/openapi-typescript/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -10138,6 +10293,37 @@ "parse-statements": "1.0.11" } }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-json/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parse-statements": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/parse-statements/-/parse-statements-1.0.11.tgz", @@ -11099,6 +11285,16 @@ "regjsparser": "bin/parser" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/reserved-identifiers": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/reserved-identifiers/-/reserved-identifiers-1.2.0.tgz", @@ -12712,6 +12908,13 @@ "punycode": "^2.1.0" } }, + "node_modules/uri-js-replace": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.2.tgz", + "integrity": "sha512-VeBBt/nMxedczht3KDL2yBBMJqIxpCQLMZp3UJ1otcWwUHdQW4IiyHpJivExzSK+FdxTowRfGL4FmwDqH9+S8Q==", + "dev": true, + "license": "MIT" + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -13554,6 +13757,13 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/yargs": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.2.0.tgz", diff --git a/front-admin/package.json b/front-admin/package.json index cb7401b..0ab0b7c 100644 --- a/front-admin/package.json +++ b/front-admin/package.json @@ -8,6 +8,7 @@ "generate": "nuxt generate", "preview": "nuxt preview", "postinstall": "nuxt prepare", + "api:gen": "openapi-typescript http://localhost:8000/docs/api.json -o app/infrastructure/http/api-types.d.ts", "lint": "eslint .", "test": "vitest run", "test:e2e": "playwright test", @@ -31,6 +32,7 @@ "daisyui": "^5.7.46", "eslint": "^10.14.0", "happy-dom": "^20.14.5", + "openapi-typescript": "^7.13.0", "tailwindcss": "^4.3.3", "typescript": "^5.9.3", "vitest": "^5.0.2", From ccee83eb61d421bd5a3ae93dff75ff55b8d74948 Mon Sep 17 00:00:00 2001 From: Rodrigo Castro Date: Wed, 4 Nov 2026 21:47:50 -0300 Subject: [PATCH 3/5] =?UTF-8?q?HttpClient:=20code/request=5Fid=20no=20erro?= =?UTF-8?q?=20e=20logout=20autom=C3=A1tico=20em=20401=20autenticado?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infrastructure/http/HttpClient.test.ts | 47 +++++++++++++++++++ .../app/infrastructure/http/HttpClient.ts | 40 ++++++++++++++-- front-admin/app/plugins/auth.client.ts | 9 +++- 3 files changed, 91 insertions(+), 5 deletions(-) diff --git a/front-admin/app/infrastructure/http/HttpClient.test.ts b/front-admin/app/infrastructure/http/HttpClient.test.ts index 613e3f2..914599d 100644 --- a/front-admin/app/infrastructure/http/HttpClient.test.ts +++ b/front-admin/app/infrastructure/http/HttpClient.test.ts @@ -9,6 +9,7 @@ describe('httpClient', () => { afterEach(() => { vi.unstubAllGlobals() httpClient.setAuthToken(null) + httpClient.setUnauthorizedHandler(null) }) it('appends a query string when query params are given', async () => { @@ -81,4 +82,50 @@ describe('httpClient', () => { }) await expect(httpClient.get('/profile')).rejects.toBeInstanceOf(HttpClientError) }) + + it('reads code and request_id from the API error envelope', async () => { + vi.mocked(fetch).mockResolvedValueOnce( + new Response( + JSON.stringify({ code: 'not_found', message: 'Recurso não encontrado.', errors: null, request_id: 'req-1' }), + { status: 404 }, + ), + ) + + await expect(httpClient.get('/editions/404')).rejects.toMatchObject({ + code: 'not_found', + requestId: 'req-1', + }) + }) + + it('falls back to the X-Request-Id header when the body has none', async () => { + vi.mocked(fetch).mockResolvedValueOnce( + new Response(JSON.stringify({ message: 'Erro interno.' }), { + status: 500, + headers: { 'X-Request-Id': 'from-header' }, + }), + ) + + await expect(httpClient.get('/boom')).rejects.toMatchObject({ requestId: 'from-header' }) + }) + + it('calls the unauthorized handler on 401 when a token was set', async () => { + const onUnauthorized = vi.fn() + httpClient.setUnauthorizedHandler(onUnauthorized) + httpClient.setAuthToken('expired-token') + vi.mocked(fetch).mockResolvedValueOnce(new Response(JSON.stringify({ message: 'Unauthenticated.' }), { status: 401 })) + + await expect(httpClient.get('/profile')).rejects.toBeInstanceOf(HttpClientError) + + expect(onUnauthorized).toHaveBeenCalledOnce() + }) + + it('does not call the unauthorized handler on 401 without a token (e.g. failed login)', async () => { + const onUnauthorized = vi.fn() + httpClient.setUnauthorizedHandler(onUnauthorized) + vi.mocked(fetch).mockResolvedValueOnce(new Response(JSON.stringify({ message: 'Invalid credentials.' }), { status: 401 })) + + await expect(httpClient.post('/auth/login', { body: { email: 'x', password: 'y' } })).rejects.toBeInstanceOf(HttpClientError) + + expect(onUnauthorized).not.toHaveBeenCalled() + }) }) diff --git a/front-admin/app/infrastructure/http/HttpClient.ts b/front-admin/app/infrastructure/http/HttpClient.ts index a8dd3c3..74311f6 100644 --- a/front-admin/app/infrastructure/http/HttpClient.ts +++ b/front-admin/app/infrastructure/http/HttpClient.ts @@ -1,12 +1,22 @@ export class HttpClientError extends Error { statusCode: number | null fieldErrors: Record | null - - constructor(message: string, statusCode: number | null, fieldErrors: Record | null = null) { + code: string | null + requestId: string | null + + constructor( + message: string, + statusCode: number | null, + fieldErrors: Record | null = null, + code: string | null = null, + requestId: string | null = null, + ) { super(message) this.name = 'HttpClientError' this.statusCode = statusCode this.fieldErrors = fieldErrors + this.code = code + this.requestId = requestId } } @@ -18,17 +28,29 @@ interface HttpClientRequestOptions { query?: Record } -interface LaravelErrorPayload { +interface ApiErrorPayload { message?: string errors?: Record + code?: string + request_id?: string } let authToken: string | null = null +let onUnauthorized: (() => void) | null = null function setAuthToken(token: string | null) { authToken = token } +/** + * Chamado quando uma requisição que levava token recebe 401 — sessão + * expirada ou revogada. Quem decide o que fazer (limpar o authStore, + * navegar pro login) é o plugin de auth, não o HttpClient. + */ +function setUnauthorizedHandler(handler: (() => void) | null) { + onUnauthorized = handler +} + // Arrays become `key[]=a&key[]=b` — the query string shape Laravel parses // natively as an array on the request side. function buildQueryString(query: HttpClientRequestOptions['query']): string { @@ -57,6 +79,7 @@ function buildQueryString(query: HttpClientRequestOptions['query']): string { async function request(method: string, path: string, options: HttpClientRequestOptions = {}): Promise { const { apiBase } = useRuntimeConfig().public + const hadToken = authToken !== null let response: Response // FormData (file upload) can't be JSON.stringify'd, and Content-Type has @@ -82,11 +105,19 @@ async function request(method: string, path: string, options: HttpClientReque const data = response.status === 204 ? null : await response.json().catch(() => null) if (!response.ok) { - const payload = data as LaravelErrorPayload | null + const payload = data as ApiErrorPayload | null + const requestId = payload?.request_id ?? response.headers.get('X-Request-Id') + + if (response.status === 401 && hadToken) { + onUnauthorized?.() + } + throw new HttpClientError( payload?.message ?? 'Something went wrong.', response.status, payload?.errors ?? null, + payload?.code ?? null, + requestId, ) } @@ -95,6 +126,7 @@ async function request(method: string, path: string, options: HttpClientReque export const httpClient = { setAuthToken, + setUnauthorizedHandler, get: (path: string, options?: HttpClientRequestOptions) => request('GET', path, options), post: (path: string, options?: HttpClientRequestOptions) => request('POST', path, options), put: (path: string, options?: HttpClientRequestOptions) => request('PUT', path, options), diff --git a/front-admin/app/plugins/auth.client.ts b/front-admin/app/plugins/auth.client.ts index 3451344..39e5df5 100644 --- a/front-admin/app/plugins/auth.client.ts +++ b/front-admin/app/plugins/auth.client.ts @@ -1,5 +1,12 @@ import { useAuthStore } from '@/stores/authStore' +import { httpClient } from '@/infrastructure/http/HttpClient' export default defineNuxtPlugin(() => { - useAuthStore().hydrate() + const authStore = useAuthStore() + authStore.hydrate() + + httpClient.setUnauthorizedHandler(() => { + authStore.clearSession() + navigateTo('/login') + }) }) From deac0745a967ed845120cede2ddf3dfb071d183e Mon Sep 17 00:00:00 2001 From: Rodrigo Castro Date: Wed, 4 Nov 2026 21:47:50 -0300 Subject: [PATCH 4/5] =?UTF-8?q?Adiciona=20useEdition()=20com=20store=20de?= =?UTF-8?q?=20edi=C3=A7=C3=A3o=20ativa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../shared/composables/useEdition.test.ts | 25 ++++++++++++++ .../shared/composables/useEdition.ts | 19 +++++++++++ front-admin/app/stores/editionStore.ts | 33 +++++++++++++++++++ 3 files changed, 77 insertions(+) create mode 100644 front-admin/app/application/shared/composables/useEdition.test.ts create mode 100644 front-admin/app/application/shared/composables/useEdition.ts create mode 100644 front-admin/app/stores/editionStore.ts diff --git a/front-admin/app/application/shared/composables/useEdition.test.ts b/front-admin/app/application/shared/composables/useEdition.test.ts new file mode 100644 index 0000000..3bc4ea4 --- /dev/null +++ b/front-admin/app/application/shared/composables/useEdition.test.ts @@ -0,0 +1,25 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it } from 'vitest' +import { useEdition } from './useEdition' + +beforeEach(() => { + setActivePinia(createPinia()) +}) + +describe('useEdition', () => { + it('começa sem edição atual', () => { + const { current } = useEdition() + + expect(current.value).toBeNull() + }) + + it('guarda e limpa a edição atual', () => { + const { current, setCurrent, clear } = useEdition() + + setCurrent({ id: 1, slug: 'phpeste-2027', name: 'PHPeste 2027' }) + expect(current.value).toEqual({ id: 1, slug: 'phpeste-2027', name: 'PHPeste 2027' }) + + clear() + expect(current.value).toBeNull() + }) +}) diff --git a/front-admin/app/application/shared/composables/useEdition.ts b/front-admin/app/application/shared/composables/useEdition.ts new file mode 100644 index 0000000..f6b7c1b --- /dev/null +++ b/front-admin/app/application/shared/composables/useEdition.ts @@ -0,0 +1,19 @@ +import { storeToRefs } from 'pinia' +import { useEditionStore } from '@/stores/editionStore' + +/** + * Edição atual do painel. Hoje só guarda em memória o que foi setado + * explicitamente (ex.: seletor de edição) — passa a vir do backend + * (`GET /api/v1/editions/current` ou equivalente) quando o módulo + * Community existir. Ver docs/adr/, decisão de Identity/Community. + */ +export function useEdition() { + const store = useEditionStore() + const { current } = storeToRefs(store) + + return { + current, + setCurrent: store.setCurrent, + clear: store.clear, + } +} diff --git a/front-admin/app/stores/editionStore.ts b/front-admin/app/stores/editionStore.ts new file mode 100644 index 0000000..f19a87c --- /dev/null +++ b/front-admin/app/stores/editionStore.ts @@ -0,0 +1,33 @@ +import { defineStore } from 'pinia' + +export interface CurrentEdition { + id: number + slug: string + name: string +} + +interface EditionState { + current: CurrentEdition | null +} + +/** + * Edição ativa do painel — lida por quase toda tela (seletor no topo, + * chamadas de API escopadas por edição), por isso é store global e não + * estado de um módulo só. Ainda não é alimentada por API nenhuma: o + * módulo Community não existe no backend. Ver useEdition(). + */ +export const useEditionStore = defineStore('edition', { + state: (): EditionState => ({ + current: null, + }), + + actions: { + setCurrent(edition: CurrentEdition) { + this.current = edition + }, + + clear() { + this.current = null + }, + }, +}) From 135e6e8bbef34d65f49038eac87e5635ef191b2b Mon Sep 17 00:00:00 2001 From: Rodrigo Castro Date: Wed, 4 Nov 2026 21:47:50 -0300 Subject: [PATCH 5/5] Documenta o cliente de API tipado em CLAUDE.md e README --- front-admin/CLAUDE.md | 26 ++++++++++++++++---------- front-admin/README.md | 5 +++-- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/front-admin/CLAUDE.md b/front-admin/CLAUDE.md index d32ba98..952abc3 100644 --- a/front-admin/CLAUDE.md +++ b/front-admin/CLAUDE.md @@ -22,7 +22,8 @@ app/ composables/ # cross-cutting, not owned by one module (useLogout, useAuthSubmit) services/ # backend calls not owned by one module (authService) infrastructure/ - http/HttpClient.ts # the only place that calls fetch() — Bearer auth, Laravel-shaped errors + http/HttpClient.ts # the only place that calls fetch() — Bearer auth, API-envelope errors + http/api-types.d.ts # gerado por `npm run api:gen` a partir do OpenAPI do Scramble — não editar à mão modules/ login/ # reference module types/index.ts @@ -33,7 +34,8 @@ app/ locales/pt-br/index.ts router/index.ts # documental — Nuxt file-based routing resolves navigation today stores/ - authStore.ts # the one legitimate global (Pinia) store — session data + authStore.ts # session data + editionStore.ts # edição ativa do painel — lido em quase toda tela pages/ login.vue # wires the login module into Nuxt's file-based router index.vue @@ -76,15 +78,19 @@ Tokens oficiais em `../docs/design/tema-sertao-tokens.png`, aplicados em `app/as [DaisyUI](https://daisyui.com/) is wired in `app/assets/css/main.css` via `@plugin 'daisyui';` (Tailwind v4 CSS-first config, no JS config file). `LoginForm.vue` demonstrates the intended usage: `fieldset`/`fieldset-legend`/`label` for form fields, `input`/`input-error`, `btn`/`btn-primary`/`btn-ghost`, `alert`/`alert-error` — component classes, not raw utilities, for anything that's a form control, button, or alert. See `.claude/rules/architecture.md`, "UI kit: DaisyUI". -## Why the backend error shape is opinionated +## Cliente de API -`HttpClient.ts` expects a Laravel-style `{message, errors}` JSON body on non-2xx responses, and Bearer-token auth matching a Sanctum-style `{user, token}` login response. This isn't generic REST client boilerplate — it's built to pair with `base_laravel_modular`'s `apps/backend`. See `.claude/rules/architecture.md` for what that backend still needs to build (`POST /auth/login`, `POST /auth/logout`) before this frontend's `login` module is more than a working shape. +`HttpClient.ts` espera o envelope de erro da API (`../docs/api.md`): `{code, message, errors, request_id}` em toda resposta não-2xx, e autenticação Bearer batendo com login estilo Sanctum (`{user, token}`). `HttpClientError` carrega `statusCode`, `fieldErrors`, `code` e `requestId`. -## Roadmap (not built yet) +- **Tipos gerados do contrato**: `npm run api:gen` lê `http://localhost:8000/docs/api.json` (backend rodando) e escreve `app/infrastructure/http/api-types.d.ts`, commitado — mudar o contrato da API sem rodar `api:gen` de novo quebra o typecheck do front. Ver `app/application/shared/services/pingService.ts` como referência de serviço tipado a partir de `operations['']`. +- **401 em requisição autenticada** chama o handler registrado por `httpClient.setUnauthorizedHandler()` — hoje só `app/plugins/auth.client.ts` registra (limpa `authStore`, navega pro `/login`). Um 401 sem token (ex.: login errado) não dispara isso — `HttpClient` só decide que a sessão expirou quando *tinha* token. +- **Token Bearer, não cookie httpOnly** — decisão registrada em `docs/adr/0005-token-bearer-no-client.md` (o app mobile também consome a mesma API com o mesmo token). +- `useEdition()` (`app/application/shared/composables/useEdition.ts`) guarda a edição ativa — hoje só em memória, sem API por trás; liga no módulo Community quando ele existir. -- No generator/skill scaffolding a new module (`base_laravel_modular` has `make new-module` wrapping a real Composer package generator; there's no equivalent tool for a Nuxt module — creating one is by hand, following `login`'s shape and `docs/ARCHITECTURE.md`). -- `register` (the natural second consumer of `useAuthSubmit`, alongside `login`) isn't built — only documented as the reason `useAuthSubmit` exists as a shared composable rather than living inside `login/`. -- No navigation chrome (sidebar/navbar) yet — the "register every module's view in it" step from `docs/ARCHITECTURE.md`'s checklist has nothing to register into today. -- `apps/backend`'s Sanctum-shaped `/auth/login`/`/auth/logout` endpoints (see above) — needed for this app to talk to a real backend instead of just having the client-side shape ready. +## Roadmap (not built yet) -Done: `npm test` passes (40 tests across 7 files, one per layer of the reference module plus the shared composables and `HttpClient`); `npm run test:e2e` passes (6 Playwright cases covering `login`'s full user-facing behavior in a real browser, network mocked at the `fetch` boundary); `npm run typecheck` passes; `npm run build` produces a working production build. +- No generator/skill scaffolding a new module — creating one is by hand, following `login`'s shape and `docs/ARCHITECTURE.md`. +- `register` (the natural second consumer of `useAuthSubmit`, alongside `login`) isn't built. +- No navigation chrome (sidebar/navbar) yet. +- `backend`'s Sanctum-shaped `/auth/login`/`/auth/logout` endpoints don't exist yet — needed for this app to talk to a real backend instead of just having the client-side shape ready. +- `useEdition()` não tem fonte de dados real (ver acima). diff --git a/front-admin/README.md b/front-admin/README.md index 3693db2..f8ea457 100644 --- a/front-admin/README.md +++ b/front-admin/README.md @@ -4,14 +4,15 @@ Painel da organização da [PHPeste Platform](../README.md). Nuxt 4, módulo-por > Design/protótipo das telas: [claude.ai/artifact/3B7ZWqjBQPDFZ5vhGgNs4N](https://claude.ai/artifact/3B7ZWqjBQPDFZ5vhGgNs4N#page-dcb81aee8d83) · tokens do tema em [`../docs/design/tema-sertao-tokens.png`](../docs/design/tema-sertao-tokens.png). -> **Status:** `login` é o módulo de referência completo (types, service + error class, composable, component, view, testes em cada camada — unitário e e2e). 40 testes unitários e 6 e2e (Playwright) passam, typecheck passa, `npm run build` gera um build de produção funcional. +> **Status:** `login` é o módulo de referência completo (types, service + error class, composable, component, view, testes em cada camada — unitário e e2e). Testes unitários, e2e (Playwright), typecheck e `npm run build` passam. ```bash npm install npx playwright install chromium # uma vez — baixa o browser que o Playwright usa cp .env.example .env npm run dev -npm run lint # eslint (via @nuxt/eslint) +npm run api:gen # regenera app/infrastructure/http/api-types.d.ts (precisa do backend rodando em :8000) +npm run lint # eslint (via @nuxt/eslint) npm run typecheck # nuxt typecheck (vue-tsc) npm test # vitest — mocka a fronteira do composable/service npm run test:e2e # playwright — browser real, mocka só a rede