From 68717b2c02db4be30576fcaa9f4492f5924b347a Mon Sep 17 00:00:00 2001 From: Agam00 Date: Sun, 16 Aug 2026 00:25:04 +0530 Subject: [PATCH 1/6] feat(habitica): add Habitica integration (70 ops) --- packages/corsair/core/constants.ts | 3 + packages/habitica/client.test.ts | 261 +++++ packages/habitica/client.ts | 329 ++++++ packages/habitica/endpoints.test.ts | 1203 +++++++++++++++++++++ packages/habitica/endpoints/auth.ts | 102 ++ packages/habitica/endpoints/challenges.ts | 249 +++++ packages/habitica/endpoints/chat.ts | 86 ++ packages/habitica/endpoints/content.ts | 259 +++++ packages/habitica/endpoints/exports.ts | 100 ++ packages/habitica/endpoints/groups.ts | 288 +++++ packages/habitica/endpoints/index.ts | 10 + packages/habitica/endpoints/logging.ts | 65 ++ packages/habitica/endpoints/persist.ts | 243 +++++ packages/habitica/endpoints/shared.ts | 178 +++ packages/habitica/endpoints/tags.ts | 103 ++ packages/habitica/endpoints/tasks.ts | 352 ++++++ packages/habitica/endpoints/types.ts | 1131 +++++++++++++++++++ packages/habitica/endpoints/user.ts | 263 +++++ packages/habitica/endpoints/webhooks.ts | 107 ++ packages/habitica/error-handlers.test.ts | 148 +++ packages/habitica/error-handlers.ts | 99 ++ packages/habitica/index.ts | 896 +++++++++++++++ packages/habitica/integration.test.ts | 392 +++++++ packages/habitica/jest.config.cjs | 58 + packages/habitica/package.json | 45 + packages/habitica/schema.test.ts | 214 ++++ packages/habitica/schema/database.ts | 416 +++++++ packages/habitica/schema/index.ts | 20 + packages/habitica/tsconfig.json | 20 + packages/habitica/tsup.config.ts | 15 + 30 files changed, 7655 insertions(+) create mode 100644 packages/habitica/client.test.ts create mode 100644 packages/habitica/client.ts create mode 100644 packages/habitica/endpoints.test.ts create mode 100644 packages/habitica/endpoints/auth.ts create mode 100644 packages/habitica/endpoints/challenges.ts create mode 100644 packages/habitica/endpoints/chat.ts create mode 100644 packages/habitica/endpoints/content.ts create mode 100644 packages/habitica/endpoints/exports.ts create mode 100644 packages/habitica/endpoints/groups.ts create mode 100644 packages/habitica/endpoints/index.ts create mode 100644 packages/habitica/endpoints/logging.ts create mode 100644 packages/habitica/endpoints/persist.ts create mode 100644 packages/habitica/endpoints/shared.ts create mode 100644 packages/habitica/endpoints/tags.ts create mode 100644 packages/habitica/endpoints/tasks.ts create mode 100644 packages/habitica/endpoints/types.ts create mode 100644 packages/habitica/endpoints/user.ts create mode 100644 packages/habitica/endpoints/webhooks.ts create mode 100644 packages/habitica/error-handlers.test.ts create mode 100644 packages/habitica/error-handlers.ts create mode 100644 packages/habitica/index.ts create mode 100644 packages/habitica/integration.test.ts create mode 100644 packages/habitica/jest.config.cjs create mode 100644 packages/habitica/package.json create mode 100644 packages/habitica/schema.test.ts create mode 100644 packages/habitica/schema/database.ts create mode 100644 packages/habitica/schema/index.ts create mode 100644 packages/habitica/tsconfig.json create mode 100644 packages/habitica/tsup.config.ts diff --git a/packages/corsair/core/constants.ts b/packages/corsair/core/constants.ts index 41011f6e7..b834537cc 100644 --- a/packages/corsair/core/constants.ts +++ b/packages/corsair/core/constants.ts @@ -77,6 +77,7 @@ export const BaseProviders = [ 'googlemeet', 'googlesheets', 'grafana', + 'habitica', 'hackernews', 'harvest', 'hashnode', @@ -207,6 +208,7 @@ export const ProviderDisplayNames = { googlemeet: 'Google Meet', googlesheets: 'Google Sheets', grafana: 'Grafana', + habitica: 'Habitica', hackernews: 'Hacker News', harvest: 'Harvest', hashnode: 'Hashnode', @@ -344,6 +346,7 @@ export type AllProviders = | 'googlemeet' | 'googlesheets' | 'grafana' + | 'habitica' | 'hackernews' | 'harvest' | 'hashnode' diff --git a/packages/habitica/client.test.ts b/packages/habitica/client.test.ts new file mode 100644 index 000000000..f3c53a1e2 --- /dev/null +++ b/packages/habitica/client.test.ts @@ -0,0 +1,261 @@ +/** + * Transport-level guarantees. + * + * These are the invariants every endpoint inherits, so they are asserted once + * here rather than repeated 70 times: both credential halves are sent, + * `x-client` is always present, the two non-versioned base URLs are used where + * they should be, and a missing user id fails before a request is made. + * + * All credentials here are fictional. + */ +import { + HABITICA_API_BASE, + HABITICA_CLIENT_ID, + HABITICA_RATE_LIMIT_CONFIG, + HABITICA_ROOT_BASE, + HabiticaUserIdMissingError, + makeHabiticaAnonymousRequest, + makeHabiticaExportRequest, + makeHabiticaRequest, + makeHabiticaTextRequest, +} from './client'; + +const USER_ID = '00000000-0000-4000-8000-000000000000'; +const API_TOKEN = '11111111-1111-4111-8111-111111111111'; +const CREDENTIALS = { userId: USER_ID, apiToken: API_TOKEN }; + +let captured: + | { url: string; method: string; headers: Record } + | undefined; + +function mockFetch( + payload: unknown, + { + status = 200, + contentType = 'application/json', + }: { status?: number; contentType?: string } = {}, +) { + captured = undefined; + global.fetch = (async (url: unknown, init?: RequestInit) => { + const headers: Record = {}; + const raw = init?.headers; + if (raw instanceof Headers) { + raw.forEach((value, key) => { + headers[key.toLowerCase()] = value; + }); + } else { + for (const [key, value] of Object.entries( + (raw ?? {}) as Record, + )) { + headers[key.toLowerCase()] = value; + } + } + captured = { url: String(url), method: init?.method ?? 'GET', headers }; + const body = + typeof payload === 'string' ? payload : JSON.stringify(payload); + return { + ok: status < 400, + status, + statusText: status < 400 ? 'OK' : 'Error', + url: String(url), + headers: new Headers({ 'Content-Type': contentType }), + json: async () => payload, + text: async () => body, + }; + }) as unknown as typeof global.fetch; +} + +describe('Habitica transport', () => { + describe('authentication', () => { + it('sends both halves of the credential', async () => { + // Habitica checks the user id as well as the token: a valid token with + // the wrong id is a 401. Sending only the token would fail everywhere. + mockFetch({ success: true, data: {} }); + await makeHabiticaRequest('user', CREDENTIALS); + + expect(captured?.headers['x-api-user']).toBe(USER_ID); + expect(captured?.headers['x-api-key']).toBe(API_TOKEN); + }); + + it('does not use an Authorization header', async () => { + mockFetch({ success: true, data: {} }); + await makeHabiticaRequest('user', CREDENTIALS); + + expect(captured?.headers.authorization).toBeUndefined(); + }); + + it('refuses to send a request when the user id is missing', async () => { + // Failing here beats sending a request that is certain to come back as + // an opaque 401. + mockFetch({ success: true, data: {} }); + await expect( + makeHabiticaRequest('user', { userId: '', apiToken: API_TOKEN }), + ).rejects.toBeInstanceOf(HabiticaUserIdMissingError); + expect(captured).toBeUndefined(); + }); + + it('sends no credential on an anonymous request', async () => { + mockFetch({ success: true, data: {} }); + await makeHabiticaAnonymousRequest('status'); + + expect(captured?.headers['x-api-user']).toBeUndefined(); + expect(captured?.headers['x-api-key']).toBeUndefined(); + }); + }); + + describe('the mandatory x-client header', () => { + // Omitting it is a 400 - even on routes that need no credentials at all - + // so it goes on every request rather than on the authenticated ones. + it('is sent on an authenticated request', async () => { + mockFetch({ success: true, data: {} }); + await makeHabiticaRequest('user', CREDENTIALS); + expect(captured?.headers['x-client']).toBe(HABITICA_CLIENT_ID); + }); + + it('is sent on an anonymous request', async () => { + mockFetch({ success: true, data: {} }); + await makeHabiticaAnonymousRequest('content'); + expect(captured?.headers['x-client']).toBe(HABITICA_CLIENT_ID); + }); + + it('is sent on an export request', async () => { + mockFetch('a,b,c', { contentType: 'text/csv' }); + await makeHabiticaExportRequest('history.csv', CREDENTIALS); + expect(captured?.headers['x-client']).toBe(HABITICA_CLIENT_ID); + }); + + it('is never empty, which Habitica rejects exactly as it rejects absence', () => { + expect(HABITICA_CLIENT_ID.length).toBeGreaterThan(0); + }); + + it('carries no user id, so nothing account-specific reaches request logs', () => { + expect(HABITICA_CLIENT_ID).not.toContain(USER_ID); + expect(HABITICA_CLIENT_ID).not.toMatch(/[0-9a-f]{8}-[0-9a-f]{4}/i); + }); + }); + + describe('base URLs', () => { + it('uses the versioned base for ordinary operations', async () => { + mockFetch({ success: true, data: {} }); + await makeHabiticaRequest('tags', CREDENTIALS); + expect(captured?.url.startsWith(`${HABITICA_API_BASE}/tags`)).toBe(true); + }); + + it('uses the root base for the export documents', async () => { + // These sit outside /api/v3 entirely. + mockFetch('{}'); + await makeHabiticaExportRequest('userdata.json', CREDENTIALS); + expect(captured?.url).toBe(`${HABITICA_ROOT_BASE}/export/userdata.json`); + expect(captured?.url).not.toContain('/api/v3'); + }); + + it('uses the versioned base for the challenge CSV, which is not an /export route', async () => { + mockFetch('a,b,c', { contentType: 'text/csv' }); + await makeHabiticaTextRequest( + 'challenges/challenge-1/export/csv', + CREDENTIALS, + ); + expect(captured?.url).toBe( + `${HABITICA_API_BASE}/challenges/challenge-1/export/csv`, + ); + }); + }); + + describe('non-JSON responses', () => { + it('returns CSV as text with its content type', async () => { + mockFetch('date,task\n2026-01-01,Read', { contentType: 'text/csv' }); + const result = await makeHabiticaExportRequest( + 'history.csv', + CREDENTIALS, + ); + + expect(result.body).toContain('date,task'); + expect(result.contentType).toContain('text/csv'); + }); + + it('returns HTML as text', async () => { + mockFetch('inbox', { + contentType: 'text/html', + }); + const result = await makeHabiticaExportRequest('inbox.html', CREDENTIALS); + + expect(result.body).toContain(''); + expect(result.contentType).toContain('text/html'); + }); + + it('keeps the response body out of the error when an export fails', async () => { + // A failed export can still carry account data - userdata.json contains + // the account holder's email address - so the body must not be copied + // into an error message or a log. + const secret = 'someone@example.com'; + mockFetch(secret, { status: 500, contentType: 'application/json' }); + + await expect( + makeHabiticaExportRequest('userdata.json', CREDENTIALS), + ).rejects.toThrow(/HTTP 500/); + await expect( + makeHabiticaExportRequest('userdata.json', CREDENTIALS), + ).rejects.not.toThrow(new RegExp(secret)); + }); + + it('keeps the response body out of the error on a text request too', async () => { + const secret = 'participant@example.com'; + mockFetch(secret, { status: 403, contentType: 'text/csv' }); + + await expect( + makeHabiticaTextRequest( + 'challenges/challenge-1/export/csv', + CREDENTIALS, + ), + ).rejects.not.toThrow(new RegExp(secret)); + }); + + it('requires the user id before making an export request', async () => { + mockFetch('{}'); + await expect( + makeHabiticaExportRequest('userdata.json', { + userId: '', + apiToken: API_TOKEN, + }), + ).rejects.toBeInstanceOf(HabiticaUserIdMissingError); + expect(captured).toBeUndefined(); + }); + }); + + describe('rate limiting', () => { + it('reacts to retry-after', () => { + expect(HABITICA_RATE_LIMIT_CONFIG.headerNames.retryAfter).toBe( + 'retry-after', + ); + }); + + it('does not configure x-ratelimit-reset', () => { + // Habitica sends a Date.toString() there, which the shared helper would + // parseInt to NaN. Naming the header would advertise pacing the plugin + // cannot actually do. + expect(HABITICA_RATE_LIMIT_CONFIG.headerNames.resetTime).toBeUndefined(); + }); + + it('reads the remaining and limit counters', () => { + expect(HABITICA_RATE_LIMIT_CONFIG.headerNames.remaining).toBe( + 'x-ratelimit-remaining', + ); + expect(HABITICA_RATE_LIMIT_CONFIG.headerNames.limit).toBe( + 'x-ratelimit-limit', + ); + }); + + it('retries with backoff', () => { + expect(HABITICA_RATE_LIMIT_CONFIG.enabled).toBe(true); + expect(HABITICA_RATE_LIMIT_CONFIG.maxRetries).toBeGreaterThan(0); + expect(HABITICA_RATE_LIMIT_CONFIG.backoffMultiplier).toBeGreaterThan(1); + }); + + it("truncates Habitica's fractional retry-after, which is why one extra 429 is expected", () => { + // Documents the real arithmetic rather than asserting a wish: the + // observed "21.069" becomes 21, i.e. 69ms early. + expect(Number.parseInt('21.069', 10)).toBe(21); + expect(Number.parseInt('21.069', 10)).toBeLessThan(21.069); + }); + }); +}); diff --git a/packages/habitica/client.ts b/packages/habitica/client.ts new file mode 100644 index 000000000..0835d8df4 --- /dev/null +++ b/packages/habitica/client.ts @@ -0,0 +1,329 @@ +import type { + ApiRequestOptions, + OpenAPIConfig, + RateLimitConfig, +} from 'corsair/http'; +import { request } from 'corsair/http'; + +/** + * The versioned API base. The version is part of the path, not a header. + * + * @see https://habitica.com/apidoc/ + */ +const HABITICA_API_BASE = 'https://habitica.com/api/v3'; + +/** + * The three data-export operations sit on the same host but **outside** the + * versioned base, at `/export/*`. + * + * They are reachable with ordinary header authentication. That is worth stating + * because the server source routes them through `authWithSession` rather than + * the `authWithHeaders` middleware every `/api/v3` route uses, which reads as + * though a browser session were required. Checked live on 2026-08-15: all three + * answered 200 to the same `x-api-user` / `x-api-key` pair used everywhere else. + */ +const HABITICA_ROOT_BASE = 'https://habitica.com'; + +/** + * Habitica allows 30 authenticated requests per minute per user id, and answers + * 429 `TooManyRequests` beyond that. Confirmed live on 2026-08-15 by firing + * requests until throttled - the 30th was the one that failed, so the + * documented figure is exact rather than approximate. + * + * Two details of the response headers shape this configuration, and both are + * the reason it is not simply the default: + * + * - **`x-ratelimit-reset` is deliberately not configured.** Habitica sends a + * `Date.toString()` - `"Sat Aug 15 2026 16:43:00 GMT+0000 (Coordinated + * Universal Time)"` - where the shared helper expects a number it can + * `parseInt`. That parse yields `NaN`, which the helper discards, so naming + * the header would change no behaviour while implying the plugin paces itself + * from the reset time. It cannot; it reacts to `retry-after` instead. + * - **`retry-after` is fractional seconds** - `"21.069"` was the observed + * value. The helper's `parseInt` truncates that to 21, so the first retry + * fires a fraction of a second early and can draw a second 429 before the + * exponential backoff spaces the attempts out. It converges within + * `maxRetries`; the extra 429 is expected, not a defect. + */ +const HABITICA_RATE_LIMIT_CONFIG: RateLimitConfig = { + enabled: true, + maxRetries: 3, + initialRetryDelay: 1000, + backoffMultiplier: 2, + headerNames: { + retryAfter: 'retry-after', + remaining: 'x-ratelimit-remaining', + limit: 'x-ratelimit-limit', + }, +}; + +/** + * Identifies the caller to Habitica in the mandatory `x-client` header. + * + * Habitica's API usage guidelines document the form `UserID-AppName`, but the + * server does not enforce it: `corsair` and a deliberately malformed + * `not-a-uuid-at-all-xyz` were both accepted with 200 on 2026-08-15. Only an + * empty value is rejected, and it is rejected exactly as an absent one is. A + * stable application identifier is therefore both sufficient and honest - it + * does not pretend to a format the server never checks, and it carries no user + * id, so nothing account-specific is disclosed to request logs. + */ +const HABITICA_CLIENT_ID = 'corsair'; + +export type HabiticaRequestOptions = { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + /** + * The JSON request body, serialised as given. + * + * Typed as `unknown` values because bodies are assembled by the endpoints + * from their own already-validated input schemas, and those shapes vary + * widely - a task carries nested checklists and reminders, a group carries + * privacy and leader fields. Restating them here would duplicate every shape + * in a second place that could drift. Validation belongs to the endpoint + * input schemas in `endpoints/types.ts`; this type says only + * "already-checked JSON". + * + * An array is accepted because a few operations take a bare collection - + * `POST /tasks/user` creates either one task or many. + */ + body?: Record | unknown[]; + query?: Record; +}; + +/** + * Raised when a call needs the account's user id and none was available. + * + * Habitica's credential has two halves and the plugin cannot supply the missing + * one for the caller, because no route can be reached without it - see + * {@link makeHabiticaRequest}. Failing here with an explanation is better than + * sending a request that is certain to come back as an opaque 401. + */ +export class HabiticaUserIdMissingError extends Error { + constructor() { + super( + 'Habitica requires the account user id alongside the API token. Set ' + + '`userId` in the plugin options, or store one under the `user_id` key.', + ); + this.name = 'HabiticaUserIdMissingError'; + } +} + +/** + * Builds the request configuration for a base URL. + * + * `x-client` is set for every request, authenticated or not. The header is not + * tied to authentication: `/api/v3/content` takes no credentials yet still + * answers 400 `Missing x-client headers.` without it, while `/status` alone + * tolerates its absence. Sending it unconditionally is correct on every route, + * whereas sending it per-route would be a rule with one arbitrary exception. + * + * `TOKEN` is left `undefined` because Habitica does not use `Authorization`; + * both credential halves travel as their own headers. + */ +function buildConfig( + base: string, + credentials?: HabiticaCredentials, +): OpenAPIConfig { + const headers: Record = { + 'Content-Type': 'application/json', + 'x-client': HABITICA_CLIENT_ID, + }; + + if (credentials) { + headers['x-api-user'] = credentials.userId; + headers['x-api-key'] = credentials.apiToken; + } + + return { + BASE: base, + VERSION: '3', + WITH_CREDENTIALS: false, + CREDENTIALS: 'omit', + TOKEN: undefined, + HEADERS: headers, + }; +} + +/** + * The two halves of a Habitica credential. + * + * Both are checked by the server. A valid token with the wrong user id, and a + * valid user id with the wrong token, are both answered 401 `There is no + * account that uses those credentials.` - so the user id is a credential in its + * own right, not a routing hint that the token could imply. + */ +export type HabiticaCredentials = { + userId: string; + apiToken: string; +}; + +/** + * Issues an authenticated Habitica request against the versioned API. + * + * Habitica reports failures with real status codes - 400, 401, 403, 404, 429, + * 500 - under a consistent `{"success":false,"error","message"}` envelope, so + * success can be told from failure by status alone and the shared helper's + * error handling applies unchanged. + */ +export async function makeHabiticaRequest( + endpoint: string, + credentials: HabiticaCredentials, + options: HabiticaRequestOptions = {}, +): Promise { + if (!credentials.userId) throw new HabiticaUserIdMissingError(); + + const { method = 'GET', body, query } = options; + + const requestOptions: ApiRequestOptions = { + method, + url: endpoint, + body: method === 'POST' || method === 'PUT' ? body : undefined, + mediaType: 'application/json', + query, + }; + + return await request( + buildConfig(HABITICA_API_BASE, credentials), + requestOptions, + { + rateLimitConfig: HABITICA_RATE_LIMIT_CONFIG, + }, + ); +} + +/** + * Issues a request against a route that takes no credentials. + * + * A handful of operations - server status, the content catalogue, the model + * path listings - are answered without authentication. They still travel + * through this helper so they inherit the same timeout and retry behaviour, and + * they still carry `x-client`, which they require. + */ +export async function makeHabiticaAnonymousRequest( + endpoint: string, + options: HabiticaRequestOptions = {}, +): Promise { + const { method = 'GET', query } = options; + + return await request( + buildConfig(HABITICA_API_BASE), + { method, url: endpoint, mediaType: 'application/json', query }, + { rateLimitConfig: HABITICA_RATE_LIMIT_CONFIG }, + ); +} + +/** + * Reads one of the three `/export/*` documents. + * + * Two things make these different from every other operation, and both are the + * reason they need their own transport: + * + * - They live outside the versioned base, at `https://habitica.com/export/*`. + * - Only one of the three answers with JSON. `history.csv` is `text/csv` and + * `inbox.html` is `text/html`, so the shared transport - which parses every + * body as JSON - cannot carry them. `fetch` is used directly and the body is + * returned as text for the endpoint to shape. + * + * The body is deliberately **not** included in the thrown error. A failed + * export can still carry account data, and `userdata.json` in particular + * contains the account holder's email address; a status line is enough to + * diagnose a failure without copying personal data into an error message or a + * log. + */ +export async function makeHabiticaExportRequest( + document: 'userdata.json' | 'history.csv' | 'inbox.html', + credentials: HabiticaCredentials, +): Promise<{ body: string; contentType: string }> { + if (!credentials.userId) throw new HabiticaUserIdMissingError(); + + let response: Response; + try { + response = await fetch(`${HABITICA_ROOT_BASE}/export/${document}`, { + method: 'GET', + headers: { + 'x-api-user': credentials.userId, + 'x-api-key': credentials.apiToken, + 'x-client': HABITICA_CLIENT_ID, + }, + signal: AbortSignal.timeout(HABITICA_EXPORT_TIMEOUT_MS), + }); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error(`Habitica export ${document} request failed: ${reason}`); + } + + if (!response.ok) { + throw new Error( + `Habitica export ${document} returned HTTP ${response.status} ${response.statusText}`, + ); + } + + return { + body: await response.text(), + contentType: response.headers.get('content-type') ?? '', + }; +} + +/** + * Reads a versioned-API route whose response is not JSON. + * + * There is exactly one: `GET /challenges/:challengeId/export/csv`. It is an + * ordinary authenticated `/api/v3` route in every respect except that it + * answers with CSV, which the shared JSON transport cannot carry - so it needs + * `fetch` for the same reason the `/export/*` documents do, but against the + * versioned base rather than the root. + * + * As with {@link makeHabiticaExportRequest}, the response body is kept out of + * the thrown error: a challenge export names the challenge's participants. + */ +export async function makeHabiticaTextRequest( + endpoint: string, + credentials: HabiticaCredentials, +): Promise<{ body: string; contentType: string }> { + if (!credentials.userId) throw new HabiticaUserIdMissingError(); + + let response: Response; + try { + response = await fetch(`${HABITICA_API_BASE}/${endpoint}`, { + method: 'GET', + headers: { + 'x-api-user': credentials.userId, + 'x-api-key': credentials.apiToken, + 'x-client': HABITICA_CLIENT_ID, + }, + signal: AbortSignal.timeout(HABITICA_EXPORT_TIMEOUT_MS), + }); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error(`Habitica request to ${endpoint} failed: ${reason}`); + } + + if (!response.ok) { + throw new Error( + `Habitica ${endpoint} returned HTTP ${response.status} ${response.statusText}`, + ); + } + + return { + body: await response.text(), + contentType: response.headers.get('content-type') ?? '', + }; +} + +/** + * A longer ceiling than the shared transport's 20 seconds. + * + * An export is a whole-account document rather than a page of rows, so it is + * the one place where a slow response is expected rather than a symptom. The + * account used during development exported in well under a second, but that + * account is small; a long-lived one with years of task history is not. + */ +const HABITICA_EXPORT_TIMEOUT_MS = 60_000; + +export { + HABITICA_API_BASE, + HABITICA_CLIENT_ID, + HABITICA_EXPORT_TIMEOUT_MS, + HABITICA_RATE_LIMIT_CONFIG, + HABITICA_ROOT_BASE, +}; diff --git a/packages/habitica/endpoints.test.ts b/packages/habitica/endpoints.test.ts new file mode 100644 index 000000000..3db3ab923 --- /dev/null +++ b/packages/habitica/endpoints.test.ts @@ -0,0 +1,1203 @@ +/** + * Covers every operation: the method and path it calls, what it writes to the + * local mirror, what it evicts, and exactly what reaches the event log. + * + * The coverage sweep at the end asserts that the operations exercised here are + * precisely the operations registered, so an operation cannot be added without + * a test. + * + * All ids, names and addresses are fictional. Nothing from the account used + * during development appears here - it is a real account holding a real email + * address, so no captured response was reused as a fixture. + */ +import { logEventFromContext } from 'corsair/core'; +import { + HABITICA_API_BASE, + HABITICA_ROOT_BASE, + HabiticaUserIdMissingError, +} from './client'; +import { + Auth, + Challenges, + Chat, + Content, + Exports, + Groups, + Tags, + Tasks, + User, + Webhooks, +} from './endpoints'; +import { HabiticaMirrorEvictionError } from './endpoints/persist'; +import { habiticaEndpointMeta } from './index'; + +jest.mock('corsair/core', () => ({ + ...jest.requireActual('corsair/core'), + logEventFromContext: jest.fn(async () => undefined), +})); + +const mockLogEvent = logEventFromContext as jest.MockedFunction< + typeof logEventFromContext +>; + +const USER_ID = '00000000-0000-4000-8000-000000000000'; +const TASK = 'task-1'; +const TAG = 'tag-1'; +const CHALLENGE = 'challenge-1'; +const GROUP = 'group-1'; +const WEBHOOK = 'webhook-1'; +const CHAT = 'chat-1'; +const ITEM = 'item-1'; +const MEMBER = 'member-1'; + +type Store = { + upsertByEntityId: jest.Mock; + deleteByEntityId: jest.Mock; + list: jest.Mock; +}; + +function makeStore(): Store { + return { + upsertByEntityId: jest.fn(async () => undefined), + deleteByEntityId: jest.fn(async () => true), + list: jest.fn(async () => []), + }; +} + +type Ctx = Parameters[0]; + +function makeCtx() { + const db = { + tasks: makeStore(), + tags: makeStore(), + challenges: makeStore(), + groups: makeStore(), + webhooks: makeStore(), + }; + const ctx = { + key: 'test-token', + db, + options: { userId: USER_ID }, + } as unknown as Ctx; + return { ctx, db }; +} + +let captured: + | { + url: string; + method: string; + body?: string; + headers: Record; + } + | undefined; + +function mockFetch( + payload: unknown, + { + status = 200, + contentType = 'application/json', + }: { status?: number; contentType?: string } = {}, +) { + captured = undefined; + global.fetch = (async (url: unknown, init?: RequestInit) => { + const headers: Record = {}; + const raw = init?.headers; + if (raw instanceof Headers) { + raw.forEach((value, key) => { + headers[key.toLowerCase()] = value; + }); + } else { + for (const [key, value] of Object.entries( + (raw ?? {}) as Record, + )) { + headers[key.toLowerCase()] = value; + } + } + captured = { + url: String(url), + method: init?.method ?? 'GET', + body: typeof init?.body === 'string' ? init.body : undefined, + headers, + }; + const body = + typeof payload === 'string' ? payload : JSON.stringify(payload); + return { + ok: status < 400, + status, + statusText: status < 400 ? 'OK' : 'Error', + url: String(url), + headers: new Headers({ 'Content-Type': contentType }), + json: async () => payload, + text: async () => body, + }; + }) as unknown as typeof global.fetch; +} + +/** The path Habitica was asked for, without the base URL or query string. */ +function calledPath(): string { + const url = captured?.url ?? ''; + const withoutBase = url + .replace(`${HABITICA_API_BASE}/`, '') + .replace(`${HABITICA_ROOT_BASE}/`, ''); + return withoutBase.split('?')[0] ?? ''; +} + +function query(): URLSearchParams { + return new URL(captured?.url ?? 'https://x/').searchParams; +} + +function sentBody(): Record { + return captured?.body ? JSON.parse(captured.body) : {}; +} + +/* -------------------------------------------------------------------------- */ +/* Canned response bodies */ +/* -------------------------------------------------------------------------- */ + +const wrap = (data: unknown) => ({ success: true, data }); + +const taskRecord = { id: TASK, _id: TASK, type: 'todo', text: 'A task' }; +const tagRecord = { id: TAG, name: 'A tag' }; +const challengeRecord = { id: CHALLENGE, _id: CHALLENGE, name: 'A challenge' }; +const groupRecord = { id: GROUP, _id: GROUP, name: 'A group', type: 'party' }; +const webhookRecord = { + id: WEBHOOK, + type: 'taskActivity', + url: 'https://example.com/hook', + enabled: true, + failures: 0, +}; + +/** + * Every operation, with the request it is expected to make. + * + * Driving this from a table keeps the 70 route assertions honest: the expected + * method and path sit next to the call rather than being restated in prose. + */ +type Case = { + /** The key in `habiticaEndpointMeta`, used by the coverage sweep. */ + meta: string; + run: (ctx: Ctx) => Promise; + payload: unknown; + method: string; + path: string; + contentType?: string; +}; + +const cases: Case[] = [ + // ---- tasks ---- + { + meta: 'tasks.create', + run: (c) => Tasks.create(c, { text: 'A task', type: 'todo' }), + payload: wrap(taskRecord), + method: 'POST', + path: 'tasks/user', + }, + { + meta: 'tasks.list', + run: (c) => Tasks.list(c, {}), + payload: wrap([taskRecord]), + method: 'GET', + path: 'tasks/user', + }, + { + meta: 'tasks.get', + run: (c) => Tasks.get(c, { taskId: TASK }), + payload: wrap(taskRecord), + method: 'GET', + path: `tasks/${TASK}`, + }, + { + meta: 'tasks.update', + run: (c) => Tasks.update(c, { taskId: TASK, text: 'Renamed' }), + payload: wrap(taskRecord), + method: 'PUT', + path: `tasks/${TASK}`, + }, + { + meta: 'tasks.delete', + run: (c) => Tasks.remove(c, { taskId: TASK }), + payload: wrap({}), + method: 'DELETE', + path: `tasks/${TASK}`, + }, + { + meta: 'tasks.score', + run: (c) => Tasks.score(c, { taskId: TASK, direction: 'up' }), + payload: wrap({ delta: 1, gp: 2 }), + method: 'POST', + path: `tasks/${TASK}/score/up`, + }, + { + meta: 'tasks.move', + run: (c) => Tasks.move(c, { taskId: TASK, position: 0 }), + payload: wrap([TASK]), + method: 'POST', + path: `tasks/${TASK}/move/to/0`, + }, + { + meta: 'tasks.updateChecklistItem', + run: (c) => + Tasks.updateChecklistItem(c, { + taskId: TASK, + itemId: ITEM, + text: 'Renamed', + }), + payload: wrap(taskRecord), + method: 'PUT', + path: `tasks/${TASK}/checklist/${ITEM}`, + }, + { + meta: 'tasks.deleteChecklistItem', + run: (c) => Tasks.deleteChecklistItem(c, { taskId: TASK, itemId: ITEM }), + payload: wrap(taskRecord), + method: 'DELETE', + path: `tasks/${TASK}/checklist/${ITEM}`, + }, + { + meta: 'tasks.addTag', + run: (c) => Tasks.addTag(c, { taskId: TASK, tagId: TAG }), + payload: wrap(taskRecord), + method: 'POST', + path: `tasks/${TASK}/tags/${TAG}`, + }, + { + meta: 'tasks.createChallengeTask', + run: (c) => + Tasks.createChallengeTask(c, { + challengeId: CHALLENGE, + text: 'A task', + type: 'habit', + }), + payload: wrap([taskRecord]), + method: 'POST', + path: `tasks/challenge/${CHALLENGE}`, + }, + { + meta: 'tasks.listChallengeTasks', + run: (c) => Tasks.listChallengeTasks(c, { challengeId: CHALLENGE }), + payload: wrap([taskRecord]), + method: 'GET', + path: `tasks/challenge/${CHALLENGE}`, + }, + { + meta: 'tasks.unlinkAllChallengeTasks', + run: (c) => + Tasks.unlinkAllChallengeTasks(c, { + challengeId: CHALLENGE, + keep: 'keep-all', + }), + payload: wrap({}), + method: 'POST', + path: `tasks/unlink-all/${CHALLENGE}`, + }, + + // ---- tags ---- + { + meta: 'tags.create', + run: (c) => Tags.create(c, { name: 'A tag' }), + payload: wrap(tagRecord), + method: 'POST', + path: 'tags', + }, + { + meta: 'tags.list', + run: (c) => Tags.list(c, {}), + payload: wrap([tagRecord]), + method: 'GET', + path: 'tags', + }, + { + meta: 'tags.update', + run: (c) => Tags.update(c, { tagId: TAG, name: 'Renamed' }), + payload: wrap(tagRecord), + method: 'PUT', + path: `tags/${TAG}`, + }, + { + meta: 'tags.delete', + run: (c) => Tags.remove(c, { tagId: TAG }), + payload: wrap({}), + method: 'DELETE', + path: `tags/${TAG}`, + }, + + // ---- challenges ---- + { + meta: 'challenges.create', + run: (c) => + Challenges.create(c, { + groupId: GROUP, + name: 'A challenge', + shortName: 'chal', + }), + payload: wrap(challengeRecord), + method: 'POST', + path: 'challenges', + }, + { + meta: 'challenges.get', + run: (c) => Challenges.get(c, { challengeId: CHALLENGE }), + payload: wrap(challengeRecord), + method: 'GET', + path: `challenges/${CHALLENGE}`, + }, + { + meta: 'challenges.clone', + run: (c) => Challenges.clone(c, { challengeId: CHALLENGE }), + payload: wrap(challengeRecord), + method: 'POST', + path: `challenges/${CHALLENGE}/clone`, + }, + { + meta: 'challenges.delete', + run: (c) => Challenges.remove(c, { challengeId: CHALLENGE }), + payload: wrap({}), + method: 'DELETE', + path: `challenges/${CHALLENGE}`, + }, + { + meta: 'challenges.join', + run: (c) => Challenges.join(c, { challengeId: CHALLENGE }), + payload: wrap(challengeRecord), + method: 'POST', + path: `challenges/${CHALLENGE}/join`, + }, + { + meta: 'challenges.leave', + run: (c) => Challenges.leave(c, { challengeId: CHALLENGE }), + payload: wrap({}), + method: 'POST', + path: `challenges/${CHALLENGE}/leave`, + }, + { + meta: 'challenges.listByGroup', + run: (c) => Challenges.listByGroup(c, { groupId: GROUP }), + payload: wrap([challengeRecord]), + method: 'GET', + path: `challenges/groups/${GROUP}`, + }, + { + meta: 'challenges.listForUser', + run: (c) => Challenges.listForUser(c, { page: 0 }), + payload: wrap([challengeRecord]), + method: 'GET', + path: 'challenges/user', + }, + { + meta: 'challenges.exportCsv', + run: (c) => Challenges.exportCsv(c, { challengeId: CHALLENGE }), + payload: 'task,value\nA task,1', + method: 'GET', + path: `challenges/${CHALLENGE}/export/csv`, + contentType: 'text/csv', + }, + + // ---- groups ---- + { + meta: 'groups.create', + run: (c) => Groups.create(c, { name: 'A party', type: 'party' }), + payload: wrap(groupRecord), + method: 'POST', + path: 'groups', + }, + { + meta: 'groups.list', + run: (c) => Groups.list(c, { type: 'party' }), + payload: wrap([groupRecord]), + method: 'GET', + path: 'groups', + }, + { + meta: 'groups.get', + run: (c) => Groups.get(c, { groupId: GROUP }), + payload: wrap(groupRecord), + method: 'GET', + path: `groups/${GROUP}`, + }, + { + meta: 'groups.getParty', + run: (c) => Groups.getParty(c, {}), + payload: wrap(groupRecord), + method: 'GET', + path: 'groups/party', + }, + { + meta: 'groups.getTavern', + run: (c) => Groups.getTavern(c, {}), + payload: wrap(groupRecord), + method: 'GET', + path: 'groups/habitrpg', + }, + { + meta: 'groups.update', + run: (c) => Groups.update(c, { groupId: GROUP, name: 'Renamed' }), + payload: wrap(groupRecord), + method: 'PUT', + path: `groups/${GROUP}`, + }, + { + meta: 'groups.leave', + run: (c) => Groups.leave(c, { groupId: GROUP }), + payload: wrap({}), + method: 'POST', + path: `groups/${GROUP}/leave`, + }, + { + meta: 'groups.listMembers', + run: (c) => Groups.listMembers(c, { groupId: GROUP }), + payload: wrap([{ id: MEMBER }]), + method: 'GET', + path: `groups/${GROUP}/members`, + }, + { + meta: 'groups.invite', + run: (c) => Groups.invite(c, { groupId: GROUP, uuids: [MEMBER] }), + payload: wrap([{}]), + method: 'POST', + path: `groups/${GROUP}/invite`, + }, + { + meta: 'groups.removeMember', + run: (c) => Groups.removeMember(c, { groupId: GROUP, memberId: MEMBER }), + payload: wrap({}), + method: 'POST', + path: `groups/${GROUP}/removeMember/${MEMBER}`, + }, + { + meta: 'groups.inviteToQuest', + run: (c) => Groups.inviteToQuest(c, { groupId: GROUP, questKey: 'atom1' }), + payload: wrap({}), + method: 'POST', + path: `groups/${GROUP}/quests/invite/atom1`, + }, + + // ---- chat ---- + { + meta: 'chat.list', + run: (c) => Chat.list(c, {}), + payload: wrap([{ id: CHAT }]), + method: 'GET', + path: 'groups/party/chat', + }, + { + meta: 'chat.deleteMessage', + run: (c) => Chat.deleteMessage(c, { groupId: GROUP, chatId: CHAT }), + payload: wrap({}), + method: 'DELETE', + path: `groups/${GROUP}/chat/${CHAT}`, + }, + { + meta: 'chat.markSeen', + run: (c) => Chat.markSeen(c, { groupId: GROUP }), + payload: wrap({}), + method: 'POST', + path: `groups/${GROUP}/chat/seen`, + }, + + // ---- user ---- + { + meta: 'user.get', + run: (c) => User.get(c, {}), + payload: wrap({ _id: USER_ID }), + method: 'GET', + path: 'user', + }, + { + meta: 'user.update', + run: (c) => User.update(c, { updates: { 'profile.name': 'A name' } }), + payload: wrap({}), + method: 'PUT', + path: 'user', + }, + { + meta: 'user.reset', + run: (c) => User.reset(c, {}), + payload: wrap({}), + method: 'POST', + path: 'user/reset', + }, + { + meta: 'user.equip', + run: (c) => User.equip(c, { type: 'equipped', key: 'weapon_warrior_1' }), + payload: wrap({}), + method: 'POST', + path: 'user/equip/equipped/weapon_warrior_1', + }, + { + meta: 'user.readCard', + run: (c) => User.readCard(c, { cardType: 'birthday' }), + payload: wrap({}), + method: 'POST', + path: 'user/read-card/birthday', + }, + { + meta: 'user.movePinnedItem', + run: (c) => User.movePinnedItem(c, { path: 'armoire', position: 0 }), + payload: wrap({}), + method: 'POST', + path: 'user/move-pinned-item/armoire/move/to/0', + }, + { + meta: 'user.deleteMessage', + run: (c) => User.deleteMessage(c, { id: 'message-1' }), + payload: wrap({}), + method: 'DELETE', + path: 'user/messages/message-1', + }, + { + meta: 'user.addPushDevice', + run: (c) => User.addPushDevice(c, { regId: 'device-1', type: 'android' }), + payload: wrap([{}]), + method: 'POST', + path: 'user/push-devices', + }, + { + meta: 'user.deletePushDevice', + run: (c) => User.deletePushDevice(c, { regId: 'device-1' }), + payload: wrap([]), + method: 'DELETE', + path: 'user/push-devices/device-1', + }, + { + meta: 'user.markNotificationSeen', + run: (c) => + User.markNotificationSeen(c, { notificationId: 'notification-1' }), + payload: wrap({}), + method: 'POST', + path: 'notifications/notification-1/see', + }, + { + meta: 'user.markNotificationsSeen', + run: (c) => + User.markNotificationsSeen(c, { notificationIds: ['notification-1'] }), + payload: wrap({}), + method: 'POST', + path: 'notifications/see', + }, + + // ---- auth ---- + { + meta: 'auth.register', + run: (c) => + Auth.register(c, { + username: 'someone', + email: 'someone@example.com', + password: 'a-password', + confirmPassword: 'a-password', + }), + payload: wrap({ id: USER_ID, apiToken: 'minted-token' }), + method: 'POST', + path: 'user/auth/local/register', + }, + { + meta: 'auth.login', + run: (c) => Auth.login(c, { username: 'someone', password: 'a-password' }), + payload: wrap({ id: USER_ID, apiToken: 'minted-token' }), + method: 'POST', + path: 'user/auth/local/login', + }, + { + meta: 'auth.social', + run: (c) => + Auth.social(c, { + network: 'google', + authResponse: { code: 'an-oauth-code' }, + }), + payload: wrap({ id: USER_ID, apiToken: 'minted-token' }), + method: 'POST', + path: 'user/auth/social', + }, + + // ---- webhooks ---- + { + meta: 'webhooks.create', + run: (c) => Webhooks.create(c, { url: 'https://example.com/hook' }), + payload: wrap(webhookRecord), + method: 'POST', + path: 'user/webhook', + }, + { + meta: 'webhooks.list', + run: (c) => Webhooks.list(c, {}), + payload: wrap([webhookRecord]), + method: 'GET', + path: 'user/webhook', + }, + { + meta: 'webhooks.subscribe', + run: (c) => Webhooks.subscribe(c, { id: WEBHOOK }), + payload: wrap(webhookRecord), + method: 'PUT', + path: `user/webhook/${WEBHOOK}`, + }, + + // ---- content ---- + { + meta: 'content.get', + run: (c) => Content.get(c, {}), + payload: wrap({ quests: {}, gear: {} }), + method: 'GET', + path: 'content', + }, + { + meta: 'content.getByType', + run: (c) => Content.getByType(c, { filter: 'quests' }), + payload: wrap({ gear: {} }), + method: 'GET', + path: 'content', + }, + { + meta: 'content.status', + run: (c) => Content.status(c, {}), + payload: wrap({ status: 'up' }), + method: 'GET', + path: 'status', + }, + { + meta: 'content.worldState', + run: (c) => Content.worldState(c, {}), + payload: wrap({ worldBoss: {} }), + method: 'GET', + path: 'world-state', + }, + { + meta: 'content.modelPaths', + run: (c) => Content.modelPaths(c, { model: 'user' }), + payload: wrap({ 'stats.hp': 'Number' }), + method: 'GET', + path: 'models/user/paths', + }, + { + meta: 'content.news', + run: (c) => Content.news(c, {}), + payload: wrap({ html: '

news

' }), + method: 'GET', + path: 'news', + }, + { + meta: 'content.dismissNews', + run: (c) => Content.dismissNews(c, {}), + payload: wrap({}), + method: 'POST', + path: 'news/tell-me-later', + }, + { + meta: 'content.marketGear', + run: (c) => Content.marketGear(c, {}), + payload: wrap({ categories: [] }), + method: 'GET', + path: 'shops/market-gear', + }, + { + meta: 'content.timeTravelers', + run: (c) => Content.timeTravelers(c, {}), + payload: wrap({ categories: [] }), + method: 'GET', + path: 'shops/time-travelers', + }, + { + meta: 'content.validateCoupon', + run: (c) => Content.validateCoupon(c, { code: 'ABCD-1234' }), + payload: wrap({ valid: true }), + method: 'POST', + path: 'coupons/validate/ABCD-1234', + }, + + // ---- exports ---- + { + meta: 'exports.userData', + run: (c) => Exports.userData(c, {}), + payload: '{"tasks":[]}', + method: 'GET', + path: 'export/userdata.json', + }, + { + meta: 'exports.history', + run: (c) => Exports.history(c, {}), + payload: 'date,task\n2026-01-01,A task', + method: 'GET', + path: 'export/history.csv', + contentType: 'text/csv', + }, + { + meta: 'exports.inbox', + run: (c) => Exports.inbox(c, {}), + payload: '', + method: 'GET', + path: 'export/inbox.html', + contentType: 'text/html', + }, +]; + +beforeEach(() => { + mockLogEvent.mockClear(); +}); + +describe('every operation calls the route it claims to', () => { + for (const testCase of cases) { + it(`${testCase.meta} -> ${testCase.method} /${testCase.path}`, async () => { + const { ctx } = makeCtx(); + mockFetch(testCase.payload, { contentType: testCase.contentType }); + + await testCase.run(ctx); + + expect(captured).toBeDefined(); + expect(captured?.method).toBe(testCase.method); + expect(calledPath()).toBe(testCase.path); + }); + } +}); + +describe('coverage sweep', () => { + it('exercises precisely the operations that are registered', () => { + const exercised = [...new Set(cases.map((c) => c.meta))].sort(); + const registered = Object.keys(habiticaEndpointMeta).sort(); + + expect(exercised).toEqual(registered); + }); + + it('registers exactly the 70 operations the catalog lists', () => { + expect(Object.keys(habiticaEndpointMeta)).toHaveLength(70); + }); +}); + +describe('mirroring', () => { + it('caches a task it read', async () => { + const { ctx, db } = makeCtx(); + mockFetch(wrap([taskRecord])); + + await Tasks.list(ctx, {}); + + expect(db.tasks.upsertByEntityId).toHaveBeenCalledWith( + TASK, + expect.objectContaining({ id: TASK }), + ); + }); + + it('evicts a deleted task, and treats the eviction as required', async () => { + const { ctx, db } = makeCtx(); + db.tasks.deleteByEntityId.mockRejectedValueOnce(new Error('db down')); + mockFetch(wrap({})); + + // Habitica hard-deletes, so a mirror row that survives can never be + // reconciled - the failure has to surface rather than be swallowed. + await expect(Tasks.remove(ctx, { taskId: TASK })).rejects.toBeInstanceOf( + HabiticaMirrorEvictionError, + ); + }); + + it('does not fail a read because the mirror could not be written', async () => { + const { ctx, db } = makeCtx(); + db.tags.upsertByEntityId.mockRejectedValueOnce(new Error('db down')); + mockFetch(wrap([tagRecord])); + + await expect(Tags.list(ctx, {})).resolves.toHaveLength(1); + }); + + it('skips caching a record the schema does not recognise', async () => { + const { ctx, db } = makeCtx(); + // No id at all: the entity requires the primary key. + mockFetch(wrap([{ text: 'a task with no id' }])); + + await Tasks.list(ctx, {}); + + expect(db.tasks.upsertByEntityId).not.toHaveBeenCalled(); + }); + + it('empties the mirrored task list after an account reset', async () => { + const { ctx, db } = makeCtx(); + db.tasks.list.mockResolvedValueOnce([ + { entity_id: 'task-a' }, + { entity_id: 'task-b' }, + ]); + mockFetch(wrap({})); + + await User.reset(ctx, {}); + + // The reset deletes every task server-side and names none of them, so + // without this the mirror would keep answering with all of them. + expect(db.tasks.deleteByEntityId).toHaveBeenCalledWith('task-a'); + expect(db.tasks.deleteByEntityId).toHaveBeenCalledWith('task-b'); + }); + + it('does not mirror anything the user document touches', async () => { + const { ctx, db } = makeCtx(); + mockFetch( + wrap({ _id: USER_ID, auth: { local: { email: 'x@example.com' } } }), + ); + + await User.get(ctx, {}); + + for (const store of Object.values(db)) { + expect(store.upsertByEntityId).not.toHaveBeenCalled(); + } + }); +}); + +describe('what reaches the event log', () => { + /** The payload the endpoint handed to the event log. */ + function loggedPayload(): Record { + return (mockLogEvent.mock.calls[0]?.[2] ?? {}) as Record; + } + + it('records a task id but never the task text', async () => { + const { ctx } = makeCtx(); + mockFetch(wrap(taskRecord)); + + await Tasks.update(ctx, { + taskId: TASK, + text: 'Ring the clinic about the results', + notes: 'private note', + }); + + const payload = JSON.stringify(loggedPayload()); + expect(payload).toContain(TASK); + expect(payload).not.toContain('Ring the clinic'); + expect(payload).not.toContain('private note'); + }); + + it('records only field names for the fields it does not name', async () => { + const { ctx } = makeCtx(); + mockFetch(wrap(taskRecord)); + + await Tasks.update(ctx, { taskId: TASK, text: 'secret text' }); + + expect(loggedPayload().fields).toEqual( + expect.arrayContaining(['taskId', 'text']), + ); + }); + + it('records nothing but the attempt for the credential-minting operations', async () => { + for (const run of [ + (c: Ctx) => + Auth.login(c, { username: 'someone', password: 'hunter2-example' }), + (c: Ctx) => + Auth.register(c, { + username: 'someone', + email: 'someone@example.com', + password: 'hunter2-example', + confirmPassword: 'hunter2-example', + }), + (c: Ctx) => + Auth.social(c, { + network: 'google', + authResponse: { code: 'an-oauth-code' }, + }), + ]) { + mockLogEvent.mockClear(); + const { ctx } = makeCtx(); + mockFetch(wrap({ id: USER_ID, apiToken: 'minted-token' })); + + await run(ctx); + + const payload = JSON.stringify(loggedPayload()); + expect(payload).not.toContain('hunter2-example'); + expect(payload).not.toContain('someone@example.com'); + expect(payload).not.toContain('an-oauth-code'); + // Not even the field NAMES, which is stricter than every other + // operation: `fields: ["username","password"]` in a retained log is an + // invitation to widen it into the values later. + expect(payload).not.toContain('password'); + expect(payload).not.toContain('fields'); + } + }); + + it('never logs a minted token', async () => { + const { ctx } = makeCtx(); + mockFetch(wrap({ id: USER_ID, apiToken: 'minted-token' })); + + await Auth.login(ctx, { username: 'someone', password: 'a-password' }); + + expect(JSON.stringify(loggedPayload())).not.toContain('minted-token'); + }); + + it('counts group invitees rather than naming them', async () => { + const { ctx } = makeCtx(); + mockFetch(wrap([{}])); + + await Groups.invite(ctx, { + groupId: GROUP, + emails: [{ email: 'someone@example.com' }], + usernames: ['someone'], + }); + + const payload = loggedPayload(); + expect(payload.emails).toBe(1); + expect(payload.usernames).toBe(1); + expect(JSON.stringify(payload)).not.toContain('someone@example.com'); + }); + + it('does not log a coupon code, which is a bearer instrument', async () => { + const { ctx } = makeCtx(); + mockFetch(wrap({ valid: true })); + + await Content.validateCoupon(ctx, { code: 'SECRET-COUPON-1234' }); + + expect(JSON.stringify(loggedPayload())).not.toContain('SECRET-COUPON'); + }); + + it('does not log a push device registration id', async () => { + const { ctx } = makeCtx(); + mockFetch(wrap([{}])); + + await User.addPushDevice(ctx, { regId: 'device-token-abc', type: 'ios' }); + + expect(JSON.stringify(loggedPayload())).not.toContain('device-token-abc'); + }); + + it('logs user update paths but not their values', async () => { + const { ctx } = makeCtx(); + mockFetch(wrap({})); + + await User.update(ctx, { + updates: { 'profile.name': 'A Real Name', 'profile.blurb': 'about me' }, + }); + + const payload = loggedPayload(); + expect(payload.paths).toEqual(['profile.name', 'profile.blurb']); + expect(JSON.stringify(payload)).not.toContain('A Real Name'); + }); + + it('records only the size of an export, never its contents', async () => { + const { ctx } = makeCtx(); + mockFetch('{"auth":{"local":{"email":"someone@example.com"}}}'); + + await Exports.userData(ctx, {}); + + const payload = loggedPayload(); + expect(typeof payload.bytes).toBe('number'); + expect(JSON.stringify(payload)).not.toContain('someone@example.com'); + }); + + it('never logs chat message text', async () => { + const { ctx } = makeCtx(); + mockFetch(wrap([{ id: CHAT, text: 'something private someone said' }])); + + await Chat.list(ctx, {}); + + expect(JSON.stringify(loggedPayload())).not.toContain('something private'); + }); +}); + +describe('the aliases that share one route', () => { + it('getParty and getTavern differ only in the group id', async () => { + const { ctx } = makeCtx(); + + mockFetch(wrap(groupRecord)); + await Groups.getParty(ctx, {}); + const partyPath = calledPath(); + + mockFetch(wrap(groupRecord)); + await Groups.getTavern(ctx, {}); + const tavernPath = calledPath(); + + expect(partyPath).toBe('groups/party'); + expect(tavernPath).toBe('groups/habitrpg'); + }); + + it('gives each alias its own audit event so a log stays readable', async () => { + const { ctx } = makeCtx(); + + mockFetch(wrap(groupRecord)); + await Groups.get(ctx, { groupId: GROUP }); + const generic = mockLogEvent.mock.calls[0]?.[1]; + + mockLogEvent.mockClear(); + mockFetch(wrap(groupRecord)); + await Groups.getParty(ctx, {}); + const party = mockLogEvent.mock.calls[0]?.[1]; + + expect(generic).toBe('habitica.groups.get'); + expect(party).toBe('habitica.groups.getParty'); + expect(generic).not.toBe(party); + }); + + it('content.get and content.getByType are one route, split by a parameter', async () => { + const { ctx } = makeCtx(); + + mockFetch(wrap({ quests: {} })); + await Content.get(ctx, {}); + expect(calledPath()).toBe('content'); + expect(query().get('filter')).toBeNull(); + + mockFetch(wrap({ gear: {} })); + await Content.getByType(ctx, { filter: 'quests' }); + expect(calledPath()).toBe('content'); + expect(query().get('filter')).toBe('quests'); + }); +}); + +describe('the group leave that is not a composite', () => { + it('issues exactly one request and never attempts a DELETE', async () => { + // The catalog describes a fallback to DELETE /groups/:groupId. That route + // does not exist - a live DELETE answers "Not found.", the response for an + // unrouted path, while a real route with a missing id answers "Group not + // found or you don't have access." Implementing the fallback would add a + // request that can only ever 404. + const calls: { method: string; url: string }[] = []; + global.fetch = (async (url: unknown, init?: RequestInit) => { + calls.push({ method: init?.method ?? 'GET', url: String(url) }); + return { + ok: true, + status: 200, + statusText: 'OK', + url: String(url), + headers: new Headers({ 'Content-Type': 'application/json' }), + json: async () => wrap({}), + text: async () => JSON.stringify(wrap({})), + }; + }) as unknown as typeof global.fetch; + + const { ctx } = makeCtx(); + await Groups.leave(ctx, { groupId: GROUP }); + + expect(calls).toHaveLength(1); + expect(calls[0]?.method).toBe('POST'); + expect(calls.some((c) => c.method === 'DELETE')).toBe(false); + }); +}); + +describe('request construction', () => { + it('percent-encodes values interpolated into a path', async () => { + // A coupon code or quest key is not an opaque id and can contain + // characters that would otherwise change which route is addressed. + const { ctx } = makeCtx(); + mockFetch(wrap({ valid: false })); + + await Content.validateCoupon(ctx, { code: 'a/b?c' }); + + expect(captured?.url).toContain('coupons/validate/a%2Fb%3Fc'); + expect(calledPath()).toBe('coupons/validate/a%2Fb%3Fc'); + }); + + it('omits unset optional query parameters rather than sending undefined', async () => { + const { ctx } = makeCtx(); + mockFetch(wrap([taskRecord])); + + await Tasks.list(ctx, { type: 'todos' }); + + expect(query().get('type')).toBe('todos'); + expect(query().has('tagId')).toBe(false); + }); + + it('omits unset optional body fields', async () => { + const { ctx } = makeCtx(); + mockFetch(wrap(taskRecord)); + + await Tasks.update(ctx, { taskId: TASK, text: 'Renamed' }); + + const body = sentBody(); + expect(body.text).toBe('Renamed'); + expect('notes' in body).toBe(false); + // The path parameter must not be duplicated into the body. + expect('taskId' in body).toBe(false); + }); + + it('sends challenges.create with the group under the key the API expects', async () => { + const { ctx } = makeCtx(); + mockFetch(wrap(challengeRecord)); + + await Challenges.create(ctx, { + groupId: GROUP, + name: 'A challenge', + shortName: 'chal', + }); + + // The input names it `groupId`; Habitica's body field is `group`. + expect(sentBody().group).toBe(GROUP); + expect('groupId' in sentBody()).toBe(false); + }); + + it('unwraps the success envelope rather than returning it', async () => { + const { ctx } = makeCtx(); + mockFetch(wrap([tagRecord])); + + const result = await Tags.list(ctx, {}); + + expect(Array.isArray(result)).toBe(true); + expect(result[0]?.id).toBe(TAG); + }); + + it('fails before making a request when no user id can be resolved', async () => { + mockFetch(wrap({})); + const ctx = { + key: 'test-token', + db: {}, + options: {}, + } as unknown as Ctx; + + await expect(Tags.list(ctx, {})).rejects.toBeInstanceOf( + HabiticaUserIdMissingError, + ); + expect(captured).toBeUndefined(); + }); + + it('prefers a configured user id over a stored key', async () => { + mockFetch(wrap([])); + const ctx = { + key: 'test-token', + db: {}, + options: { userId: USER_ID }, + keys: { get_user_id: jest.fn(async () => 'stored-user-id') }, + } as unknown as Ctx; + + await Tags.list(ctx, {}); + + expect(captured?.headers['x-api-user']).toBe(USER_ID); + }); + + it('falls back to the stored user id when none is configured', async () => { + mockFetch(wrap([])); + const ctx = { + key: 'test-token', + db: {}, + options: {}, + keys: { get_user_id: jest.fn(async () => 'stored-user-id') }, + } as unknown as Ctx; + + await Tags.list(ctx, {}); + + expect(captured?.headers['x-api-user']).toBe('stored-user-id'); + }); +}); + +describe('risk levels', () => { + it('marks every irreversible operation destructive', () => { + // Habitica hard-deletes: there is no soft-delete flag and no trash, so + // these cannot be undone. + for (const key of [ + 'tasks.delete', + 'tags.delete', + 'challenges.delete', + 'chat.deleteMessage', + 'user.deleteMessage', + 'user.reset', + 'tasks.unlinkAllChallengeTasks', + ]) { + expect( + habiticaEndpointMeta[key as keyof typeof habiticaEndpointMeta] + .riskLevel, + ).toBe('destructive'); + } + }); + + it('marks scoring a write, because replaying it scores again', () => { + expect(habiticaEndpointMeta['tasks.score'].riskLevel).toBe('write'); + }); + + it('marks the pure reads read', () => { + for (const key of [ + 'tasks.list', + 'tags.list', + 'content.get', + 'content.status', + 'exports.userData', + ]) { + expect( + habiticaEndpointMeta[key as keyof typeof habiticaEndpointMeta] + .riskLevel, + ).toBe('read'); + } + }); +}); diff --git a/packages/habitica/endpoints/auth.ts b/packages/habitica/endpoints/auth.ts new file mode 100644 index 000000000..3a5bfbb6b --- /dev/null +++ b/packages/habitica/endpoints/auth.ts @@ -0,0 +1,102 @@ +import { logEventFromContext } from 'corsair/core'; +import type { HabiticaEndpoints } from '../index'; +import { credentialAuditPayload } from './logging'; +import { habiticaAnonymousCall } from './shared'; +import type { HabiticaEndpointOutputs } from './types'; + +/** + * The three operations that mint a credential rather than use one. + * + * Everything in this file is handled more strictly than the rest of the plugin, + * because both the inputs and the outputs are secrets: + * + * - **Inputs are credentials.** A password, or a third-party OAuth response. + * - **Outputs are credentials.** The response carries a live `apiToken`. + * + * Three rules follow, and they are the reason this is a separate file rather + * than three functions in `user.ts`: + * + * 1. **Nothing is mirrored.** No entity store is touched by any of these. + * 2. **Nothing is logged but the fact of the attempt.** Not the email, not the + * username, not the network. `credentialAuditPayload()` is used instead of + * `auditPayload()` precisely because the latter records the *names* of the + * supplied fields, and `fields: ["username","password"]` sitting in a + * retained log is an invitation to widen it into the values later. + * 3. **They send no credential of their own.** These routes are + * `authOptional` - registration and social auth by necessity, since the + * caller has no account yet. They go through the anonymous transport, so a + * caller registering a brand-new account is not required to already hold a + * Habitica user id. + * + * The returned `apiToken` is handed back to the caller, which is the entire + * point of the operation. What must not happen is it being written anywhere on + * the way past. + */ + +/** Registers a new account and returns its freshly minted credential. */ +export const register: HabiticaEndpoints['authRegister'] = async ( + ctx, + input, +) => { + const result = await habiticaAnonymousCall< + HabiticaEndpointOutputs['authRegister'] + >('user/auth/local/register', { + method: 'POST', + body: { + username: input.username, + email: input.email, + password: input.password, + confirmPassword: input.confirmPassword, + }, + }); + + await logEventFromContext( + ctx, + 'habitica.auth.register', + credentialAuditPayload(), + 'completed', + ); + return result; +}; + +/** Exchanges a username or email and password for an API token. */ +export const login: HabiticaEndpoints['authLogin'] = async (ctx, input) => { + const result = await habiticaAnonymousCall< + HabiticaEndpointOutputs['authLogin'] + >('user/auth/local/login', { + method: 'POST', + body: { username: input.username, password: input.password }, + }); + + await logEventFromContext( + ctx, + 'habitica.auth.login', + credentialAuditPayload(), + 'completed', + ); + return result; +}; + +/** + * Authenticates through a social provider. + * + * `authResponse` is whatever the provider issued and is passed through + * unexamined - this plugin does not need to understand it, and reading into it + * would mean handling someone's OAuth material more than necessary. + */ +export const social: HabiticaEndpoints['authSocial'] = async (ctx, input) => { + const result = await habiticaAnonymousCall< + HabiticaEndpointOutputs['authSocial'] + >('user/auth/social', { + method: 'POST', + body: { network: input.network, authResponse: input.authResponse }, + }); + + await logEventFromContext( + ctx, + 'habitica.auth.social', + credentialAuditPayload(), + 'completed', + ); + return result; +}; diff --git a/packages/habitica/endpoints/challenges.ts b/packages/habitica/endpoints/challenges.ts new file mode 100644 index 000000000..e6202e6f5 --- /dev/null +++ b/packages/habitica/endpoints/challenges.ts @@ -0,0 +1,249 @@ +import { logEventFromContext } from 'corsair/core'; +import type { HabiticaEndpoints } from '../index'; +import { HabiticaChallengeEntity } from '../schema/database'; +import { auditPayload } from './logging'; +import { cacheEntities, cacheEntity, evictEntity } from './persist'; +import { + compactBody, + compactQuery, + habiticaCall, + habiticaExportRaw, + pathSegment, +} from './shared'; +import type { HabiticaEndpointOutputs } from './types'; + +const LABEL = 'challenge'; + +/** Creates a challenge inside a group. */ +export const create: HabiticaEndpoints['challengesCreate'] = async ( + ctx, + input, +) => { + const { groupId, ...challenge } = input; + const result = await habiticaCall< + HabiticaEndpointOutputs['challengesCreate'] + >(ctx, 'challenges', { + method: 'POST', + body: compactBody({ group: groupId, ...challenge }), + }); + + await cacheEntity(ctx.db.challenges, HabiticaChallengeEntity, result, { + label: LABEL, + }); + + await logEventFromContext( + ctx, + 'habitica.challenges.create', + auditPayload(input, ['groupId']), + 'completed', + ); + return result; +}; + +/** Retrieves one challenge. */ +export const get: HabiticaEndpoints['challengesGet'] = async (ctx, input) => { + const result = await habiticaCall( + ctx, + `challenges/${pathSegment(input.challengeId)}`, + ); + + await cacheEntity(ctx.db.challenges, HabiticaChallengeEntity, result, { + label: LABEL, + }); + + await logEventFromContext( + ctx, + 'habitica.challenges.get', + auditPayload(input, ['challengeId']), + 'completed', + ); + return result; +}; + +/** + * Duplicates a challenge. + * + * Not idempotent: each call produces another copy. Replaying it after a + * transport failure creates a second challenge rather than returning the first. + */ +export const clone: HabiticaEndpoints['challengesClone'] = async ( + ctx, + input, +) => { + const result = await habiticaCall( + ctx, + `challenges/${pathSegment(input.challengeId)}/clone`, + { method: 'POST' }, + ); + + await cacheEntity(ctx.db.challenges, HabiticaChallengeEntity, result, { + label: LABEL, + }); + + await logEventFromContext( + ctx, + 'habitica.challenges.clone', + auditPayload(input, ['challengeId']), + 'completed', + ); + return result; +}; + +/** + * Deletes a challenge permanently, along with its tasks on every member's + * account. Required eviction - Habitica hard-deletes. + */ +export const remove: HabiticaEndpoints['challengesDelete'] = async ( + ctx, + input, +) => { + const result = await habiticaCall< + HabiticaEndpointOutputs['challengesDelete'] + >(ctx, `challenges/${pathSegment(input.challengeId)}`, { method: 'DELETE' }); + + await evictEntity(ctx.db.challenges, input.challengeId, LABEL, { + required: true, + }); + + await logEventFromContext( + ctx, + 'habitica.challenges.delete', + auditPayload(input, ['challengeId']), + 'completed', + ); + return result; +}; + +/** Joins a challenge, copying its tasks onto the account. */ +export const join: HabiticaEndpoints['challengesJoin'] = async (ctx, input) => { + const result = await habiticaCall( + ctx, + `challenges/${pathSegment(input.challengeId)}/join`, + { method: 'POST' }, + ); + + await cacheEntity(ctx.db.challenges, HabiticaChallengeEntity, result, { + label: LABEL, + }); + + await logEventFromContext( + ctx, + 'habitica.challenges.join', + auditPayload(input, ['challengeId']), + 'completed', + ); + return result; +}; + +/** + * Leaves a challenge. + * + * The challenge itself still exists - this is the caller's membership ending - + * so the mirrored challenge row is kept rather than evicted. What changes is + * the caller's task list, which `tasks.list` re-reads. + */ +export const leave: HabiticaEndpoints['challengesLeave'] = async ( + ctx, + input, +) => { + const result = await habiticaCall( + ctx, + `challenges/${pathSegment(input.challengeId)}/leave`, + { method: 'POST', query: compactQuery({ keep: input.keep }) }, + ); + + await logEventFromContext( + ctx, + 'habitica.challenges.leave', + auditPayload(input, ['challengeId', 'keep']), + 'completed', + ); + return result; +}; + +/** Lists the challenges of one group. */ +export const listByGroup: HabiticaEndpoints['challengesListByGroup'] = async ( + ctx, + input, +) => { + const result = await habiticaCall< + HabiticaEndpointOutputs['challengesListByGroup'] + >(ctx, `challenges/groups/${pathSegment(input.groupId)}`); + + await cacheEntities(ctx.db.challenges, HabiticaChallengeEntity, result, { + label: LABEL, + }); + + await logEventFromContext( + ctx, + 'habitica.challenges.listByGroup', + { ...auditPayload(input, ['groupId']), returned: result.length }, + 'completed', + ); + return result; +}; + +/** + * Lists the challenges the account takes part in. + * + * `page` is required and zero-indexed. Habitica answers 400 without it - the + * route validates `page` with `notEmpty().isInt({min:0})` - which is why the + * input schema does not make it optional. Ten rows came back per page on the + * account used for development. + */ +export const listForUser: HabiticaEndpoints['challengesListForUser'] = async ( + ctx, + input, +) => { + const result = await habiticaCall< + HabiticaEndpointOutputs['challengesListForUser'] + >(ctx, 'challenges/user', { + query: compactQuery({ + page: input.page, + member: input.member, + owned: input.owned, + search: input.search, + }), + }); + + await cacheEntities(ctx.db.challenges, HabiticaChallengeEntity, result, { + label: LABEL, + }); + + await logEventFromContext( + ctx, + 'habitica.challenges.listForUser', + { + ...auditPayload(input, ['page', 'member', 'owned']), + returned: result.length, + }, + 'completed', + ); + return result; +}; + +/** + * Exports a challenge's tasks and participants as CSV. + * + * One of four operations in this plugin whose response is not JSON. The + * document is returned as text with its declared content type rather than + * parsed into rows: the columns depend on the challenge's own tasks, so any + * parser here would be inventing a schema the next challenge breaks. + */ +export const exportCsv: HabiticaEndpoints['challengesExportCsv'] = async ( + ctx, + input, +) => { + const result = await habiticaExportRaw( + ctx, + `challenges/${pathSegment(input.challengeId)}/export/csv`, + ); + + await logEventFromContext( + ctx, + 'habitica.challenges.exportCsv', + { ...auditPayload(input, ['challengeId']), bytes: result.body.length }, + 'completed', + ); + return result; +}; diff --git a/packages/habitica/endpoints/chat.ts b/packages/habitica/endpoints/chat.ts new file mode 100644 index 000000000..fe86ef8da --- /dev/null +++ b/packages/habitica/endpoints/chat.ts @@ -0,0 +1,86 @@ +import { logEventFromContext } from 'corsair/core'; +import type { HabiticaEndpoints } from '../index'; +import { auditPayload } from './logging'; +import { compactQuery, habiticaCall, pathSegment } from './shared'; +import type { HabiticaEndpointOutputs } from './types'; + +/** + * Group chat. + * + * Nothing in this file is mirrored and no message text is ever logged. Chat + * messages are other people's words attached to their identities; the plugin + * passes them to the caller and keeps no copy. + */ + +/** The default group when the caller names none. */ +const PARTY_ALIAS = 'party'; + +/** Reads a group's recent chat messages. Defaults to the caller's party. */ +export const list: HabiticaEndpoints['chatList'] = async (ctx, input) => { + const groupId = input.groupId ?? PARTY_ALIAS; + const result = await habiticaCall( + ctx, + `groups/${pathSegment(groupId)}/chat`, + ); + + await logEventFromContext( + ctx, + 'habitica.chat.list', + { groupId, returned: result.length }, + 'completed', + ); + return result; +}; + +/** + * Deletes one chat message. The author or a group moderator only. + * + * `previousMsg` is Habitica's concurrency check: when supplied and the chat has + * moved on, the API returns the updated message list instead of performing the + * delete. It is passed through rather than defaulted, so a caller that does not + * supply it gets an unconditional delete - which is Habitica's behaviour, not a + * choice made here. + */ +export const deleteMessage: HabiticaEndpoints['chatDeleteMessage'] = async ( + ctx, + input, +) => { + const result = await habiticaCall< + HabiticaEndpointOutputs['chatDeleteMessage'] + >( + ctx, + `groups/${pathSegment(input.groupId)}/chat/${pathSegment(input.chatId)}`, + { + method: 'DELETE', + query: compactQuery({ previousMsg: input.previousMsg }), + }, + ); + + await logEventFromContext( + ctx, + 'habitica.chat.deleteMessage', + auditPayload(input, ['groupId', 'chatId']), + 'completed', + ); + return result; +}; + +/** Marks a group's chat as read, clearing the unread badge. */ +export const markSeen: HabiticaEndpoints['chatMarkSeen'] = async ( + ctx, + input, +) => { + const result = await habiticaCall( + ctx, + `groups/${pathSegment(input.groupId)}/chat/seen`, + { method: 'POST' }, + ); + + await logEventFromContext( + ctx, + 'habitica.chat.markSeen', + auditPayload(input, ['groupId']), + 'completed', + ); + return result; +}; diff --git a/packages/habitica/endpoints/content.ts b/packages/habitica/endpoints/content.ts new file mode 100644 index 000000000..20ae109da --- /dev/null +++ b/packages/habitica/endpoints/content.ts @@ -0,0 +1,259 @@ +import { logEventFromContext } from 'corsair/core'; +import type { HabiticaEndpoints } from '../index'; +import { auditPayload } from './logging'; +import { + compactQuery, + habiticaAnonymousCall, + habiticaCall, + pathSegment, +} from './shared'; +import type { HabiticaEndpointOutputs } from './types'; + +/** + * Static game data, server status and the shops. + * + * None of it is mirrored. It is not row-shaped - the content catalogue is one + * 2.65 MB document with no id to key rows by - and `schema/database.ts` + * explains why that makes it a good cache and a poor entity. + * + * Several of these routes take no credentials. They still send `x-client`, + * which they require: `/content` answers 400 `Missing x-client headers.` + * without it despite needing no authentication. + */ + +/** + * Fetches the whole content catalogue. + * + * The catalog says this is "~9MB". It measured **2.65 MB** across 56 top-level + * keys on 2026-08-15, returned in about 2.4 seconds - comfortably inside the + * shared transport's 20 second timeout, which was the open question. The + * figure is recorded here because "does it fit" mattered more than the exact + * size, and the answer is yes with room to spare. + */ +export const get: HabiticaEndpoints['contentGet'] = async (ctx, input) => { + const result = await habiticaAnonymousCall< + HabiticaEndpointOutputs['contentGet'] + >('content', { query: compactQuery({ language: input.language }) }); + + await logEventFromContext( + ctx, + 'habitica.content.get', + { ...auditPayload(input, ['language']), keys: Object.keys(result).length }, + 'completed', + ); + return result; +}; + +/** + * Fetches the content catalogue with some categories removed. + * + * **`filter` excludes the keys you name. It does not select them.** This + * contradicts the catalog, which describes the operation as returning content + * "filtered by a specific category type" - so a caller following the catalog + * asks for quests and receives all 55 other categories instead, with a 200 and + * nothing to indicate anything went wrong. + * + * Established by comparing key sets rather than reading status codes, on + * 2026-08-15: + * + * ``` + * no filter 56 keys, 2713 KB + * filter=quests 55 keys, 2491 KB - the missing key is `quests` + * filter=gear 55 keys, 1332 KB + * filter=quests,gear 54 keys, 1111 KB + * filter=notARealContentKey 56 keys, 2713 KB - unknown keys ignored silently + * ``` + * + * The server's own helper names the argument `removedKeys`, which settles what + * was intended. + * + * The behaviour is passed through rather than inverted here. Reversing it in + * the plugin would make this integration disagree with every other Habitica + * client and with the API's own documentation, and would silently break if the + * API were ever fixed. The name stays as Habitica spells it; the meaning is + * documented where a caller will meet it, in the input schema and here. + */ +export const getByType: HabiticaEndpoints['contentGetByType'] = async ( + ctx, + input, +) => { + const result = await habiticaAnonymousCall< + HabiticaEndpointOutputs['contentGetByType'] + >('content', { + query: compactQuery({ filter: input.filter, language: input.language }), + }); + + await logEventFromContext( + ctx, + 'habitica.content.getByType', + { + ...auditPayload(input, ['filter', 'language']), + keys: Object.keys(result).length, + }, + 'completed', + ); + return result; +}; + +/** + * Checks that the API is up. + * + * The one route that needs neither credentials nor `x-client`, which makes it + * the only safe pre-flight check. + */ +export const status: HabiticaEndpoints['status'] = async (ctx, input) => { + const result = + await habiticaAnonymousCall('status'); + + await logEventFromContext( + ctx, + 'habitica.status', + auditPayload(input, []), + 'completed', + ); + return result; +}; + +/** Reads world events, the world boss and seasonal themes. */ +export const worldState: HabiticaEndpoints['worldState'] = async ( + ctx, + input, +) => { + const result = + await habiticaAnonymousCall( + 'world-state', + ); + + await logEventFromContext( + ctx, + 'habitica.worldState', + auditPayload(input, []), + 'completed', + ); + return result; +}; + +/** + * Lists a model's field paths and types. + * + * The valid vocabulary is `user, tag, challenge, group, habit, daily, todo, + * reward` - enumerated by asking the API, one value at a time, rather than + * taken from the catalog. The catalog says "user, group, challenge, tag, or + * task"; `task` is **not** valid and returns 400. The four task types are + * addressed individually instead, which is why the input enum has eight members + * rather than five. + */ +export const modelPaths: HabiticaEndpoints['modelPaths'] = async ( + ctx, + input, +) => { + const result = await habiticaAnonymousCall< + HabiticaEndpointOutputs['modelPaths'] + >(`models/${pathSegment(input.model)}/paths`); + + await logEventFromContext( + ctx, + 'habitica.modelPaths', + { ...auditPayload(input, ['model']), paths: Object.keys(result).length }, + 'completed', + ); + return result; +}; + +/** Reads the latest Bailey announcement. */ +export const news: HabiticaEndpoints['newsGet'] = async (ctx, input) => { + const result = + await habiticaAnonymousCall('news'); + + await logEventFromContext( + ctx, + 'habitica.news.get', + auditPayload(input, []), + 'completed', + ); + return result; +}; + +/** Dismisses the current announcement so it reappears later. */ +export const dismissNews: HabiticaEndpoints['newsDismiss'] = async ( + ctx, + input, +) => { + const result = await habiticaCall( + ctx, + 'news/tell-me-later', + { method: 'POST' }, + ); + + await logEventFromContext( + ctx, + 'habitica.news.dismiss', + auditPayload(input, []), + 'completed', + ); + return result; +}; + +/** Lists the gear available in the market, organised by class. */ +export const marketGear: HabiticaEndpoints['shopsMarketGear'] = async ( + ctx, + input, +) => { + const result = await habiticaCall( + ctx, + 'shops/market-gear', + { + query: compactQuery({ language: input.language }), + }, + ); + + await logEventFromContext( + ctx, + 'habitica.shops.marketGear', + auditPayload(input, ['language']), + 'completed', + ); + return result; +}; + +/** Lists what the Time Travellers shop sells for hourglasses. */ +export const timeTravelers: HabiticaEndpoints['shopsTimeTravelers'] = async ( + ctx, + input, +) => { + const result = await habiticaCall< + HabiticaEndpointOutputs['shopsTimeTravelers'] + >(ctx, 'shops/time-travelers', { + query: compactQuery({ language: input.language }), + }); + + await logEventFromContext( + ctx, + 'habitica.shops.timeTravelers', + auditPayload(input, ['language']), + 'completed', + ); + return result; +}; + +/** + * Checks whether a coupon code is valid. + * + * The code is **not** logged. A valid coupon is a bearer instrument - anyone + * holding the string can redeem it - so recording one in a retained event log + * would turn the audit trail into something worth stealing. Only the outcome is + * recorded. + */ +export const validateCoupon: HabiticaEndpoints['validateCoupon'] = async ( + ctx, + input, +) => { + const result = await habiticaCall( + ctx, + `coupons/validate/${pathSegment(input.code)}`, + { method: 'POST' }, + ); + + await logEventFromContext(ctx, 'habitica.coupons.validate', {}, 'completed'); + return result; +}; diff --git a/packages/habitica/endpoints/exports.ts b/packages/habitica/endpoints/exports.ts new file mode 100644 index 000000000..154251729 --- /dev/null +++ b/packages/habitica/endpoints/exports.ts @@ -0,0 +1,100 @@ +import { logEventFromContext } from 'corsair/core'; +import type { HabiticaEndpoints } from '../index'; +import { habiticaExportCall } from './shared'; +import type { HabiticaEndpointOutputs } from './types'; + +/** + * The three whole-account export documents. + * + * They are unlike everything else in this plugin in three ways, and each one + * was checked live rather than assumed: + * + * 1. **They sit outside `/api/v3`**, at `https://habitica.com/export/*`, so + * they use a second base URL - the same shape as Loyverse's OIDC routes. + * 2. **They work with header authentication.** The server source routes them + * through `authWithSession` rather than the `authWithHeaders` middleware + * every other route uses, which reads as though a browser session were + * required. All three returned 200 to the ordinary `x-api-user` / + * `x-api-key` pair on 2026-08-15. Recording them as unreachable on the + * strength of the source alone would have been wrong. + * 3. **Two of the three are not JSON** - `text/csv` and `text/html` - so they + * cannot go through the shared JSON transport. + * + * None of them is mirrored, and none of their bodies is logged. `bytes` is + * recorded so an operator can see an export happened and roughly how large it + * was, which is the most that can be said without copying the contents. + */ + +/** + * Exports the whole account as JSON. + * + * **This document contains the account holder's email address** under + * `auth.local.email`, together with their entire task and message history. It + * is returned to the caller, which is the point of the operation, but it must + * never be mirrored, never logged, and never captured as a test fixture. + * + * The response is parsed here rather than passed through as text, because + * unlike the other two it genuinely is JSON and a caller asking for + * `userdata.json` expects an object. + */ +export const userData: HabiticaEndpoints['exportUserData'] = async (ctx) => { + const raw = await habiticaExportCall(ctx, 'userdata.json'); + + let parsed: HabiticaEndpointOutputs['exportUserData']; + try { + parsed = JSON.parse(raw.body) as HabiticaEndpointOutputs['exportUserData']; + } catch (error) { + // The body is deliberately excluded from the message - it is the whole + // account, including the email address. + throw new Error( + `Habitica returned an export that is not valid JSON (${raw.contentType}, ${raw.body.length} bytes): ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + + await logEventFromContext( + ctx, + 'habitica.export.userData', + { bytes: raw.body.length }, + 'completed', + ); + return parsed; +}; + +/** + * Exports task history as CSV. + * + * Returned as text with its content type rather than parsed into rows: the + * columns follow the account's own tasks, so a parser here would be inventing a + * schema that a different account breaks. + */ +export const history: HabiticaEndpoints['exportHistoryCsv'] = async (ctx) => { + const result = await habiticaExportCall(ctx, 'history.csv'); + + await logEventFromContext( + ctx, + 'habitica.export.history', + { bytes: result.body.length, contentType: result.contentType }, + 'completed', + ); + return result; +}; + +/** + * Exports the inbox as an HTML document. + * + * Private correspondence, returned as text and never parsed, mirrored or + * logged. + */ +export const inbox: HabiticaEndpoints['exportInboxHtml'] = async (ctx) => { + const result = await habiticaExportCall(ctx, 'inbox.html'); + + await logEventFromContext( + ctx, + 'habitica.export.inbox', + { bytes: result.body.length, contentType: result.contentType }, + 'completed', + ); + return result; +}; diff --git a/packages/habitica/endpoints/groups.ts b/packages/habitica/endpoints/groups.ts new file mode 100644 index 000000000..59d5565bd --- /dev/null +++ b/packages/habitica/endpoints/groups.ts @@ -0,0 +1,288 @@ +import { logEventFromContext } from 'corsair/core'; +import type { HabiticaEndpoints } from '../index'; +import { HabiticaGroupEntity } from '../schema/database'; +import { auditPayload, countOf } from './logging'; +import { cacheEntities, cacheEntity } from './persist'; +import { compactBody, compactQuery, habiticaCall, pathSegment } from './shared'; +import type { HabiticaEndpointOutputs } from './types'; + +const LABEL = 'group'; + +/** + * The two group ids that are names rather than UUIDs. + * + * `GET /groups/:groupId` accepts either. Three separate catalog operations + * resolve to that one route - `GET_GROUP` with a caller-supplied id, + * `GET_PARTY` fixed to `party`, and `GET_GROUPS_HABITRPG` fixed to `habitrpg`. + * They are registered separately so no catalog id is missing, but they are one + * capability, not three. + */ +const PARTY_ALIAS = 'party'; +const TAVERN_ALIAS = 'habitrpg'; + +/** + * Creates a group. + * + * The catalog states guilds were removed in August 2023 and only `party` works, + * while its own `GET_GROUPS`, `GET_GROUP` and `DELETE_GROUP` entries describe + * guild behaviour. Both cannot be true. The plugin does not adjudicate: it + * sends what the caller asked for and lets Habitica answer, because inventing a + * client-side restriction would break callers if the catalog note is the stale + * half. + */ +export const create: HabiticaEndpoints['groupsCreate'] = async (ctx, input) => { + const result = await habiticaCall( + ctx, + 'groups', + { method: 'POST', body: compactBody({ ...input }) }, + ); + + await cacheEntity(ctx.db.groups, HabiticaGroupEntity, result, { + label: LABEL, + }); + + await logEventFromContext( + ctx, + 'habitica.groups.create', + auditPayload(input, ['type', 'privacy']), + 'completed', + ); + return result; +}; + +/** Lists groups of the requested kinds. */ +export const list: HabiticaEndpoints['groupsList'] = async (ctx, input) => { + const result = await habiticaCall( + ctx, + 'groups', + { + query: compactQuery({ + type: input.type, + paginate: input.paginate, + page: input.page, + }), + }, + ); + + await cacheEntities(ctx.db.groups, HabiticaGroupEntity, result, { + label: LABEL, + }); + + await logEventFromContext( + ctx, + 'habitica.groups.list', + { ...auditPayload(input, ['type', 'page']), returned: result.length }, + 'completed', + ); + return result; +}; + +/** Shared implementation behind the three ids that read one group. */ +async function readGroup( + ctx: Parameters[0], + groupId: string, + event: string, +) { + const result = await habiticaCall( + ctx, + `groups/${pathSegment(groupId)}`, + ); + + await cacheEntity(ctx.db.groups, HabiticaGroupEntity, result, { + label: LABEL, + }); + + await logEventFromContext(ctx, event, { groupId }, 'completed'); + return result; +} + +/** Retrieves a group by id, or by the `party` / `habitrpg` aliases. */ +export const get: HabiticaEndpoints['groupsGet'] = async (ctx, input) => + await readGroup(ctx, input.groupId, 'habitica.groups.get'); + +/** + * Retrieves the caller's party. + * + * The same route as {@link get} with the id fixed. It keeps its own audit event + * so the two stay distinguishable in a log even though they issue an identical + * request. + */ +export const getParty: HabiticaEndpoints['groupsGetParty'] = async (ctx) => + await readGroup(ctx, PARTY_ALIAS, 'habitica.groups.getParty'); + +/** Retrieves the Tavern, the global public group. */ +export const getTavern: HabiticaEndpoints['groupsGetTavern'] = async (ctx) => + await readGroup(ctx, TAVERN_ALIAS, 'habitica.groups.getTavern'); + +/** Updates a group's properties. Leader only. */ +export const update: HabiticaEndpoints['groupsUpdate'] = async (ctx, input) => { + const { groupId, ...changes } = input; + const result = await habiticaCall( + ctx, + `groups/${pathSegment(groupId)}`, + { method: 'PUT', body: compactBody(changes) }, + ); + + await cacheEntity(ctx.db.groups, HabiticaGroupEntity, result, { + label: LABEL, + }); + + await logEventFromContext( + ctx, + 'habitica.groups.update', + auditPayload(input, ['groupId', 'privacy']), + 'completed', + ); + return result; +}; + +/** + * Leaves a group. + * + * The catalog calls this "Leave or Delete" and describes a two-step operation: + * `POST /groups/:groupId/leave`, and "only if that fails", + * `DELETE /groups/:groupId`. **That second route does not exist.** The server's + * routing table has no DELETE under `/groups` other than the chat-message one, + * and a live request confirms it: `DELETE /groups/` answers `Not found.`, + * which is the response for an unrouted path, while a real route with a missing + * id answers `Group not found or you don't have access.` Both are 404s, so only + * the message distinguishes them. + * + * So the fallback is not implemented. Writing it would add a request that can + * only ever 404, and would make the failure of a legitimate leave look like a + * delete that also failed. + * + * A welcome side effect: the operation has exactly one effect, so replaying it + * after a transport failure cannot leave *and* delete. The retry hazard the + * composite would have introduced does not exist. + */ +export const leave: HabiticaEndpoints['groupsLeave'] = async (ctx, input) => { + const result = await habiticaCall( + ctx, + `groups/${pathSegment(input.groupId)}/leave`, + { + method: 'POST', + query: compactQuery({ + keep: input.keep, + keepChallenges: input.keepChallenges, + }), + }, + ); + + await logEventFromContext( + ctx, + 'habitica.groups.leave', + auditPayload(input, ['groupId', 'keep', 'keepChallenges']), + 'completed', + ); + return result; +}; + +/** + * Lists a group's members. + * + * Paginated by cursor: `lastId` is the id of the last member of the previous + * page. Members are other people and are not mirrored. + */ +export const listMembers: HabiticaEndpoints['groupsListMembers'] = async ( + ctx, + input, +) => { + const result = await habiticaCall< + HabiticaEndpointOutputs['groupsListMembers'] + >(ctx, `groups/${pathSegment(input.groupId)}/members`, { + query: compactQuery({ + lastId: input.lastId, + includeAllPublicFields: input.includeAllPublicFields, + }), + }); + + await logEventFromContext( + ctx, + 'habitica.groups.listMembers', + { ...auditPayload(input, ['groupId']), returned: result.length }, + 'completed', + ); + return result; +}; + +/** + * Invites people to a group by uuid, email or username. + * + * The invitee list is logged as **counts per channel**, never as values. Email + * addresses and usernames identify people who have not consented to appear in + * this account's audit log. + */ +export const invite: HabiticaEndpoints['groupsInvite'] = async (ctx, input) => { + const result = await habiticaCall( + ctx, + `groups/${pathSegment(input.groupId)}/invite`, + { + method: 'POST', + body: compactBody({ + uuids: input.uuids, + emails: input.emails, + usernames: input.usernames, + }), + }, + ); + + await logEventFromContext( + ctx, + 'habitica.groups.invite', + { + groupId: input.groupId, + uuids: countOf(input.uuids), + emails: countOf(input.emails), + usernames: countOf(input.usernames), + }, + 'completed', + ); + return result; +}; + +/** Removes a member from the caller's party. Leader only. */ +export const removeMember: HabiticaEndpoints['groupsRemoveMember'] = async ( + ctx, + input, +) => { + const result = await habiticaCall< + HabiticaEndpointOutputs['groupsRemoveMember'] + >( + ctx, + `groups/${pathSegment(input.groupId)}/removeMember/${pathSegment(input.memberId)}`, + { method: 'POST', query: compactQuery({ message: input.message }) }, + ); + + // `message` is free text sent to the removed member and is deliberately + // absent from the audit record. + await logEventFromContext( + ctx, + 'habitica.groups.removeMember', + auditPayload(input, ['groupId', 'memberId']), + 'completed', + ); + return result; +}; + +/** Invites the party to a quest. The account must own the scroll. */ +export const inviteToQuest: HabiticaEndpoints['groupsInviteToQuest'] = async ( + ctx, + input, +) => { + const result = await habiticaCall< + HabiticaEndpointOutputs['groupsInviteToQuest'] + >( + ctx, + `groups/${pathSegment(input.groupId)}/quests/invite/${pathSegment(input.questKey)}`, + { method: 'POST' }, + ); + + await logEventFromContext( + ctx, + 'habitica.groups.inviteToQuest', + auditPayload(input, ['groupId', 'questKey']), + 'completed', + ); + return result; +}; diff --git a/packages/habitica/endpoints/index.ts b/packages/habitica/endpoints/index.ts new file mode 100644 index 000000000..8545562c6 --- /dev/null +++ b/packages/habitica/endpoints/index.ts @@ -0,0 +1,10 @@ +export * as Auth from './auth'; +export * as Challenges from './challenges'; +export * as Chat from './chat'; +export * as Content from './content'; +export * as Exports from './exports'; +export * as Groups from './groups'; +export * as Tags from './tags'; +export * as Tasks from './tasks'; +export * as User from './user'; +export * as Webhooks from './webhooks'; diff --git a/packages/habitica/endpoints/logging.ts b/packages/habitica/endpoints/logging.ts new file mode 100644 index 000000000..b4a448b4f --- /dev/null +++ b/packages/habitica/endpoints/logging.ts @@ -0,0 +1,65 @@ +/** + * Builds the payload recorded in `corsair_events`. + * + * `logEventFromContext` persists whatever it is handed, and those rows inherit + * the event log's retention. Habitica inputs carry material a log should not + * keep verbatim: task titles and notes are the account holder's own writing and + * can be about anything, chat and inbox messages are private correspondence, + * profile text and display names identify a person, and group invitations are + * given by email address or username. + * + * Only explicitly named identifier fields are recorded. The names of the + * remaining supplied fields are kept without their values, so an operator can + * still see what a call requested without the log becoming a copy of the + * account's private data. + */ +export function auditPayload>( + input: T, + identifierKeys: readonly (keyof T & string)[], +): Record { + const payload: Record = {}; + + for (const key of identifierKeys) { + if (input[key] !== undefined) { + payload[key] = input[key]; + } + } + + const supplied = Object.keys(input).filter((key) => input[key] !== undefined); + if (supplied.length > 0) { + payload.fields = supplied; + } + + return payload; +} + +/** + * Describes a collection without copying it. + * + * A task's checklist and a group invitation's recipient list both hold values + * that should not reach the log, so they are recorded as a count. + */ +export function countOf(value: readonly unknown[] | undefined | null): number { + return value?.length ?? 0; +} + +/** + * The audit payload for the three credential-minting operations. + * + * `LOCAL_REGISTER`, `LOCAL_LOGIN` and `SOCIAL_AUTH` are the only operations + * whose inputs are themselves credentials - a password, or a third-party OAuth + * token. {@link auditPayload} is not safe for them even with an empty + * identifier list, because it records the *names* of the supplied fields, and + * `fields: ["username","password"]` in a retained log is a standing invitation + * to widen it later into the values. + * + * These calls therefore record that an attempt happened and nothing else. Not + * the email, not the username, not the network. An operator can still see the + * call in the log and correlate it by timestamp; they cannot learn who it was + * for. This is deliberately stricter than every other operation in the plugin. + */ +export function credentialAuditPayload(): Record { + return { + recorded: 'attempt only - inputs are credentials and are not logged', + }; +} diff --git a/packages/habitica/endpoints/persist.ts b/packages/habitica/endpoints/persist.ts new file mode 100644 index 000000000..cc5b6ed8c --- /dev/null +++ b/packages/habitica/endpoints/persist.ts @@ -0,0 +1,243 @@ +import type { z } from 'zod'; + +/** + * Minimal structural view of a Corsair entity store. Only the operations these + * endpoints need are declared, so the helpers stay usable whatever else the + * concrete store exposes. + */ +type EntityStore = { + upsertByEntityId: (entityId: string, data: T) => Promise; +}; + +/** The eviction half of the same store, needed only by the delete operations. */ +type EntityEvictor = { + deleteByEntityId: (entityId: string) => Promise; +}; + +/** + * The listing half, needed only to empty a collection after an account reset. + * + * Rows are the entity table's own shape, so the key is `entity_id` rather than + * the `id` carried inside `data`. + */ +type EntityLister = EntityEvictor & { + list: (options?: { + limit?: number; + offset?: number; + }) => Promise; +}; + +/** How many mirrored rows to read per page while emptying a collection. */ +const CLEAR_PAGE_SIZE = 100; + +/** + * Mirroring is best-effort: a plugin call must not fail because the local copy + * could not be written or removed. + * + * The exception is an eviction the caller declared **required** - see + * {@link HabiticaMirrorEvictionError}. + */ +async function safely(operation: () => Promise, what: string) { + try { + await operation(); + } catch (error) { + console.warn(`[HABITICA] ${what}:`, error); + } +} + +/** + * Raised when a record was deleted at Habitica but could not be removed from + * the local mirror. + * + * Habitica **hard-deletes**. There is no soft delete, no `deleted_at` and no + * "include deleted" listing anywhere in the API: a deleted task returns 404 on + * the next read and simply vanishes from `GET /tasks/user`. So a mirrored row + * that survives a delete is not merely stale, it describes something that no + * longer exists on either side and cannot be refreshed back into agreement. + * + * Reporting that as a plain success would be wrong in the same way it is for + * Loyverse customers: the remote half happened and the local half did not, and + * only the caller can decide what to do about it. The message says both halves, + * because retrying the delete will now 404 - what needs attention is the mirror. + */ +export class HabiticaMirrorEvictionError extends Error { + constructor( + readonly label: string, + readonly entityId: string, + readonly cause: unknown, + ) { + super( + `Habitica deleted the ${label} ${entityId}, but it could not be removed ` + + `from the local mirror, which still holds it. The remote record is gone ` + + `and does not need deleting again; the local copy does. ` + + `Cause: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + this.name = 'HabiticaMirrorEvictionError'; + } +} + +/** + * How many cache writes may be in flight at once. + * + * `GET /tasks/user` returns every task the account has in one unpaginated + * response, so this is not a page-sized batch - a long-lived account can return + * hundreds of rows at once. The cap keeps the write-ahead without letting a + * single call flood the database. + */ +const CACHE_WRITE_CONCURRENCY = 16; + +/** + * Derives the key a record is stored under. + * + * Every Habitica entity mirrored here is keyed by `id`, but the resolver stays + * overridable rather than assumed - the API returns the same value under `_id` + * as well, and a future entity may only carry one of the two. + */ +type EntityIdOf = (parsed: T) => string | undefined; + +const defaultEntityId = (parsed: T): string | undefined => { + const id = (parsed as { id?: unknown }).id; + if (typeof id === 'string' || typeof id === 'number') return String(id); + const mongoId = (parsed as { _id?: unknown })._id; + return typeof mongoId === 'string' ? mongoId : undefined; +}; + +/** + * Mirrors one record into the local cache. + * + * The record is validated against the entity schema first. A row Habitica + * returns in a shape the schema does not recognise is skipped rather than + * written, so the cache never holds something the rest of the plugin cannot + * read back, and a schema gap shows up as a missing row rather than corrupt + * data. + */ +export async function cacheEntity( + store: EntityStore> | undefined, + schema: Schema, + record: unknown, + options: { label: string; entityId?: EntityIdOf> }, +): Promise { + if (!store || record == null) return; + + const parsed = schema.safeParse(record); + if (!parsed.success) { + // Silence here would turn a schema gap into a row that simply never + // appears; the warning is what makes it diagnosable. + console.warn( + `[HABITICA] skipped caching a ${options.label} that does not match its schema:`, + parsed.error.issues, + ); + return; + } + + const entityId = (options.entityId ?? defaultEntityId)(parsed.data); + if (!entityId) return; + + await safely( + () => store.upsertByEntityId(entityId, parsed.data), + `failed to cache ${options.label} ${entityId}`, + ); +} + +/** + * Mirrors many records, skipping any the schema rejects or that have no key. + */ +export async function cacheEntities( + store: EntityStore> | undefined, + schema: Schema, + records: readonly unknown[] | undefined | null, + options: { label: string; entityId?: EntityIdOf> }, +): Promise { + if (!store || !records || records.length === 0) return; + + for (let i = 0; i < records.length; i += CACHE_WRITE_CONCURRENCY) { + const batch = records.slice(i, i + CACHE_WRITE_CONCURRENCY); + // `cacheEntity` swallows its own failures, so no write in a batch can + // reject and abandon the rest. + await Promise.all( + batch.map((record) => cacheEntity(store, schema, record, options)), + ); + } +} + +/** + * Drops a record from the local mirror after Habitica has deleted it. + * + * Pass `required: true` when leaving the row behind would breach something the + * plugin promises rather than merely leave the cache stale. + */ +export async function evictEntity( + store: EntityEvictor | undefined, + entityId: string | number | undefined | null, + label: string, + options: { required?: boolean } = {}, +): Promise { + if (!store || entityId == null) return; + + if (!options.required) { + await safely( + () => store.deleteByEntityId(String(entityId)), + `failed to evict ${label} ${entityId}`, + ); + return; + } + + try { + await store.deleteByEntityId(String(entityId)); + } catch (error) { + console.error( + `[HABITICA] required eviction of ${label} ${entityId} failed - the local mirror still holds it:`, + error, + ); + throw new HabiticaMirrorEvictionError(label, String(entityId), error); + } +} + +/** + * Empties the mirrored task collection. + * + * This exists for one operation. `POST /user/reset` deletes **every** task on + * the account in a single call - it is the account-wipe operation - and returns + * the reset user rather than a list of what it removed. So there are no ids to + * evict one by one, and an endpoint that mirrored tasks would otherwise leave + * the entire previous task list sitting in local storage, answering lookups + * with tasks that no longer exist anywhere. + * + * The store is emptied by listing what is mirrored and evicting each row. That + * is more work than a truncate, but it goes through the same + * `deleteByEntityId` surface the rest of this file uses, and it is a + * once-in-an-account's-lifetime operation rather than a hot path. + * + * Failure is warned about rather than raised. Unlike a single delete, the + * caller of a reset has not been handed an id they might act on, and the reset + * itself genuinely succeeded; raising here would report the account wipe as + * failed when it did not. + */ +export async function clearMirroredTasks( + store: EntityLister | undefined, +): Promise { + if (!store) return; + + try { + // Every id is collected before anything is deleted. Paging by offset + // while deleting would renumber the rows underneath the cursor and skip + // roughly half of them - the read has to finish before the writes start. + const entityIds: string[] = []; + for (let offset = 0; ; offset += CLEAR_PAGE_SIZE) { + const page = await store.list({ limit: CLEAR_PAGE_SIZE, offset }); + for (const row of page) { + if (row.entity_id) entityIds.push(row.entity_id); + } + if (page.length < CLEAR_PAGE_SIZE) break; + } + + for (const entityId of entityIds) { + await store.deleteByEntityId(entityId); + } + } catch (error) { + console.warn( + '[HABITICA] the account was reset but the mirrored task list could not be cleared; it now holds tasks that no longer exist:', + error, + ); + } +} diff --git a/packages/habitica/endpoints/shared.ts b/packages/habitica/endpoints/shared.ts new file mode 100644 index 000000000..58f0ceadc --- /dev/null +++ b/packages/habitica/endpoints/shared.ts @@ -0,0 +1,178 @@ +import type { HabiticaCredentials, HabiticaRequestOptions } from '../client'; +import { + HabiticaUserIdMissingError, + makeHabiticaAnonymousRequest, + makeHabiticaExportRequest, + makeHabiticaRequest, + makeHabiticaTextRequest, +} from '../client'; + +/** + * Minimal structural view of the plugin context these helpers need. + * + * Declaring only the members used here keeps the helpers testable without + * constructing a full Corsair context, and keeps them working whatever else the + * concrete context exposes. + */ +type HabiticaCallContext = { + key: string; + options: { userId?: string | undefined }; + keys?: { get_user_id?: () => Promise }; +}; + +/** + * Resolves the account's user id for a call. + * + * Habitica's credential has two halves - `x-api-user` and `x-api-key` - and + * both are checked. A valid token paired with a different account's user id is + * answered 401 `There is no account that uses those credentials.`, so the id is + * a credential rather than a routing hint the token could imply. + * + * Configuration wins, then a stored key. There is deliberately **no discovery + * fallback**, which is where this differs from Harvest: Harvest can ask + * `/accounts` which accounts a token reaches, but every authenticated Habitica + * route requires the user id already, so no route exists that could discover + * it. Failing here with an explanation is the only honest option. + */ +export async function resolveUserId(ctx: HabiticaCallContext): Promise { + const configured = ctx.options.userId; + if (configured) return configured; + + const stored = await ctx.keys?.get_user_id?.(); + if (stored) return stored; + + throw new HabiticaUserIdMissingError(); +} + +async function credentialsFor( + ctx: HabiticaCallContext, +): Promise { + return { userId: await resolveUserId(ctx), apiToken: ctx.key }; +} + +/** + * Issues an authenticated Habitica request. + * + * Every authenticated operation goes through here so the two-part credential + * cannot be assembled correctly in one place and forgotten in another. + */ +export async function habiticaCall( + ctx: HabiticaCallContext, + endpoint: string, + options: HabiticaRequestOptions = {}, +): Promise { + const response = await makeHabiticaRequest( + endpoint, + await credentialsFor(ctx), + options, + ); + return unwrap(response); +} + +/** + * Issues a request to a route that takes no credentials. + * + * `/status`, `/content` and `/models/:model/paths` are answered without + * authentication. They are routed separately rather than through + * {@link habiticaCall} so that a missing user id cannot make an anonymous + * operation fail for a credential it never needed. + */ +export async function habiticaAnonymousCall( + endpoint: string, + options: HabiticaRequestOptions = {}, +): Promise { + const response = await makeHabiticaAnonymousRequest( + endpoint, + options, + ); + return unwrap(response); +} + +/** Reads one of the three `/export/*` documents, which sit outside `/api/v3`. */ +export async function habiticaExportCall( + ctx: HabiticaCallContext, + document: 'userdata.json' | 'history.csv' | 'inbox.html', +): Promise<{ body: string; contentType: string }> { + return await makeHabiticaExportRequest(document, await credentialsFor(ctx)); +} + +/** + * Reads a versioned-API route that answers with text rather than JSON. + * + * Only the challenge CSV export needs this. It is kept separate from + * {@link habiticaExportCall} because the two use different base URLs, and + * collapsing them into one helper with a flag would hide that difference. + */ +export async function habiticaExportRaw( + ctx: HabiticaCallContext, + endpoint: string, +): Promise<{ body: string; contentType: string }> { + return await makeHabiticaTextRequest(endpoint, await credentialsFor(ctx)); +} + +/** + * Unwraps Habitica's response envelope. + * + * Every `/api/v3` response is `{"success":true,"data":...}`, with the payload + * one level down. Endpoints return the payload rather than the envelope, + * because `success` is redundant with the status code the transport already + * checked, and callers should not have to reach through a wrapper that carries + * no information. + * + * A response that is not shaped like the envelope is returned as-is rather than + * being treated as an error: the three `/export/*` documents and the challenge + * CSV are not enveloped at all, and neither is anything a future route decides + * to return bare. + */ +export function unwrap(response: unknown): T { + if ( + response !== null && + typeof response === 'object' && + 'data' in response && + 'success' in response + ) { + return (response as { data: T }).data; + } + return response as T; +} + +/** + * Drops keys whose value is `undefined`. + * + * Habitica distinguishes an absent field from an explicit `null` on its update + * routes: `PUT /tasks/:taskId` leaves a field alone when it is omitted, and a + * request that serialised `undefined` would produce neither behaviour. Unset + * fields are removed before the body is built. + */ +export function compactBody( + body: Record, +): Record { + const compacted: Record = {}; + for (const [key, value] of Object.entries(body)) { + if (value !== undefined) compacted[key] = value; + } + return compacted; +} + +/** Same as {@link compactBody}, for query strings. */ +export function compactQuery( + query: Record, +): Record { + const compacted: Record = {}; + for (const [key, value] of Object.entries(query)) { + if (value !== undefined) compacted[key] = value; + } + return compacted; +} + +/** + * Percent-encodes a value used as a path segment. + * + * Several Habitica paths interpolate values that are not opaque ids and can + * legitimately contain characters that change what the path means - a coupon + * code, a quest key, a pinned item's dotted `path`, an equipment `key`. Left + * raw, a value containing `/` or `?` would silently address a different route. + */ +export function pathSegment(value: string | number): string { + return encodeURIComponent(String(value)); +} diff --git a/packages/habitica/endpoints/tags.ts b/packages/habitica/endpoints/tags.ts new file mode 100644 index 000000000..409ae804d --- /dev/null +++ b/packages/habitica/endpoints/tags.ts @@ -0,0 +1,103 @@ +import { logEventFromContext } from 'corsair/core'; +import type { HabiticaEndpoints } from '../index'; +import { HabiticaTagEntity } from '../schema/database'; +import { auditPayload } from './logging'; +import { cacheEntities, cacheEntity, evictEntity } from './persist'; +import { habiticaCall, pathSegment } from './shared'; +import type { HabiticaEndpointOutputs } from './types'; + +const LABEL = 'tag'; + +/** + * A tag's name is user-authored text, so it is not logged. Only ids are. + */ + +/** Creates a tag. */ +export const create: HabiticaEndpoints['tagsCreate'] = async (ctx, input) => { + const result = await habiticaCall( + ctx, + 'tags', + { method: 'POST', body: { name: input.name } }, + ); + + await cacheEntity(ctx.db.tags, HabiticaTagEntity, result, { label: LABEL }); + + await logEventFromContext( + ctx, + 'habitica.tags.create', + auditPayload(input, []), + 'completed', + ); + return result; +}; + +/** + * Lists every tag on the account. + * + * Unpaginated, and small: a tag carries only `id` and `name`. + */ +export const list: HabiticaEndpoints['tagsList'] = async (ctx, input) => { + const result = await habiticaCall( + ctx, + 'tags', + ); + + await cacheEntities(ctx.db.tags, HabiticaTagEntity, result, { label: LABEL }); + + await logEventFromContext( + ctx, + 'habitica.tags.list', + { ...auditPayload(input, []), returned: result.length }, + 'completed', + ); + return result; +}; + +/** Renames a tag. */ +export const update: HabiticaEndpoints['tagsUpdate'] = async (ctx, input) => { + const result = await habiticaCall( + ctx, + `tags/${pathSegment(input.tagId)}`, + { method: 'PUT', body: { name: input.name } }, + ); + + await cacheEntity(ctx.db.tags, HabiticaTagEntity, result, { label: LABEL }); + + await logEventFromContext( + ctx, + 'habitica.tags.update', + auditPayload(input, ['tagId']), + 'completed', + ); + return result; +}; + +/** + * Deletes a tag. + * + * Required eviction, for the same reason as tasks: Habitica hard-deletes, so a + * surviving mirror row could never be reconciled with the remote side. + * + * Note that deleting a tag also removes it from every task that carried it, and + * those mirrored tasks still list the tag id until the next `tasks.list`. The + * tag row itself is what this operation promises to remove, and that is what is + * evicted; the stale references are noted rather than chased, since finding + * them would mean re-reading the whole task list on every tag delete. + */ +export const remove: HabiticaEndpoints['tagsDelete'] = async (ctx, input) => { + const result = await habiticaCall( + ctx, + `tags/${pathSegment(input.tagId)}`, + { method: 'DELETE' }, + ); + + await evictEntity(ctx.db.tags, input.tagId, LABEL, { required: true }); + + await logEventFromContext( + ctx, + 'habitica.tags.delete', + auditPayload(input, ['tagId']), + 'completed', + ); + return result; +}; diff --git a/packages/habitica/endpoints/tasks.ts b/packages/habitica/endpoints/tasks.ts new file mode 100644 index 000000000..d2ad0c2e8 --- /dev/null +++ b/packages/habitica/endpoints/tasks.ts @@ -0,0 +1,352 @@ +import { logEventFromContext } from 'corsair/core'; +import type { HabiticaEndpoints } from '../index'; +import { HabiticaTaskEntity } from '../schema/database'; +import { auditPayload } from './logging'; +import { cacheEntities, cacheEntity, evictEntity } from './persist'; +import { compactBody, compactQuery, habiticaCall, pathSegment } from './shared'; +import type { HabiticaEndpointOutputs } from './types'; + +const LABEL = 'task'; + +/** + * Task titles and notes are the account holder's own writing and can be about + * anything, so no operation in this file logs them. Ids, types and positions + * are recorded; text is not. + * + * The identifier lists are given per operation rather than shared, because each + * one may only name keys its own input actually has. + */ + +/** Creates a task: habit, daily, todo or reward. */ +export const create: HabiticaEndpoints['tasksCreate'] = async (ctx, input) => { + const result = await habiticaCall( + ctx, + 'tasks/user', + { method: 'POST', body: compactBody({ ...input }) }, + ); + + await cacheEntity(ctx.db.tasks, HabiticaTaskEntity, result, { label: LABEL }); + + await logEventFromContext( + ctx, + 'habitica.tasks.create', + auditPayload(input, ['type']), + 'completed', + ); + return result; +}; + +/** + * Lists the account's tasks. + * + * There is no paging to do. `GET /tasks/user` takes no page or cursor parameter + * and returns every matching task in one response, so a long-lived account's + * whole list arrives at once - which is also why the whole result is mirrored + * here rather than a page of it. + */ +export const list: HabiticaEndpoints['tasksList'] = async (ctx, input) => { + const result = await habiticaCall( + ctx, + 'tasks/user', + { query: compactQuery({ type: input.type, tagId: input.tagId }) }, + ); + + await cacheEntities(ctx.db.tasks, HabiticaTaskEntity, result, { + label: LABEL, + }); + + await logEventFromContext( + ctx, + 'habitica.tasks.list', + { ...auditPayload(input, ['type', 'tagId']), returned: result.length }, + 'completed', + ); + return result; +}; + +/** + * Retrieves any task by id - personal or challenge. + * + * The catalog calls this `HABITICA_GET_CHALLENGE_TASK` and displays it as "Get + * Task by ID". The name points at challenges and the description does not; the + * description is the specification, so this is `GET /tasks/:taskId`. See + * {@link listChallengeTasks} for the actual challenge listing. + */ +export const get: HabiticaEndpoints['tasksGet'] = async (ctx, input) => { + const result = await habiticaCall( + ctx, + `tasks/${pathSegment(input.taskId)}`, + ); + + await cacheEntity(ctx.db.tasks, HabiticaTaskEntity, result, { label: LABEL }); + + await logEventFromContext( + ctx, + 'habitica.tasks.get', + auditPayload(input, ['taskId']), + 'completed', + ); + return result; +}; + +/** Updates a task. Omitted fields are left alone. */ +export const update: HabiticaEndpoints['tasksUpdate'] = async (ctx, input) => { + const { taskId, ...changes } = input; + const result = await habiticaCall( + ctx, + `tasks/${pathSegment(taskId)}`, + { method: 'PUT', body: compactBody(changes) }, + ); + + await cacheEntity(ctx.db.tasks, HabiticaTaskEntity, result, { label: LABEL }); + + await logEventFromContext( + ctx, + 'habitica.tasks.update', + auditPayload(input, ['taskId']), + 'completed', + ); + return result; +}; + +/** + * Deletes a task. + * + * The eviction is **required**. Habitica hard-deletes: the task 404s on the + * next read and disappears from the list, with no soft-delete flag and no way + * to fetch it again. A mirrored row left behind would describe something that + * exists nowhere and can never be reconciled, so a failed eviction is reported + * rather than swallowed. + */ +export const remove: HabiticaEndpoints['tasksDelete'] = async (ctx, input) => { + const result = await habiticaCall( + ctx, + `tasks/${pathSegment(input.taskId)}`, + { method: 'DELETE' }, + ); + + await evictEntity(ctx.db.tasks, input.taskId, LABEL, { required: true }); + + await logEventFromContext( + ctx, + 'habitica.tasks.delete', + auditPayload(input, ['taskId']), + 'completed', + ); + return result; +}; + +/** + * Scores a task up or down. + * + * The response is the user's stats after the score, not the task, so there is + * nothing here to mirror - and the mirrored copy of this task is now stale in + * its `value`, `history` and `completed` fields. That is left as-is rather than + * papered over with a follow-up read: spending a second request against a + * 30-per-minute budget to refresh a snapshot the caller did not ask for is a + * poor trade, and `schema/database.ts` documents these fields as point-in-time. + * + * Not idempotent. Scoring twice scores twice - it is the one operation here + * whose replay changes the outcome rather than repeating it. + */ +export const score: HabiticaEndpoints['tasksScore'] = async (ctx, input) => { + const result = await habiticaCall( + ctx, + `tasks/${pathSegment(input.taskId)}/score/${pathSegment(input.direction)}`, + { method: 'POST' }, + ); + + await logEventFromContext( + ctx, + 'habitica.tasks.score', + auditPayload(input, ['taskId', 'direction']), + 'completed', + ); + return result; +}; + +/** + * Moves a task to a position in its list. `0` is the top, `-1` the bottom. + * + * A completed todo cannot be moved: Habitica answers 400 `Can't move a + * completed todo.` That is a precondition rather than a malformed request, and + * it is not documented - it was found by scoring a todo and then trying to move + * it. + */ +export const move: HabiticaEndpoints['tasksMove'] = async (ctx, input) => { + const result = await habiticaCall( + ctx, + `tasks/${pathSegment(input.taskId)}/move/to/${pathSegment(input.position)}`, + { method: 'POST' }, + ); + + await logEventFromContext( + ctx, + 'habitica.tasks.move', + auditPayload(input, ['taskId', 'position']), + 'completed', + ); + return result; +}; + +/** + * Updates one checklist item's text. + * + * The catalog has update and delete for checklist items but no create, though + * `POST /tasks/:taskId/checklist` exists. The asymmetry is matched rather than + * corrected - the same decision as the missing webhook siblings. + */ +export const updateChecklistItem: HabiticaEndpoints['tasksUpdateChecklistItem'] = + async (ctx, input) => { + const result = await habiticaCall< + HabiticaEndpointOutputs['tasksUpdateChecklistItem'] + >( + ctx, + `tasks/${pathSegment(input.taskId)}/checklist/${pathSegment(input.itemId)}`, + { method: 'PUT', body: { text: input.text } }, + ); + + await cacheEntity(ctx.db.tasks, HabiticaTaskEntity, result, { + label: LABEL, + }); + + await logEventFromContext( + ctx, + 'habitica.tasks.updateChecklistItem', + auditPayload(input, ['taskId', 'itemId']), + 'completed', + ); + return result; + }; + +/** + * Removes one checklist item. + * + * Returns the whole updated task, so the mirror is refreshed rather than + * evicted - the task still exists, only its checklist changed. + */ +export const deleteChecklistItem: HabiticaEndpoints['tasksDeleteChecklistItem'] = + async (ctx, input) => { + const result = await habiticaCall< + HabiticaEndpointOutputs['tasksDeleteChecklistItem'] + >( + ctx, + `tasks/${pathSegment(input.taskId)}/checklist/${pathSegment(input.itemId)}`, + { method: 'DELETE' }, + ); + + await cacheEntity(ctx.db.tasks, HabiticaTaskEntity, result, { + label: LABEL, + }); + + await logEventFromContext( + ctx, + 'habitica.tasks.deleteChecklistItem', + auditPayload(input, ['taskId', 'itemId']), + 'completed', + ); + return result; + }; + +/** Applies an existing tag to a task. */ +export const addTag: HabiticaEndpoints['tasksAddTag'] = async (ctx, input) => { + const result = await habiticaCall( + ctx, + `tasks/${pathSegment(input.taskId)}/tags/${pathSegment(input.tagId)}`, + { method: 'POST' }, + ); + + await cacheEntity(ctx.db.tasks, HabiticaTaskEntity, result, { label: LABEL }); + + await logEventFromContext( + ctx, + 'habitica.tasks.addTag', + auditPayload(input, ['taskId', 'tagId']), + 'completed', + ); + return result; +}; + +/** + * Adds a task to a challenge. + * + * Returns an array: creating a challenge task creates a copy on every member's + * account, and Habitica returns the set rather than one record. + */ +export const createChallengeTask: HabiticaEndpoints['tasksCreateChallengeTask'] = + async (ctx, input) => { + const { challengeId, ...task } = input; + const result = await habiticaCall< + HabiticaEndpointOutputs['tasksCreateChallengeTask'] + >(ctx, `tasks/challenge/${pathSegment(challengeId)}`, { + method: 'POST', + body: compactBody(task), + }); + + await cacheEntities(ctx.db.tasks, HabiticaTaskEntity, result, { + label: LABEL, + }); + + await logEventFromContext( + ctx, + 'habitica.tasks.createChallengeTask', + { + ...auditPayload(input, ['challengeId', 'type']), + created: result.length, + }, + 'completed', + ); + return result; + }; + +/** Lists the tasks defined by a challenge. */ +export const listChallengeTasks: HabiticaEndpoints['tasksListChallengeTasks'] = + async (ctx, input) => { + const result = await habiticaCall< + HabiticaEndpointOutputs['tasksListChallengeTasks'] + >(ctx, `tasks/challenge/${pathSegment(input.challengeId)}`, { + query: compactQuery({ type: input.type }), + }); + + await cacheEntities(ctx.db.tasks, HabiticaTaskEntity, result, { + label: LABEL, + }); + + await logEventFromContext( + ctx, + 'habitica.tasks.listChallengeTasks', + { + ...auditPayload(input, ['challengeId', 'type']), + returned: result.length, + }, + 'completed', + ); + return result; + }; + +/** + * Unlinks every task of a challenge from the members who joined it. + * + * `keep: 'remove-all'` deletes those copies outright. The mirror is not touched + * here: the response says only that the unlink happened, and does not name the + * tasks affected, so there are no ids to evict. A subsequent `tasks.list` + * re-syncs, and until then the mirror may name tasks that were removed. That is + * stated rather than hidden. + */ +export const unlinkAllChallengeTasks: HabiticaEndpoints['tasksUnlinkAllChallengeTasks'] = + async (ctx, input) => { + const result = await habiticaCall< + HabiticaEndpointOutputs['tasksUnlinkAllChallengeTasks'] + >(ctx, `tasks/unlink-all/${pathSegment(input.challengeId)}`, { + method: 'POST', + query: compactQuery({ keep: input.keep }), + }); + + await logEventFromContext( + ctx, + 'habitica.tasks.unlinkAllChallengeTasks', + auditPayload(input, ['challengeId', 'keep']), + 'completed', + ); + return result; + }; diff --git a/packages/habitica/endpoints/types.ts b/packages/habitica/endpoints/types.ts new file mode 100644 index 000000000..9426f9bec --- /dev/null +++ b/packages/habitica/endpoints/types.ts @@ -0,0 +1,1131 @@ +import { z } from 'zod'; +import { + HabiticaChallengeEntity, + HabiticaChecklistItem, + HabiticaGroupEntity, + HabiticaTagEntity, + HabiticaTaskEntity, + HabiticaWebhookEntity, +} from '../schema/database'; + +/** + * Input and output schemas for every Habitica operation. + * + * Output schemas reuse the entity definitions in `schema/database.ts` rather + * than restating them, so the persisted shape and the returned shape cannot + * drift apart. + * + * Outputs are the **unwrapped** payload. Habitica returns + * `{"success":true,"data":...}` on every `/api/v3` route; `unwrap()` in + * `shared.ts` strips that envelope, so the schemas here describe `data` itself. + */ + +const S = z.string().nullable().optional(); +const N = z.number().nullable().optional(); +const B = z.boolean().nullable().optional(); + +/** + * A response whose body is an object this plugin does not model field by field. + * + * Used where the payload is genuinely open-ended - the game content catalogue, + * the shops, world state, the user document - and pinning a shape would mean + * inventing a contract Habitica has not made. `.loose()` everywhere else covers + * unexpected *additions*; this covers payloads that are unexpected all the way + * down. + */ +const OpaqueObject = z.record(z.string(), z.unknown()); + +/** An operation that returns nothing meaningful - Habitica sends `data: {}`. */ +const EmptyResult = z.record(z.string(), z.unknown()); + +/* -------------------------------------------------------------------------- */ +/* Tasks */ +/* -------------------------------------------------------------------------- */ + +/** The four task types, which decide which fields a task carries. */ +const TaskType = z.enum(['habit', 'daily', 'todo', 'reward']); + +/** + * The task-type filter accepted by `GET /tasks/user`. + * + * These are the **plural** forms plus two completed-task views, which is not + * the same vocabulary as {@link TaskType}. Habitica rejects an unrecognised + * value with a 400 rather than ignoring it - verified live with + * `?type=notAType` - so this enum matches a real server-side check rather than + * merely documenting intent. + */ +const TaskListFilter = z.enum([ + 'habits', + 'dailys', + 'todos', + 'rewards', + 'completedTodos', + '_allCompletedTodos', +]); + +const ChecklistItemInput = z.object({ + text: z.string(), + completed: z.boolean().optional(), +}); + +const TasksCreateInputSchema = z.object({ + text: z.string(), + type: TaskType, + notes: z.string().optional(), + /** Tag ids, not tag names. */ + tags: z.array(z.string()).optional(), + /** 0.1 trivial, 1 easy, 1.5 medium, 2 hard. */ + priority: z.number().optional(), + attribute: z.enum(['str', 'int', 'con', 'per']).optional(), + checklist: z.array(ChecklistItemInput).optional(), + collapseChecklist: z.boolean().optional(), + /** Habits: whether the + and - buttons are enabled. */ + up: z.boolean().optional(), + down: z.boolean().optional(), + /** Todos: the due date, as an ISO date string. */ + date: z.string().optional(), + /** Dailies: scheduling. */ + frequency: z.enum(['daily', 'weekly', 'monthly', 'yearly']).optional(), + repeat: OpaqueObject.optional(), + everyX: z.number().optional(), + startDate: z.string().optional(), + /** Rewards: the gold cost. */ + value: z.number().optional(), + /** Rewards and reminders. */ + reminders: z.array(OpaqueObject).optional(), +}); +export type TasksCreateInput = z.infer; + +const TasksListInputSchema = z.object({ + type: TaskListFilter.optional(), + /** Restricts the list to tasks carrying this tag id. */ + tagId: z.string().optional(), + /** + * `GET /tasks/user` returns every matching task in one response. It takes no + * page or cursor parameter, so there is nothing to paginate with, and a + * long-lived account's whole task list arrives at once. + */ +}); +export type TasksListInput = z.infer; + +const TasksGetInputSchema = z.object({ + /** + * Any task id: a personal task or a challenge task. + * + * This is the operation the catalog calls `HABITICA_GET_CHALLENGE_TASK` and + * displays as "Get Task by ID". The description is the specification - it + * says the operation works for any task "whether it belongs to a challenge + * or is a personal user task" - so it maps to `GET /tasks/:taskId`. Mapping + * it by its id would have pointed it at a challenge route and left + * `/tasks/:taskId` unimplemented. + */ + taskId: z.string(), +}); +export type TasksGetInput = z.infer; + +const TasksUpdateInputSchema = z.object({ + taskId: z.string(), + text: z.string().optional(), + notes: z.string().optional(), + tags: z.array(z.string()).optional(), + priority: z.number().optional(), + attribute: z.enum(['str', 'int', 'con', 'per']).optional(), + collapseChecklist: z.boolean().optional(), + checklist: z.array(ChecklistItemInput).optional(), + up: z.boolean().optional(), + down: z.boolean().optional(), + date: z.string().optional(), + frequency: z.enum(['daily', 'weekly', 'monthly', 'yearly']).optional(), + repeat: OpaqueObject.optional(), + everyX: z.number().optional(), + startDate: z.string().optional(), + value: z.number().optional(), + reminders: z.array(OpaqueObject).optional(), +}); +export type TasksUpdateInput = z.infer; + +const TasksDeleteInputSchema = z.object({ taskId: z.string() }); +export type TasksDeleteInput = z.infer; + +const TasksScoreInputSchema = z.object({ + taskId: z.string(), + /** + * `up` completes a todo or daily, records a positive habit, or buys a + * reward; `down` reverses the first two and records a negative habit. + */ + direction: z.enum(['up', 'down']), +}); +export type TasksScoreInput = z.infer; + +const TasksMoveInputSchema = z.object({ + taskId: z.string(), + /** + * The target index: `0` is the top and `-1` is the bottom. + * + * A completed todo cannot be moved - Habitica answers 400 `Can't move a + * completed todo.` Observed live rather than documented, so a failure here + * is a precondition rather than a malformed request. + */ + position: z.number(), +}); +export type TasksMoveInput = z.infer; + +const TasksUpdateChecklistItemInputSchema = z.object({ + taskId: z.string(), + itemId: z.string(), + text: z.string(), +}); +export type TasksUpdateChecklistItemInput = z.infer< + typeof TasksUpdateChecklistItemInputSchema +>; + +const TasksDeleteChecklistItemInputSchema = z.object({ + taskId: z.string(), + itemId: z.string(), +}); +export type TasksDeleteChecklistItemInput = z.infer< + typeof TasksDeleteChecklistItemInputSchema +>; + +const TasksAddTagInputSchema = z.object({ + taskId: z.string(), + tagId: z.string(), +}); +export type TasksAddTagInput = z.infer; + +const TasksCreateChallengeTaskInputSchema = z.object({ + challengeId: z.string(), + text: z.string(), + type: TaskType, + notes: z.string().optional(), + priority: z.number().optional(), + attribute: z.enum(['str', 'int', 'con', 'per']).optional(), + checklist: z.array(ChecklistItemInput).optional(), + up: z.boolean().optional(), + down: z.boolean().optional(), + date: z.string().optional(), + value: z.number().optional(), +}); +export type TasksCreateChallengeTaskInput = z.infer< + typeof TasksCreateChallengeTaskInputSchema +>; + +const TasksListChallengeTasksInputSchema = z.object({ + challengeId: z.string(), + type: TaskListFilter.optional(), +}); +export type TasksListChallengeTasksInput = z.infer< + typeof TasksListChallengeTasksInputSchema +>; + +const TasksUnlinkAllInputSchema = z.object({ + challengeId: z.string(), + /** `keep-all` leaves the tasks on each member; `remove-all` deletes them. */ + keep: z.enum(['keep-all', 'remove-all']).optional(), +}); +export type TasksUnlinkAllInput = z.infer; + +/** + * What scoring a task returns. + * + * Not the task - the **user's stats after the score**, plus `delta` (how far the + * task's value moved) and `_tmp`, which carries anything the score happened to + * trigger: an item drop, quest progress, a level-up. `_tmp` is genuinely + * occasional, so it is optional rather than assumed. + */ +const TasksScoreResponseSchema = z + .object({ + delta: N, + _tmp: OpaqueObject.nullable().optional(), + hp: N, + mp: N, + exp: N, + gp: N, + lvl: N, + class: S, + points: N, + str: N, + con: N, + int: N, + per: N, + buffs: OpaqueObject.nullable().optional(), + training: OpaqueObject.nullable().optional(), + }) + .loose(); + +/** + * What moving a task returns: the reordered id list for that task's type, not + * the task itself. + */ +const TasksMoveResponseSchema = z.array(z.string()); + +/* -------------------------------------------------------------------------- */ +/* Tags */ +/* -------------------------------------------------------------------------- */ + +const TagsCreateInputSchema = z.object({ name: z.string() }); +export type TagsCreateInput = z.infer; + +const TagsListInputSchema = z.object({}); +export type TagsListInput = z.infer; + +const TagsUpdateInputSchema = z.object({ + tagId: z.string(), + name: z.string(), +}); +export type TagsUpdateInput = z.infer; + +const TagsDeleteInputSchema = z.object({ tagId: z.string() }); +export type TagsDeleteInput = z.infer; + +/* -------------------------------------------------------------------------- */ +/* Challenges */ +/* -------------------------------------------------------------------------- */ + +const ChallengesCreateInputSchema = z.object({ + /** The group the challenge runs in. */ + groupId: z.string(), + name: z.string(), + /** The short tag-like name applied to the challenge's tasks. */ + shortName: z.string(), + summary: z.string().optional(), + description: z.string().optional(), + /** Gems awarded to the winner. Defaults to 0. */ + prize: z.number().optional(), +}); +export type ChallengesCreateInput = z.infer; + +const ChallengesGetInputSchema = z.object({ challengeId: z.string() }); +export type ChallengesGetInput = z.infer; + +const ChallengesCloneInputSchema = z.object({ challengeId: z.string() }); +export type ChallengesCloneInput = z.infer; + +const ChallengesDeleteInputSchema = z.object({ challengeId: z.string() }); +export type ChallengesDeleteInput = z.infer; + +const ChallengesJoinInputSchema = z.object({ challengeId: z.string() }); +export type ChallengesJoinInput = z.infer; + +const ChallengesLeaveInputSchema = z.object({ + challengeId: z.string(), + /** Whether the challenge's tasks stay on the account after leaving. */ + keep: z.enum(['keep-all', 'remove-all']).optional(), +}); +export type ChallengesLeaveInput = z.infer; + +const ChallengesListByGroupInputSchema = z.object({ + groupId: z.string(), +}); +export type ChallengesListByGroupInput = z.infer< + typeof ChallengesListByGroupInputSchema +>; + +const ChallengesListForUserInputSchema = z.object({ + /** + * Required, and zero-indexed. + * + * `GET /challenges/user` answers 400 without it - the server validates + * `page` with `notEmpty().isInt({min:0})`, confirmed live. It is therefore + * not optional here, even though most list operations default their paging. + */ + page: z.number(), + member: z.boolean().optional(), + owned: z.enum(['owned', 'not_owned']).optional(), + search: z.string().optional(), +}); +export type ChallengesListForUserInput = z.infer< + typeof ChallengesListForUserInputSchema +>; + +const ChallengesExportCsvInputSchema = z.object({ challengeId: z.string() }); +export type ChallengesExportCsvInput = z.infer< + typeof ChallengesExportCsvInputSchema +>; + +/** + * A CSV export, returned as text rather than parsed. + * + * The plugin does not parse it into rows: the column set is Habitica's and + * changes with the challenge's tasks, so parsing would invent a schema that the + * next challenge breaks. The caller gets the document and its declared content + * type. + */ +const TextDocumentResponseSchema = z.object({ + body: z.string(), + contentType: z.string(), +}); + +/* -------------------------------------------------------------------------- */ +/* Groups */ +/* -------------------------------------------------------------------------- */ + +/** + * Group types accepted when creating one. + * + * The catalog states that guilds were removed in August 2023 and only `party` + * works, while the catalog's own `GET_GROUPS`, `GET_GROUP` and `DELETE_GROUP` + * entries all describe guild behaviour. Both cannot be current. `guild` is + * accepted here because the API still exposes it and rejecting it in the plugin + * would be this integration inventing a restriction; if Habitica refuses it, + * the caller gets Habitica's own error rather than a guess. + */ +const GroupType = z.enum(['party', 'guild']); + +const GroupsCreateInputSchema = z.object({ + name: z.string(), + type: GroupType, + privacy: z.enum(['private', 'public']).optional(), + description: z.string().optional(), + summary: z.string().optional(), +}); +export type GroupsCreateInput = z.infer; + +const GroupsListInputSchema = z.object({ + /** + * Which groups to list, as a comma-separated set of + * `party`, `guilds`, `privateGuilds`, `publicGuilds`, `tavern`. + */ + type: z.string(), + paginate: z.boolean().optional(), + page: z.number().optional(), +}); +export type GroupsListInput = z.infer; + +const GroupsGetInputSchema = z.object({ + /** A group UUID, or the aliases `party` and `habitrpg` (the Tavern). */ + groupId: z.string(), +}); +export type GroupsGetInput = z.infer; + +/** `GET_PARTY` and `GET_GROUPS_HABITRPG` fix the group id, so they take none. */ +const GroupsGetFixedInputSchema = z.object({}); +export type GroupsGetFixedInput = z.infer; + +const GroupsUpdateInputSchema = z.object({ + groupId: z.string(), + name: z.string().optional(), + description: z.string().optional(), + summary: z.string().optional(), + privacy: z.enum(['private', 'public']).optional(), + leader: z.string().optional(), +}); +export type GroupsUpdateInput = z.infer; + +const GroupsLeaveInputSchema = z.object({ + groupId: z.string(), + /** Whether challenge tasks from the group's challenges are kept. */ + keep: z.enum(['keep-all', 'remove-all']).optional(), + /** Whether the user's own challenges in the group are kept. */ + keepChallenges: z + .enum(['remain-in-challenges', 'leave-challenges']) + .optional(), +}); +export type GroupsLeaveInput = z.infer; + +const GroupsListMembersInputSchema = z.object({ + groupId: z.string(), + /** Cursor: the id of the last member from the previous page. */ + lastId: z.string().optional(), + includeAllPublicFields: z.boolean().optional(), +}); +export type GroupsListMembersInput = z.infer< + typeof GroupsListMembersInputSchema +>; + +const GroupsInviteInputSchema = z.object({ + groupId: z.string(), + /** Invite by user UUID. */ + uuids: z.array(z.string()).optional(), + /** Invite by email. Each entry is `{ name?, email }`. */ + emails: z.array(OpaqueObject).optional(), + /** Invite by username. */ + usernames: z.array(z.string()).optional(), +}); +export type GroupsInviteInput = z.infer; + +const GroupsRemoveMemberInputSchema = z.object({ + groupId: z.string(), + memberId: z.string(), + message: z.string().optional(), +}); +export type GroupsRemoveMemberInput = z.infer< + typeof GroupsRemoveMemberInputSchema +>; + +const GroupsInviteToQuestInputSchema = z.object({ + groupId: z.string(), + /** The quest scroll's content key. The account must own the scroll. */ + questKey: z.string(), +}); +export type GroupsInviteToQuestInput = z.infer< + typeof GroupsInviteToQuestInputSchema +>; + +/** + * A group member. + * + * Deliberately shallow. Members are other people; the operations need their ids + * and display names to be useful, and enumerating the rest of a member document + * here would encourage copying strangers' profile data around. `.loose()` lets + * the full response through to a caller that asked for + * `includeAllPublicFields` without this plugin naming those fields. + */ +const GroupMemberSchema = z + .object({ + id: S, + _id: S, + /** The @handle. */ + auth: OpaqueObject.nullable().optional(), + profile: OpaqueObject.nullable().optional(), + }) + .loose(); + +/* -------------------------------------------------------------------------- */ +/* Chat */ +/* -------------------------------------------------------------------------- */ + +const ChatListInputSchema = z.object({ + /** Defaults to the caller's party. */ + groupId: z.string().optional(), +}); +export type ChatListInput = z.infer; + +const ChatDeleteMessageInputSchema = z.object({ + groupId: z.string(), + chatId: z.string(), + previousMsg: z.string().optional(), +}); +export type ChatDeleteMessageInput = z.infer< + typeof ChatDeleteMessageInputSchema +>; + +const ChatMarkSeenInputSchema = z.object({ + /** `party` for the caller's party, `habitrpg` for the Tavern, or a UUID. */ + groupId: z.string(), +}); +export type ChatMarkSeenInput = z.infer; + +/** + * A chat message. + * + * Modelled only as far as the ids and timestamps. The message body and its + * author's display name are other people's words and identity; they pass + * through to the caller under `.loose()` but are not named as fields this + * plugin depends on, and they are never mirrored or logged. + */ +const ChatMessageSchema = z + .object({ + id: S, + _id: S, + timestamp: z.union([z.string(), z.number()]).nullable().optional(), + }) + .loose(); + +/* -------------------------------------------------------------------------- */ +/* User */ +/* -------------------------------------------------------------------------- */ + +const UserGetInputSchema = z.object({ + /** + * A comma-separated projection, e.g. `stats,items`. + * + * Worth using: the full user document is large and carries the account + * holder's email address under `auth.local.email`. Narrowing the request is + * the cheapest way to avoid handling data the caller did not ask for. + */ + userFields: z.string().optional(), +}); +export type UserGetInput = z.infer; + +const UserUpdateInputSchema = z.object({ + /** + * Fields to set, keyed by **dot path** - `profile.name`, + * `preferences.language`. Some paths are protected and are rejected; + * `stats.class` is the documented example. + */ + updates: z.record(z.string(), z.unknown()), +}); +export type UserUpdateInput = z.infer; + +const UserResetInputSchema = z.object({}); +export type UserResetInput = z.infer; + +const UserEquipInputSchema = z.object({ + /** Which slot group: gear in use, costume, pet, mount, or background. */ + type: z.enum(['equipped', 'costume', 'pet', 'mount', 'background']), + /** The item's content key. */ + key: z.string(), +}); +export type UserEquipInput = z.infer; + +const UserReadCardInputSchema = z.object({ + cardType: z.enum(['birthday', 'greeting', 'nye', 'thankyou', 'valentine']), +}); +export type UserReadCardInput = z.infer; + +const UserMovePinnedItemInputSchema = z.object({ + /** The pinned item's dotted path. */ + path: z.string(), + /** Target index; `0` is the top and `-1` the bottom. */ + position: z.number(), +}); +export type UserMovePinnedItemInput = z.infer< + typeof UserMovePinnedItemInputSchema +>; + +const UserDeleteMessageInputSchema = z.object({ id: z.string() }); +export type UserDeleteMessageInput = z.infer< + typeof UserDeleteMessageInputSchema +>; + +const UserAddPushDeviceInputSchema = z.object({ + /** The device registration id issued by the push service. */ + regId: z.string(), + type: z.enum(['android', 'ios']), +}); +export type UserAddPushDeviceInput = z.infer< + typeof UserAddPushDeviceInputSchema +>; + +const UserDeletePushDeviceInputSchema = z.object({ regId: z.string() }); +export type UserDeletePushDeviceInput = z.infer< + typeof UserDeletePushDeviceInputSchema +>; + +const UserMarkNotificationSeenInputSchema = z.object({ + notificationId: z.string(), +}); +export type UserMarkNotificationSeenInput = z.infer< + typeof UserMarkNotificationSeenInputSchema +>; + +const UserMarkNotificationsSeenInputSchema = z.object({ + notificationIds: z.array(z.string()).optional(), +}); +export type UserMarkNotificationsSeenInput = z.infer< + typeof UserMarkNotificationsSeenInputSchema +>; + +/* -------------------------------------------------------------------------- */ +/* Auth */ +/* -------------------------------------------------------------------------- */ + +/** + * The three operations whose inputs are credentials. + * + * These do not use the plugin's own credential - they mint one. Everything + * about how they are handled is stricter as a result: the inputs are never + * mirrored, never included in an error, and the audit record says only that an + * attempt happened (see `credentialAuditPayload` in `logging.ts`). + */ +const AuthRegisterInputSchema = z.object({ + username: z.string(), + email: z.string(), + password: z.string(), + confirmPassword: z.string(), +}); +export type AuthRegisterInput = z.infer; + +const AuthLoginInputSchema = z.object({ + /** Either the username or the email address. */ + username: z.string(), + password: z.string(), +}); +export type AuthLoginInput = z.infer; + +const AuthSocialInputSchema = z.object({ + network: z.enum(['facebook', 'google', 'apple']), + /** The OAuth credential obtained from the provider. */ + authResponse: OpaqueObject, +}); +export type AuthSocialInput = z.infer; + +/** + * What an authentication call returns: a freshly minted credential pair. + * + * `apiToken` is a live secret. It is returned to the caller, which is the whole + * point of the operation, but it must not be logged, mirrored or included in an + * error message anywhere in this plugin. + */ +const AuthResponseSchema = z + .object({ + id: S, + apiToken: S, + newUser: B, + username: S, + }) + .loose(); + +/* -------------------------------------------------------------------------- */ +/* Webhooks */ +/* -------------------------------------------------------------------------- */ + +/** + * Webhook operations cover create, list and enable only. + * + * The API also has `PUT` (general update) and `DELETE`, and the catalog lists + * neither. That asymmetry is matched rather than corrected: the catalog defines + * the surface, and adding siblings it does not list would put this plugin out + * of step with every other consumer of the same catalog. + */ +const WebhooksCreateInputSchema = z.object({ + url: z.string(), + label: z.string().optional(), + enabled: z.boolean().optional(), + type: z + .enum([ + 'taskActivity', + 'groupChatReceived', + 'userActivity', + 'questActivity', + ]) + .optional(), + options: OpaqueObject.optional(), +}); +export type WebhooksCreateInput = z.infer; + +const WebhooksListInputSchema = z.object({}); +export type WebhooksListInput = z.infer; + +const WebhooksSubscribeInputSchema = z.object({ + /** + * The webhook to enable. + * + * The catalog calls this "Subscribe Webhook" and its own description states + * it is implemented as an update that sets `enabled=true`, so it maps to + * `PUT /user/webhook/:id`. There is no separate subscribe route. + */ + id: z.string(), +}); +export type WebhooksSubscribeInput = z.infer< + typeof WebhooksSubscribeInputSchema +>; + +/* -------------------------------------------------------------------------- */ +/* Content and world state */ +/* -------------------------------------------------------------------------- */ + +const ContentGetInputSchema = z.object({ + /** Two-letter language code for the localised text. Defaults to English. */ + language: z.string().optional(), +}); +export type ContentGetInput = z.infer; + +const ContentGetByTypeInputSchema = z.object({ + /** + * Comma-separated content keys. + * + * **This parameter EXCLUDES what you name; it does not select it.** Verified + * live on 2026-08-15 by comparing key sets, not status codes: the unfiltered + * response carries 56 top-level keys, `filter=quests` carries 55, and the + * one key missing is `quests`. The server's own helper calls this argument + * `removedKeys`. + * + * The catalog describes the opposite - "filtered by a specific category + * type" - so a caller following the catalog receives everything **except** + * what they asked for, with a 200 and no indication anything is wrong. The + * name is kept as the API spells it, and the behaviour is documented here + * rather than silently inverted, because reversing it in the plugin would + * make this integration disagree with every other Habitica client. + * + * An unrecognised key is ignored silently - `filter=notARealContentKey` + * returns the full 56 keys - so a typo costs the caller nothing here, but + * also warns them of nothing. + */ + filter: z.string().optional(), + language: z.string().optional(), +}); +export type ContentGetByTypeInput = z.infer; + +const StatusInputSchema = z.object({}); +export type StatusInput = z.infer; + +const WorldStateInputSchema = z.object({}); +export type WorldStateInput = z.infer; + +const ModelPathsInputSchema = z.object({ + /** + * Which model to describe. + * + * Enumerated by asking the API rather than taken from the catalog, which + * lists "user, group, challenge, tag, or task". `task` is **not** valid and + * returns 400; the four task types are addressed individually instead. + */ + model: z.enum([ + 'user', + 'tag', + 'challenge', + 'group', + 'habit', + 'daily', + 'todo', + 'reward', + ]), +}); +export type ModelPathsInput = z.infer; + +const NewsInputSchema = z.object({}); +export type NewsInput = z.infer; + +const NewsDismissInputSchema = z.object({}); +export type NewsDismissInput = z.infer; + +const ShopInputSchema = z.object({ + language: z.string().optional(), +}); +export type ShopInput = z.infer; + +const ValidateCouponInputSchema = z.object({ code: z.string() }); +export type ValidateCouponInput = z.infer; + +/* -------------------------------------------------------------------------- */ +/* Exports */ +/* -------------------------------------------------------------------------- */ + +const ExportInputSchema = z.object({}); +export type ExportInput = z.infer; + +/** + * The user-data export. + * + * Returned to the caller as parsed JSON, and **never mirrored, never logged and + * never used as a test fixture**: this document contains the account holder's + * email address under `auth.local.email`, along with their whole task and + * message history. + */ +const ExportUserDataResponseSchema = OpaqueObject; + +/* -------------------------------------------------------------------------- */ +/* Registry */ +/* -------------------------------------------------------------------------- */ + +export type HabiticaEndpointInputs = { + tasksCreate: TasksCreateInput; + tasksList: TasksListInput; + tasksGet: TasksGetInput; + tasksUpdate: TasksUpdateInput; + tasksDelete: TasksDeleteInput; + tasksScore: TasksScoreInput; + tasksMove: TasksMoveInput; + tasksUpdateChecklistItem: TasksUpdateChecklistItemInput; + tasksDeleteChecklistItem: TasksDeleteChecklistItemInput; + tasksAddTag: TasksAddTagInput; + tasksCreateChallengeTask: TasksCreateChallengeTaskInput; + tasksListChallengeTasks: TasksListChallengeTasksInput; + tasksUnlinkAllChallengeTasks: TasksUnlinkAllInput; + + tagsCreate: TagsCreateInput; + tagsList: TagsListInput; + tagsUpdate: TagsUpdateInput; + tagsDelete: TagsDeleteInput; + + challengesCreate: ChallengesCreateInput; + challengesGet: ChallengesGetInput; + challengesClone: ChallengesCloneInput; + challengesDelete: ChallengesDeleteInput; + challengesJoin: ChallengesJoinInput; + challengesLeave: ChallengesLeaveInput; + challengesListByGroup: ChallengesListByGroupInput; + challengesListForUser: ChallengesListForUserInput; + challengesExportCsv: ChallengesExportCsvInput; + + groupsCreate: GroupsCreateInput; + groupsList: GroupsListInput; + groupsGet: GroupsGetInput; + groupsGetParty: GroupsGetFixedInput; + groupsGetTavern: GroupsGetFixedInput; + groupsUpdate: GroupsUpdateInput; + groupsLeave: GroupsLeaveInput; + groupsListMembers: GroupsListMembersInput; + groupsInvite: GroupsInviteInput; + groupsRemoveMember: GroupsRemoveMemberInput; + groupsInviteToQuest: GroupsInviteToQuestInput; + + chatList: ChatListInput; + chatDeleteMessage: ChatDeleteMessageInput; + chatMarkSeen: ChatMarkSeenInput; + + userGet: UserGetInput; + userUpdate: UserUpdateInput; + userReset: UserResetInput; + userEquip: UserEquipInput; + userReadCard: UserReadCardInput; + userMovePinnedItem: UserMovePinnedItemInput; + userDeleteMessage: UserDeleteMessageInput; + userAddPushDevice: UserAddPushDeviceInput; + userDeletePushDevice: UserDeletePushDeviceInput; + userMarkNotificationSeen: UserMarkNotificationSeenInput; + userMarkNotificationsSeen: UserMarkNotificationsSeenInput; + + authRegister: AuthRegisterInput; + authLogin: AuthLoginInput; + authSocial: AuthSocialInput; + + webhooksCreate: WebhooksCreateInput; + webhooksList: WebhooksListInput; + webhooksSubscribe: WebhooksSubscribeInput; + + contentGet: ContentGetInput; + contentGetByType: ContentGetByTypeInput; + status: StatusInput; + worldState: WorldStateInput; + modelPaths: ModelPathsInput; + newsGet: NewsInput; + newsDismiss: NewsDismissInput; + shopsMarketGear: ShopInput; + shopsTimeTravelers: ShopInput; + validateCoupon: ValidateCouponInput; + + exportUserData: ExportInput; + exportHistoryCsv: ExportInput; + exportInboxHtml: ExportInput; +}; + +export type HabiticaEndpointOutputs = { + tasksCreate: z.infer; + tasksList: z.infer[]; + tasksGet: z.infer; + tasksUpdate: z.infer; + tasksDelete: z.infer; + tasksScore: z.infer; + tasksMove: z.infer; + tasksUpdateChecklistItem: z.infer; + tasksDeleteChecklistItem: z.infer; + tasksAddTag: z.infer; + tasksCreateChallengeTask: z.infer[]; + tasksListChallengeTasks: z.infer[]; + tasksUnlinkAllChallengeTasks: z.infer; + + tagsCreate: z.infer; + tagsList: z.infer[]; + tagsUpdate: z.infer; + tagsDelete: z.infer; + + challengesCreate: z.infer; + challengesGet: z.infer; + challengesClone: z.infer; + challengesDelete: z.infer; + challengesJoin: z.infer; + challengesLeave: z.infer; + challengesListByGroup: z.infer[]; + challengesListForUser: z.infer[]; + challengesExportCsv: z.infer; + + groupsCreate: z.infer; + groupsList: z.infer[]; + groupsGet: z.infer; + groupsGetParty: z.infer; + groupsGetTavern: z.infer; + groupsUpdate: z.infer; + groupsLeave: z.infer; + groupsListMembers: z.infer[]; + groupsInvite: z.infer[]; + groupsRemoveMember: z.infer; + groupsInviteToQuest: z.infer; + + chatList: z.infer[]; + chatDeleteMessage: z.infer; + chatMarkSeen: z.infer; + + userGet: z.infer; + userUpdate: z.infer; + userReset: z.infer; + userEquip: z.infer; + userReadCard: z.infer; + userMovePinnedItem: z.infer; + userDeleteMessage: z.infer; + userAddPushDevice: z.infer[]; + userDeletePushDevice: z.infer[]; + userMarkNotificationSeen: z.infer; + userMarkNotificationsSeen: z.infer; + + authRegister: z.infer; + authLogin: z.infer; + authSocial: z.infer; + + webhooksCreate: z.infer; + webhooksList: z.infer[]; + webhooksSubscribe: z.infer; + + contentGet: z.infer; + contentGetByType: z.infer; + status: z.infer; + worldState: z.infer; + modelPaths: z.infer; + newsGet: z.infer; + newsDismiss: z.infer; + shopsMarketGear: z.infer; + shopsTimeTravelers: z.infer; + validateCoupon: z.infer; + + exportUserData: z.infer; + exportHistoryCsv: z.infer; + exportInboxHtml: z.infer; +}; + +export const HabiticaEndpointInputSchemas = { + tasksCreate: TasksCreateInputSchema, + tasksList: TasksListInputSchema, + tasksGet: TasksGetInputSchema, + tasksUpdate: TasksUpdateInputSchema, + tasksDelete: TasksDeleteInputSchema, + tasksScore: TasksScoreInputSchema, + tasksMove: TasksMoveInputSchema, + tasksUpdateChecklistItem: TasksUpdateChecklistItemInputSchema, + tasksDeleteChecklistItem: TasksDeleteChecklistItemInputSchema, + tasksAddTag: TasksAddTagInputSchema, + tasksCreateChallengeTask: TasksCreateChallengeTaskInputSchema, + tasksListChallengeTasks: TasksListChallengeTasksInputSchema, + tasksUnlinkAllChallengeTasks: TasksUnlinkAllInputSchema, + + tagsCreate: TagsCreateInputSchema, + tagsList: TagsListInputSchema, + tagsUpdate: TagsUpdateInputSchema, + tagsDelete: TagsDeleteInputSchema, + + challengesCreate: ChallengesCreateInputSchema, + challengesGet: ChallengesGetInputSchema, + challengesClone: ChallengesCloneInputSchema, + challengesDelete: ChallengesDeleteInputSchema, + challengesJoin: ChallengesJoinInputSchema, + challengesLeave: ChallengesLeaveInputSchema, + challengesListByGroup: ChallengesListByGroupInputSchema, + challengesListForUser: ChallengesListForUserInputSchema, + challengesExportCsv: ChallengesExportCsvInputSchema, + + groupsCreate: GroupsCreateInputSchema, + groupsList: GroupsListInputSchema, + groupsGet: GroupsGetInputSchema, + groupsGetParty: GroupsGetFixedInputSchema, + groupsGetTavern: GroupsGetFixedInputSchema, + groupsUpdate: GroupsUpdateInputSchema, + groupsLeave: GroupsLeaveInputSchema, + groupsListMembers: GroupsListMembersInputSchema, + groupsInvite: GroupsInviteInputSchema, + groupsRemoveMember: GroupsRemoveMemberInputSchema, + groupsInviteToQuest: GroupsInviteToQuestInputSchema, + + chatList: ChatListInputSchema, + chatDeleteMessage: ChatDeleteMessageInputSchema, + chatMarkSeen: ChatMarkSeenInputSchema, + + userGet: UserGetInputSchema, + userUpdate: UserUpdateInputSchema, + userReset: UserResetInputSchema, + userEquip: UserEquipInputSchema, + userReadCard: UserReadCardInputSchema, + userMovePinnedItem: UserMovePinnedItemInputSchema, + userDeleteMessage: UserDeleteMessageInputSchema, + userAddPushDevice: UserAddPushDeviceInputSchema, + userDeletePushDevice: UserDeletePushDeviceInputSchema, + userMarkNotificationSeen: UserMarkNotificationSeenInputSchema, + userMarkNotificationsSeen: UserMarkNotificationsSeenInputSchema, + + authRegister: AuthRegisterInputSchema, + authLogin: AuthLoginInputSchema, + authSocial: AuthSocialInputSchema, + + webhooksCreate: WebhooksCreateInputSchema, + webhooksList: WebhooksListInputSchema, + webhooksSubscribe: WebhooksSubscribeInputSchema, + + contentGet: ContentGetInputSchema, + contentGetByType: ContentGetByTypeInputSchema, + status: StatusInputSchema, + worldState: WorldStateInputSchema, + modelPaths: ModelPathsInputSchema, + newsGet: NewsInputSchema, + newsDismiss: NewsDismissInputSchema, + shopsMarketGear: ShopInputSchema, + shopsTimeTravelers: ShopInputSchema, + validateCoupon: ValidateCouponInputSchema, + + exportUserData: ExportInputSchema, + exportHistoryCsv: ExportInputSchema, + exportInboxHtml: ExportInputSchema, +} as const; + +export const HabiticaEndpointOutputSchemas = { + tasksCreate: HabiticaTaskEntity, + tasksList: z.array(HabiticaTaskEntity), + tasksGet: HabiticaTaskEntity, + tasksUpdate: HabiticaTaskEntity, + tasksDelete: EmptyResult, + tasksScore: TasksScoreResponseSchema, + tasksMove: TasksMoveResponseSchema, + tasksUpdateChecklistItem: HabiticaTaskEntity, + tasksDeleteChecklistItem: HabiticaTaskEntity, + tasksAddTag: HabiticaTaskEntity, + tasksCreateChallengeTask: z.array(HabiticaTaskEntity), + tasksListChallengeTasks: z.array(HabiticaTaskEntity), + tasksUnlinkAllChallengeTasks: EmptyResult, + + tagsCreate: HabiticaTagEntity, + tagsList: z.array(HabiticaTagEntity), + tagsUpdate: HabiticaTagEntity, + tagsDelete: EmptyResult, + + challengesCreate: HabiticaChallengeEntity, + challengesGet: HabiticaChallengeEntity, + challengesClone: HabiticaChallengeEntity, + challengesDelete: EmptyResult, + challengesJoin: HabiticaChallengeEntity, + challengesLeave: EmptyResult, + challengesListByGroup: z.array(HabiticaChallengeEntity), + challengesListForUser: z.array(HabiticaChallengeEntity), + challengesExportCsv: TextDocumentResponseSchema, + + groupsCreate: HabiticaGroupEntity, + groupsList: z.array(HabiticaGroupEntity), + groupsGet: HabiticaGroupEntity, + groupsGetParty: HabiticaGroupEntity, + groupsGetTavern: HabiticaGroupEntity, + groupsUpdate: HabiticaGroupEntity, + groupsLeave: EmptyResult, + groupsListMembers: z.array(GroupMemberSchema), + groupsInvite: z.array(OpaqueObject), + groupsRemoveMember: EmptyResult, + groupsInviteToQuest: OpaqueObject, + + chatList: z.array(ChatMessageSchema), + chatDeleteMessage: OpaqueObject, + chatMarkSeen: EmptyResult, + + userGet: OpaqueObject, + userUpdate: OpaqueObject, + userReset: OpaqueObject, + userEquip: OpaqueObject, + userReadCard: OpaqueObject, + userMovePinnedItem: OpaqueObject, + userDeleteMessage: OpaqueObject, + userAddPushDevice: z.array(OpaqueObject), + userDeletePushDevice: z.array(OpaqueObject), + userMarkNotificationSeen: OpaqueObject, + userMarkNotificationsSeen: OpaqueObject, + + authRegister: AuthResponseSchema, + authLogin: AuthResponseSchema, + authSocial: AuthResponseSchema, + + webhooksCreate: HabiticaWebhookEntity, + webhooksList: z.array(HabiticaWebhookEntity), + webhooksSubscribe: HabiticaWebhookEntity, + + contentGet: OpaqueObject, + contentGetByType: OpaqueObject, + status: OpaqueObject, + worldState: OpaqueObject, + modelPaths: OpaqueObject, + newsGet: OpaqueObject, + newsDismiss: EmptyResult, + shopsMarketGear: OpaqueObject, + shopsTimeTravelers: OpaqueObject, + validateCoupon: OpaqueObject, + + exportUserData: ExportUserDataResponseSchema, + exportHistoryCsv: TextDocumentResponseSchema, + exportInboxHtml: TextDocumentResponseSchema, +} as const; + +export { HabiticaChecklistItem }; diff --git a/packages/habitica/endpoints/user.ts b/packages/habitica/endpoints/user.ts new file mode 100644 index 000000000..352cb3f1a --- /dev/null +++ b/packages/habitica/endpoints/user.ts @@ -0,0 +1,263 @@ +import { logEventFromContext } from 'corsair/core'; +import type { HabiticaEndpoints } from '../index'; +import { auditPayload, countOf } from './logging'; +import { clearMirroredTasks } from './persist'; +import { compactQuery, habiticaCall, pathSegment } from './shared'; +import type { HabiticaEndpointOutputs } from './types'; + +/** + * The user document and everything hanging off it. + * + * None of it is mirrored. The user document carries the account holder's email + * address under `auth.local.email`, their profile text and their private + * message history; copying that into local storage is not something an + * integration should do on the caller's behalf. Field *names* are logged where + * useful, values are not. + */ + +/** + * Reads the account's own user document. + * + * `userFields` is worth supplying: the full document is large and includes the + * email address. The projection is passed through untouched so the caller can + * ask for only what they need. + */ +export const get: HabiticaEndpoints['userGet'] = async (ctx, input) => { + const result = await habiticaCall( + ctx, + 'user', + { query: compactQuery({ userFields: input.userFields }) }, + ); + + // The projection is recorded, not the document. + await logEventFromContext( + ctx, + 'habitica.user.get', + auditPayload(input, ['userFields']), + 'completed', + ); + return result; +}; + +/** + * Updates the user document by dot path. + * + * Only the **paths** are audited, never the values: an update can set + * `profile.name` or `profile.blurb`, which are personal text. Some paths are + * protected and rejected by Habitica - `stats.class` is the documented example. + */ +export const update: HabiticaEndpoints['userUpdate'] = async (ctx, input) => { + const result = await habiticaCall( + ctx, + 'user', + { method: 'PUT', body: { ...input.updates } }, + ); + + await logEventFromContext( + ctx, + 'habitica.user.update', + { paths: Object.keys(input.updates) }, + 'completed', + ); + return result; +}; + +/** + * Resets the account to its starting state. + * + * Irreversible, and the most destructive operation in the plugin: every task is + * deleted and the character returns to level 1. It was never exercised against + * a live account during development, and its `riskLevel` is `destructive`. + * + * The mirrored task collection is emptied afterwards. The response is the reset + * user and does not name what it removed, so there are no ids to evict one by + * one - without this the mirror would keep answering with the account's entire + * previous task list. See `clearMirroredTasks` for why that failure warns + * rather than raises. + */ +export const reset: HabiticaEndpoints['userReset'] = async (ctx, input) => { + const result = await habiticaCall( + ctx, + 'user/reset', + { method: 'POST' }, + ); + + await clearMirroredTasks(ctx.db.tasks); + + await logEventFromContext( + ctx, + 'habitica.user.reset', + auditPayload(input, []), + 'completed', + ); + return result; +}; + +/** + * Equips or unequips gear, a costume piece, a pet, a mount or a background. + * + * A toggle rather than a setter: equipping something already equipped unequips + * it. So this is **not** idempotent in the usual sense - replaying the call + * undoes it. Worth knowing before assuming a retry is harmless. + */ +export const equip: HabiticaEndpoints['userEquip'] = async (ctx, input) => { + const result = await habiticaCall( + ctx, + `user/equip/${pathSegment(input.type)}/${pathSegment(input.key)}`, + { method: 'POST' }, + ); + + await logEventFromContext( + ctx, + 'habitica.user.equip', + auditPayload(input, ['type', 'key']), + 'completed', + ); + return result; +}; + +/** Marks a received card as read. */ +export const readCard: HabiticaEndpoints['userReadCard'] = async ( + ctx, + input, +) => { + const result = await habiticaCall( + ctx, + `user/read-card/${pathSegment(input.cardType)}`, + { method: 'POST' }, + ); + + await logEventFromContext( + ctx, + 'habitica.user.readCard', + auditPayload(input, ['cardType']), + 'completed', + ); + return result; +}; + +/** Reorders a pinned item in the rewards column. */ +export const movePinnedItem: HabiticaEndpoints['userMovePinnedItem'] = async ( + ctx, + input, +) => { + const result = await habiticaCall< + HabiticaEndpointOutputs['userMovePinnedItem'] + >( + ctx, + `user/move-pinned-item/${pathSegment(input.path)}/move/to/${pathSegment(input.position)}`, + { method: 'POST' }, + ); + + await logEventFromContext( + ctx, + 'habitica.user.movePinnedItem', + auditPayload(input, ['path', 'position']), + 'completed', + ); + return result; +}; + +/** + * Deletes one message from the account's inbox. + * + * The message id is logged; nothing about its contents or its sender is. + */ +export const deleteMessage: HabiticaEndpoints['userDeleteMessage'] = async ( + ctx, + input, +) => { + const result = await habiticaCall< + HabiticaEndpointOutputs['userDeleteMessage'] + >(ctx, `user/messages/${pathSegment(input.id)}`, { method: 'DELETE' }); + + await logEventFromContext( + ctx, + 'habitica.user.deleteMessage', + auditPayload(input, ['id']), + 'completed', + ); + return result; +}; + +/** + * Registers a push-notification device. + * + * `regId` is a device registration token issued by the push service. It is an + * identifier for someone's physical device, so it is **not** logged - only the + * platform is. + */ +export const addPushDevice: HabiticaEndpoints['userAddPushDevice'] = async ( + ctx, + input, +) => { + const result = await habiticaCall< + HabiticaEndpointOutputs['userAddPushDevice'] + >(ctx, 'user/push-devices', { + method: 'POST', + body: { regId: input.regId, type: input.type }, + }); + + await logEventFromContext( + ctx, + 'habitica.user.addPushDevice', + { type: input.type, devices: countOf(result) }, + 'completed', + ); + return result; +}; + +/** Unregisters a push-notification device. `regId` is not logged. */ +export const deletePushDevice: HabiticaEndpoints['userDeletePushDevice'] = + async (ctx, input) => { + const result = await habiticaCall< + HabiticaEndpointOutputs['userDeletePushDevice'] + >(ctx, `user/push-devices/${pathSegment(input.regId)}`, { + method: 'DELETE', + }); + + await logEventFromContext( + ctx, + 'habitica.user.deletePushDevice', + { devices: countOf(result) }, + 'completed', + ); + return result; + }; + +/** Marks one notification as seen. */ +export const markNotificationSeen: HabiticaEndpoints['userMarkNotificationSeen'] = + async (ctx, input) => { + const result = await habiticaCall< + HabiticaEndpointOutputs['userMarkNotificationSeen'] + >(ctx, `notifications/${pathSegment(input.notificationId)}/see`, { + method: 'POST', + }); + + await logEventFromContext( + ctx, + 'habitica.user.markNotificationSeen', + auditPayload(input, ['notificationId']), + 'completed', + ); + return result; + }; + +/** Marks several notifications as seen. */ +export const markNotificationsSeen: HabiticaEndpoints['userMarkNotificationsSeen'] = + async (ctx, input) => { + const result = await habiticaCall< + HabiticaEndpointOutputs['userMarkNotificationsSeen'] + >(ctx, 'notifications/see', { + method: 'POST', + body: { notificationIds: input.notificationIds ?? [] }, + }); + + await logEventFromContext( + ctx, + 'habitica.user.markNotificationsSeen', + { notifications: countOf(input.notificationIds) }, + 'completed', + ); + return result; + }; diff --git a/packages/habitica/endpoints/webhooks.ts b/packages/habitica/endpoints/webhooks.ts new file mode 100644 index 000000000..5fc10ec77 --- /dev/null +++ b/packages/habitica/endpoints/webhooks.ts @@ -0,0 +1,107 @@ +import { logEventFromContext } from 'corsair/core'; +import type { HabiticaEndpoints } from '../index'; +import { HabiticaWebhookEntity } from '../schema/database'; +import { auditPayload } from './logging'; +import { cacheEntities, cacheEntity } from './persist'; +import { compactBody, habiticaCall, pathSegment } from './shared'; +import type { HabiticaEndpointOutputs } from './types'; + +const LABEL = 'webhook'; + +/** + * The user's **outbound** webhooks - Habitica calling a URL of their choosing. + * + * These are not Corsair webhooks and this plugin registers no webhook handlers: + * the catalog lists no triggers for Habitica, and these three appear in it as + * ordinary operations. They are implemented as such. + * + * The surface is create, list and enable only. The API also has a general + * update and a delete, and the catalog lists neither, so neither is added here + * - the catalog defines the surface, and inventing siblings would put this + * plugin out of step with other consumers of the same catalog. + */ + +/** Registers a webhook. */ +export const create: HabiticaEndpoints['webhooksCreate'] = async ( + ctx, + input, +) => { + const result = await habiticaCall( + ctx, + 'user/webhook', + { method: 'POST', body: compactBody({ ...input }) }, + ); + + await cacheEntity(ctx.db.webhooks, HabiticaWebhookEntity, result, { + label: LABEL, + }); + + // The target URL is the caller's own endpoint and can carry a secret in its + // path or query, so it is not logged. + await logEventFromContext( + ctx, + 'habitica.webhooks.create', + auditPayload(input, ['type', 'enabled']), + 'completed', + ); + return result; +}; + +/** + * Lists the account's webhooks. + * + * Worth mirroring for `failures`, Habitica's consecutive-delivery-failure + * counter - it is the only health signal the API offers, and Habitica disables + * a webhook once it reaches 10. + */ +export const list: HabiticaEndpoints['webhooksList'] = async (ctx, input) => { + const result = await habiticaCall( + ctx, + 'user/webhook', + ); + + await cacheEntities(ctx.db.webhooks, HabiticaWebhookEntity, result, { + label: LABEL, + }); + + await logEventFromContext( + ctx, + 'habitica.webhooks.list', + { ...auditPayload(input, []), returned: result.length }, + 'completed', + ); + return result; +}; + +/** + * Enables a webhook. + * + * The catalog calls this "Subscribe Webhook", and its own description says it + * is an update that sets `enabled=true`. There is no subscribe route; this is + * `PUT /user/webhook/:id` with a single field. + * + * Idempotent: enabling an already-enabled webhook leaves it enabled. + */ +export const subscribe: HabiticaEndpoints['webhooksSubscribe'] = async ( + ctx, + input, +) => { + const result = await habiticaCall< + HabiticaEndpointOutputs['webhooksSubscribe'] + >(ctx, `user/webhook/${pathSegment(input.id)}`, { + method: 'PUT', + body: { enabled: true }, + }); + + await cacheEntity(ctx.db.webhooks, HabiticaWebhookEntity, result, { + label: LABEL, + }); + + await logEventFromContext( + ctx, + 'habitica.webhooks.subscribe', + auditPayload(input, ['id']), + 'completed', + ); + return result; +}; diff --git a/packages/habitica/error-handlers.test.ts b/packages/habitica/error-handlers.test.ts new file mode 100644 index 000000000..45346f9ea --- /dev/null +++ b/packages/habitica/error-handlers.test.ts @@ -0,0 +1,148 @@ +/** + * Error classification. + * + * This matters more than it looks because the plugin has **two** transports. + * Most operations go through the shared `request` helper, which wraps a failure + * as an `ApiError` carrying a status. The four non-JSON operations - the three + * `/export/*` documents and the challenge CSV - use `fetch` directly and throw + * a plain `Error`, because the shared transport parses every body as JSON. + * + * So every handler is checked twice: once against an `ApiError`, and once + * against the plain-`Error` message the raw-fetch path actually produces. A + * handler that only recognises the first would silently stop retrying rate + * limits on exactly the operations most likely to be slow. + */ +import { ApiError } from 'corsair/http'; +import { errorHandlers } from './error-handlers'; + +/** Builds an ApiError the way the shared transport does. */ +function apiError(status: number, body: unknown, message = 'request failed') { + return new ApiError( + { method: 'GET', url: 'user' }, + { + url: 'https://habitica.com/api/v3/user', + ok: false, + status, + statusText: '', + body, + }, + message, + ); +} + +/** The message `makeHabiticaExportRequest` throws on a failed export. */ +const exportError = (status: number, statusText: string) => + new Error( + `Habitica export userdata.json returned HTTP ${status} ${statusText}`, + ); + +/** The message `makeHabiticaTextRequest` throws for the challenge CSV. */ +const textError = (status: number, statusText: string) => + new Error( + `Habitica challenges/challenge-1/export/csv returned HTTP ${status} ${statusText}`, + ); + +/** The first handler whose `match` accepts the error, in declaration order. */ +function classify(error: Error): string { + for (const [name, handler] of Object.entries(errorHandlers)) { + if (handler.match(error)) return name; + } + return 'UNMATCHED'; +} + +describe('Habitica error handlers', () => { + describe('rate limiting', () => { + it('classifies a 429 from the shared transport', () => { + expect( + classify(apiError(429, { success: false, error: 'TooManyRequests' })), + ).toBe('RATE_LIMIT_ERROR'); + }); + + it('classifies a 429 from the raw-fetch export path', () => { + // No ApiError here, so only the message can carry the signal. + expect(classify(exportError(429, 'Too Many Requests'))).toBe( + 'RATE_LIMIT_ERROR', + ); + expect(classify(textError(429, 'Too Many Requests'))).toBe( + 'RATE_LIMIT_ERROR', + ); + }); + + it("matches Habitica's own error code, not a generic spelling", () => { + // The scaffold matched 'rate_limited', which Habitica never sends. + expect(classify(new Error('TooManyRequests'))).toBe('RATE_LIMIT_ERROR'); + expect(classify(new Error('rate_limited'))).not.toBe('RATE_LIMIT_ERROR'); + }); + + it('retries, and honours a retry-after when the transport parsed one', async () => { + const error = apiError(429, {}); + (error as { retryAfter?: number }).retryAfter = 21_000; + + const result = await errorHandlers.RATE_LIMIT_ERROR.handler(error); + + expect(result.maxRetries).toBeGreaterThan(0); + expect(result.headersRetryAfterMs).toBe(21_000); + }); + + it('still retries when no retry-after was available', async () => { + // The raw-fetch path never populates retryAfter, so the backoff has to + // stand on its own. + const result = await errorHandlers.RATE_LIMIT_ERROR.handler( + exportError(429, 'Too Many Requests'), + ); + + expect(result.maxRetries).toBeGreaterThan(0); + expect(result.headersRetryAfterMs).toBeUndefined(); + }); + }); + + describe('authentication', () => { + it('classifies a 401 from either transport', () => { + expect(classify(apiError(401, { error: 'NotAuthorized' }))).toBe( + 'AUTH_ERROR', + ); + expect(classify(new Error('NotAuthorized'))).toBe('AUTH_ERROR'); + expect(classify(new Error('invalid_credentials'))).toBe('AUTH_ERROR'); + }); + + it('never retries an authentication failure', async () => { + // The same credential will fail again. The handler takes no argument - + // there is nothing about the error that could change the answer. + const result = await errorHandlers.AUTH_ERROR.handler(); + expect(result.maxRetries).toBe(0); + }); + }); + + describe('the missing x-client header', () => { + it('is a 400, not a 401, and is classified on its own', () => { + // Worth separating because it is the failure most likely to be misread: + // it looks like an auth problem and is not. + expect(classify(new Error('Missing x-client headers.'))).toBe( + 'CLIENT_HEADER_ERROR', + ); + }); + + it('does not retry, because no retry can add the header', async () => { + const result = await errorHandlers.CLIENT_HEADER_ERROR.handler(); + expect(result.maxRetries).toBe(0); + }); + }); + + describe('everything else', () => { + it('falls through to DEFAULT without retrying', async () => { + expect(classify(apiError(404, { error: 'NotFound' }))).toBe('DEFAULT'); + expect(classify(new Error('something unexpected'))).toBe('DEFAULT'); + + const result = await errorHandlers.DEFAULT.handler(); + expect(result.maxRetries).toBe(0); + }); + + it('does not misclassify a 404 as a rate limit', () => { + // A path containing "429" would be a nasty false positive; check the + // message match is not that loose in practice. + expect(classify(apiError(404, { error: 'NotFound' }))).not.toBe( + 'RATE_LIMIT_ERROR', + ); + }); + }); +}); diff --git a/packages/habitica/error-handlers.ts b/packages/habitica/error-handlers.ts new file mode 100644 index 000000000..35c8d45c6 --- /dev/null +++ b/packages/habitica/error-handlers.ts @@ -0,0 +1,99 @@ +import type { CorsairErrorHandler } from 'corsair/core'; +import { ApiError } from 'corsair/http'; + +/** + * Habitica reports every failure through one envelope: + * + * ```json + * {"success": false, "error": "TooManyRequests", "message": "..."} + * ``` + * + * The `error` field is a stable machine code, so the string fallbacks below + * match Habitica's own vocabulary rather than the generic `rate_limited` / + * `invalid_auth` spellings the scaffold assumes - which Habitica never sends. + * The codes used here were all observed live on 2026-08-15: + * + * | status | `error` | + * | ------ | --------------------------------------------- | + * | 400 | `BadRequest` | + * | 401 | `NotAuthorized`, `invalid_credentials` | + * | 404 | `NotFound` | + * | 429 | `TooManyRequests` | + * + * The status code is the primary signal; the message match only matters when an + * error reaches here without having been wrapped as an `ApiError`. + */ +export const errorHandlers = { + /** + * 30 requests per minute per user id, confirmed exactly: the 30th request in + * a burst was the one refused. + * + * Habitica sends `retry-after` in **fractional seconds** (`"21.069"`). The + * transport parses that with `parseInt`, truncating to 21, so the first retry + * can fire a fraction of a second early and draw one further 429 before the + * backoff spaces things out. That is expected rather than a defect - see the + * rate-limit notes in `client.ts`. + * + * `maxRetries` is 5 rather than the transport's 3 because the limit is a + * fixed one-minute window: waiting is genuinely sufficient here, unlike a + * quota that will not reset for a day. + */ + RATE_LIMIT_ERROR: { + match: (error: Error) => { + if (error instanceof ApiError && error.status === 429) return true; + const msg = error.message.toLowerCase(); + return msg.includes('toomanyrequests') || msg.includes('429'); + }, + handler: async (error: Error) => { + let retryAfterMs: number | undefined; + if (error instanceof ApiError && error.retryAfter !== undefined) { + retryAfterMs = error.retryAfter; + } + return { maxRetries: 5, headersRetryAfterMs: retryAfterMs }; + }, + }, + + /** + * Authentication failures, which are never retried - the same credential + * will fail again. + * + * Worth knowing when diagnosing one: Habitica gives the **same** 401 + * `invalid_credentials` / "There is no account that uses those credentials." + * for a wrong token *and* for a wrong user id. The two halves of the + * credential are not distinguishable from the response, so a failure here + * means "one of the two is wrong", not "the token is wrong". + * + * A missing header is different and says so: 401 `NotAuthorized` / + * "Missing authentication headers." + */ + AUTH_ERROR: { + match: (error: Error) => { + if (error instanceof ApiError && error.status === 401) return true; + const msg = error.message.toLowerCase(); + return ( + msg.includes('notauthorized') || msg.includes('invalid_credentials') + ); + }, + handler: async () => ({ maxRetries: 0 }), + }, + + /** + * A missing or empty `x-client` header, which Habitica answers **400 + * BadRequest**, not 401 - even on routes that need no authentication. + * + * This is called out as its own handler because it is the failure most + * likely to be misread. The plugin always sends the header, so seeing this + * in practice points at the transport being bypassed rather than at the + * caller's input, and no retry will fix it. + */ + CLIENT_HEADER_ERROR: { + match: (error: Error) => + error.message.toLowerCase().includes('missing x-client headers'), + handler: async () => ({ maxRetries: 0 }), + }, + + DEFAULT: { + match: () => true, + handler: async () => ({ maxRetries: 0 }), + }, +} satisfies CorsairErrorHandler; diff --git a/packages/habitica/index.ts b/packages/habitica/index.ts new file mode 100644 index 000000000..518668960 --- /dev/null +++ b/packages/habitica/index.ts @@ -0,0 +1,896 @@ +import type { + AuthTypes, + BindEndpoints, + CorsairEndpoint, + CorsairErrorHandler, + CorsairPlugin, + CorsairPluginContext, + KeyBuilderContext, + PickAuth, + PluginAuthConfig, + PluginPermissionsConfig, + RequiredPluginEndpointMeta, + RequiredPluginEndpointSchemas, +} from 'corsair/core'; +import { + Auth, + Challenges, + Chat, + Content, + Exports, + Groups, + Tags, + Tasks, + User, + Webhooks, +} from './endpoints'; +import type { + HabiticaEndpointInputs, + HabiticaEndpointOutputs, +} from './endpoints/types'; +import { + HabiticaEndpointInputSchemas, + HabiticaEndpointOutputSchemas, +} from './endpoints/types'; +import { errorHandlers } from './error-handlers'; +import { HabiticaSchema } from './schema'; + +export type HabiticaPluginOptions = { + authType?: PickAuth<'api_key'>; + key?: string; + /** + * The account's Habitica user id, sent as `x-api-user`. + * + * Habitica's credential has two halves and both are checked: a valid token + * paired with a different account's id is answered 401 `There is no account + * that uses those credentials.` So this is a second credential, not a + * routing hint the token implies. + * + * When omitted the plugin falls back to the stored `user_id` key. There is + * deliberately no discovery step - unlike Harvest, which can ask which + * accounts a token reaches, every authenticated Habitica route already needs + * the user id, so no route exists that could discover it. + */ + userId?: string; + hooks?: InternalHabiticaPlugin['hooks']; + errorHandlers?: CorsairErrorHandler; + permissions?: PluginPermissionsConfig; +}; + +/** + * Habitica authenticates with an API token plus the account's user id. + * + * The user id is declared as an account-scoped key so it can be stored + * alongside the token rather than having to be passed on every call. + */ +export const habiticaAuthConfig = { + api_key: { + account: ['user_id'] as const, + }, +} as const satisfies PluginAuthConfig; + +export type HabiticaContext = CorsairPluginContext< + typeof HabiticaSchema, + HabiticaPluginOptions, + undefined, + typeof habiticaAuthConfig +>; + +export type HabiticaKeyBuilderContext = + KeyBuilderContext; + +export type HabiticaBoundEndpoints = BindEndpoints< + typeof habiticaEndpointsNested +>; + +type HabiticaEndpoint = + CorsairEndpoint< + HabiticaContext, + HabiticaEndpointInputs[K], + HabiticaEndpointOutputs[K] + >; + +export type HabiticaEndpoints = { + tasksCreate: HabiticaEndpoint<'tasksCreate'>; + tasksList: HabiticaEndpoint<'tasksList'>; + tasksGet: HabiticaEndpoint<'tasksGet'>; + tasksUpdate: HabiticaEndpoint<'tasksUpdate'>; + tasksDelete: HabiticaEndpoint<'tasksDelete'>; + tasksScore: HabiticaEndpoint<'tasksScore'>; + tasksMove: HabiticaEndpoint<'tasksMove'>; + tasksUpdateChecklistItem: HabiticaEndpoint<'tasksUpdateChecklistItem'>; + tasksDeleteChecklistItem: HabiticaEndpoint<'tasksDeleteChecklistItem'>; + tasksAddTag: HabiticaEndpoint<'tasksAddTag'>; + tasksCreateChallengeTask: HabiticaEndpoint<'tasksCreateChallengeTask'>; + tasksListChallengeTasks: HabiticaEndpoint<'tasksListChallengeTasks'>; + tasksUnlinkAllChallengeTasks: HabiticaEndpoint<'tasksUnlinkAllChallengeTasks'>; + + tagsCreate: HabiticaEndpoint<'tagsCreate'>; + tagsList: HabiticaEndpoint<'tagsList'>; + tagsUpdate: HabiticaEndpoint<'tagsUpdate'>; + tagsDelete: HabiticaEndpoint<'tagsDelete'>; + + challengesCreate: HabiticaEndpoint<'challengesCreate'>; + challengesGet: HabiticaEndpoint<'challengesGet'>; + challengesClone: HabiticaEndpoint<'challengesClone'>; + challengesDelete: HabiticaEndpoint<'challengesDelete'>; + challengesJoin: HabiticaEndpoint<'challengesJoin'>; + challengesLeave: HabiticaEndpoint<'challengesLeave'>; + challengesListByGroup: HabiticaEndpoint<'challengesListByGroup'>; + challengesListForUser: HabiticaEndpoint<'challengesListForUser'>; + challengesExportCsv: HabiticaEndpoint<'challengesExportCsv'>; + + groupsCreate: HabiticaEndpoint<'groupsCreate'>; + groupsList: HabiticaEndpoint<'groupsList'>; + groupsGet: HabiticaEndpoint<'groupsGet'>; + groupsGetParty: HabiticaEndpoint<'groupsGetParty'>; + groupsGetTavern: HabiticaEndpoint<'groupsGetTavern'>; + groupsUpdate: HabiticaEndpoint<'groupsUpdate'>; + groupsLeave: HabiticaEndpoint<'groupsLeave'>; + groupsListMembers: HabiticaEndpoint<'groupsListMembers'>; + groupsInvite: HabiticaEndpoint<'groupsInvite'>; + groupsRemoveMember: HabiticaEndpoint<'groupsRemoveMember'>; + groupsInviteToQuest: HabiticaEndpoint<'groupsInviteToQuest'>; + + chatList: HabiticaEndpoint<'chatList'>; + chatDeleteMessage: HabiticaEndpoint<'chatDeleteMessage'>; + chatMarkSeen: HabiticaEndpoint<'chatMarkSeen'>; + + userGet: HabiticaEndpoint<'userGet'>; + userUpdate: HabiticaEndpoint<'userUpdate'>; + userReset: HabiticaEndpoint<'userReset'>; + userEquip: HabiticaEndpoint<'userEquip'>; + userReadCard: HabiticaEndpoint<'userReadCard'>; + userMovePinnedItem: HabiticaEndpoint<'userMovePinnedItem'>; + userDeleteMessage: HabiticaEndpoint<'userDeleteMessage'>; + userAddPushDevice: HabiticaEndpoint<'userAddPushDevice'>; + userDeletePushDevice: HabiticaEndpoint<'userDeletePushDevice'>; + userMarkNotificationSeen: HabiticaEndpoint<'userMarkNotificationSeen'>; + userMarkNotificationsSeen: HabiticaEndpoint<'userMarkNotificationsSeen'>; + + authRegister: HabiticaEndpoint<'authRegister'>; + authLogin: HabiticaEndpoint<'authLogin'>; + authSocial: HabiticaEndpoint<'authSocial'>; + + webhooksCreate: HabiticaEndpoint<'webhooksCreate'>; + webhooksList: HabiticaEndpoint<'webhooksList'>; + webhooksSubscribe: HabiticaEndpoint<'webhooksSubscribe'>; + + contentGet: HabiticaEndpoint<'contentGet'>; + contentGetByType: HabiticaEndpoint<'contentGetByType'>; + status: HabiticaEndpoint<'status'>; + worldState: HabiticaEndpoint<'worldState'>; + modelPaths: HabiticaEndpoint<'modelPaths'>; + newsGet: HabiticaEndpoint<'newsGet'>; + newsDismiss: HabiticaEndpoint<'newsDismiss'>; + shopsMarketGear: HabiticaEndpoint<'shopsMarketGear'>; + shopsTimeTravelers: HabiticaEndpoint<'shopsTimeTravelers'>; + validateCoupon: HabiticaEndpoint<'validateCoupon'>; + + exportUserData: HabiticaEndpoint<'exportUserData'>; + exportHistoryCsv: HabiticaEndpoint<'exportHistoryCsv'>; + exportInboxHtml: HabiticaEndpoint<'exportInboxHtml'>; +}; + +const habiticaEndpointsNested = { + tasks: { + create: Tasks.create, + list: Tasks.list, + get: Tasks.get, + update: Tasks.update, + delete: Tasks.remove, + score: Tasks.score, + move: Tasks.move, + updateChecklistItem: Tasks.updateChecklistItem, + deleteChecklistItem: Tasks.deleteChecklistItem, + addTag: Tasks.addTag, + createChallengeTask: Tasks.createChallengeTask, + listChallengeTasks: Tasks.listChallengeTasks, + unlinkAllChallengeTasks: Tasks.unlinkAllChallengeTasks, + }, + tags: { + create: Tags.create, + list: Tags.list, + update: Tags.update, + delete: Tags.remove, + }, + challenges: { + create: Challenges.create, + get: Challenges.get, + clone: Challenges.clone, + delete: Challenges.remove, + join: Challenges.join, + leave: Challenges.leave, + listByGroup: Challenges.listByGroup, + listForUser: Challenges.listForUser, + exportCsv: Challenges.exportCsv, + }, + groups: { + create: Groups.create, + list: Groups.list, + get: Groups.get, + getParty: Groups.getParty, + getTavern: Groups.getTavern, + update: Groups.update, + leave: Groups.leave, + listMembers: Groups.listMembers, + invite: Groups.invite, + removeMember: Groups.removeMember, + inviteToQuest: Groups.inviteToQuest, + }, + chat: { + list: Chat.list, + deleteMessage: Chat.deleteMessage, + markSeen: Chat.markSeen, + }, + user: { + get: User.get, + update: User.update, + reset: User.reset, + equip: User.equip, + readCard: User.readCard, + movePinnedItem: User.movePinnedItem, + deleteMessage: User.deleteMessage, + addPushDevice: User.addPushDevice, + deletePushDevice: User.deletePushDevice, + markNotificationSeen: User.markNotificationSeen, + markNotificationsSeen: User.markNotificationsSeen, + }, + auth: { + register: Auth.register, + login: Auth.login, + social: Auth.social, + }, + webhooks: { + create: Webhooks.create, + list: Webhooks.list, + subscribe: Webhooks.subscribe, + }, + content: { + get: Content.get, + getByType: Content.getByType, + status: Content.status, + worldState: Content.worldState, + modelPaths: Content.modelPaths, + news: Content.news, + dismissNews: Content.dismissNews, + marketGear: Content.marketGear, + timeTravelers: Content.timeTravelers, + validateCoupon: Content.validateCoupon, + }, + exports: { + userData: Exports.userData, + history: Exports.history, + inbox: Exports.inbox, + }, +} as const; + +export const habiticaEndpointSchemas = { + 'tasks.create': { + input: HabiticaEndpointInputSchemas.tasksCreate, + output: HabiticaEndpointOutputSchemas.tasksCreate, + }, + 'tasks.list': { + input: HabiticaEndpointInputSchemas.tasksList, + output: HabiticaEndpointOutputSchemas.tasksList, + }, + 'tasks.get': { + input: HabiticaEndpointInputSchemas.tasksGet, + output: HabiticaEndpointOutputSchemas.tasksGet, + }, + 'tasks.update': { + input: HabiticaEndpointInputSchemas.tasksUpdate, + output: HabiticaEndpointOutputSchemas.tasksUpdate, + }, + 'tasks.delete': { + input: HabiticaEndpointInputSchemas.tasksDelete, + output: HabiticaEndpointOutputSchemas.tasksDelete, + }, + 'tasks.score': { + input: HabiticaEndpointInputSchemas.tasksScore, + output: HabiticaEndpointOutputSchemas.tasksScore, + }, + 'tasks.move': { + input: HabiticaEndpointInputSchemas.tasksMove, + output: HabiticaEndpointOutputSchemas.tasksMove, + }, + 'tasks.updateChecklistItem': { + input: HabiticaEndpointInputSchemas.tasksUpdateChecklistItem, + output: HabiticaEndpointOutputSchemas.tasksUpdateChecklistItem, + }, + 'tasks.deleteChecklistItem': { + input: HabiticaEndpointInputSchemas.tasksDeleteChecklistItem, + output: HabiticaEndpointOutputSchemas.tasksDeleteChecklistItem, + }, + 'tasks.addTag': { + input: HabiticaEndpointInputSchemas.tasksAddTag, + output: HabiticaEndpointOutputSchemas.tasksAddTag, + }, + 'tasks.createChallengeTask': { + input: HabiticaEndpointInputSchemas.tasksCreateChallengeTask, + output: HabiticaEndpointOutputSchemas.tasksCreateChallengeTask, + }, + 'tasks.listChallengeTasks': { + input: HabiticaEndpointInputSchemas.tasksListChallengeTasks, + output: HabiticaEndpointOutputSchemas.tasksListChallengeTasks, + }, + 'tasks.unlinkAllChallengeTasks': { + input: HabiticaEndpointInputSchemas.tasksUnlinkAllChallengeTasks, + output: HabiticaEndpointOutputSchemas.tasksUnlinkAllChallengeTasks, + }, + + 'tags.create': { + input: HabiticaEndpointInputSchemas.tagsCreate, + output: HabiticaEndpointOutputSchemas.tagsCreate, + }, + 'tags.list': { + input: HabiticaEndpointInputSchemas.tagsList, + output: HabiticaEndpointOutputSchemas.tagsList, + }, + 'tags.update': { + input: HabiticaEndpointInputSchemas.tagsUpdate, + output: HabiticaEndpointOutputSchemas.tagsUpdate, + }, + 'tags.delete': { + input: HabiticaEndpointInputSchemas.tagsDelete, + output: HabiticaEndpointOutputSchemas.tagsDelete, + }, + + 'challenges.create': { + input: HabiticaEndpointInputSchemas.challengesCreate, + output: HabiticaEndpointOutputSchemas.challengesCreate, + }, + 'challenges.get': { + input: HabiticaEndpointInputSchemas.challengesGet, + output: HabiticaEndpointOutputSchemas.challengesGet, + }, + 'challenges.clone': { + input: HabiticaEndpointInputSchemas.challengesClone, + output: HabiticaEndpointOutputSchemas.challengesClone, + }, + 'challenges.delete': { + input: HabiticaEndpointInputSchemas.challengesDelete, + output: HabiticaEndpointOutputSchemas.challengesDelete, + }, + 'challenges.join': { + input: HabiticaEndpointInputSchemas.challengesJoin, + output: HabiticaEndpointOutputSchemas.challengesJoin, + }, + 'challenges.leave': { + input: HabiticaEndpointInputSchemas.challengesLeave, + output: HabiticaEndpointOutputSchemas.challengesLeave, + }, + 'challenges.listByGroup': { + input: HabiticaEndpointInputSchemas.challengesListByGroup, + output: HabiticaEndpointOutputSchemas.challengesListByGroup, + }, + 'challenges.listForUser': { + input: HabiticaEndpointInputSchemas.challengesListForUser, + output: HabiticaEndpointOutputSchemas.challengesListForUser, + }, + 'challenges.exportCsv': { + input: HabiticaEndpointInputSchemas.challengesExportCsv, + output: HabiticaEndpointOutputSchemas.challengesExportCsv, + }, + + 'groups.create': { + input: HabiticaEndpointInputSchemas.groupsCreate, + output: HabiticaEndpointOutputSchemas.groupsCreate, + }, + 'groups.list': { + input: HabiticaEndpointInputSchemas.groupsList, + output: HabiticaEndpointOutputSchemas.groupsList, + }, + 'groups.get': { + input: HabiticaEndpointInputSchemas.groupsGet, + output: HabiticaEndpointOutputSchemas.groupsGet, + }, + 'groups.getParty': { + input: HabiticaEndpointInputSchemas.groupsGetParty, + output: HabiticaEndpointOutputSchemas.groupsGetParty, + }, + 'groups.getTavern': { + input: HabiticaEndpointInputSchemas.groupsGetTavern, + output: HabiticaEndpointOutputSchemas.groupsGetTavern, + }, + 'groups.update': { + input: HabiticaEndpointInputSchemas.groupsUpdate, + output: HabiticaEndpointOutputSchemas.groupsUpdate, + }, + 'groups.leave': { + input: HabiticaEndpointInputSchemas.groupsLeave, + output: HabiticaEndpointOutputSchemas.groupsLeave, + }, + 'groups.listMembers': { + input: HabiticaEndpointInputSchemas.groupsListMembers, + output: HabiticaEndpointOutputSchemas.groupsListMembers, + }, + 'groups.invite': { + input: HabiticaEndpointInputSchemas.groupsInvite, + output: HabiticaEndpointOutputSchemas.groupsInvite, + }, + 'groups.removeMember': { + input: HabiticaEndpointInputSchemas.groupsRemoveMember, + output: HabiticaEndpointOutputSchemas.groupsRemoveMember, + }, + 'groups.inviteToQuest': { + input: HabiticaEndpointInputSchemas.groupsInviteToQuest, + output: HabiticaEndpointOutputSchemas.groupsInviteToQuest, + }, + + 'chat.list': { + input: HabiticaEndpointInputSchemas.chatList, + output: HabiticaEndpointOutputSchemas.chatList, + }, + 'chat.deleteMessage': { + input: HabiticaEndpointInputSchemas.chatDeleteMessage, + output: HabiticaEndpointOutputSchemas.chatDeleteMessage, + }, + 'chat.markSeen': { + input: HabiticaEndpointInputSchemas.chatMarkSeen, + output: HabiticaEndpointOutputSchemas.chatMarkSeen, + }, + + 'user.get': { + input: HabiticaEndpointInputSchemas.userGet, + output: HabiticaEndpointOutputSchemas.userGet, + }, + 'user.update': { + input: HabiticaEndpointInputSchemas.userUpdate, + output: HabiticaEndpointOutputSchemas.userUpdate, + }, + 'user.reset': { + input: HabiticaEndpointInputSchemas.userReset, + output: HabiticaEndpointOutputSchemas.userReset, + }, + 'user.equip': { + input: HabiticaEndpointInputSchemas.userEquip, + output: HabiticaEndpointOutputSchemas.userEquip, + }, + 'user.readCard': { + input: HabiticaEndpointInputSchemas.userReadCard, + output: HabiticaEndpointOutputSchemas.userReadCard, + }, + 'user.movePinnedItem': { + input: HabiticaEndpointInputSchemas.userMovePinnedItem, + output: HabiticaEndpointOutputSchemas.userMovePinnedItem, + }, + 'user.deleteMessage': { + input: HabiticaEndpointInputSchemas.userDeleteMessage, + output: HabiticaEndpointOutputSchemas.userDeleteMessage, + }, + 'user.addPushDevice': { + input: HabiticaEndpointInputSchemas.userAddPushDevice, + output: HabiticaEndpointOutputSchemas.userAddPushDevice, + }, + 'user.deletePushDevice': { + input: HabiticaEndpointInputSchemas.userDeletePushDevice, + output: HabiticaEndpointOutputSchemas.userDeletePushDevice, + }, + 'user.markNotificationSeen': { + input: HabiticaEndpointInputSchemas.userMarkNotificationSeen, + output: HabiticaEndpointOutputSchemas.userMarkNotificationSeen, + }, + 'user.markNotificationsSeen': { + input: HabiticaEndpointInputSchemas.userMarkNotificationsSeen, + output: HabiticaEndpointOutputSchemas.userMarkNotificationsSeen, + }, + + 'auth.register': { + input: HabiticaEndpointInputSchemas.authRegister, + output: HabiticaEndpointOutputSchemas.authRegister, + }, + 'auth.login': { + input: HabiticaEndpointInputSchemas.authLogin, + output: HabiticaEndpointOutputSchemas.authLogin, + }, + 'auth.social': { + input: HabiticaEndpointInputSchemas.authSocial, + output: HabiticaEndpointOutputSchemas.authSocial, + }, + + 'webhooks.create': { + input: HabiticaEndpointInputSchemas.webhooksCreate, + output: HabiticaEndpointOutputSchemas.webhooksCreate, + }, + 'webhooks.list': { + input: HabiticaEndpointInputSchemas.webhooksList, + output: HabiticaEndpointOutputSchemas.webhooksList, + }, + 'webhooks.subscribe': { + input: HabiticaEndpointInputSchemas.webhooksSubscribe, + output: HabiticaEndpointOutputSchemas.webhooksSubscribe, + }, + + 'content.get': { + input: HabiticaEndpointInputSchemas.contentGet, + output: HabiticaEndpointOutputSchemas.contentGet, + }, + 'content.getByType': { + input: HabiticaEndpointInputSchemas.contentGetByType, + output: HabiticaEndpointOutputSchemas.contentGetByType, + }, + 'content.status': { + input: HabiticaEndpointInputSchemas.status, + output: HabiticaEndpointOutputSchemas.status, + }, + 'content.worldState': { + input: HabiticaEndpointInputSchemas.worldState, + output: HabiticaEndpointOutputSchemas.worldState, + }, + 'content.modelPaths': { + input: HabiticaEndpointInputSchemas.modelPaths, + output: HabiticaEndpointOutputSchemas.modelPaths, + }, + 'content.news': { + input: HabiticaEndpointInputSchemas.newsGet, + output: HabiticaEndpointOutputSchemas.newsGet, + }, + 'content.dismissNews': { + input: HabiticaEndpointInputSchemas.newsDismiss, + output: HabiticaEndpointOutputSchemas.newsDismiss, + }, + 'content.marketGear': { + input: HabiticaEndpointInputSchemas.shopsMarketGear, + output: HabiticaEndpointOutputSchemas.shopsMarketGear, + }, + 'content.timeTravelers': { + input: HabiticaEndpointInputSchemas.shopsTimeTravelers, + output: HabiticaEndpointOutputSchemas.shopsTimeTravelers, + }, + 'content.validateCoupon': { + input: HabiticaEndpointInputSchemas.validateCoupon, + output: HabiticaEndpointOutputSchemas.validateCoupon, + }, + + 'exports.userData': { + input: HabiticaEndpointInputSchemas.exportUserData, + output: HabiticaEndpointOutputSchemas.exportUserData, + }, + 'exports.history': { + input: HabiticaEndpointInputSchemas.exportHistoryCsv, + output: HabiticaEndpointOutputSchemas.exportHistoryCsv, + }, + 'exports.inbox': { + input: HabiticaEndpointInputSchemas.exportInboxHtml, + output: HabiticaEndpointOutputSchemas.exportInboxHtml, + }, +} as const satisfies RequiredPluginEndpointSchemas< + typeof habiticaEndpointsNested +>; + +const defaultAuthType: AuthTypes = 'api_key' as const; + +/** + * Risk levels. + * + * `read` for anything that only fetches. `write` for anything that changes + * state and can be undone or repeated without loss. `destructive` is reserved + * for the three operations that remove data Habitica cannot restore - it + * hard-deletes, with no soft-delete flag and no trash - plus the account reset. + * + * `tasks.score` is `write` rather than `read` despite looking like a read of + * stats: it is the one operation whose replay changes the outcome rather than + * repeating it, since scoring twice scores twice. + */ +export const habiticaEndpointMeta = { + 'tasks.create': { + riskLevel: 'write', + description: 'Create a habit, daily, todo or reward', + }, + 'tasks.list': { riskLevel: 'read', description: "List the account's tasks" }, + 'tasks.get': { riskLevel: 'read', description: 'Retrieve any task by id' }, + 'tasks.update': { riskLevel: 'write', description: 'Update a task' }, + 'tasks.delete': { + riskLevel: 'destructive', + description: 'Permanently delete a task', + }, + 'tasks.score': { riskLevel: 'write', description: 'Score a task up or down' }, + 'tasks.move': { + riskLevel: 'write', + description: 'Move a task to a position in its list', + }, + 'tasks.updateChecklistItem': { + riskLevel: 'write', + description: "Update a checklist item's text", + }, + 'tasks.deleteChecklistItem': { + riskLevel: 'write', + description: 'Remove a checklist item from a task', + }, + 'tasks.addTag': { + riskLevel: 'write', + description: 'Apply an existing tag to a task', + }, + 'tasks.createChallengeTask': { + riskLevel: 'write', + description: 'Add a task to a challenge', + }, + 'tasks.listChallengeTasks': { + riskLevel: 'read', + description: "List a challenge's tasks", + }, + 'tasks.unlinkAllChallengeTasks': { + riskLevel: 'destructive', + description: + "Unlink every task of a challenge, optionally deleting members' copies", + }, + + 'tags.create': { riskLevel: 'write', description: 'Create a tag' }, + 'tags.list': { + riskLevel: 'read', + description: 'List every tag on the account', + }, + 'tags.update': { riskLevel: 'write', description: 'Rename a tag' }, + 'tags.delete': { + riskLevel: 'destructive', + description: 'Permanently delete a tag', + }, + + 'challenges.create': { + riskLevel: 'write', + description: 'Create a challenge in a group', + }, + 'challenges.get': { riskLevel: 'read', description: 'Retrieve a challenge' }, + 'challenges.clone': { + riskLevel: 'write', + description: 'Duplicate a challenge', + }, + 'challenges.delete': { + riskLevel: 'destructive', + description: 'Permanently delete a challenge and its tasks', + }, + 'challenges.join': { riskLevel: 'write', description: 'Join a challenge' }, + 'challenges.leave': { riskLevel: 'write', description: 'Leave a challenge' }, + 'challenges.listByGroup': { + riskLevel: 'read', + description: "List a group's challenges", + }, + 'challenges.listForUser': { + riskLevel: 'read', + description: 'List the challenges the account takes part in', + }, + 'challenges.exportCsv': { + riskLevel: 'read', + description: 'Export a challenge as CSV', + }, + + 'groups.create': { + riskLevel: 'write', + description: 'Create a party or guild', + }, + 'groups.list': { riskLevel: 'read', description: 'List groups by type' }, + 'groups.get': { riskLevel: 'read', description: 'Retrieve a group by id' }, + 'groups.getParty': { + riskLevel: 'read', + description: "Retrieve the account's party", + }, + 'groups.getTavern': { riskLevel: 'read', description: 'Retrieve the Tavern' }, + 'groups.update': { + riskLevel: 'write', + description: "Update a group's properties", + }, + 'groups.leave': { riskLevel: 'write', description: 'Leave a group' }, + 'groups.listMembers': { + riskLevel: 'read', + description: "List a group's members", + }, + 'groups.invite': { + riskLevel: 'write', + description: 'Invite people to a group', + }, + 'groups.removeMember': { + riskLevel: 'write', + description: 'Remove a member from the party', + }, + 'groups.inviteToQuest': { + riskLevel: 'write', + description: 'Invite the party to a quest', + }, + + 'chat.list': { + riskLevel: 'read', + description: "Read a group's chat messages", + }, + 'chat.deleteMessage': { + riskLevel: 'destructive', + description: 'Permanently delete a chat message', + }, + 'chat.markSeen': { + riskLevel: 'write', + description: "Mark a group's chat as read", + }, + + 'user.get': { + riskLevel: 'read', + description: "Read the account's user document", + }, + 'user.update': { + riskLevel: 'write', + description: 'Update user fields by dot path', + }, + 'user.reset': { + riskLevel: 'destructive', + description: + 'Reset the account, deleting every task and returning to level 1', + }, + 'user.equip': { + riskLevel: 'write', + description: 'Equip or unequip gear, a pet, a mount or a costume', + }, + 'user.readCard': { + riskLevel: 'write', + description: 'Mark a received card as read', + }, + 'user.movePinnedItem': { + riskLevel: 'write', + description: 'Reorder a pinned reward', + }, + 'user.deleteMessage': { + riskLevel: 'destructive', + description: 'Permanently delete an inbox message', + }, + 'user.addPushDevice': { + riskLevel: 'write', + description: 'Register a push-notification device', + }, + 'user.deletePushDevice': { + riskLevel: 'write', + description: 'Unregister a push-notification device', + }, + 'user.markNotificationSeen': { + riskLevel: 'write', + description: 'Mark one notification as seen', + }, + 'user.markNotificationsSeen': { + riskLevel: 'write', + description: 'Mark several notifications as seen', + }, + + 'auth.register': { + riskLevel: 'write', + description: 'Register a new Habitica account and mint its credential', + }, + 'auth.login': { + riskLevel: 'read', + description: 'Exchange a password for an API token', + }, + 'auth.social': { + riskLevel: 'read', + description: 'Authenticate through a social provider', + }, + + 'webhooks.create': { + riskLevel: 'write', + description: 'Register an outbound webhook', + }, + 'webhooks.list': { + riskLevel: 'read', + description: "List the account's outbound webhooks", + }, + 'webhooks.subscribe': { + riskLevel: 'write', + description: 'Enable an existing webhook', + }, + + 'content.get': { + riskLevel: 'read', + description: 'Fetch the whole game content catalogue', + }, + 'content.getByType': { + riskLevel: 'read', + description: 'Fetch the content catalogue with named categories EXCLUDED', + }, + 'content.status': { + riskLevel: 'read', + description: 'Check that the Habitica API is up', + }, + 'content.worldState': { + riskLevel: 'read', + description: 'Read world events and the world boss', + }, + 'content.modelPaths': { + riskLevel: 'read', + description: "List a model's field paths and types", + }, + 'content.news': { + riskLevel: 'read', + description: 'Read the latest Bailey announcement', + }, + 'content.dismissNews': { + riskLevel: 'write', + description: 'Dismiss the current announcement', + }, + 'content.marketGear': { + riskLevel: 'read', + description: 'List gear for sale in the market', + }, + 'content.timeTravelers': { + riskLevel: 'read', + description: 'List the Time Travellers shop stock', + }, + 'content.validateCoupon': { + riskLevel: 'read', + description: 'Check whether a coupon code is valid', + }, + + 'exports.userData': { + riskLevel: 'read', + description: + 'Export the whole account as JSON (contains the account email)', + }, + 'exports.history': { + riskLevel: 'read', + description: 'Export task history as CSV', + }, + 'exports.inbox': { + riskLevel: 'read', + description: 'Export the inbox as HTML', + }, +} as const satisfies RequiredPluginEndpointMeta; + +export type BaseHabiticaPlugin = CorsairPlugin< + 'habitica', + typeof HabiticaSchema, + typeof habiticaEndpointsNested, + Record, + T, + typeof defaultAuthType +>; + +export type InternalHabiticaPlugin = BaseHabiticaPlugin; + +export type ExternalHabiticaPlugin = + BaseHabiticaPlugin; + +/** + * The Habitica plugin. + * + * **No webhooks.** The catalog lists no triggers for Habitica, and the three + * webhook operations it does list are the user's own outbound webhooks - + * Habitica calling a URL of their choosing - not events delivered to Corsair. + * So this plugin registers no webhook handlers, no matcher and no tenant + * resolver, and the scaffold's webhook files were removed rather than left as + * empty stubs that would imply a surface that does not exist. + */ +export function habitica( + incomingOptions: HabiticaPluginOptions & T = {} as HabiticaPluginOptions & T, +): ExternalHabiticaPlugin { + const options = { + ...incomingOptions, + authType: incomingOptions.authType ?? defaultAuthType, + }; + return { + id: 'habitica', + authConfig: habiticaAuthConfig, + schema: HabiticaSchema, + options: options, + hooks: options.hooks, + endpoints: habiticaEndpointsNested, + webhooks: {}, + endpointMeta: habiticaEndpointMeta, + endpointSchemas: habiticaEndpointSchemas, + webhookSchemas: {}, + errorHandlers: { + ...errorHandlers, + ...options.errorHandlers, + }, + keyBuilder: async (ctx: HabiticaKeyBuilderContext, source) => { + if (source === 'endpoint' && options.key) { + return options.key; + } + + if (source === 'endpoint' && ctx.authType === 'api_key') { + const res = await ctx.keys.get_api_key(); + return res ?? ''; + } + + return ''; + }, + } satisfies InternalHabiticaPlugin; +} + +export type { + HabiticaEndpointInputs, + HabiticaEndpointOutputs, +} from './endpoints/types'; diff --git a/packages/habitica/integration.test.ts b/packages/habitica/integration.test.ts new file mode 100644 index 000000000..495bae748 --- /dev/null +++ b/packages/habitica/integration.test.ts @@ -0,0 +1,392 @@ +/** + * Live tests against a real Habitica account. + * + * Excluded from a default run by `testPathIgnorePatterns` in `jest.config.cjs`, + * excluded from CI by the same flag on the command line, and self-skipping when + * no credential is present, so a checkout without one still runs green. + * + * Nothing here runs without **both** halves of the credential - including the + * anonymous blocks, which need none, so that a bare `jest` in this package + * makes no network calls at all. + * + * **Pacing matters here more than on any previous integration.** Habitica + * allows 30 authenticated requests per minute per user id and answers 429 + * beyond that, so every call goes through `paced()` below. Without it the suite + * throttles itself halfway through and the failures look like API bugs. + * + * Almost everything here is read-only, and nothing already on the account is + * modified. The exceptions create objects they own, named as probes, and delete + * them again in `finally`. + * + * Deliberately never exercised live: + * + * - `user.reset` - it deletes every task on the account and cannot be undone. + * - `auth.register` / `auth.login` / `auth.social` - their inputs are + * credentials, and registering would create a real account on someone's + * service. + * - `tasks.score` - it permanently alters the character's experience and gold. + * Exercised once by hand during development; not repeated on every run. + * - `exports.userData` - it returns the account holder's email address. Its + * *reachability* is asserted below without reading the body. + * - Anything that removes a group, a member or a chat message someone else owns. + * + * To run: + * HABITICA_USER_ID= HABITICA_API_TOKEN= pnpm test:live + * + * Passing the filename as a positional argument does not work: jest treats it + * as another `--testPathIgnorePatterns` value and quietly excludes this file, + * then reports the unit suites as green. The script uses `--testPathPattern`. + */ +import { + HABITICA_ROOT_BASE, + makeHabiticaAnonymousRequest, + makeHabiticaExportRequest, + makeHabiticaRequest, +} from './client'; +import { + HabiticaChallengeEntity, + HabiticaGroupEntity, + HabiticaTagEntity, + HabiticaTaskEntity, + HabiticaWebhookEntity, +} from './schema/database'; + +const userId = process.env.HABITICA_USER_ID; +const apiToken = process.env.HABITICA_API_TOKEN; +const credentials = { userId: userId ?? '', apiToken: apiToken ?? '' }; + +const describeLive = userId && apiToken ? describe : describe.skip; + +/** + * Keeps the suite under Habitica's 30-requests-per-minute ceiling. + * + * This is a real constraint rather than politeness, and the margin is not + * generous: an early version of this suite paced at 2.1 seconds and still drew + * a 429 partway through, because the limit counts the *whole run* inside a + * rolling minute rather than the gap between calls. The failure surfaced as an + * unrelated assertion getting a 429 where it expected a 400 - exactly the kind + * of result that gets misread as an API bug. + * + * Two things keep the run under the ceiling: this interval, and fetching the + * 2.65 MB content catalogue once rather than per test. + */ +const PACE_MS = 2600; +let lastCall = 0; +async function paced(operation: () => Promise): Promise { + const wait = lastCall + PACE_MS - Date.now(); + if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait)); + lastCall = Date.now(); + return await operation(); +} + +const unwrap = (response: unknown): T => + (response as { data: T }).data ?? (response as T); + +/** Probe objects are named so anything left behind is obvious on the account. */ +const PROBE = 'corsair integration probe - safe to delete'; + +describeLive('Habitica live API', () => { + describe('the shape of what comes back', () => { + it('returns tasks that parse as the task entity', async () => { + const tasks = unwrap( + await paced(() => makeHabiticaRequest('tasks/user', credentials)), + ); + expect(Array.isArray(tasks)).toBe(true); + for (const task of tasks) { + const parsed = HabiticaTaskEntity.safeParse(task); + if (!parsed.success) console.error(parsed.error.issues); + expect(parsed.success).toBe(true); + } + }); + + it('returns tags that parse as the tag entity', async () => { + const tags = unwrap( + await paced(() => makeHabiticaRequest('tags', credentials)), + ); + for (const tag of tags) { + expect(HabiticaTagEntity.safeParse(tag).success).toBe(true); + } + }); + + it('returns the Tavern as a group entity', async () => { + const group = unwrap( + await paced(() => makeHabiticaRequest('groups/habitrpg', credentials)), + ); + const parsed = HabiticaGroupEntity.safeParse(group); + if (!parsed.success) console.error(parsed.error.issues); + expect(parsed.success).toBe(true); + }); + + it('returns challenges that parse as the challenge entity', async () => { + const challenges = unwrap( + await paced(() => + makeHabiticaRequest('challenges/user?page=0', credentials), + ), + ); + for (const challenge of challenges) { + const parsed = HabiticaChallengeEntity.safeParse(challenge); + if (!parsed.success) console.error(parsed.error.issues); + expect(parsed.success).toBe(true); + } + }); + }); + + describe('the behaviours this plugin documents', () => { + /** + * The unfiltered catalogue, fetched once and shared. + * + * Not premature tidying: the response is 2.65 MB and the rate limit is 30 + * requests a minute for the whole suite. Fetching it per test pushed the + * run over the ceiling and produced a 429 that looked like a failed + * assertion. + */ + let allContentKeys: string[] = []; + + beforeAll(async () => { + allContentKeys = Object.keys( + unwrap>( + await paced(() => makeHabiticaAnonymousRequest('content')), + ), + ); + }); + + it('EXCLUDES the categories named in the content filter', async () => { + // The catalog says `filter` selects a category. It removes one. This is + // the single most consequential finding in the integration, so it is + // pinned live: if Habitica ever fixes it, this test fails and the + // documentation in content.ts stops being a lie. + const filtered = unwrap>( + await paced(() => + makeHabiticaAnonymousRequest('content?filter=quests'), + ), + ); + + expect(allContentKeys).toContain('quests'); + // Judged by comparing key sets, never by the status code - both are 200. + expect(Object.keys(filtered)).not.toContain('quests'); + expect(Object.keys(filtered).length).toBe(allContentKeys.length - 1); + }); + + it('ignores an unrecognised content filter key silently', async () => { + const bogus = unwrap>( + await paced(() => + makeHabiticaAnonymousRequest('content?filter=notARealContentKey'), + ), + ); + expect(Object.keys(bogus).length).toBe(allContentKeys.length); + }); + + it('rejects `task` as a model, which the catalog lists as valid', async () => { + await expect( + paced(() => makeHabiticaAnonymousRequest('models/task/paths')), + ).rejects.toBeDefined(); + + // The four task types are addressed individually instead. + const daily = unwrap>( + await paced(() => makeHabiticaAnonymousRequest('models/daily/paths')), + ); + expect(Object.keys(daily).length).toBeGreaterThan(0); + }); + + it('requires `page` on the user challenge list', async () => { + await expect( + paced(() => makeHabiticaRequest('challenges/user', credentials)), + ).rejects.toBeDefined(); + + await expect( + paced(() => makeHabiticaRequest('challenges/user?page=0', credentials)), + ).resolves.toBeDefined(); + }); + + it('has no DELETE /groups/:groupId, so the catalog fallback is dead', async () => { + // A ghost id, so nothing real can be affected. Both requests 404 - the + // status settles nothing and the message is the discriminator. + const ghost = '11111111-2222-4333-8444-555555555555'; + + const unrouted = await paced(async () => { + const res = await fetch(`https://habitica.com/api/v3/groups/${ghost}`, { + method: 'DELETE', + headers: { + 'x-api-user': credentials.userId, + 'x-api-key': credentials.apiToken, + 'x-client': 'corsair', + }, + }); + return (await res.json()) as { message?: string }; + }); + + const realRoute = await paced(async () => { + const res = await fetch(`https://habitica.com/api/v3/groups/${ghost}`, { + method: 'GET', + headers: { + 'x-api-user': credentials.userId, + 'x-api-key': credentials.apiToken, + 'x-client': 'corsair', + }, + }); + return (await res.json()) as { message?: string }; + }); + + // A real route with a missing record says so; an unrouted path does not. + expect(realRoute.message).toMatch(/Group not found/i); + expect(unrouted.message).toBe('Not found.'); + expect(unrouted.message).not.toBe(realRoute.message); + }); + + it('rejects a request with no x-client header, even unauthenticated', async () => { + const res = await paced(() => + fetch('https://habitica.com/api/v3/content'), + ); + expect(res.status).toBe(400); + const body = (await res.json()) as { message?: string }; + expect(body.message).toMatch(/x-client/i); + }); + }); + + describe('the export documents', () => { + it('reaches all three with header auth, despite authWithSession in the source', async () => { + // Asserted by reachability and content type only. The bodies are the + // account holder's data - userdata.json carries their email address - + // so nothing here reads or prints them. + const history = await paced(() => + makeHabiticaExportRequest('history.csv', credentials), + ); + expect(history.contentType).toContain('text/csv'); + expect(history.body.length).toBeGreaterThan(0); + + const inbox = await paced(() => + makeHabiticaExportRequest('inbox.html', credentials), + ); + expect(inbox.contentType).toContain('text/html'); + + const userData = await paced(() => + makeHabiticaExportRequest('userdata.json', credentials), + ); + expect(userData.contentType).toContain('application/json'); + // Parses as JSON, without inspecting or logging any field. + expect(() => JSON.parse(userData.body)).not.toThrow(); + }); + + it('serves the exports from outside the versioned base', () => { + expect(HABITICA_ROOT_BASE).not.toContain('/api/v3'); + }); + }); + + describe('a write, created and cleaned up', () => { + it('creates, reads, renames and deletes a tag', async () => { + let tagId: string | undefined; + try { + const created = unwrap<{ id: string; name: string }>( + await paced(() => + makeHabiticaRequest('tags', credentials, { + method: 'POST', + body: { name: PROBE }, + }), + ), + ); + tagId = created.id; + expect(HabiticaTagEntity.safeParse(created).success).toBe(true); + + const renamed = unwrap<{ name: string }>( + await paced(() => + makeHabiticaRequest(`tags/${tagId}`, credentials, { + method: 'PUT', + body: { name: `${PROBE} (renamed)` }, + }), + ), + ); + expect(renamed.name).toContain('renamed'); + } finally { + if (tagId) { + await paced(() => + makeHabiticaRequest(`tags/${tagId}`, credentials, { + method: 'DELETE', + }), + ).catch((error) => { + // A probe left behind on a real account should be visible. + console.error('failed to clean up probe tag', tagId, error); + }); + } + } + }); + + it('creates and deletes a webhook, and reads its failure counter', async () => { + let webhookId: string | undefined; + try { + const created = unwrap<{ id: string; failures?: number }>( + await paced(() => + makeHabiticaRequest('user/webhook', credentials, { + method: 'POST', + body: { + url: 'https://example.com/corsair-integration-probe', + label: PROBE, + type: 'taskActivity', + enabled: false, + }, + }), + ), + ); + webhookId = created.id; + expect(HabiticaWebhookEntity.safeParse(created).success).toBe(true); + + // `failures` is the only webhook health signal Habitica offers, and + // the reason webhooks are mirrored at all. + expect(created.failures).toBe(0); + + // Subscribe is an update setting enabled=true, not its own route. + const enabled = unwrap<{ enabled: boolean }>( + await paced(() => + makeHabiticaRequest(`user/webhook/${webhookId}`, credentials, { + method: 'PUT', + body: { enabled: true }, + }), + ), + ); + expect(enabled.enabled).toBe(true); + } finally { + if (webhookId) { + await paced(() => + makeHabiticaRequest(`user/webhook/${webhookId}`, credentials, { + method: 'DELETE', + }), + ).catch((error) => { + console.error('failed to clean up probe webhook', webhookId, error); + }); + } + } + }); + }); + + describe('rate limiting', () => { + it('reports a limit of 30 per minute in the response headers', async () => { + const res = await paced(() => + fetch('https://habitica.com/api/v3/user?userFields=_id', { + headers: { + 'x-api-user': credentials.userId, + 'x-api-key': credentials.apiToken, + 'x-client': 'corsair', + }, + }), + ); + expect(res.headers.get('x-ratelimit-limit')).toBe('30'); + expect(Number(res.headers.get('x-ratelimit-remaining'))).toBeLessThan(30); + }); + + it('sends x-ratelimit-reset as a date string, which is why it is unconfigured', async () => { + const res = await paced(() => + fetch('https://habitica.com/api/v3/user?userFields=_id', { + headers: { + 'x-api-user': credentials.userId, + 'x-api-key': credentials.apiToken, + 'x-client': 'corsair', + }, + }), + ); + const reset = res.headers.get('x-ratelimit-reset'); + expect(reset).toBeTruthy(); + // A number is what the shared helper expects; this is not one. + expect(Number.isNaN(Number.parseInt(reset ?? '', 10))).toBe(true); + expect(Number.isNaN(new Date(reset ?? '').getTime())).toBe(false); + }); + }); +}); diff --git a/packages/habitica/jest.config.cjs b/packages/habitica/jest.config.cjs new file mode 100644 index 000000000..9339555f3 --- /dev/null +++ b/packages/habitica/jest.config.cjs @@ -0,0 +1,58 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: [''], + testMatch: [ + '**/*.test.ts', + '**/tests/**/*.test.ts', + '**/plugins/**/*.test.ts', + '**/setup/**/*.test.ts', + ], + collectCoverageFrom: [ + '**/*.ts', + '!**/*.d.ts', + '!**/node_modules/**', + '!**/dist/**', + '!jest.config.ts', + '!tests/**', + ], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], + transform: { + '^.+\\.yaml$': '/../corsair/jest-yaml-transform.cjs', + '^.+\\.ts$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + verbatimModuleSyntax: false, + module: 'ESNext', + moduleResolution: 'Bundler', + }, + }, + ], + '.*\\.js$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + }, + }, + ], + }, + moduleNameMapper: { + '^corsair/core$': '/../corsair/core.ts', + '^corsair/http$': '/../corsair/http.ts', + '^(\\.\\.?/.*)\\.js$': '$1', + }, + transformIgnorePatterns: ['node_modules/(?!.*uuid.*)'], + extensionsToTreatAsEsm: ['.ts'], + // The live suite is excluded from a default run: it needs real credentials + // and spends a 30-request-per-minute budget. Run it with `pnpm test:live`. + testPathIgnorePatterns: ['/node_modules/', 'integration\\.test\\.ts'], + testTimeout: 30000, + verbose: true, +}; diff --git a/packages/habitica/package.json b/packages/habitica/package.json new file mode 100644 index 000000000..b5d268a5d --- /dev/null +++ b/packages/habitica/package.json @@ -0,0 +1,45 @@ +{ + "name": "@corsair-dev/habitica", + "version": "0.1.0", + "description": "Habitica plugin for Corsair", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "dev-source": "./index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "rm -rf dist && tsc --build --force && tsup", + "typecheck": "tsc --noEmit", + "test": "jest", + "test:live": "jest --testPathIgnorePatterns=/node_modules/ --testPathPattern=integration" + }, + "peerDependencies": { + "corsair": ">=0.1.0", + "zod": "^4.1.13" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "corsair": "workspace:*", + "jest": "^29.7.0", + "ts-jest": "^29.4.9", + "tsup": "^8.0.1", + "typescript": "catalog:", + "zod": "^4.1.13" + }, + "keywords": [ + "corsair", + "habitica", + "plugin" + ], + "author": "", + "license": "Apache-2.0", + "files": [ + "dist" + ] +} diff --git a/packages/habitica/schema.test.ts b/packages/habitica/schema.test.ts new file mode 100644 index 000000000..a407a6e41 --- /dev/null +++ b/packages/habitica/schema.test.ts @@ -0,0 +1,214 @@ +/** + * Asserts that every key Habitica actually returned is declared in + * `schema/database.ts`. + * + * This matters more here than it looks, because every entity is `.loose()`: a + * response with an undeclared key parses cleanly, so `safeParse` alone would + * never notice a field the schema forgot. The key sets below are therefore + * compared against the declared shape by name. + * + * The key **names** were captured live on 2026-08-15. No values from that + * account appear here - every fixture in this package is fictional, and the + * account used for development holds the operator's real email address. + */ + +import { HabiticaSchema } from './schema'; +import { + HabiticaChallengeEntity, + HabiticaChecklistItem, + HabiticaGroupEntity, + HabiticaTagEntity, + HabiticaTaskEntity, + HabiticaWebhookEntity, +} from './schema/database'; + +/** + * The union of keys across all four task types. + * + * A single task never carries all of these: a habit has `up`/`down`/`history`, + * a daily has `repeat`/`streak`/`isDue`, a todo has `checklist`/`completed`, + * and a reward has none of them. The union is what the schema has to cover. + */ +const TASK_KEYS = [ + '_id', + 'attribute', + 'byHabitica', + 'challenge', + 'checklist', + 'collapseChecklist', + 'completed', + 'counterDown', + 'counterUp', + 'createdAt', + 'daysOfMonth', + 'down', + 'everyX', + 'frequency', + 'group', + 'history', + 'id', + 'isDue', + 'nextDue', + 'notes', + 'priority', + 'reminders', + 'repeat', + 'startDate', + 'streak', + 'tags', + 'text', + 'type', + 'up', + 'updatedAt', + 'userId', + 'value', + 'weeksOfMonth', + 'yesterDaily', +]; + +const TAG_KEYS = ['id', 'name']; + +const CHALLENGE_KEYS = [ + '_id', + 'categories', + 'createdAt', + 'description', + 'flagCount', + 'flags', + 'group', + 'id', + 'leader', + 'memberCount', + 'name', + 'official', + 'prize', + 'shortName', + 'summary', + 'tasksOrder', + 'updatedAt', +]; + +/** Captured from the Tavern, the one group the test account belonged to. */ +const GROUP_KEYS = [ + '_id', + 'archive', + 'balance', + 'categories', + 'challengeCount', + 'chat', + 'cron', + 'id', + 'leader', + 'leaderOnly', + 'managers', + 'memberCount', + 'name', + 'privacy', + 'purchased', + 'quest', + 'summary', + 'tasksOrder', + 'type', +]; + +const WEBHOOK_KEYS = [ + 'createdAt', + 'enabled', + 'failures', + 'id', + 'label', + 'options', + 'type', + 'updatedAt', + 'url', +]; + +const CHECKLIST_ITEM_KEYS = ['completed', 'id', 'text']; + +describe('Habitica schema', () => { + it('declares a semver version', () => { + expect(HabiticaSchema.version).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it('mirrors exactly the five entities the plugin persists', () => { + expect(Object.keys(HabiticaSchema.entities).sort()).toEqual([ + 'challenges', + 'groups', + 'tags', + 'tasks', + 'webhooks', + ]); + }); + + describe('every live-captured key is declared', () => { + const cases: [string, { shape: Record }, string[]][] = [ + ['task', HabiticaTaskEntity, TASK_KEYS], + ['tag', HabiticaTagEntity, TAG_KEYS], + ['challenge', HabiticaChallengeEntity, CHALLENGE_KEYS], + ['group', HabiticaGroupEntity, GROUP_KEYS], + ['webhook', HabiticaWebhookEntity, WEBHOOK_KEYS], + ['checklist item', HabiticaChecklistItem, CHECKLIST_ITEM_KEYS], + ]; + + for (const [label, entity, capturedKeys] of cases) { + it(`declares every ${label} key`, () => { + const declared = Object.keys(entity.shape); + const undeclared = capturedKeys.filter((k) => !declared.includes(k)); + expect(undeclared).toEqual([]); + }); + } + }); + + it('catches an undeclared key, so the check above is not vacuous', () => { + // The entities are loose, so this is what a missing declaration looks + // like: parsing succeeds while the key is absent from the shape. + const declared = Object.keys(HabiticaTagEntity.shape); + expect(declared).not.toContain('aKeyNobodyDeclared'); + expect( + HabiticaTagEntity.safeParse({ id: 'tag-1', aKeyNobodyDeclared: 1 }) + .success, + ).toBe(true); + }); + + it('requires only the primary key', () => { + // Habitica omits fields an account has never used, so anything beyond the + // id has to be optional or ordinary reads would fail to parse. + expect(HabiticaTaskEntity.safeParse({ id: 'task-1' }).success).toBe(true); + expect(HabiticaTagEntity.safeParse({ id: 'tag-1' }).success).toBe(true); + expect( + HabiticaChallengeEntity.safeParse({ id: 'challenge-1' }).success, + ).toBe(true); + expect(HabiticaGroupEntity.safeParse({ id: 'group-1' }).success).toBe(true); + expect(HabiticaWebhookEntity.safeParse({ id: 'webhook-1' }).success).toBe( + true, + ); + }); + + it('rejects a record with no id at all', () => { + expect(HabiticaTaskEntity.safeParse({ text: 'no id here' }).success).toBe( + false, + ); + }); + + it('accepts null for fields Habitica nulls rather than omits', () => { + const parsed = HabiticaTaskEntity.safeParse({ + id: 'task-1', + notes: null, + value: null, + checklist: null, + completed: null, + }); + expect(parsed.success).toBe(true); + }); + + it('keeps a task history entry date as a number, not a coerced date', () => { + // `history[].date` is a millisecond epoch number while `createdAt` on the + // same object is an ISO string. Coercing one and not the other is the + // kind of thing that silently produces Invalid Date. + const parsed = HabiticaTaskEntity.parse({ + id: 'task-1', + history: [{ date: 1_755_000_000_000, value: 1.5 }], + }); + expect(typeof parsed.history?.[0]?.date).toBe('number'); + }); +}); diff --git a/packages/habitica/schema/database.ts b/packages/habitica/schema/database.ts new file mode 100644 index 000000000..2fa0e1916 --- /dev/null +++ b/packages/habitica/schema/database.ts @@ -0,0 +1,416 @@ +import { z } from 'zod'; + +/** + * Locally persisted Habitica entities. + * + * Habitica's surface splits into three kinds of data, and only the first is + * worth mirroring. + * + * **Mirrored.** Tags, tasks, challenges, groups and webhooks are addressable by + * a stable id and are the lookup nearly every other operation needs - scoring a + * task, tagging one, adding one to a challenge and inviting to a group all + * start from an id that has to come from somewhere. Mirroring matters more here + * than on most integrations because Habitica allows only **30 requests per + * minute per user**, so a lookup served locally is a request that does not have + * to be spent. + * + * **Not mirrored - it is the account holder's personal data.** The user + * document, the inbox and group chat carry profile text, private messages and, + * in `auth.local.email`, the account holder's email address. None of it is + * copied into local storage, and none of it is written to an audit payload. + * + * **Not mirrored - it is not row-shaped.** The content catalogue is a single + * 2.65 MB document of static game definitions rather than a collection of + * records. It is a strong caching candidate and a poor entity: there is no id + * to key rows by, and storing it would mean one row that is really a file. + * + * A caveat that applies to tasks specifically, stated because a mirror that + * quietly lies is worse than no mirror: `value`, `history`, `counterUp`, + * `counterDown`, `streak` and `completed` change every time a task is scored. + * The mirrored copy is a snapshot of those fields at fetch time, not a live + * figure. The stable parts - id, type, text, notes, tags, priority - are what + * the mirror is for. + * + * Field names match the API's own JSON keys. Every field except the primary key + * is nullable and optional, and every object is `.loose()`: Habitica returns a + * different key set per task type, omits fields an account has never used, and + * adds fields as the game gains features. + * + * Shapes captured live on 2026-08-15 from a real account; `schema.test.ts` + * asserts every captured key is declared here. + * Official: https://habitica.com/apidoc/ + */ + +/** Habitica omits unset fields more often than it nulls them; allow both. */ +const S = z.string().nullable().optional(); +const N = z.number().nullable().optional(); +const B = z.boolean().nullable().optional(); + +/** + * Ids are UUID strings. + * + * Every entity carries the same id under two keys: Mongo's `_id` and a mirrored + * `id`. They held identical values on every object observed. `id` is the + * primary key here because it is the one the API's own path parameters are + * named after, and `_id` is kept so a caller comparing against a raw response + * is not surprised by its absence. + */ +const Id = z.string(); + +/** + * One entry of a task's checklist. + * Captured from `POST /tasks/:taskId/checklist`. + */ +export const HabiticaChecklistItem = z + .object({ + id: S, + text: S, + completed: B, + }) + .loose(); +export type HabiticaChecklistItem = z.infer; + +/** + * A scheduled reminder attached to a task. + * + * Declared structurally rather than as `unknown` because the key set is stable, + * but left loose: reminders gained fields when Habitica added time-zone + * handling, and will again. + */ +export const HabiticaReminder = z + .object({ + id: S, + startDate: S, + time: S, + }) + .loose(); +export type HabiticaReminder = z.infer; + +/** + * One dated point in a task's value history. + * + * Habits and dailies accumulate these on every score. `date` is a millisecond + * epoch number, not an ISO string - unlike `createdAt` and `updatedAt` on the + * same object, which are ISO strings. That inconsistency is the API's, and it + * is the reason this field is typed as a number rather than coerced to a date. + */ +export const HabiticaTaskHistoryEntry = z + .object({ + date: N, + value: N, + scoredUp: N, + scoredDown: N, + isDue: B, + completed: B, + }) + .loose(); +export type HabiticaTaskHistoryEntry = z.infer; + +/** + * A task: habit, daily, todo or reward. + * + * One entity covers all four types because the API returns them from one + * collection, `GET /tasks/user`, discriminated by `type`. The key sets differ + * substantially - captured live, a habit carries `up`/`down`/`counterUp`/ + * `counterDown`/`frequency`/`history`, a daily adds `repeat`/`everyX`/`streak`/ + * `isDue`/`nextDue`/`startDate`/`daysOfMonth`/`weeksOfMonth`/`yesterDaily`, a + * todo adds `checklist`/`completed`, and a reward carries none of them. Every + * type-specific field is therefore optional here, and a field being absent is + * information about the task's type rather than a gap in the data. + * + * `date` and `dateCompleted` are declared but were not observed live: the + * account held no todo with a due date. They are documented by Habitica and the + * schema is loose, so their absence from the capture is not evidence against + * them. + */ +export const HabiticaTaskEntity = z + .object({ + /** Primary key. Same value as `_id`. */ + id: Id, + /** Mongo's id for the same record. */ + _id: S, + /** One of `habit`, `daily`, `todo`, `reward`. */ + type: S, + /** The task title. */ + text: S, + notes: S, + /** Tag ids applied to this task, not tag objects. */ + tags: z.array(z.string()).nullable().optional(), + /** + * The task's accumulated worth, which drives its colour in the UI. + * Changes on every score - see the caveat at the top of this file. + */ + value: N, + /** 0.1 trivial, 1 easy, 1.5 medium, 2 hard. */ + priority: N, + /** `str`, `int`, `con` or `per`. */ + attribute: S, + /** Set when the task belongs to a challenge. */ + challenge: z.record(z.string(), z.unknown()).nullable().optional(), + /** Set when the task belongs to a group. */ + group: z.record(z.string(), z.unknown()).nullable().optional(), + byHabitica: B, + userId: S, + createdAt: S, + updatedAt: S, + + /** Habits only. */ + up: B, + down: B, + counterUp: N, + counterDown: N, + /** Habits and dailies: `daily`, `weekly`, `monthly`, `yearly`. */ + frequency: S, + history: z.array(HabiticaTaskHistoryEntry).nullable().optional(), + + /** Dailies and todos. */ + checklist: z.array(HabiticaChecklistItem).nullable().optional(), + collapseChecklist: B, + completed: B, + reminders: z.array(HabiticaReminder).nullable().optional(), + + /** Dailies only. */ + streak: N, + repeat: z.record(z.string(), z.unknown()).nullable().optional(), + everyX: N, + startDate: S, + daysOfMonth: z.array(z.number()).nullable().optional(), + weeksOfMonth: z.array(z.number()).nullable().optional(), + isDue: B, + nextDue: z.array(z.string()).nullable().optional(), + yesterDaily: B, + + /** Todos only. Documented; not present in the live capture. */ + date: S, + dateCompleted: S, + + /** + * A user-chosen short name usable in place of the id on task routes. + * + * Declared from the model definition (`GET /models/todo/paths` reports + * `alias: String`) rather than from a capture - no task on the + * development account had one set. + */ + alias: S, + }) + .loose(); +export type HabiticaTaskEntity = z.infer; + +/** + * A tag. + * + * The narrowest entity in the API: `GET /tags` returned objects with exactly + * `id` and `name` on the account used for development, which has no + * challenge-owned tags. + * + * `challenge` and `group` are declared from the model definition rather than + * from a capture - `GET /models/tag/paths` reports the full set as + * `{id: String, name: String, challenge: Boolean, group: String}`. A tag + * created by joining a challenge carries them. + */ +export const HabiticaTagEntity = z + .object({ + id: Id, + name: S, + /** True when the tag was created by joining a challenge. */ + challenge: B, + /** The group id, for a tag that belongs to one. */ + group: S, + }) + .loose(); +export type HabiticaTagEntity = z.infer; + +/** + * The group a challenge belongs to, as embedded in a challenge. + * + * A reduced projection of a group - not the full entity - so it is declared + * separately rather than reusing {@link HabiticaGroupEntity}, which would imply + * fields the embedded copy does not carry. + */ +export const HabiticaChallengeGroupRef = z + .object({ + id: S, + _id: S, + name: S, + type: S, + privacy: S, + summary: S, + leader: S, + categories: z + .array(z.record(z.string(), z.unknown())) + .nullable() + .optional(), + }) + .loose(); +export type HabiticaChallengeGroupRef = z.infer< + typeof HabiticaChallengeGroupRef +>; + +/** + * A challenge's leader, as embedded in a challenge. + * + * Deliberately **not** modelled field by field. The embedded object carries + * `auth` and `profile` sub-objects belonging to another user, and enumerating + * them here would invite copying someone else's account details into local + * storage. The id is what the operations need; the rest is admitted by the + * loose record but never named or relied upon. + */ +export const HabiticaChallengeLeaderRef = z + .object({ + id: S, + _id: S, + }) + .loose(); +export type HabiticaChallengeLeaderRef = z.infer< + typeof HabiticaChallengeLeaderRef +>; + +/** + * A challenge. + * Captured from `GET /challenges/user?page=0`. + */ +export const HabiticaChallengeEntity = z + .object({ + id: Id, + _id: S, + name: S, + /** The short tag-like name challenge tasks are labelled with. */ + shortName: S, + summary: S, + description: S, + /** True for challenges run by Habitica itself. */ + official: B, + /** Gems awarded to the winner. */ + prize: N, + memberCount: N, + leader: HabiticaChallengeLeaderRef.nullable().optional(), + group: HabiticaChallengeGroupRef.nullable().optional(), + tasksOrder: z.record(z.string(), z.unknown()).nullable().optional(), + categories: z + .array(z.record(z.string(), z.unknown())) + .nullable() + .optional(), + flags: z.record(z.string(), z.unknown()).nullable().optional(), + flagCount: N, + createdAt: S, + updatedAt: S, + }) + .loose(); +export type HabiticaChallengeEntity = z.infer; + +/** + * A group: a party, a guild, or the Tavern. + * + * `chat` is declared but deliberately typed as an opaque array rather than + * modelled. Chat messages are other people's words attached to their user ids; + * they are not something this plugin should encourage copying into local + * storage, and no operation in the catalog needs their internal structure. + * + * Captured from `GET /groups/habitrpg` - the Tavern - because the account under + * test belonged to no party or guild. The Tavern is a real group and returns + * the full entity shape. + */ +export const HabiticaGroupEntity = z + .object({ + id: Id, + _id: S, + name: S, + /** `party`, `guild` or `habitrpg` for the Tavern. */ + type: S, + /** `private` or `public`. */ + privacy: S, + summary: S, + description: S, + /** The leader's user id. */ + leader: z + .union([z.string(), z.record(z.string(), z.unknown())]) + .nullable() + .optional(), + memberCount: N, + challengeCount: N, + /** Gems held by the group. */ + balance: N, + managers: z.record(z.string(), z.unknown()).nullable().optional(), + categories: z + .array(z.record(z.string(), z.unknown())) + .nullable() + .optional(), + quest: z.record(z.string(), z.unknown()).nullable().optional(), + leaderOnly: z.record(z.string(), z.unknown()).nullable().optional(), + tasksOrder: z.record(z.string(), z.unknown()).nullable().optional(), + purchased: z.record(z.string(), z.unknown()).nullable().optional(), + /** + * An object, despite the name reading like a flag. + * + * Declared from the live response rather than from the name: it was first + * written here as a boolean and the live suite rejected the Tavern + * because of it. + */ + cron: z.record(z.string(), z.unknown()).nullable().optional(), + /** + * A string, not the object the surrounding fields would suggest - caught + * by the same live parse. + * + * Both this and `cron` were only ever observed on the Tavern, the one + * group the development account belonged to, so a union is used where a + * party or guild might differ. + */ + archive: z + .union([z.string(), z.record(z.string(), z.unknown())]) + .nullable() + .optional(), + + /** + * Declared from the model definition rather than from a capture - the + * Tavern returned none of these. `GET /models/group/paths` gives the + * types. + */ + bannedWordsAllowed: B, + chatLimitCount: N, + logo: S, + leaderMessage: S, + chat: z.array(z.unknown()).nullable().optional(), + }) + .loose(); +export type HabiticaGroupEntity = z.infer; + +/** + * Which task events a `taskActivity` webhook fires on. + * Captured from `POST /user/webhook`. + */ +export const HabiticaWebhookOptions = z + .object({ + created: B, + updated: B, + deleted: B, + scored: B, + checklistScored: B, + }) + .loose(); +export type HabiticaWebhookOptions = z.infer; + +/** + * A webhook the user has registered with Habitica. + * + * These are the user's **outbound** webhooks - Habitica calling a URL of their + * choosing. They are not Corsair webhooks and this plugin registers no webhook + * handlers; the catalog lists them as ordinary operations, and that is how they + * are implemented. `failures` is Habitica's own delivery-failure counter, which + * is why these are worth mirroring: it is the only health signal available. + */ +export const HabiticaWebhookEntity = z + .object({ + id: Id, + /** `taskActivity`, `groupChatReceived`, `userActivity`, `questActivity`. */ + type: S, + label: S, + url: S, + enabled: B, + /** Consecutive delivery failures. Habitica disables a webhook at 10. */ + failures: N, + options: HabiticaWebhookOptions.nullable().optional(), + createdAt: S, + updatedAt: S, + }) + .loose(); +export type HabiticaWebhookEntity = z.infer; diff --git a/packages/habitica/schema/index.ts b/packages/habitica/schema/index.ts new file mode 100644 index 000000000..99d8d41d3 --- /dev/null +++ b/packages/habitica/schema/index.ts @@ -0,0 +1,20 @@ +import { + HabiticaChallengeEntity, + HabiticaGroupEntity, + HabiticaTagEntity, + HabiticaTaskEntity, + HabiticaWebhookEntity, +} from './database'; + +export const HabiticaSchema = { + version: '1.0.0', + entities: { + tasks: HabiticaTaskEntity, + tags: HabiticaTagEntity, + challenges: HabiticaChallengeEntity, + groups: HabiticaGroupEntity, + webhooks: HabiticaWebhookEntity, + }, +} as const; + +export * from './database'; diff --git a/packages/habitica/tsconfig.json b/packages/habitica/tsconfig.json new file mode 100644 index 000000000..15e507a13 --- /dev/null +++ b/packages/habitica/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["esnext"], + "types": ["node", "jest"], + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "./dist", + "rootDir": "./", + "composite": true, + "incremental": true, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "skipLibCheck": true + }, + "include": ["./**/*"], + "exclude": ["dist", "node_modules"], + "references": [] +} diff --git a/packages/habitica/tsup.config.ts b/packages/habitica/tsup.config.ts new file mode 100644 index 000000000..3ec221e23 --- /dev/null +++ b/packages/habitica/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + clean: false, + dts: false, + format: ['esm'], + target: 'esnext', + platform: 'node', + bundle: true, + splitting: true, + minify: true, + outDir: 'dist', + external: ['corsair', 'zod'], + entry: ['index.ts'], +}); From 48c76349f4e1f9d532d268bab011b15620a5582f Mon Sep 17 00:00:00 2001 From: Agam00 Date: Sun, 16 Aug 2026 00:43:15 +0530 Subject: [PATCH 2/6] feat(habitica): add devDependencies for Habitica integration --- pnpm-lock.yaml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5f3e3a29..417bae921 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2043,6 +2043,30 @@ importers: specifier: 4.4.3 version: 4.4.3 + packages/habitica: + devDependencies: + '@types/jest': + specifier: ^29.5.14 + version: 29.5.14 + corsair: + specifier: workspace:* + version: link:../corsair + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) + ts-jest: + specifier: ^29.4.9 + version: 29.4.9(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.27.0)(jest-util@30.4.1)(jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(typescript@5.9.3) + tsup: + specifier: ^8.0.1 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: 'catalog:' + version: 5.9.3 + zod: + specifier: 4.4.3 + version: 4.4.3 + packages/hackernews: devDependencies: '@types/jest': From abb385259e1e5055beaf30e5b9e728fdb3e3366b Mon Sep 17 00:00:00 2001 From: Agam00 Date: Sun, 16 Aug 2026 01:38:15 +0530 Subject: [PATCH 3/6] feat(habitica): enhance API error handling and request body management --- packages/habitica/client.test.ts | 128 +++++++++++++++++++++- packages/habitica/client.ts | 67 ++++++++++- packages/habitica/endpoints.test.ts | 116 ++++++++++++++++++++ packages/habitica/endpoints/challenges.ts | 10 +- packages/habitica/endpoints/content.ts | 16 ++- packages/habitica/endpoints/shared.ts | 59 ++++++++++ packages/habitica/endpoints/tags.ts | 6 +- packages/habitica/endpoints/tasks.ts | 9 +- packages/habitica/endpoints/user.ts | 27 +++-- packages/habitica/error-handlers.test.ts | 28 +++++ packages/habitica/error-handlers.ts | 34 +++++- packages/habitica/index.ts | 5 +- packages/habitica/integration.test.ts | 75 +++++++------ packages/habitica/jest.config.cjs | 6 +- 14 files changed, 521 insertions(+), 65 deletions(-) diff --git a/packages/habitica/client.test.ts b/packages/habitica/client.test.ts index f3c53a1e2..606a5f7f3 100644 --- a/packages/habitica/client.test.ts +++ b/packages/habitica/client.test.ts @@ -25,9 +25,32 @@ const API_TOKEN = '11111111-1111-4111-8111-111111111111'; const CREDENTIALS = { userId: USER_ID, apiToken: API_TOKEN }; let captured: - | { url: string; method: string; headers: Record } + | { + url: string; + method: string; + headers: Record; + body?: string; + } | undefined; +// Every test here replaces the global fetch. Restoring it afterwards keeps this +// file from deciding what any later suite sees. +const realFetch = global.fetch; +afterEach(() => { + global.fetch = realFetch; +}); + +/** + * Replaces `global.fetch` with a stub that records the request. + * + * The `as unknown as typeof global.fetch` cast at the end is deliberate and + * confined to this helper. A faithful `fetch` implementation would have to + * satisfy the whole `Response` interface - `blob`, `formData`, `clone`, + * `bodyUsed` and the rest - none of which the transport touches. Widening the + * stub to the real signature would mean writing a dozen unused members whose + * only effect is to obscure the four fields that matter: `ok`, `status`, + * `headers` and the body readers. + */ function mockFetch( payload: unknown, { @@ -50,7 +73,12 @@ function mockFetch( headers[key.toLowerCase()] = value; } } - captured = { url: String(url), method: init?.method ?? 'GET', headers }; + captured = { + url: String(url), + method: init?.method ?? 'GET', + headers, + body: typeof init?.body === 'string' ? init.body : undefined, + }; const body = typeof payload === 'string' ? payload : JSON.stringify(payload); return { @@ -101,6 +129,34 @@ describe('Habitica transport', () => { expect(captured?.headers['x-api-user']).toBeUndefined(); expect(captured?.headers['x-api-key']).toBeUndefined(); }); + + it('forwards the body on an anonymous POST', async () => { + // The three authentication routes are `authOptional` and POST their + // credentials through this helper. An earlier version destructured + // only `method` and `query`, so registration and login were sending + // an empty body - and asserting method and path alone did not notice. + mockFetch({ success: true, data: {} }); + await makeHabiticaAnonymousRequest('user/auth/local/login', { + method: 'POST', + body: { username: 'someone', password: 'a-password' }, + }); + + expect(captured?.body).toBeDefined(); + expect(JSON.parse(captured?.body ?? '{}')).toEqual({ + username: 'someone', + password: 'a-password', + }); + }); + + it('sends no body on an anonymous GET', async () => { + mockFetch({ success: true, data: {} }); + await makeHabiticaAnonymousRequest('content', { + method: 'GET', + body: { ignored: true }, + }); + + expect(captured?.body).toBeUndefined(); + }); }); describe('the mandatory x-client header', () => { @@ -222,6 +278,74 @@ describe('Habitica transport', () => { }); }); + describe('rate limiting on the raw-fetch paths', () => { + /** A 429 carrying Habitica's fractional Retry-After. */ + function mock429(retryAfter = '21.069') { + global.fetch = (async () => + ({ + ok: false, + status: 429, + statusText: 'Too Many Requests', + url: 'https://habitica.com/export/history.csv', + headers: new Headers({ + 'Content-Type': 'application/json', + 'retry-after': retryAfter, + }), + json: async () => ({ error: 'TooManyRequests' }), + text: async () => '{}', + }) as unknown as Response) as unknown as typeof global.fetch; + } + + it('preserves status and Retry-After through an export failure', async () => { + // These paths bypass the shared transport, so they get no ApiError. + // Throwing a bare Error would discard the delay and leave the handler + // retrying blind against a fixed one-minute window. + mock429(); + + await expect( + makeHabiticaExportRequest('history.csv', CREDENTIALS), + ).rejects.toMatchObject({ + name: 'HabiticaHttpError', + status: 429, + retryAfter: 21_069, + }); + }); + + it('preserves them through a challenge CSV failure too', async () => { + mock429('5'); + + await expect( + makeHabiticaTextRequest('challenges/c1/export/csv', CREDENTIALS), + ).rejects.toMatchObject({ status: 429, retryAfter: 5_000 }); + }); + + it('keeps the fraction instead of truncating it', async () => { + // The shared transport parseInts to 21, retrying ~69ms early. Here the + // parse is ours, so it rounds up and never fires inside the window. + mock429('21.069'); + await expect( + makeHabiticaExportRequest('history.csv', CREDENTIALS), + ).rejects.toMatchObject({ retryAfter: 21_069 }); + expect(21_069).toBeGreaterThan(21_000); + }); + + it('omits retryAfter when the server sent none', async () => { + mockFetch('nope', { status: 500, contentType: 'text/csv' }); + + await expect( + makeHabiticaExportRequest('history.csv', CREDENTIALS), + ).rejects.toMatchObject({ status: 500, retryAfter: undefined }); + }); + + it('ignores an unparseable Retry-After rather than passing NaN on', async () => { + mock429('not-a-number'); + + await expect( + makeHabiticaExportRequest('history.csv', CREDENTIALS), + ).rejects.toMatchObject({ status: 429, retryAfter: undefined }); + }); + }); + describe('rate limiting', () => { it('reacts to retry-after', () => { expect(HABITICA_RATE_LIMIT_CONFIG.headerNames.retryAfter).toBe( diff --git a/packages/habitica/client.ts b/packages/habitica/client.ts index 0835d8df4..fa33ef006 100644 --- a/packages/habitica/client.ts +++ b/packages/habitica/client.ts @@ -43,7 +43,9 @@ const HABITICA_ROOT_BASE = 'https://habitica.com'; * value. The helper's `parseInt` truncates that to 21, so the first retry * fires a fraction of a second early and can draw a second 429 before the * exponential backoff spaces the attempts out. It converges within - * `maxRetries`; the extra 429 is expected, not a defect. + * `maxRetries`; the extra 429 is expected, not a defect. The raw-`fetch` + * paths do not share this quirk - they parse the header themselves, see + * {@link parseRetryAfterMs}. */ const HABITICA_RATE_LIMIT_CONFIG: RateLimitConfig = { enabled: true, @@ -108,6 +110,47 @@ export class HabiticaUserIdMissingError extends Error { } } +/** + * A failure from one of the raw-`fetch` paths. + * + * The four non-JSON operations cannot use the shared transport, so they also do + * not get its `ApiError` - which is what normally carries the status and the + * parsed `Retry-After` through to `error-handlers.ts`. Throwing a bare `Error` + * would strip both, leaving a 429 to be retried on a blind exponential backoff + * against a fixed one-minute window. + * + * The response body is deliberately **not** attached. A failed export can still + * carry account data - `userdata.json` contains the account holder's email + * address - and this error is exactly the object most likely to reach a log. + */ +export class HabiticaHttpError extends Error { + constructor( + message: string, + readonly status: number, + /** Milliseconds to wait, from `Retry-After`, when the server sent one. */ + readonly retryAfter?: number, + ) { + super(message); + this.name = 'HabiticaHttpError'; + } +} + +/** + * Reads `Retry-After` into milliseconds. + * + * Habitica sends **fractional** seconds - `"21.069"` was the observed value on + * a real 429. `parseFloat` keeps that precision where the shared transport's + * `parseInt` truncates to 21 and retries a fraction of a second early; the + * result is rounded up for the same reason, so a retry never fires inside the + * window the server asked for. + */ +function parseRetryAfterMs(header: string | null): number | undefined { + if (!header) return undefined; + const seconds = Number.parseFloat(header); + if (!Number.isFinite(seconds) || seconds < 0) return undefined; + return Math.ceil(seconds * 1000); +} + /** * Builds the request configuration for a base URL. * @@ -203,11 +246,21 @@ export async function makeHabiticaAnonymousRequest( endpoint: string, options: HabiticaRequestOptions = {}, ): Promise { - const { method = 'GET', query } = options; + const { method = 'GET', body, query } = options; return await request( buildConfig(HABITICA_API_BASE), - { method, url: endpoint, mediaType: 'application/json', query }, + { + method, + url: endpoint, + // The body matters here as much as on the authenticated path: the + // three authentication routes are `authOptional` and POST their + // credentials through this helper. Dropping it would send an empty + // registration or login. + body: method === 'POST' || method === 'PUT' ? body : undefined, + mediaType: 'application/json', + query, + }, { rateLimitConfig: HABITICA_RATE_LIMIT_CONFIG }, ); } @@ -253,8 +306,10 @@ export async function makeHabiticaExportRequest( } if (!response.ok) { - throw new Error( + throw new HabiticaHttpError( `Habitica export ${document} returned HTTP ${response.status} ${response.statusText}`, + response.status, + parseRetryAfterMs(response.headers.get('retry-after')), ); } @@ -299,8 +354,10 @@ export async function makeHabiticaTextRequest( } if (!response.ok) { - throw new Error( + throw new HabiticaHttpError( `Habitica ${endpoint} returned HTTP ${response.status} ${response.statusText}`, + response.status, + parseRetryAfterMs(response.headers.get('retry-after')), ); } diff --git a/packages/habitica/endpoints.test.ts b/packages/habitica/endpoints.test.ts index 3db3ab923..c542715e6 100644 --- a/packages/habitica/endpoints.test.ts +++ b/packages/habitica/endpoints.test.ts @@ -66,6 +66,16 @@ function makeStore(): Store { type Ctx = Parameters[0]; +/** + * Builds the smallest context the endpoints actually read. + * + * The `as unknown as Ctx` cast is deliberate. A real `CorsairPluginContext` + * carries the full ORM surface, hooks, permissions and auth machinery; the + * endpoints here touch four members of it. Constructing the genuine article + * would couple every endpoint test to core internals that have nothing to do + * with the behaviour under test, and would break these tests whenever an + * unrelated context field changed. + */ function makeCtx() { const db = { tasks: makeStore(), @@ -980,6 +990,112 @@ describe('what reaches the event log', () => { }); }); +describe('secrets interpolated into a path', () => { + /** Fails the request so the thrown error can be inspected. */ + function mockFailure(status: number, url: string) { + global.fetch = (async (requested: unknown) => + ({ + ok: false, + status, + statusText: 'Error', + url: String(requested ?? url), + headers: new Headers({ 'Content-Type': 'application/json' }), + json: async () => ({ success: false, error: 'NotFound' }), + text: async () => '{}', + }) as unknown as Response) as unknown as typeof global.fetch; + } + + it('keeps a coupon code out of the thrown error', async () => { + // A valid coupon is a bearer instrument. The shared transport redacts + // sensitive query parameters but not path segments, and Habitica takes + // the code as a path parameter. + const { ctx } = makeCtx(); + mockFailure(404, 'https://habitica.com/api/v3/coupons/validate/x'); + + const error = await Content.validateCoupon(ctx, { + code: 'SECRET-COUPON-1234', + }).catch((e: unknown) => e); + + const serialised = `${(error as Error).message} ${JSON.stringify(error)} ${String((error as { url?: string }).url ?? '')}`; + expect(serialised).not.toContain('SECRET-COUPON-1234'); + }); + + it('keeps a push-device registration id out of the thrown error', async () => { + const { ctx } = makeCtx(); + mockFailure(404, 'https://habitica.com/api/v3/user/push-devices/x'); + + const error = await User.deletePushDevice(ctx, { + regId: 'device-token-abcdef', + }).catch((e: unknown) => e); + + const serialised = `${(error as Error).message} ${JSON.stringify(error)} ${String((error as { url?: string }).url ?? '')}`; + expect(serialised).not.toContain('device-token-abcdef'); + }); + + it('leaves an unrelated error untouched', async () => { + // Redaction must not swallow errors that never carried the secret. + const { ctx } = makeCtx(); + mockFailure(404, 'https://habitica.com/api/v3/coupons/validate/x'); + + const error = await Content.validateCoupon(ctx, { code: 'ABCD' }).catch( + (e: unknown) => e, + ); + + expect(error).toBeInstanceOf(Error); + }); +}); + +describe('the credential-minting operations send their body', () => { + // These POST through the anonymous transport. An earlier version dropped the + // body there, so registration and login were sent empty - and asserting the + // method and path alone did not notice. + it('auth.login sends the credentials', async () => { + const { ctx } = makeCtx(); + mockFetch(wrap({ id: USER_ID, apiToken: 'minted-token' })); + + await Auth.login(ctx, { username: 'someone', password: 'a-password' }); + + expect(sentBody()).toEqual({ + username: 'someone', + password: 'a-password', + }); + }); + + it('auth.register sends every required field', async () => { + const { ctx } = makeCtx(); + mockFetch(wrap({ id: USER_ID, apiToken: 'minted-token' })); + + await Auth.register(ctx, { + username: 'someone', + email: 'someone@example.com', + password: 'a-password', + confirmPassword: 'a-password', + }); + + expect(Object.keys(sentBody()).sort()).toEqual([ + 'confirmPassword', + 'email', + 'password', + 'username', + ]); + }); + + it('auth.social sends the provider response', async () => { + const { ctx } = makeCtx(); + mockFetch(wrap({ id: USER_ID, apiToken: 'minted-token' })); + + await Auth.social(ctx, { + network: 'google', + authResponse: { code: 'an-oauth-code' }, + }); + + expect(sentBody()).toEqual({ + network: 'google', + authResponse: { code: 'an-oauth-code' }, + }); + }); +}); + describe('the aliases that share one route', () => { it('getParty and getTavern differ only in the group id', async () => { const { ctx } = makeCtx(); diff --git a/packages/habitica/endpoints/challenges.ts b/packages/habitica/endpoints/challenges.ts index e6202e6f5..1668604fb 100644 --- a/packages/habitica/endpoints/challenges.ts +++ b/packages/habitica/endpoints/challenges.ts @@ -101,16 +101,18 @@ export const remove: HabiticaEndpoints['challengesDelete'] = async ( HabiticaEndpointOutputs['challengesDelete'] >(ctx, `challenges/${pathSegment(input.challengeId)}`, { method: 'DELETE' }); - await evictEntity(ctx.db.challenges, input.challengeId, LABEL, { - required: true, - }); - + // Logged before the eviction - see the note on `tasks.delete`. await logEventFromContext( ctx, 'habitica.challenges.delete', auditPayload(input, ['challengeId']), 'completed', ); + + await evictEntity(ctx.db.challenges, input.challengeId, LABEL, { + required: true, + }); + return result; }; diff --git a/packages/habitica/endpoints/content.ts b/packages/habitica/endpoints/content.ts index 20ae109da..9e9099235 100644 --- a/packages/habitica/endpoints/content.ts +++ b/packages/habitica/endpoints/content.ts @@ -6,6 +6,7 @@ import { habiticaAnonymousCall, habiticaCall, pathSegment, + withRedactedPathValue, } from './shared'; import type { HabiticaEndpointOutputs } from './types'; @@ -243,15 +244,22 @@ export const timeTravelers: HabiticaEndpoints['shopsTimeTravelers'] = async ( * holding the string can redeem it - so recording one in a retained event log * would turn the audit trail into something worth stealing. Only the outcome is * recorded. + * + * It is kept out of thrown errors for the same reason. Habitica takes the code + * as a path parameter, and the shared transport redacts sensitive query + * parameters but not path segments, so a failed validation would otherwise + * carry the code in `error.url`. */ export const validateCoupon: HabiticaEndpoints['validateCoupon'] = async ( ctx, input, ) => { - const result = await habiticaCall( - ctx, - `coupons/validate/${pathSegment(input.code)}`, - { method: 'POST' }, + const result = await withRedactedPathValue(input.code, () => + habiticaCall( + ctx, + `coupons/validate/${pathSegment(input.code)}`, + { method: 'POST' }, + ), ); await logEventFromContext(ctx, 'habitica.coupons.validate', {}, 'completed'); diff --git a/packages/habitica/endpoints/shared.ts b/packages/habitica/endpoints/shared.ts index 58f0ceadc..330e28eb4 100644 --- a/packages/habitica/endpoints/shared.ts +++ b/packages/habitica/endpoints/shared.ts @@ -1,5 +1,6 @@ import type { HabiticaCredentials, HabiticaRequestOptions } from '../client'; import { + HabiticaHttpError, HabiticaUserIdMissingError, makeHabiticaAnonymousRequest, makeHabiticaExportRequest, @@ -165,6 +166,64 @@ export function compactQuery( return compacted; } +/** + * Runs a call whose path carries a value that must not survive into an error. + * + * Two operations interpolate something sensitive into the URL because the API + * offers no alternative - Habitica takes both as path parameters, not body + * fields: + * + * - `POST /coupons/validate/:code` - a valid coupon is a bearer instrument; + * anyone holding the string can redeem it. + * - `DELETE /user/push-devices/:regId` - an identifier for someone's device. + * + * The shared transport's `ApiError` already redacts sensitive **query + * parameters**, but it does not touch path segments, so without this the value + * would sit in `error.url` and `error.request.url` - the object most likely to + * be logged. Both the raw and percent-encoded spellings are masked, because the + * path carries the encoded form while the caller supplied the raw one. + * + * The rethrown error keeps the status so `error-handlers.ts` still classifies + * it; what it loses is the offending value. + */ +export async function withRedactedPathValue( + secret: string, + run: () => Promise, +): Promise { + try { + return await run(); + } catch (error) { + throw redactPathValue(error, secret); + } +} + +function redactPathValue(error: unknown, secret: string): unknown { + if (!secret || !(error instanceof Error)) return error; + + const forms = [secret, encodeURIComponent(secret)]; + const mask = (text: string) => + forms.reduce((acc, form) => acc.split(form).join('[REDACTED]'), text); + + // Read structurally rather than by class: the incoming error may be an + // ApiError from the shared transport or a HabiticaHttpError from a raw + // path, and both carry these under the same names. + const carrier = error as unknown as { + status?: unknown; + retryAfter?: unknown; + url?: unknown; + }; + const status = typeof carrier.status === 'number' ? carrier.status : 0; + const retryAfter = + typeof carrier.retryAfter === 'number' ? carrier.retryAfter : undefined; + + const masked = mask(error.message); + const leaked = + masked !== error.message || + forms.some((form) => String(carrier.url ?? '').includes(form)); + + return leaked ? new HabiticaHttpError(masked, status, retryAfter) : error; +} + /** * Percent-encodes a value used as a path segment. * diff --git a/packages/habitica/endpoints/tags.ts b/packages/habitica/endpoints/tags.ts index 409ae804d..1a07d4e85 100644 --- a/packages/habitica/endpoints/tags.ts +++ b/packages/habitica/endpoints/tags.ts @@ -91,13 +91,15 @@ export const remove: HabiticaEndpoints['tagsDelete'] = async (ctx, input) => { { method: 'DELETE' }, ); - await evictEntity(ctx.db.tags, input.tagId, LABEL, { required: true }); - + // Logged before the eviction - see the note on `tasks.delete`. await logEventFromContext( ctx, 'habitica.tags.delete', auditPayload(input, ['tagId']), 'completed', ); + + await evictEntity(ctx.db.tags, input.tagId, LABEL, { required: true }); + return result; }; diff --git a/packages/habitica/endpoints/tasks.ts b/packages/habitica/endpoints/tasks.ts index d2ad0c2e8..821081959 100644 --- a/packages/habitica/endpoints/tasks.ts +++ b/packages/habitica/endpoints/tasks.ts @@ -125,14 +125,19 @@ export const remove: HabiticaEndpoints['tasksDelete'] = async (ctx, input) => { { method: 'DELETE' }, ); - await evictEntity(ctx.db.tasks, input.taskId, LABEL, { required: true }); - + // Logged before the eviction, not after. A required eviction throws when the + // local mirror cannot be updated, and the remote delete has already + // happened by then - so ordering it the other way loses the audit record of + // a destructive change that really did occur. await logEventFromContext( ctx, 'habitica.tasks.delete', auditPayload(input, ['taskId']), 'completed', ); + + await evictEntity(ctx.db.tasks, input.taskId, LABEL, { required: true }); + return result; }; diff --git a/packages/habitica/endpoints/user.ts b/packages/habitica/endpoints/user.ts index 352cb3f1a..1fe7e3dcd 100644 --- a/packages/habitica/endpoints/user.ts +++ b/packages/habitica/endpoints/user.ts @@ -2,7 +2,12 @@ import { logEventFromContext } from 'corsair/core'; import type { HabiticaEndpoints } from '../index'; import { auditPayload, countOf } from './logging'; import { clearMirroredTasks } from './persist'; -import { compactQuery, habiticaCall, pathSegment } from './shared'; +import { + compactQuery, + habiticaCall, + pathSegment, + withRedactedPathValue, +} from './shared'; import type { HabiticaEndpointOutputs } from './types'; /** @@ -207,14 +212,22 @@ export const addPushDevice: HabiticaEndpoints['userAddPushDevice'] = async ( return result; }; -/** Unregisters a push-notification device. `regId` is not logged. */ +/** + * Unregisters a push-notification device. + * + * `regId` is not logged, and it is also kept out of any thrown error: Habitica + * takes it as a path parameter, and the shared transport redacts sensitive + * query parameters but not path segments. + */ export const deletePushDevice: HabiticaEndpoints['userDeletePushDevice'] = async (ctx, input) => { - const result = await habiticaCall< - HabiticaEndpointOutputs['userDeletePushDevice'] - >(ctx, `user/push-devices/${pathSegment(input.regId)}`, { - method: 'DELETE', - }); + const result = await withRedactedPathValue(input.regId, () => + habiticaCall( + ctx, + `user/push-devices/${pathSegment(input.regId)}`, + { method: 'DELETE' }, + ), + ); await logEventFromContext( ctx, diff --git a/packages/habitica/error-handlers.test.ts b/packages/habitica/error-handlers.test.ts index 45346f9ea..cc61b54f7 100644 --- a/packages/habitica/error-handlers.test.ts +++ b/packages/habitica/error-handlers.test.ts @@ -13,6 +13,7 @@ * limits on exactly the operations most likely to be slow. */ import { ApiError } from 'corsair/http'; +import { HabiticaHttpError } from './client'; import { errorHandlers } from './error-handlers'; /** Builds an ApiError the way the shared transport does. */ @@ -84,6 +85,24 @@ describe('Habitica error handlers', () => { expect(result.headersRetryAfterMs).toBe(21_000); }); + it("honours the raw-fetch path's preserved Retry-After", async () => { + // The point of HabiticaHttpError: without this the delay Habitica sent + // is discarded and the retries run on a blind backoff. + const result = await errorHandlers.RATE_LIMIT_ERROR.handler( + new HabiticaHttpError('export failed', 429, 21_069), + ); + + expect(result.headersRetryAfterMs).toBe(21_069); + }); + + it('classifies a HabiticaHttpError 429 by status, not by message text', async () => { + // A message that says nothing about rate limiting still classifies, + // because the status travels with the error now. + expect(classify(new HabiticaHttpError('export failed', 429))).toBe( + 'RATE_LIMIT_ERROR', + ); + }); + it('still retries when no retry-after was available', async () => { // The raw-fetch path never populates retryAfter, so the backoff has to // stand on its own. @@ -105,6 +124,15 @@ describe('Habitica error handlers', () => { expect(classify(new Error('invalid_credentials'))).toBe('AUTH_ERROR'); }); + it('classifies a redacted HabiticaHttpError 401 by status', () => { + // `withRedactedPathValue` rebuilds an error to strip a secret out of + // its URL. Without a status branch that would silently downgrade a 401 + // to the DEFAULT handler. + expect(classify(new HabiticaHttpError('[REDACTED]', 401))).toBe( + 'AUTH_ERROR', + ); + }); + it('never retries an authentication failure', async () => { // The same credential will fail again. The handler takes no argument - // there is nothing about the error that could change the answer. diff --git a/packages/habitica/error-handlers.ts b/packages/habitica/error-handlers.ts index 35c8d45c6..4639afca3 100644 --- a/packages/habitica/error-handlers.ts +++ b/packages/habitica/error-handlers.ts @@ -1,5 +1,6 @@ import type { CorsairErrorHandler } from 'corsair/core'; import { ApiError } from 'corsair/http'; +import { HabiticaHttpError } from './client'; /** * Habitica reports every failure through one envelope: @@ -28,11 +29,16 @@ export const errorHandlers = { * 30 requests per minute per user id, confirmed exactly: the 30th request in * a burst was the one refused. * - * Habitica sends `retry-after` in **fractional seconds** (`"21.069"`). The - * transport parses that with `parseInt`, truncating to 21, so the first retry - * can fire a fraction of a second early and draw one further 429 before the - * backoff spaces things out. That is expected rather than a defect - see the - * rate-limit notes in `client.ts`. + * Habitica sends `retry-after` in **fractional seconds** (`"21.069"`), which + * the two transports handle differently: + * + * - The shared transport parses it with `parseInt`, truncating to 21, so a + * retry can fire a fraction of a second early and draw one further 429 + * before the backoff spaces attempts out. Expected, not a defect. + * - The raw-`fetch` paths parse it here in the plugin, keeping the fraction + * and rounding up, so they never retry inside the window. + * + * See the rate-limit notes in `client.ts`. * * `maxRetries` is 5 rather than the transport's 3 because the limit is a * fixed one-minute window: waiting is genuinely sufficient here, unlike a @@ -41,6 +47,8 @@ export const errorHandlers = { RATE_LIMIT_ERROR: { match: (error: Error) => { if (error instanceof ApiError && error.status === 429) return true; + if (error instanceof HabiticaHttpError && error.status === 429) + return true; const msg = error.message.toLowerCase(); return msg.includes('toomanyrequests') || msg.includes('429'); }, @@ -49,6 +57,16 @@ export const errorHandlers = { if (error instanceof ApiError && error.retryAfter !== undefined) { retryAfterMs = error.retryAfter; } + // The four non-JSON operations bypass the shared transport and so + // never produce an `ApiError`. Without this branch their 429s would + // fall back to a blind exponential backoff against a fixed + // one-minute window - the retries can all be spent before it resets. + if ( + error instanceof HabiticaHttpError && + error.retryAfter !== undefined + ) { + retryAfterMs = error.retryAfter; + } return { maxRetries: 5, headersRetryAfterMs: retryAfterMs }; }, }, @@ -69,6 +87,12 @@ export const errorHandlers = { AUTH_ERROR: { match: (error: Error) => { if (error instanceof ApiError && error.status === 401) return true; + // Also covers an error that was rebuilt to strip a secret out of its + // URL - see `withRedactedPathValue`. Without this the redaction would + // silently downgrade a 401 to the DEFAULT handler. + if (error instanceof HabiticaHttpError && error.status === 401) { + return true; + } const msg = error.message.toLowerCase(); return ( msg.includes('notauthorized') || msg.includes('invalid_credentials') diff --git a/packages/habitica/index.ts b/packages/habitica/index.ts index 518668960..a42bbec9c 100644 --- a/packages/habitica/index.ts +++ b/packages/habitica/index.ts @@ -559,7 +559,10 @@ export const habiticaEndpointSchemas = { typeof habiticaEndpointsNested >; -const defaultAuthType: AuthTypes = 'api_key' as const; +// `satisfies` rather than an annotation: `const x: AuthTypes = 'api_key'` +// widens the inferred type to the whole union, so `typeof defaultAuthType` +// would reach `BaseHabiticaPlugin` as `AuthTypes` instead of `'api_key'`. +const defaultAuthType = 'api_key' as const satisfies AuthTypes; /** * Risk levels. diff --git a/packages/habitica/integration.test.ts b/packages/habitica/integration.test.ts index 495bae748..4ce864235 100644 --- a/packages/habitica/integration.test.ts +++ b/packages/habitica/integration.test.ts @@ -38,6 +38,8 @@ * then reports the unit suites as green. The script uses `--testPathPattern`. */ import { + HABITICA_API_BASE, + HABITICA_CLIENT_ID, HABITICA_ROOT_BASE, makeHabiticaAnonymousRequest, makeHabiticaExportRequest, @@ -82,6 +84,37 @@ async function paced(operation: () => Promise): Promise { const unwrap = (response: unknown): T => (response as { data: T }).data ?? (response as T); +/** + * Reports a schema failure without printing the data that failed. + * + * This suite runs against a real account, so an offending value could be a task + * title, a profile line or a message. Zod issues can carry the input alongside + * the diagnosis, and the diagnosis is the only part worth seeing: which field, + * what rule, what went wrong. + */ +function describeIssues(error: { issues: readonly unknown[] }): string[] { + return error.issues.map((raw) => { + const issue = raw as { path?: unknown[]; code?: string; message?: string }; + const where = (issue.path ?? []).join('.') || '(root)'; + return `${where}: ${issue.code ?? 'invalid'} - ${issue.message ?? ''}`; + }); +} + +/** + * A raw request, for the few checks that must bypass the plugin's transport. + * + * Those checks look at status codes and headers the helpers deliberately hide - + * whether a route exists at all, what the rate-limit headers say - so they call + * `fetch` directly. Building the URL and headers from the exported constants + * keeps them pointed at the same API the plugin uses. + */ +const apiUrl = (path: string) => `${HABITICA_API_BASE}/${path}`; +const authHeaders = () => ({ + 'x-api-user': credentials.userId, + 'x-api-key': credentials.apiToken, + 'x-client': HABITICA_CLIENT_ID, +}); + /** Probe objects are named so anything left behind is obvious on the account. */ const PROBE = 'corsair integration probe - safe to delete'; @@ -94,7 +127,7 @@ describeLive('Habitica live API', () => { expect(Array.isArray(tasks)).toBe(true); for (const task of tasks) { const parsed = HabiticaTaskEntity.safeParse(task); - if (!parsed.success) console.error(parsed.error.issues); + if (!parsed.success) console.error(describeIssues(parsed.error)); expect(parsed.success).toBe(true); } }); @@ -113,7 +146,7 @@ describeLive('Habitica live API', () => { await paced(() => makeHabiticaRequest('groups/habitrpg', credentials)), ); const parsed = HabiticaGroupEntity.safeParse(group); - if (!parsed.success) console.error(parsed.error.issues); + if (!parsed.success) console.error(describeIssues(parsed.error)); expect(parsed.success).toBe(true); }); @@ -125,7 +158,7 @@ describeLive('Habitica live API', () => { ); for (const challenge of challenges) { const parsed = HabiticaChallengeEntity.safeParse(challenge); - if (!parsed.success) console.error(parsed.error.issues); + if (!parsed.success) console.error(describeIssues(parsed.error)); expect(parsed.success).toBe(true); } }); @@ -204,25 +237,17 @@ describeLive('Habitica live API', () => { const ghost = '11111111-2222-4333-8444-555555555555'; const unrouted = await paced(async () => { - const res = await fetch(`https://habitica.com/api/v3/groups/${ghost}`, { + const res = await fetch(apiUrl(`groups/${ghost}`), { method: 'DELETE', - headers: { - 'x-api-user': credentials.userId, - 'x-api-key': credentials.apiToken, - 'x-client': 'corsair', - }, + headers: authHeaders(), }); return (await res.json()) as { message?: string }; }); const realRoute = await paced(async () => { - const res = await fetch(`https://habitica.com/api/v3/groups/${ghost}`, { + const res = await fetch(apiUrl(`groups/${ghost}`), { method: 'GET', - headers: { - 'x-api-user': credentials.userId, - 'x-api-key': credentials.apiToken, - 'x-client': 'corsair', - }, + headers: authHeaders(), }); return (await res.json()) as { message?: string }; }); @@ -234,9 +259,7 @@ describeLive('Habitica live API', () => { }); it('rejects a request with no x-client header, even unauthenticated', async () => { - const res = await paced(() => - fetch('https://habitica.com/api/v3/content'), - ); + const res = await paced(() => fetch(apiUrl('content'))); expect(res.status).toBe(400); const body = (await res.json()) as { message?: string }; expect(body.message).toMatch(/x-client/i); @@ -360,13 +383,7 @@ describeLive('Habitica live API', () => { describe('rate limiting', () => { it('reports a limit of 30 per minute in the response headers', async () => { const res = await paced(() => - fetch('https://habitica.com/api/v3/user?userFields=_id', { - headers: { - 'x-api-user': credentials.userId, - 'x-api-key': credentials.apiToken, - 'x-client': 'corsair', - }, - }), + fetch(apiUrl('user?userFields=_id'), { headers: authHeaders() }), ); expect(res.headers.get('x-ratelimit-limit')).toBe('30'); expect(Number(res.headers.get('x-ratelimit-remaining'))).toBeLessThan(30); @@ -374,13 +391,7 @@ describeLive('Habitica live API', () => { it('sends x-ratelimit-reset as a date string, which is why it is unconfigured', async () => { const res = await paced(() => - fetch('https://habitica.com/api/v3/user?userFields=_id', { - headers: { - 'x-api-user': credentials.userId, - 'x-api-key': credentials.apiToken, - 'x-client': 'corsair', - }, - }), + fetch(apiUrl('user?userFields=_id'), { headers: authHeaders() }), ); const reset = res.headers.get('x-ratelimit-reset'); expect(reset).toBeTruthy(); diff --git a/packages/habitica/jest.config.cjs b/packages/habitica/jest.config.cjs index 9339555f3..0961f901a 100644 --- a/packages/habitica/jest.config.cjs +++ b/packages/habitica/jest.config.cjs @@ -13,7 +13,11 @@ module.exports = { '!**/*.d.ts', '!**/node_modules/**', '!**/dist/**', - '!jest.config.ts', + // The generator emitted `!jest.config.ts`, but this file is `.cjs`, so + // the exclusion never matched anything. + '!jest.config.cjs', + // Test files are not the subject of coverage measurement. + '!**/*.test.ts', '!tests/**', ], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], From 50d51c97224b8d16ef8fc1c463b342c7cf74df1c Mon Sep 17 00:00:00 2001 From: Agam00 Date: Sun, 16 Aug 2026 02:28:16 +0530 Subject: [PATCH 4/6] feat(habitica): enhance error redaction in API responses to prevent secret leakage --- packages/habitica/endpoints.test.ts | 102 ++++++++++++++++++++------ packages/habitica/endpoints/shared.ts | 14 +++- 2 files changed, 91 insertions(+), 25 deletions(-) diff --git a/packages/habitica/endpoints.test.ts b/packages/habitica/endpoints.test.ts index c542715e6..b0092d62d 100644 --- a/packages/habitica/endpoints.test.ts +++ b/packages/habitica/endpoints.test.ts @@ -1005,6 +1005,38 @@ describe('secrets interpolated into a path', () => { }) as unknown as Response) as unknown as typeof global.fetch; } + /** + * Returns the error a call rejected with, and fails loudly if it did not + * reject at all. + * + * Without this the redaction assertions below are vacuous: `not.toContain` + * passes just as happily against a resolved value as against a redacted + * error. Verified by making the mocked request succeed - both checks still + * passed, proving they were asserting nothing. + */ + const RESOLVED = Symbol('resolved'); + async function rejection(promise: Promise): Promise { + const outcome = await promise.then( + () => RESOLVED, + (error: unknown) => error, + ); + if (outcome === RESOLVED) { + throw new Error( + 'expected the call to reject; it resolved, so the assertions that follow would prove nothing', + ); + } + return outcome; + } + + /** Everywhere the secret could hide on the way to a log. */ + const serialise = (error: unknown) => + [ + (error as Error)?.message, + JSON.stringify(error), + String((error as { url?: string })?.url ?? ''), + String((error as { request?: { url?: string } })?.request?.url ?? ''), + ].join(' '); + it('keeps a coupon code out of the thrown error', async () => { // A valid coupon is a bearer instrument. The shared transport redacts // sensitive query parameters but not path segments, and Habitica takes @@ -1012,36 +1044,61 @@ describe('secrets interpolated into a path', () => { const { ctx } = makeCtx(); mockFailure(404, 'https://habitica.com/api/v3/coupons/validate/x'); - const error = await Content.validateCoupon(ctx, { - code: 'SECRET-COUPON-1234', - }).catch((e: unknown) => e); + const error = await rejection( + Content.validateCoupon(ctx, { code: 'SECRET-COUPON-1234' }), + ); - const serialised = `${(error as Error).message} ${JSON.stringify(error)} ${String((error as { url?: string }).url ?? '')}`; - expect(serialised).not.toContain('SECRET-COUPON-1234'); + expect(error).toBeInstanceOf(Error); + // The status survives redaction, so error-handlers can still classify it. + expect((error as { status?: number }).status).toBe(404); + expect(serialise(error)).not.toContain('SECRET-COUPON-1234'); }); it('keeps a push-device registration id out of the thrown error', async () => { const { ctx } = makeCtx(); mockFailure(404, 'https://habitica.com/api/v3/user/push-devices/x'); - const error = await User.deletePushDevice(ctx, { - regId: 'device-token-abcdef', - }).catch((e: unknown) => e); + const error = await rejection( + User.deletePushDevice(ctx, { regId: 'device-token-abcdef' }), + ); - const serialised = `${(error as Error).message} ${JSON.stringify(error)} ${String((error as { url?: string }).url ?? '')}`; - expect(serialised).not.toContain('device-token-abcdef'); + expect(error).toBeInstanceOf(Error); + expect((error as { status?: number }).status).toBe(404); + expect(serialise(error)).not.toContain('device-token-abcdef'); }); - it('leaves an unrelated error untouched', async () => { - // Redaction must not swallow errors that never carried the secret. + it('stays diagnosable after redaction', async () => { + // Redaction must not cost an operator the ability to tell which call + // failed. The transport's message is often just "Not Found" and the + // detail lives in the URL, so the masked URL is folded into the message. const { ctx } = makeCtx(); mockFailure(404, 'https://habitica.com/api/v3/coupons/validate/x'); - const error = await Content.validateCoupon(ctx, { code: 'ABCD' }).catch( - (e: unknown) => e, + const error = await rejection( + Content.validateCoupon(ctx, { code: 'SECRET-COUPON-1234' }), ); - expect(error).toBeInstanceOf(Error); + const message = (error as Error).message; + expect(message).toContain('coupons/validate'); + expect(message).toContain('[REDACTED]'); + expect(message).not.toContain('SECRET-COUPON-1234'); + }); + + it('passes through an error that never carried the secret', async () => { + // Redaction rebuilds the error only when the value actually leaked. A + // missing user id fails before any request, so nothing needs masking and + // the original error type must survive. + const ctx = { + key: 'test-token', + db: {}, + options: {}, + } as unknown as Ctx; + + const error = await rejection( + Content.validateCoupon(ctx, { code: 'SECRET-COUPON-1234' }), + ); + + expect(error).toBeInstanceOf(HabiticaUserIdMissingError); }); }); @@ -1072,12 +1129,15 @@ describe('the credential-minting operations send their body', () => { confirmPassword: 'a-password', }); - expect(Object.keys(sentBody()).sort()).toEqual([ - 'confirmPassword', - 'email', - 'password', - 'username', - ]); + // The whole payload, not just its keys: a key check would pass against + // empty strings or values swapped between fields, and an empty body was + // exactly the bug this test exists to catch. + expect(sentBody()).toEqual({ + username: 'someone', + email: 'someone@example.com', + password: 'a-password', + confirmPassword: 'a-password', + }); }); it('auth.social sends the provider response', async () => { diff --git a/packages/habitica/endpoints/shared.ts b/packages/habitica/endpoints/shared.ts index 330e28eb4..7e22f2e51 100644 --- a/packages/habitica/endpoints/shared.ts +++ b/packages/habitica/endpoints/shared.ts @@ -216,12 +216,18 @@ function redactPathValue(error: unknown, secret: string): unknown { const retryAfter = typeof carrier.retryAfter === 'number' ? carrier.retryAfter : undefined; + const url = String(carrier.url ?? ''); const masked = mask(error.message); - const leaked = - masked !== error.message || - forms.some((form) => String(carrier.url ?? '').includes(form)); + const leaked = masked !== error.message || forms.some((f) => url.includes(f)); - return leaked ? new HabiticaHttpError(masked, status, retryAfter) : error; + if (!leaked) return error; + + // The rebuilt error has no `url`, which is usually where the secret sits - + // the transport's own message is often just "Not Found". The masked URL is + // therefore folded into the message, so redaction does not cost an operator + // the ability to tell which call failed. + const detail = url ? ` (${mask(url)})` : ''; + return new HabiticaHttpError(`${masked}${detail}`, status, retryAfter); } /** From 2062b06c2a9b0cdc4ca2e581d181ef3e4796596c Mon Sep 17 00:00:00 2001 From: Dhirender Choudhary Date: Sun, 16 Aug 2026 05:06:58 +0530 Subject: [PATCH 5/6] fix(habitica): match official API and cover remaining live routes --- packages/habitica/endpoints.test.ts | 19 +- packages/habitica/endpoints/chat.ts | 6 +- packages/habitica/endpoints/groups.ts | 1 + packages/habitica/endpoints/tasks.ts | 12 +- packages/habitica/endpoints/types.ts | 21 +- packages/habitica/integration.test.ts | 344 ++++++++++++++++++++++++++ packages/habitica/schema/database.ts | 341 +++++++++---------------- 7 files changed, 510 insertions(+), 234 deletions(-) diff --git a/packages/habitica/endpoints.test.ts b/packages/habitica/endpoints.test.ts index b0092d62d..3c87ef0dc 100644 --- a/packages/habitica/endpoints.test.ts +++ b/packages/habitica/endpoints.test.ts @@ -1254,7 +1254,24 @@ describe('request construction', () => { await Tasks.list(ctx, { type: 'todos' }); expect(query().get('type')).toBe('todos'); - expect(query().has('tagId')).toBe(false); + expect(query().has('dueDate')).toBe(false); + }); + + it('normalizes a single challenge-task create into an array', async () => { + const { ctx, db } = makeCtx(); + mockFetch(wrap(taskRecord)); + + const result = await Tasks.createChallengeTask(ctx, { + challengeId: CHALLENGE, + text: 'A task', + type: 'habit', + }); + + expect(result).toEqual([expect.objectContaining({ id: TASK })]); + expect(db.tasks.upsertByEntityId).toHaveBeenCalledWith( + TASK, + expect.objectContaining({ id: TASK }), + ); }); it('omits unset optional body fields', async () => { diff --git a/packages/habitica/endpoints/chat.ts b/packages/habitica/endpoints/chat.ts index fe86ef8da..fd2b42f4a 100644 --- a/packages/habitica/endpoints/chat.ts +++ b/packages/habitica/endpoints/chat.ts @@ -15,7 +15,11 @@ import type { HabiticaEndpointOutputs } from './types'; /** The default group when the caller names none. */ const PARTY_ALIAS = 'party'; -/** Reads a group's recent chat messages. Defaults to the caller's party. */ +/** + * Reads a group's recent chat messages. Defaults to the caller's party. + * + * Public groups, including the Tavern, answer 400 "This feature is no longer supported." + */ export const list: HabiticaEndpoints['chatList'] = async (ctx, input) => { const groupId = input.groupId ?? PARTY_ALIAS; const result = await habiticaCall( diff --git a/packages/habitica/endpoints/groups.ts b/packages/habitica/endpoints/groups.ts index 59d5565bd..30d87a75a 100644 --- a/packages/habitica/endpoints/groups.ts +++ b/packages/habitica/endpoints/groups.ts @@ -193,6 +193,7 @@ export const listMembers: HabiticaEndpoints['groupsListMembers'] = async ( >(ctx, `groups/${pathSegment(input.groupId)}/members`, { query: compactQuery({ lastId: input.lastId, + limit: input.limit, includeAllPublicFields: input.includeAllPublicFields, }), }); diff --git a/packages/habitica/endpoints/tasks.ts b/packages/habitica/endpoints/tasks.ts index 821081959..ef843d385 100644 --- a/packages/habitica/endpoints/tasks.ts +++ b/packages/habitica/endpoints/tasks.ts @@ -48,7 +48,7 @@ export const list: HabiticaEndpoints['tasksList'] = async (ctx, input) => { const result = await habiticaCall( ctx, 'tasks/user', - { query: compactQuery({ type: input.type, tagId: input.tagId }) }, + { query: compactQuery({ type: input.type, dueDate: input.dueDate }) }, ); await cacheEntities(ctx.db.tasks, HabiticaTaskEntity, result, { @@ -58,7 +58,7 @@ export const list: HabiticaEndpoints['tasksList'] = async (ctx, input) => { await logEventFromContext( ctx, 'habitica.tasks.list', - { ...auditPayload(input, ['type', 'tagId']), returned: result.length }, + { ...auditPayload(input, ['type', 'dueDate']), returned: result.length }, 'completed', ); return result; @@ -281,12 +281,16 @@ export const addTag: HabiticaEndpoints['tasksAddTag'] = async (ctx, input) => { export const createChallengeTask: HabiticaEndpoints['tasksCreateChallengeTask'] = async (ctx, input) => { const { challengeId, ...task } = input; - const result = await habiticaCall< - HabiticaEndpointOutputs['tasksCreateChallengeTask'] + const raw = await habiticaCall< + | HabiticaEndpointOutputs['tasksCreateChallengeTask'][number] + | HabiticaEndpointOutputs['tasksCreateChallengeTask'] >(ctx, `tasks/challenge/${pathSegment(challengeId)}`, { method: 'POST', body: compactBody(task), }); + // Official POST /tasks/challenge/:id returns the task object when one + // is created, and an array only when the body is a list. + const result = Array.isArray(raw) ? raw : [raw]; await cacheEntities(ctx.db.tasks, HabiticaTaskEntity, result, { label: LABEL, diff --git a/packages/habitica/endpoints/types.ts b/packages/habitica/endpoints/types.ts index 9426f9bec..762ce97cd 100644 --- a/packages/habitica/endpoints/types.ts +++ b/packages/habitica/endpoints/types.ts @@ -98,13 +98,8 @@ export type TasksCreateInput = z.infer; const TasksListInputSchema = z.object({ type: TaskListFilter.optional(), - /** Restricts the list to tasks carrying this tag id. */ - tagId: z.string().optional(), - /** - * `GET /tasks/user` returns every matching task in one response. It takes no - * page or cursor parameter, so there is nothing to paginate with, and a - * long-lived account's whole task list arrives at once. - */ + /** Official: date used to compute `nextDue` on each returned daily. */ + dueDate: z.string().optional(), }); export type TasksListInput = z.infer; @@ -210,9 +205,17 @@ export type TasksCreateChallengeTaskInput = z.infer< typeof TasksCreateChallengeTaskInputSchema >; +const ChallengeTaskListFilter = z.enum([ + 'habits', + 'dailys', + 'todos', + 'rewards', +]); + const TasksListChallengeTasksInputSchema = z.object({ challengeId: z.string(), - type: TaskListFilter.optional(), + /** Official GET /tasks/challenge/:id — no completedTodos filter. */ + type: ChallengeTaskListFilter.optional(), }); export type TasksListChallengeTasksInput = z.infer< typeof TasksListChallengeTasksInputSchema @@ -425,8 +428,8 @@ export type GroupsLeaveInput = z.infer; const GroupsListMembersInputSchema = z.object({ groupId: z.string(), - /** Cursor: the id of the last member from the previous page. */ lastId: z.string().optional(), + limit: z.number().optional(), includeAllPublicFields: z.boolean().optional(), }); export type GroupsListMembersInput = z.infer< diff --git a/packages/habitica/integration.test.ts b/packages/habitica/integration.test.ts index 4ce864235..80dfb389c 100644 --- a/packages/habitica/integration.test.ts +++ b/packages/habitica/integration.test.ts @@ -44,6 +44,7 @@ import { makeHabiticaAnonymousRequest, makeHabiticaExportRequest, makeHabiticaRequest, + makeHabiticaTextRequest, } from './client'; import { HabiticaChallengeEntity, @@ -258,6 +259,17 @@ describeLive('Habitica live API', () => { expect(unrouted.message).not.toBe(realRoute.message); }); + it('rejects Tavern chat because public group chat is retired', async () => { + const body = await paced(async () => { + const res = await fetch(apiUrl('groups/habitrpg/chat'), { + headers: authHeaders(), + }); + return (await res.json()) as { message?: string }; + }); + expect(body.message).not.toBe('Not found.'); + expect(body.message).toMatch(/no longer supported/i); + }); + it('rejects a request with no x-client header, even unauthenticated', async () => { const res = await paced(() => fetch(apiUrl('content'))); expect(res.status).toBe(400); @@ -380,6 +392,338 @@ describeLive('Habitica live API', () => { }); }); + describe('remaining catalog routes', () => { + it('answers the remaining public and authenticated reads', async () => { + const read = async (path: string, auth = true) => { + try { + return auth + ? unwrap(await paced(() => makeHabiticaRequest(path, credentials))) + : unwrap(await paced(() => makeHabiticaAnonymousRequest(path))); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + throw new Error(`${path}: ${message}`); + } + }; + + const status = (await read('status', false)) as { status: string }; + expect(status.status).toBe('up'); + + const world = (await read('world-state', false)) as Record< + string, + unknown + >; + expect(world).toHaveProperty('worldBoss'); + + expect(await read('news', false)).toBeDefined(); + + for (const model of [ + 'user', + 'tag', + 'challenge', + 'group', + 'habit', + 'daily', + 'todo', + 'reward', + ] as const) { + const paths = (await read(`models/${model}/paths`, false)) as Record< + string, + unknown + >; + expect(Object.keys(paths).length).toBeGreaterThan(0); + } + + const user = (await read('user?userFields=_id')) as Record< + string, + unknown + >; + expect(user._id ?? user.id).toBeTruthy(); + + const groups = (await read( + 'groups?type=party,guilds,tavern', + )) as unknown[]; + expect(Array.isArray(groups)).toBe(true); + + const members = (await read( + 'groups/habitrpg/members?limit=5', + )) as unknown[]; + expect(Array.isArray(members)).toBe(true); + + const hooks = (await read('user/webhook')) as unknown[]; + expect(Array.isArray(hooks)).toBe(true); + + const tavernChallenges = (await read( + 'challenges/groups/habitrpg', + )) as unknown[]; + expect(Array.isArray(tavernChallenges)).toBe(true); + + for (const path of [ + 'shops/market-gear', + 'shops/time-travelers', + 'groups/party', + 'groups/party/chat', + ]) { + const body = (await paced(async () => { + const res = await fetch(apiUrl(path), { headers: authHeaders() }); + return (await res.json()) as { + message?: string; + data?: unknown; + }; + })) as { message?: string; data?: unknown }; + expect(body.message).not.toBe('Not found.'); + } + }, 180000); + + it('creates, updates, scores, moves and deletes a probe task', async () => { + let taskId: string | undefined; + let tagId: string | undefined; + try { + const tag = unwrap<{ id: string }>( + await paced(() => + makeHabiticaRequest('tags', credentials, { + method: 'POST', + body: { name: PROBE }, + }), + ), + ); + tagId = tag.id; + + const created = unwrap>( + await paced(() => + makeHabiticaRequest('tasks/user', credentials, { + method: 'POST', + body: { + text: PROBE, + type: 'todo', + notes: 'probe', + checklist: [{ text: 'item', completed: false }], + }, + }), + ), + ); + expect(HabiticaTaskEntity.safeParse(created).success).toBe(true); + taskId = String(created.id); + + const got = unwrap>( + await paced(() => + makeHabiticaRequest(`tasks/${taskId}`, credentials), + ), + ); + expect(got.id).toBe(taskId); + + const updated = unwrap>( + await paced(() => + makeHabiticaRequest(`tasks/${taskId}`, credentials, { + method: 'PUT', + body: { notes: 'probe renamed' }, + }), + ), + ); + expect(updated.notes).toBe('probe renamed'); + + const tagged = unwrap>( + await paced(() => + makeHabiticaRequest(`tasks/${taskId}/tags/${tagId}`, credentials, { + method: 'POST', + }), + ), + ); + expect(Array.isArray(tagged.tags)).toBe(true); + + const itemId = (created.checklist as { id?: string }[] | undefined)?.[0] + ?.id; + if (itemId) { + const item = unwrap>( + await paced(() => + makeHabiticaRequest( + `tasks/${taskId}/checklist/${itemId}`, + credentials, + { method: 'PUT', body: { text: 'item renamed' } }, + ), + ), + ); + expect(HabiticaTaskEntity.safeParse(item).success).toBe(true); + + await paced(() => + makeHabiticaRequest( + `tasks/${taskId}/checklist/${itemId}`, + credentials, + { method: 'DELETE' }, + ), + ); + } + + await paced(() => + makeHabiticaRequest(`tasks/${taskId}/move/to/0`, credentials, { + method: 'POST', + }), + ); + + const scored = unwrap>( + await paced(() => + makeHabiticaRequest(`tasks/${taskId}/score/up`, credentials, { + method: 'POST', + }), + ), + ); + expect(scored).toHaveProperty('gp'); + } finally { + if (taskId) { + await paced(() => + makeHabiticaRequest(`tasks/${taskId}`, credentials, { + method: 'DELETE', + }), + ).catch((error) => { + console.error('failed to clean up probe task', taskId, error); + }); + } + if (tagId) { + await paced(() => + makeHabiticaRequest(`tags/${tagId}`, credentials, { + method: 'DELETE', + }), + ).catch((error) => { + console.error('failed to clean up probe tag', tagId, error); + }); + } + } + }, 120000); + + it('reads a live challenge, its tasks, and its CSV export', async () => { + const listed = unwrap<{ id?: string }[]>( + await paced(() => + makeHabiticaRequest('challenges/user?page=0', credentials), + ), + ); + const tavern = unwrap<{ id?: string }[]>( + await paced(() => + makeHabiticaRequest('challenges/groups/habitrpg', credentials), + ), + ); + const challengeId = listed[0]?.id ?? tavern[0]?.id; + expect(challengeId).toBeTruthy(); + if (!challengeId) return; + + const challenge = unwrap( + await paced(() => + makeHabiticaRequest(`challenges/${challengeId}`, credentials), + ), + ); + expect(HabiticaChallengeEntity.safeParse(challenge).success).toBe(true); + + const tasks = unwrap( + await paced(() => + makeHabiticaRequest(`tasks/challenge/${challengeId}`, credentials), + ), + ); + expect(Array.isArray(tasks)).toBe(true); + for (const task of tasks.slice(0, 3)) { + expect(HabiticaTaskEntity.safeParse(task).success).toBe(true); + } + + const csv = await paced(() => + makeHabiticaTextRequest( + `challenges/${challengeId}/export/csv`, + credentials, + ), + ); + expect(csv.contentType).toMatch(/csv|text/i); + expect(csv.body.length).toBeGreaterThan(0); + }, 120000); + + it('registers and deletes a probe push device, and snoozes news', async () => { + const regId = `corsair-probe-${Date.now()}`; + try { + const added = unwrap( + await paced(() => + makeHabiticaRequest('user/push-devices', credentials, { + method: 'POST', + body: { regId, type: 'android' }, + }), + ), + ); + expect(Array.isArray(added)).toBe(true); + + const news = unwrap( + await paced(() => + makeHabiticaRequest('news/tell-me-later', credentials, { + method: 'POST', + }), + ), + ); + expect(news).toBeDefined(); + } finally { + await paced(() => + makeHabiticaRequest(`user/push-devices/${regId}`, credentials, { + method: 'DELETE', + }), + ).catch((error) => { + console.error('failed to clean up probe push device', error); + }); + } + }, 60000); + + it('hits remaining write routes as real endpoints, not unrouted 404s', async () => { + const ghost = '11111111-2222-4333-8444-555555555555'; + const cases: [string, string][] = [ + ['GET', `tasks/${ghost}`], + ['PUT', `tasks/${ghost}`], + ['DELETE', `tasks/${ghost}`], + ['POST', `tasks/${ghost}/score/up`], + ['POST', `tasks/${ghost}/move/to/0`], + ['PUT', `tasks/${ghost}/checklist/${ghost}`], + ['DELETE', `tasks/${ghost}/checklist/${ghost}`], + ['POST', `tasks/${ghost}/tags/${ghost}`], + ['GET', `tasks/challenge/${ghost}`], + ['POST', `tasks/challenge/${ghost}`], + ['POST', `tasks/unlink-all/${ghost}?keep=keep-all`], + ['GET', `challenges/${ghost}`], + ['POST', `challenges/${ghost}/clone`], + ['DELETE', `challenges/${ghost}`], + ['POST', `challenges/${ghost}/join`], + ['POST', `challenges/${ghost}/leave`], + ['GET', `challenges/groups/${ghost}`], + ['PUT', `groups/${ghost}`], + ['POST', `groups/${ghost}/leave`], + ['GET', `groups/${ghost}/members`], + ['POST', `groups/${ghost}/invite`], + ['POST', `groups/${ghost}/removeMember/${ghost}`], + ['POST', `groups/${ghost}/quests/invite/atom1`], + ['GET', `groups/${ghost}/chat`], + ['DELETE', `groups/${ghost}/chat/${ghost}`], + ['POST', `groups/${ghost}/chat/seen`], + ['POST', `user/equip/equipped/not_a_real_item`], + ['POST', `user/read-card/birthday`], + ['POST', `user/move-pinned-item/armoire/move/to/0`], + ['DELETE', `user/messages/${ghost}`], + ['PUT', `user/webhook/${ghost}`], + ['POST', `notifications/${ghost}/see`], + ['POST', 'notifications/see'], + ['POST', 'coupons/validate/NOT-A-CODE'], + ['GET', `challenges/${ghost}/export/csv`], + ['POST', 'user/push-devices'], + ['DELETE', `user/push-devices/${ghost}`], + ['PUT', 'user'], + ['POST', 'challenges'], + ['POST', 'user/auth/local/login'], + ['POST', 'user/auth/social'], + ]; + + for (const [method, path] of cases) { + const body = await paced(async () => { + const res = await fetch(apiUrl(path), { + method, + headers: { ...authHeaders(), 'Content-Type': 'application/json' }, + body: method === 'GET' || method === 'DELETE' ? undefined : '{}', + }); + return (await res.json()) as { message?: string; error?: string }; + }); + expect(body.message).not.toBe('Not found.'); + } + }, 240000); + }); + describe('rate limiting', () => { it('reports a limit of 30 per minute in the response headers', async () => { const res = await paced(() => diff --git a/packages/habitica/schema/database.ts b/packages/habitica/schema/database.ts index 2fa0e1916..86b973295 100644 --- a/packages/habitica/schema/database.ts +++ b/packages/habitica/schema/database.ts @@ -1,82 +1,26 @@ import { z } from 'zod'; /** - * Locally persisted Habitica entities. - * - * Habitica's surface splits into three kinds of data, and only the first is - * worth mirroring. - * - * **Mirrored.** Tags, tasks, challenges, groups and webhooks are addressable by - * a stable id and are the lookup nearly every other operation needs - scoring a - * task, tagging one, adding one to a challenge and inviting to a group all - * start from an id that has to come from somewhere. Mirroring matters more here - * than on most integrations because Habitica allows only **30 requests per - * minute per user**, so a lookup served locally is a request that does not have - * to be spent. - * - * **Not mirrored - it is the account holder's personal data.** The user - * document, the inbox and group chat carry profile text, private messages and, - * in `auth.local.email`, the account holder's email address. None of it is - * copied into local storage, and none of it is written to an audit payload. - * - * **Not mirrored - it is not row-shaped.** The content catalogue is a single - * 2.65 MB document of static game definitions rather than a collection of - * records. It is a strong caching candidate and a poor entity: there is no id - * to key rows by, and storing it would mean one row that is really a file. - * - * A caveat that applies to tasks specifically, stated because a mirror that - * quietly lies is worse than no mirror: `value`, `history`, `counterUp`, - * `counterDown`, `streak` and `completed` change every time a task is scored. - * The mirrored copy is a snapshot of those fields at fetch time, not a live - * figure. The stable parts - id, type, text, notes, tags, priority - are what - * the mirror is for. - * - * Field names match the API's own JSON keys. Every field except the primary key - * is nullable and optional, and every object is `.loose()`: Habitica returns a - * different key set per task type, omits fields an account has never used, and - * adds fields as the game gains features. - * - * Shapes captured live on 2026-08-15 from a real account; `schema.test.ts` - * asserts every captured key is declared here. - * Official: https://habitica.com/apidoc/ + * Field names match official JSON keys. + * https://apidoc.habitica.com/ + * GET /api/v3/models/:model/paths */ -/** Habitica omits unset fields more often than it nulls them; allow both. */ const S = z.string().nullable().optional(); const N = z.number().nullable().optional(); const B = z.boolean().nullable().optional(); - -/** - * Ids are UUID strings. - * - * Every entity carries the same id under two keys: Mongo's `_id` and a mirrored - * `id`. They held identical values on every object observed. `id` is the - * primary key here because it is the one the API's own path parameters are - * named after, and `_id` is kept so a caller comparing against a raw response - * is not surprised by its absence. - */ const Id = z.string(); -/** - * One entry of a task's checklist. - * Captured from `POST /tasks/:taskId/checklist`. - */ export const HabiticaChecklistItem = z .object({ id: S, text: S, completed: B, + linkId: S, }) .loose(); export type HabiticaChecklistItem = z.infer; -/** - * A scheduled reminder attached to a task. - * - * Declared structurally rather than as `unknown` because the key set is stable, - * but left loose: reminders gained fields when Habitica added time-zone - * handling, and will again. - */ export const HabiticaReminder = z .object({ id: S, @@ -86,14 +30,6 @@ export const HabiticaReminder = z .loose(); export type HabiticaReminder = z.infer; -/** - * One dated point in a task's value history. - * - * Habits and dailies accumulate these on every score. `date` is a millisecond - * epoch number, not an ISO string - unlike `createdAt` and `updatedAt` on the - * same object, which are ISO strings. That inconsistency is the API's, and it - * is the reason this field is typed as a number rather than coerced to a date. - */ export const HabiticaTaskHistoryEntry = z .object({ date: N, @@ -106,72 +42,93 @@ export const HabiticaTaskHistoryEntry = z .loose(); export type HabiticaTaskHistoryEntry = z.infer; -/** - * A task: habit, daily, todo or reward. - * - * One entity covers all four types because the API returns them from one - * collection, `GET /tasks/user`, discriminated by `type`. The key sets differ - * substantially - captured live, a habit carries `up`/`down`/`counterUp`/ - * `counterDown`/`frequency`/`history`, a daily adds `repeat`/`everyX`/`streak`/ - * `isDue`/`nextDue`/`startDate`/`daysOfMonth`/`weeksOfMonth`/`yesterDaily`, a - * todo adds `checklist`/`completed`, and a reward carries none of them. Every - * type-specific field is therefore optional here, and a field being absent is - * information about the task's type rather than a gap in the data. - * - * `date` and `dateCompleted` are declared but were not observed live: the - * account held no todo with a due date. They are documented by Habitica and the - * schema is loose, so their absence from the capture is not evidence against - * them. - */ +export const HabiticaTaskChallenge = z + .object({ + shortName: S, + id: S, + taskId: S, + broken: S, + winner: S, + }) + .loose(); +export type HabiticaTaskChallenge = z.infer; + +export const HabiticaTaskGroup = z + .object({ + id: S, + assignedDate: S, + assigningUsername: S, + assignedUsers: z.array(z.string()).nullable().optional(), + assignedUsersDetail: z + .record(z.string(), z.unknown()) + .nullable() + .optional(), + taskId: S, + managerNotes: S, + completedBy: z + .object({ + userId: S, + date: S, + }) + .loose() + .nullable() + .optional(), + approval: z + .object({ + required: B, + approved: B, + requested: B, + }) + .loose() + .nullable() + .optional(), + }) + .loose(); +export type HabiticaTaskGroup = z.infer; + +export const HabiticaDailyRepeat = z + .object({ + m: B, + t: B, + w: B, + th: B, + f: B, + s: B, + su: B, + }) + .loose(); +export type HabiticaDailyRepeat = z.infer; + export const HabiticaTaskEntity = z .object({ - /** Primary key. Same value as `_id`. */ id: Id, - /** Mongo's id for the same record. */ _id: S, - /** One of `habit`, `daily`, `todo`, `reward`. */ type: S, - /** The task title. */ text: S, notes: S, - /** Tag ids applied to this task, not tag objects. */ + alias: S, tags: z.array(z.string()).nullable().optional(), - /** - * The task's accumulated worth, which drives its colour in the UI. - * Changes on every score - see the caveat at the top of this file. - */ value: N, - /** 0.1 trivial, 1 easy, 1.5 medium, 2 hard. */ priority: N, - /** `str`, `int`, `con` or `per`. */ attribute: S, - /** Set when the task belongs to a challenge. */ - challenge: z.record(z.string(), z.unknown()).nullable().optional(), - /** Set when the task belongs to a group. */ - group: z.record(z.string(), z.unknown()).nullable().optional(), - byHabitica: B, userId: S, + challenge: HabiticaTaskChallenge.nullable().optional(), + group: HabiticaTaskGroup.nullable().optional(), + reminders: z.array(HabiticaReminder).nullable().optional(), + byHabitica: B, createdAt: S, updatedAt: S, - - /** Habits only. */ up: B, down: B, counterUp: N, counterDown: N, - /** Habits and dailies: `daily`, `weekly`, `monthly`, `yearly`. */ frequency: S, history: z.array(HabiticaTaskHistoryEntry).nullable().optional(), - - /** Dailies and todos. */ checklist: z.array(HabiticaChecklistItem).nullable().optional(), collapseChecklist: B, completed: B, - reminders: z.array(HabiticaReminder).nullable().optional(), - - /** Dailies only. */ streak: N, - repeat: z.record(z.string(), z.unknown()).nullable().optional(), + repeat: HabiticaDailyRepeat.nullable().optional(), everyX: N, startDate: S, daysOfMonth: z.array(z.number()).nullable().optional(), @@ -179,54 +136,22 @@ export const HabiticaTaskEntity = z isDue: B, nextDue: z.array(z.string()).nullable().optional(), yesterDaily: B, - - /** Todos only. Documented; not present in the live capture. */ date: S, dateCompleted: S, - - /** - * A user-chosen short name usable in place of the id on task routes. - * - * Declared from the model definition (`GET /models/todo/paths` reports - * `alias: String`) rather than from a capture - no task on the - * development account had one set. - */ - alias: S, }) .loose(); export type HabiticaTaskEntity = z.infer; -/** - * A tag. - * - * The narrowest entity in the API: `GET /tags` returned objects with exactly - * `id` and `name` on the account used for development, which has no - * challenge-owned tags. - * - * `challenge` and `group` are declared from the model definition rather than - * from a capture - `GET /models/tag/paths` reports the full set as - * `{id: String, name: String, challenge: Boolean, group: String}`. A tag - * created by joining a challenge carries them. - */ export const HabiticaTagEntity = z .object({ id: Id, name: S, - /** True when the tag was created by joining a challenge. */ challenge: B, - /** The group id, for a tag that belongs to one. */ group: S, }) .loose(); export type HabiticaTagEntity = z.infer; -/** - * The group a challenge belongs to, as embedded in a challenge. - * - * A reduced projection of a group - not the full entity - so it is declared - * separately rather than reusing {@link HabiticaGroupEntity}, which would imply - * fields the embedded copy does not carry. - */ export const HabiticaChallengeGroupRef = z .object({ id: S, @@ -246,15 +171,6 @@ export type HabiticaChallengeGroupRef = z.infer< typeof HabiticaChallengeGroupRef >; -/** - * A challenge's leader, as embedded in a challenge. - * - * Deliberately **not** modelled field by field. The embedded object carries - * `auth` and `profile` sub-objects belonging to another user, and enumerating - * them here would invite copying someone else's account details into local - * storage. The id is what the operations need; the rest is admitted by the - * loose record but never named or relied upon. - */ export const HabiticaChallengeLeaderRef = z .object({ id: S, @@ -265,27 +181,30 @@ export type HabiticaChallengeLeaderRef = z.infer< typeof HabiticaChallengeLeaderRef >; -/** - * A challenge. - * Captured from `GET /challenges/user?page=0`. - */ +export const HabiticaTasksOrder = z + .object({ + habits: z.array(z.string()).nullable().optional(), + dailys: z.array(z.string()).nullable().optional(), + todos: z.array(z.string()).nullable().optional(), + rewards: z.array(z.string()).nullable().optional(), + }) + .loose(); +export type HabiticaTasksOrder = z.infer; + export const HabiticaChallengeEntity = z .object({ id: Id, _id: S, name: S, - /** The short tag-like name challenge tasks are labelled with. */ shortName: S, summary: S, description: S, - /** True for challenges run by Habitica itself. */ official: B, - /** Gems awarded to the winner. */ prize: N, memberCount: N, leader: HabiticaChallengeLeaderRef.nullable().optional(), group: HabiticaChallengeGroupRef.nullable().optional(), - tasksOrder: z.record(z.string(), z.unknown()).nullable().optional(), + tasksOrder: HabiticaTasksOrder.nullable().optional(), categories: z .array(z.record(z.string(), z.unknown())) .nullable() @@ -298,86 +217,73 @@ export const HabiticaChallengeEntity = z .loose(); export type HabiticaChallengeEntity = z.infer; -/** - * A group: a party, a guild, or the Tavern. - * - * `chat` is declared but deliberately typed as an opaque array rather than - * modelled. Chat messages are other people's words attached to their user ids; - * they are not something this plugin should encourage copying into local - * storage, and no operation in the catalog needs their internal structure. - * - * Captured from `GET /groups/habitrpg` - the Tavern - because the account under - * test belonged to no party or guild. The Tavern is a real group and returns - * the full entity shape. - */ +export const HabiticaGroupLeaderOnly = z + .object({ + challenges: B, + getGems: B, + }) + .loose(); +export type HabiticaGroupLeaderOnly = z.infer; + +export const HabiticaGroupQuest = z + .object({ + key: S, + active: B, + leader: S, + progress: z + .object({ + hp: N, + collect: z.record(z.string(), z.unknown()).nullable().optional(), + rage: N, + }) + .loose() + .nullable() + .optional(), + members: z.record(z.string(), z.unknown()).nullable().optional(), + extra: z.record(z.string(), z.unknown()).nullable().optional(), + }) + .loose(); +export type HabiticaGroupQuest = z.infer; + export const HabiticaGroupEntity = z .object({ id: Id, _id: S, name: S, - /** `party`, `guild` or `habitrpg` for the Tavern. */ type: S, - /** `private` or `public`. */ privacy: S, summary: S, description: S, - /** The leader's user id. */ leader: z .union([z.string(), z.record(z.string(), z.unknown())]) .nullable() .optional(), memberCount: N, challengeCount: N, - /** Gems held by the group. */ balance: N, managers: z.record(z.string(), z.unknown()).nullable().optional(), categories: z .array(z.record(z.string(), z.unknown())) .nullable() .optional(), - quest: z.record(z.string(), z.unknown()).nullable().optional(), - leaderOnly: z.record(z.string(), z.unknown()).nullable().optional(), - tasksOrder: z.record(z.string(), z.unknown()).nullable().optional(), + quest: HabiticaGroupQuest.nullable().optional(), + leaderOnly: HabiticaGroupLeaderOnly.nullable().optional(), + tasksOrder: HabiticaTasksOrder.nullable().optional(), purchased: z.record(z.string(), z.unknown()).nullable().optional(), - /** - * An object, despite the name reading like a flag. - * - * Declared from the live response rather than from the name: it was first - * written here as a boolean and the live suite rejected the Tavern - * because of it. - */ - cron: z.record(z.string(), z.unknown()).nullable().optional(), - /** - * A string, not the object the surrounding fields would suggest - caught - * by the same live parse. - * - * Both this and `cron` were only ever observed on the Tavern, the one - * group the development account belonged to, so a union is used where a - * party or guild might differ. - */ - archive: z - .union([z.string(), z.record(z.string(), z.unknown())]) - .nullable() - .optional(), - - /** - * Declared from the model definition rather than from a capture - the - * Tavern returned none of these. `GET /models/group/paths` gives the - * types. - */ + cron: z.object({ lastProcessed: S }).loose().nullable().optional(), bannedWordsAllowed: B, chatLimitCount: N, logo: S, leaderMessage: S, chat: z.array(z.unknown()).nullable().optional(), + archive: z + .union([z.string(), z.record(z.string(), z.unknown())]) + .nullable() + .optional(), }) .loose(); export type HabiticaGroupEntity = z.infer; -/** - * Which task events a `taskActivity` webhook fires on. - * Captured from `POST /user/webhook`. - */ export const HabiticaWebhookOptions = z .object({ created: B, @@ -385,29 +291,26 @@ export const HabiticaWebhookOptions = z deleted: B, scored: B, checklistScored: B, + groupId: S, + petHatched: B, + mountRaised: B, + leveledUp: B, + questStarted: B, + questFinished: B, + questInvited: B, }) .loose(); export type HabiticaWebhookOptions = z.infer; -/** - * A webhook the user has registered with Habitica. - * - * These are the user's **outbound** webhooks - Habitica calling a URL of their - * choosing. They are not Corsair webhooks and this plugin registers no webhook - * handlers; the catalog lists them as ordinary operations, and that is how they - * are implemented. `failures` is Habitica's own delivery-failure counter, which - * is why these are worth mirroring: it is the only health signal available. - */ export const HabiticaWebhookEntity = z .object({ id: Id, - /** `taskActivity`, `groupChatReceived`, `userActivity`, `questActivity`. */ type: S, label: S, url: S, enabled: B, - /** Consecutive delivery failures. Habitica disables a webhook at 10. */ failures: N, + lastFailureAt: S, options: HabiticaWebhookOptions.nullable().optional(), createdAt: S, updatedAt: S, From b43830967ddfd3b54563e927777d114d3b155bf2 Mon Sep 17 00:00:00 2001 From: Dhirender Choudhary Date: Sun, 16 Aug 2026 05:16:22 +0530 Subject: [PATCH 6/6] fix(habitica): strip chat/url from cache and cap member limit --- packages/habitica/endpoints.test.ts | 51 ++++++++++++++++++++++++++ packages/habitica/endpoints/persist.ts | 35 +++++++++++++++++- packages/habitica/endpoints/types.ts | 2 +- packages/habitica/integration.test.ts | 28 ++++++++++---- 4 files changed, 106 insertions(+), 10 deletions(-) diff --git a/packages/habitica/endpoints.test.ts b/packages/habitica/endpoints.test.ts index 3c87ef0dc..e0d6d97c8 100644 --- a/packages/habitica/endpoints.test.ts +++ b/packages/habitica/endpoints.test.ts @@ -29,6 +29,7 @@ import { Webhooks, } from './endpoints'; import { HabiticaMirrorEvictionError } from './endpoints/persist'; +import { HabiticaEndpointInputSchemas } from './endpoints/types'; import { habiticaEndpointMeta } from './index'; jest.mock('corsair/core', () => ({ @@ -831,6 +832,41 @@ describe('mirroring', () => { expect(db.tasks.deleteByEntityId).toHaveBeenCalledWith('task-b'); }); + it('does not persist group chat, webhook urls, or undeclared keys', async () => { + const { ctx, db } = makeCtx(); + mockFetch( + wrap({ + id: GROUP, + name: 'A group', + chat: [{ text: 'other people talking' }], + aKeyNobodyDeclared: 1, + }), + ); + await Groups.get(ctx, { groupId: GROUP }); + const groupRow = db.groups.upsertByEntityId.mock.calls[0]?.[1] as Record< + string, + unknown + >; + expect(groupRow).not.toHaveProperty('chat'); + expect(groupRow).not.toHaveProperty('aKeyNobodyDeclared'); + expect(groupRow.id).toBe(GROUP); + + mockFetch( + wrap({ + ...webhookRecord, + aKeyNobodyDeclared: 1, + }), + ); + await Webhooks.create(ctx, { url: 'https://example.com/hook' }); + const hookRow = db.webhooks.upsertByEntityId.mock.calls[0]?.[1] as Record< + string, + unknown + >; + expect(hookRow).not.toHaveProperty('url'); + expect(hookRow).not.toHaveProperty('aKeyNobodyDeclared'); + expect(hookRow.id).toBe(WEBHOOK); + }); + it('does not mirror anything the user document touches', async () => { const { ctx, db } = makeCtx(); mockFetch( @@ -1257,6 +1293,21 @@ describe('request construction', () => { expect(query().has('dueDate')).toBe(false); }); + it('rejects a members list limit above 60', () => { + expect( + HabiticaEndpointInputSchemas.groupsListMembers.safeParse({ + groupId: GROUP, + limit: 61, + }).success, + ).toBe(false); + expect( + HabiticaEndpointInputSchemas.groupsListMembers.safeParse({ + groupId: GROUP, + limit: 5, + }).success, + ).toBe(true); + }); + it('normalizes a single challenge-task create into an array', async () => { const { ctx, db } = makeCtx(); mockFetch(wrap(taskRecord)); diff --git a/packages/habitica/endpoints/persist.ts b/packages/habitica/endpoints/persist.ts index cc5b6ed8c..dfb168287 100644 --- a/packages/habitica/endpoints/persist.ts +++ b/packages/habitica/endpoints/persist.ts @@ -95,6 +95,35 @@ const CACHE_WRITE_CONCURRENCY = 16; */ type EntityIdOf = (parsed: T) => string | undefined; +/** Response-only / secret fields that must not be written to the mirror. */ +const OMIT_FROM_CACHE: Record> = { + group: new Set(['chat']), + webhook: new Set(['url']), +}; + +/** + * Keeps declared schema keys only, minus fields the mirror must not store. + * + * Entities are `.loose()`, so `parsed.data` still carries unknown properties + * and group `chat` / webhook `url`. The schema shape is the allowlist. + */ +function projectForCache( + schema: Schema, + data: z.infer, + label: string, +): z.infer { + const shape = (schema as { shape?: Record }).shape; + if (!shape) return data; + const omit = OMIT_FROM_CACHE[label]; + const src = data as Record; + const out: Record = {}; + for (const key of Object.keys(shape)) { + if (omit?.has(key) || !(key in src)) continue; + out[key] = src[key]; + } + return out as z.infer; +} + const defaultEntityId = (parsed: T): string | undefined => { const id = (parsed as { id?: unknown }).id; if (typeof id === 'string' || typeof id === 'number') return String(id); @@ -134,7 +163,11 @@ export async function cacheEntity( if (!entityId) return; await safely( - () => store.upsertByEntityId(entityId, parsed.data), + () => + store.upsertByEntityId( + entityId, + projectForCache(schema, parsed.data, options.label), + ), `failed to cache ${options.label} ${entityId}`, ); } diff --git a/packages/habitica/endpoints/types.ts b/packages/habitica/endpoints/types.ts index 762ce97cd..0da639e96 100644 --- a/packages/habitica/endpoints/types.ts +++ b/packages/habitica/endpoints/types.ts @@ -429,7 +429,7 @@ export type GroupsLeaveInput = z.infer; const GroupsListMembersInputSchema = z.object({ groupId: z.string(), lastId: z.string().optional(), - limit: z.number().optional(), + limit: z.number().max(60).optional(), includeAllPublicFields: z.boolean().optional(), }); export type GroupsListMembersInput = z.infer< diff --git a/packages/habitica/integration.test.ts b/packages/habitica/integration.test.ts index 80dfb389c..a601b3665 100644 --- a/packages/habitica/integration.test.ts +++ b/packages/habitica/integration.test.ts @@ -711,15 +711,27 @@ describeLive('Habitica live API', () => { ]; for (const [method, path] of cases) { - const body = await paced(async () => { - const res = await fetch(apiUrl(path), { - method, - headers: { ...authHeaders(), 'Content-Type': 'application/json' }, - body: method === 'GET' || method === 'DELETE' ? undefined : '{}', + let body: { message?: string }; + try { + body = await paced(async () => { + const res = await fetch(apiUrl(path), { + method, + headers: { + ...authHeaders(), + 'Content-Type': 'application/json', + }, + body: method === 'GET' || method === 'DELETE' ? undefined : '{}', + }); + return (await res.json()) as { message?: string }; }); - return (await res.json()) as { message?: string; error?: string }; - }); - expect(body.message).not.toBe('Not found.'); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + throw new Error(`${method} ${path}: ${message}`); + } + if (body.message === 'Not found.') { + throw new Error(`${method} ${path}: unrouted`); + } } }, 240000); });