diff --git a/README.md b/README.md index c2454d5..5d474bc 100644 --- a/README.md +++ b/README.md @@ -54,11 +54,43 @@ npm install npm run dev # electron-vite, hot reload npm test # vitest npm run typecheck +npm run smoke # starts the real app, asserts the bridge works and the window drew npm run package # unsigned installer into dist/ ``` Point at a local API with `RAIDIFY_API_URL=http://localhost:5001 npm run dev`. +### Running against a real API + +`src/main/live.test.ts` signs in for real, exchanges a code for a token, lists guilds and +uploads a night. It skips unless pointed at an API, because a suite that needs a database +running is a suite people stop running. + +```bash +# in the raidify repo +docker compose up -d +dotnet run --project apps/api/src/Raidify.Api --urls http://localhost:5001 + +# here +RAIDIFY_E2E_API_URL=http://localhost:5001 npm test +``` + +Needs `AllowTestLogin`, which `appsettings.Development.json` already sets. It cannot run +against production — test-login 404s there and the first step fails loudly rather than +quietly touching real data. + +Worth running after any change to `src/shared/contract.ts`. Every other test in this repo +can pass while the app cannot sign in; this is the one that would notice. It found the +204 sign-out bug on its first run. + +### Refreshing the API reference + +`npm run schema:pull [baseUrl]` rewrites `reference/openapi.json`, then read the diff. + +It keeps only the eight companion endpoints and the schemas they reach — 14 of the API's +328. The full document is a complete map of the private API, and this repo may be made +public so anyone wary of an unsigned binary can read what it does. + ## Releasing Tag `v*`. CI typechecks, tests, packages, publishes the installer and `latest.yml` to a @@ -87,6 +119,12 @@ only knows who it *saw*, so uploading would silently turn every no-show into not ## Not built yet -The sign-in flow proper — device-code pairing against the web app. `src/main/index.ts` -has the IPC shape so the boundary is visible; the browser handoff is next. After that, -the UI: guided first-run, upload history, and the consent screen. +**The Lua parse runs on the main process.** A forty-man file parses fast enough that +nobody notices, but it is still the UI thread doing it, and the file only grows. + +**Nothing is uploaded automatically.** Deliberate for now — see `NightCard.tsx` for why +the officer is the one who decides a session was the guild's raid. An "always send +finished nights for this guild" setting is reasonable once the matching has earned trust. + +**Linux `basic_text` detection.** `safeStorage` can silently fall back to plaintext on +Linux; `canPersist()` reports availability but not which backend answered. diff --git a/reference/openapi.json b/reference/openapi.json new file mode 100644 index 0000000..b2eca21 --- /dev/null +++ b/reference/openapi.json @@ -0,0 +1,694 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "Raidify.Api | v1", + "version": "1.0.0" + }, + "paths": { + "/api/v1/auth/companion/approve": { + "post": { + "tags": [ + "CompanionAuth" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApproveCompanionRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ApproveCompanionRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ApproveCompanionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ApproveCompanionResponse" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApproveCompanionResponse" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ApproveCompanionResponse" + } + } + } + } + } + } + }, + "/api/v1/auth/companion/token": { + "post": { + "tags": [ + "CompanionAuth" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompanionTokenRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CompanionTokenRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CompanionTokenRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/CompanionTokenResponse" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompanionTokenResponse" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CompanionTokenResponse" + } + } + } + } + } + } + }, + "/api/v1/account/companion-tokens": { + "get": { + "tags": [ + "CompanionAuth" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CompanionTokenDto" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CompanionTokenDto" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CompanionTokenDto" + } + } + } + } + } + } + } + }, + "/api/v1/account/companion-tokens/{tokenId}": { + "delete": { + "tags": [ + "CompanionAuth" + ], + "parameters": [ + { + "name": "tokenId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/companion/sign-out": { + "post": { + "tags": [ + "CompanionAuth" + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/companion/compat": { + "get": { + "tags": [ + "Companion" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/CompanionCompatDto" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompanionCompatDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CompanionCompatDto" + } + } + } + } + } + } + }, + "/api/v1/companion/guilds": { + "get": { + "tags": [ + "Companion" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CompanionGuildDto" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CompanionGuildDto" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CompanionGuildDto" + } + } + } + } + } + } + } + }, + "/api/v1/guilds/{guildId}/companion/attendance": { + "post": { + "tags": [ + "Companion" + ], + "parameters": [ + { + "name": "guildId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompanionAttendanceUploadRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CompanionAttendanceUploadRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CompanionAttendanceUploadRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/CompanionAttendanceUploadResult" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompanionAttendanceUploadResult" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CompanionAttendanceUploadResult" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "ApproveCompanionRequest": { + "required": [ + "codeChallenge", + "redirectPort" + ], + "type": "object", + "properties": { + "codeChallenge": { + "type": "string" + }, + "redirectPort": { + "type": "integer", + "format": "int32" + }, + "label": { + "type": "string", + "default": null, + "nullable": true + }, + "codeChallengeMethod": { + "type": "string", + "default": "S256" + } + } + }, + "ApproveCompanionResponse": { + "required": [ + "code", + "expiresAt" + ], + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "expiresAt": { + "type": "string", + "format": "date-time" + } + } + }, + "CompanionAttendanceBucket": { + "type": "integer" + }, + "CompanionAttendanceRow": { + "required": [ + "name", + "bucket" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "bucket": { + "$ref": "#/components/schemas/CompanionAttendanceBucket" + }, + "realm": { + "type": "string", + "default": null, + "nullable": true + }, + "firstSeen": { + "type": "string", + "format": "date-time", + "default": null, + "nullable": true + }, + "lastSeen": { + "type": "string", + "format": "date-time", + "default": null, + "nullable": true + }, + "markedBy": { + "type": "string", + "default": null, + "nullable": true + }, + "markedAt": { + "type": "string", + "format": "date-time", + "default": null, + "nullable": true + }, + "markRoute": { + "type": "string", + "default": null, + "nullable": true + } + } + }, + "CompanionAttendanceRowResult": { + "required": [ + "name", + "bucket", + "applied", + "skipped" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "bucket": { + "$ref": "#/components/schemas/CompanionAttendanceBucket" + }, + "applied": { + "type": "boolean" + }, + "skipped": { + "type": "string", + "nullable": true + } + } + }, + "CompanionAttendanceUploadRequest": { + "required": [ + "rows" + ], + "type": "object", + "properties": { + "rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CompanionAttendanceRow" + } + }, + "startedAt": { + "type": "string", + "format": "date-time", + "default": null, + "nullable": true + }, + "endedAt": { + "type": "string", + "format": "date-time", + "default": null, + "nullable": true + }, + "raidIdHint": { + "type": "string", + "format": "uuid", + "default": null, + "nullable": true + }, + "clientVersion": { + "type": "string", + "default": null, + "nullable": true + }, + "addonVersion": { + "type": "string", + "default": null, + "nullable": true + }, + "dryRun": { + "type": "boolean", + "default": false + } + } + }, + "CompanionAttendanceUploadResult": { + "required": [ + "dryRun", + "match", + "raidId", + "raidTitle", + "eventKey", + "occurredAt", + "rowsReceived", + "recorded", + "updated", + "unchanged", + "unmatchedRaiders", + "rows", + "warnings" + ], + "type": "object", + "properties": { + "dryRun": { + "type": "boolean" + }, + "match": { + "$ref": "#/components/schemas/CompanionRaidMatch" + }, + "raidId": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "raidTitle": { + "type": "string", + "nullable": true + }, + "eventKey": { + "type": "string" + }, + "occurredAt": { + "type": "string", + "format": "date-time" + }, + "rowsReceived": { + "type": "integer", + "format": "int32" + }, + "recorded": { + "type": "integer", + "format": "int32" + }, + "updated": { + "type": "integer", + "format": "int32" + }, + "unchanged": { + "type": "integer", + "format": "int32" + }, + "unmatchedRaiders": { + "type": "array", + "items": { + "type": "string" + } + }, + "rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CompanionAttendanceRowResult" + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "CompanionCompatDto": { + "required": [ + "minimumClientVersion", + "latestClientVersion", + "minimumAddonVersion", + "payloadVersion", + "uploadEnabled", + "downloadUrl", + "notice" + ], + "type": "object", + "properties": { + "minimumClientVersion": { + "type": "string" + }, + "latestClientVersion": { + "type": "string" + }, + "minimumAddonVersion": { + "type": "string" + }, + "payloadVersion": { + "type": "integer", + "format": "int32" + }, + "uploadEnabled": { + "type": "boolean" + }, + "downloadUrl": { + "type": "string", + "nullable": true + }, + "notice": { + "type": "string", + "nullable": true + } + } + }, + "CompanionGuildDto": { + "required": [ + "id", + "name", + "slug", + "gameVersion", + "avatarUrl" + ], + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "gameVersion": { + "$ref": "#/components/schemas/GameVersion" + }, + "avatarUrl": { + "type": "string", + "nullable": true + } + } + }, + "CompanionRaidMatch": { + "type": "integer" + }, + "CompanionTokenDto": { + "required": [ + "id", + "label", + "createdAt", + "lastUsedAt", + "lastClientVersion" + ], + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "label": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "lastUsedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "lastClientVersion": { + "type": "string", + "nullable": true + } + } + }, + "CompanionTokenRequest": { + "required": [ + "code", + "codeVerifier" + ], + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "codeVerifier": { + "type": "string" + } + } + }, + "CompanionTokenResponse": { + "required": [ + "token", + "tokenId", + "label", + "createdAt" + ], + "type": "object", + "properties": { + "token": { + "type": "string" + }, + "tokenId": { + "type": "string", + "format": "uuid" + }, + "label": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, + "GameVersion": { + "type": "integer" + } + } + } +} diff --git a/scripts/pull-schema.mjs b/scripts/pull-schema.mjs index 6038115..ccb9f58 100644 --- a/scripts/pull-schema.mjs +++ b/scripts/pull-schema.mjs @@ -41,13 +41,58 @@ if (!document) { process.exit(1); } -// Pretty-printed and sorted so the diff is readable — the whole point is reviewing it. -await writeFile(target, `${JSON.stringify(document, null, 2)}\n`, 'utf8'); +const companionPaths = Object.keys(document.paths ?? {}).filter((p) => p.includes('companion')); +if (!companionPaths.length) { + console.error('No companion routes in this document — refusing to write a reference without them.'); + process.exit(1); +} + +/** + * Only the companion's own corner of the API. + * + * The full document is 248 paths and 328 schemas — every admin route, every internal DTO, + * the whole shape of Raidify. This repo may be made public so that anyone worried about + * an unsigned binary can read what it does, and a complete map of the private API is + * exactly the kind of detail that must not travel with it. + * + * Narrowing costs nothing: the point of this file is noticing a change to the eight + * endpoints the companion speaks to, and the other 240 could not produce a diff worth + * reading anyway. + */ +const reference = { + openapi: document.openapi, + info: { title: document.info?.title, version: document.info?.version }, + paths: Object.fromEntries(companionPaths.map((p) => [p, document.paths[p]])), + components: { schemas: collectSchemas(document, companionPaths) }, +}; + +await writeFile(target, `${JSON.stringify(reference, null, 2)}\n`, 'utf8'); console.log(`wrote ${target}`); +console.log(`companion routes:\n ${companionPaths.join('\n ')}`); +console.log(`schemas kept: ${Object.keys(reference.components.schemas).length}`); -const companionPaths = Object.keys(document.paths ?? {}).filter((p) => p.includes('companion')); -console.log( - companionPaths.length - ? `companion routes:\n ${companionPaths.join('\n ')}` - : 'WARNING: no companion routes in this document.', -); +/** Every schema those paths reach, following $ref transitively. */ +function collectSchemas(doc, paths) { + const all = doc.components?.schemas ?? {}; + const kept = {}; + const queue = paths.flatMap((p) => refsIn(doc.paths[p])); + + while (queue.length) { + const name = queue.pop(); + if (!all[name] || kept[name]) continue; + kept[name] = all[name]; + queue.push(...refsIn(all[name])); + } + + // Sorted, so a schema being added does not reshuffle the whole file and bury the change. + return Object.fromEntries(Object.keys(kept).sort().map((k) => [k, kept[k]])); +} + +function refsIn(node) { + if (node === null || typeof node !== 'object') return []; + if (Array.isArray(node)) return node.flatMap(refsIn); + + return Object.entries(node).flatMap(([key, value]) => + key === '$ref' && typeof value === 'string' ? [value.split('/').pop()] : refsIn(value), + ); +} diff --git a/src/main/api.ts b/src/main/api.ts index f4ac373..41337c1 100644 --- a/src/main/api.ts +++ b/src/main/api.ts @@ -143,6 +143,13 @@ export class ApiClient { throw new ApiError(message, response.status); } + // 204 is a success with no body, and sign-out returns one. Parsing it threw + // "Unexpected end of JSON input" *after* the server had already revoked the token, so + // a sign-out that worked perfectly surfaced as a failure — and the caller's catch + // block then reported it as "offline, or already revoked", which was the opposite of + // what had happened. + if (response.status === 204) return undefined as T; + return (await response.json()) as T; } } diff --git a/src/main/live.test.ts b/src/main/live.test.ts new file mode 100644 index 0000000..d5256e1 --- /dev/null +++ b/src/main/live.test.ts @@ -0,0 +1,173 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { ApiClient } from './api'; +import { createPkcePair } from './auth'; +import { AttendanceBucket, evaluateCompat, PAYLOAD_VERSION } from '../shared/contract'; + +/** + * The whole chain, against a real API. + * + * Everything else in this suite tests a piece in isolation: the parser against fixtures, + * the watcher against a temp file, the compat rules against invented responses. All of it + * can pass while the app cannot sign in, because nothing here had ever spoken to a server. + * That is the same gap that once shipped an app whose preload never loaded — every check + * green, the thing itself dead. + * + * So this signs in for real, exchanges a real code for a real token, asks which guilds it + * may write to, and uploads a night. It is skipped unless pointed at an API, because a + * test suite that needs a database running to pass is a test suite people stop running. + * + * docker compose up -d # in the raidify repo + * dotnet run --project apps/api/src/Raidify.Api --urls http://localhost:5001 + * RAIDIFY_E2E_API_URL=http://localhost:5001 npm test + * + * Needs `AllowTestLogin: true`, which appsettings.Development.json already sets. It will + * not run against production: test-login 404s there, and the first step would fail loudly + * rather than quietly doing something to real data. + */ + +const BASE_URL = process.env.RAIDIFY_E2E_API_URL; +const describeLive = BASE_URL ? describe : describe.skip; + +describeLive('the companion against a real API', () => { + let webJwt: string; + let guildId: string; + let companionToken: string | null = null; + + const api = new ApiClient({ + baseUrl: BASE_URL, + clientVersion: '0.1.0', + getToken: async () => companionToken, + }); + + /** The web app's half: an ordinary signed-in session, and a guild it can manage. */ + async function asWebUser(method: string, path: string, body?: unknown): Promise { + const response = await fetch(`${BASE_URL}${path}`, { + method, + headers: { + Accept: 'application/json', + Authorization: `Bearer ${webJwt}`, + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error(`${method} ${path} → ${response.status} ${await response.text()}`); + } + return (await response.json()) as T; + } + + beforeAll(async () => { + const login = await fetch(`${BASE_URL}/api/v1/auth/test-login`, { method: 'POST' }); + if (!login.ok) { + throw new Error( + `test-login → ${login.status}. Set AllowTestLogin, and do not point this at production.`, + ); + } + webJwt = ((await login.json()) as { accessToken: string }).accessToken; + + // A fresh guild per run. Reusing one would make the upload assertions depend on + // whatever the previous run left behind. + const suffix = Date.now().toString(36); + const guild = await asWebUser<{ id: string }>('POST', '/api/v1/guilds', { + name: `Companion E2E ${suffix}`, + description: 'Created by the companion live test.', + gameVersion: 1, + }); + guildId = guild.id; + }, 60_000); + + it('is a version this server still accepts', async () => { + const compat = await api.compat(); + + expect(compat.payloadVersion).toBe(PAYLOAD_VERSION); + expect(evaluateCompat(compat, '0.1.0').kind).toBe('ok'); + }); + + /** + * The half of sign-in that has no browser in it. + * + * The consent page is what turns a session into an approval; here the test plays that + * page. What is being checked is the pair — a code that came back through the browser + * and a verifier that never left this machine — actually buying a token from the real + * endpoint, with the real PKCE implementation on both sides. + */ + it('trades an approved code for a token', async () => { + const { verifier, challenge } = createPkcePair(); + + const approved = await asWebUser<{ code: string }>('POST', '/api/v1/auth/companion/approve', { + codeChallenge: challenge, + redirectPort: 51789, + label: 'live test', + }); + + const exchanged = await api.exchangeCode(approved.code, verifier); + + expect(exchanged.token).toBeTruthy(); + companionToken = exchanged.token; + }); + + it('refuses a code presented with the wrong verifier', async () => { + const { challenge } = createPkcePair(); + const approved = await asWebUser<{ code: string }>('POST', '/api/v1/auth/companion/approve', { + codeChallenge: challenge, + redirectPort: 51790, + }); + + await expect(api.exchangeCode(approved.code, createPkcePair().verifier)).rejects.toThrow(); + }); + + it('lists the guild it may report for', async () => { + const guilds = await api.guilds(); + + expect(guilds.map((g) => g.id)).toContain(guildId); + }); + + /** + * The point of the whole app. + * + * Dry run, so this leaves nothing behind — and because preview and commit run the same + * server path, exercising the preview exercises the write. + */ + it('uploads a night', async () => { + const startedAt = new Date(); + startedAt.setHours(startedAt.getHours() - 3); + + const result = await api.uploadAttendance(guildId, { + rows: [ + { name: 'Torvald', bucket: AttendanceBucket.Present }, + { name: 'Kaya', bucket: AttendanceBucket.Late }, + { + name: 'Bren', + bucket: AttendanceBucket.Benched, + markedBy: 'Torvald', + markedAt: new Date().toISOString(), + markRoute: 'sweep', + }, + ], + startedAt: startedAt.toISOString(), + endedAt: new Date().toISOString(), + clientVersion: '0.1.0', + dryRun: true, + }); + + expect(result.dryRun).toBe(true); + expect(result.rowsReceived).toBe(3); + // Nobody in a brand-new guild has a linked character, so every name is unmatched. + // That is the correct answer, and it is also the shape the UI renders — a result the + // client cannot read is as bad as no result. + expect(result.unmatchedRaiders).toHaveLength(3); + expect(result.eventKey).toBeTruthy(); + }); + + /** + * Signing out has to reach the server. Deleting the local copy of a token is not + * disconnecting — that left the credential valid forever, so selling the laptop handed + * it on. + */ + it('revokes the token when it signs out', async () => { + await api.signOut(); + + await expect(api.guilds()).rejects.toThrow(); + }); +});