diff --git a/SECURITY.md b/SECURITY.md index 2438928b..a189f59f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -66,7 +66,7 @@ The findings below describe the repository at the start of the 2026-07-12 assess | RISK-021 | Admin state transitions accept impossible combinations: cancel paid without refund decision, fulfill unpaid/cancelled orders, substitute without new waiver, or comp without proven waiver. | Financial, legal, fulfillment, and waiver records can be inconsistent. | Centralize allowed state transitions and authorization; require reason/evidence and test every forbidden edge. | | RISK-022 | Existing rate-limit documents contain raw IP or email keys and values; the profile-thumbnail slice instead uses a domain-separated SHA-256 account-key pseudonym, which is stable and not anonymous. Fixed windows are distributed only by those identifiers and cleanup depends on an externally configured TTL. The profile-thumbnail upload limiter charges every image-processing invocation, including an exact domain-command retry, because request ID alone cannot prove unchanged image bytes without retaining more image-derived data. | Additional PII or linkable pseudonymous storage, unbounded growth if TTL is absent, identifier-targeted denial of service, retry quota consumption, and easy distributed abuse. | HMAC identifiers with a rotating secret, verify TTL, add per-user/business budgets, monitor rejects/cost, and keep retry side effects limited to abuse-control bookkeeping rather than profile/photo/audit state. | | RISK-023 | The legacy `functions.config().api.key` endpoint uses a static shared key with no replay control, source restriction, App Check, authenticated operator, or request audit. The API is also scheduled for Firebase decommissioning in March 2027. | Key theft permits bulk role changes and future deployment failure. | Retire it in favor of authenticated admin workflow or narrowly authenticated scheduled import; migrate any remaining config to Secret Manager. | -| RISK-024 | OAuth tokens remain plaintext. Repository Rules deny browser access, but protected deployment and live behavior are unproven; least-privilege runtime IAM and an encryption decision remain open. OAUTH-001A1H [#606](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/606) source transactionally rereads the exact access-token, refresh-token, and expiry tuple before persisting a refresh response. A retry rereads the tuple; changed, missing, or malformed state returns one fixed failure without a stale write or downstream bearer call. OAUTH-001B9 [#608](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/608) source records the disconnect secret's Firestore update time before optional revocation, then transactionally rereads it and deletes the local secret/connection pair only when the secret is absent or still has that exact version. A newer version, including byte-identical replacement, is preserved with one fixed unknown result; a transaction conflict rereads without repeating provider work. Synthetic tests use no real token, account, or provider call. Provider calls are still not serialized; provider/local and lost-acknowledgement reconciliation, a trusted connection-only write that does not also version the secret, client confirmation admission, deployment, and live behavior remain unproven. | Token theft exposes member activity data; an undeployed boundary or an unhandled provider/acknowledgement race can still lose availability or leave local and provider state ambiguous. | Deploy and read back server/IAM-only secret access, decide encryption, serialize or otherwise reconcile refresh and disconnect operations and lost acknowledgements, minimize scopes, audit access, and verify only with approved made-up staged accounts. | +| RISK-024 | OAuth tokens remain plaintext. Repository Rules deny browser access, but protected deployment and live behavior are unproven; least-privilege runtime IAM and an encryption decision remain open. OAUTH-001A1H [#606](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/606) source transactionally rereads the exact access-token, refresh-token, and expiry tuple before persisting a refresh response. A retry rereads the tuple; changed, missing, or malformed state returns one fixed failure without a stale write or downstream bearer call. OAUTH-001B9 [#608](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/608) source records the disconnect secret's Firestore update time before optional revocation, then transactionally rereads it and deletes the local secret/connection pair only when the secret is absent or still has that exact version. A newer version, including byte-identical replacement, is preserved with one fixed unknown result; a transaction conflict rereads without repeating provider work. OAUTH-001B10 [#610](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/610) browser source admits only a fulfilled response value that presents exactly one own enumerable data field, `ok` equal to `true`; it returns a new frozen confirmation and routes other fulfilled results or exceptional inspection through the existing fixed unknown-result view without hiding the connection. Browser JavaScript cannot distinguish a transparent Proxy that faithfully presents that contract, so the received object is never retained. Synthetic tests use no real token, account, or provider call. Provider calls are still not serialized; an exact browser confirmation does not independently prove provider revocation, and provider/local and lost-acknowledgement reconciliation, a trusted connection-only write that does not also version the secret, deployment, and live behavior remain unproven. | Token theft exposes member activity data; an undeployed boundary or an unhandled provider/acknowledgement race can still lose availability or leave local and provider state ambiguous. | Deploy and read back server/IAM-only secret access, decide encryption, serialize or otherwise reconcile refresh and disconnect operations and lost acknowledgements, minimize scopes, audit access, and verify only with approved made-up staged accounts. | | RISK-025 | Admin authentication relies on password Auth plus a long-lived role token; no repository evidence of MFA, recent-auth checks, re-auth for refunds/role grants, or rapid revocation workflow. | A stolen admin session has broad durable impact. | Require MFA for privileged accounts, recent-auth for sensitive actions, capability roles, short sessions/forced refresh, and break-glass procedures. | | RISK-042 | Profile photos and an officer people finder have no approved privacy notice, retention/backup treatment, scoped capability, or production release path. The existing Admin Members page downloads complete account documents and cannot enforce an opt-out promise. #505/#506 source now supplies the private processed thumbnail, default-off preference, minimum server-only projection, and bounded audited verified-admin name search, but none is deployed. | A photo, name, query, preference, prefix digest, or stable entry reference could be exposed, retained, enumerated, correlated, or mistaken for membership evidence; automated face matching would add unapproved biometric processing. | Keep the feature default-hidden and separate from the official roster. Preserve current-source revalidation, direct Rules denials, fixed query/result bounds, no-store responses, pseudonymous rate keys, query-free audit, and synthetic race/opt-out/removal proof. Never store the original, persist raw queries, or add face recognition. Complete #110 policy inputs, AUTH-003 scoped authorization follow-up, #133 protected authority, and #507 backend-first release/readback before live use. | | RISK-026 | Firestore Admin SDK bypasses rules and the Functions runtime IAM scope is not documented. #135 removes the long-lived service-account JSON path from release source, but the least-privilege short-lived deploy identity is not configured yet. | Server or CI compromise may expose the entire project; missing provider configuration also blocks release. | Complete the private IAM inventory and #133 OIDC/WIF configuration; keep runtime and deploy identities separate; never restore a JSON key shortcut. | diff --git a/docs/officers/EVENTS_SHOP_MEMBERS.md b/docs/officers/EVENTS_SHOP_MEMBERS.md index dbf34445..a1985ab5 100644 --- a/docs/officers/EVENTS_SHOP_MEMBERS.md +++ b/docs/officers/EVENTS_SHOP_MEMBERS.md @@ -1709,28 +1709,28 @@ No system diagram changes for this source slice because page structure, data mov **Approver:** membership lead plus platform/security owner. -**Prerequisites:** issue [#252](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/252) must be merged for source review. Calling the sentence live also requires a protected website publication and an exact revision check on `runmprc.com`. This source change does not deploy Firebase, contact Strava, change provider settings, revoke access, use production data, or prove live behavior. +**Prerequisites:** issues [#252](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/252) and [#610](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/610) must be merged for the current source review. Calling the sentence live also requires a protected website publication and an exact revision check on `runmprc.com`. These source changes do not deploy Firebase, contact Strava, change provider settings, revoke access, use production data, or prove live behavior. Officer review steps after the source merge: 1. Keep the disconnect-failure sentence marked **NOT AVAILABLE YET**. -2. Ask the platform owner for the exact #252 issue, pull request, merged commit, and synthetic frontend test result. -3. Confirm the tests use only a made-up connection, made-up activity, a mocked confirmation, and a mocked disconnect result. +2. Ask the platform owner for the exact #252 and #610 issues, pull requests, merged commits, and synthetic frontend test results. +3. Confirm the tests use only a made-up connection, made-up activity, a mocked browser question, and mocked rejected, confirmed, or unconfirmed disconnect results. 4. Confirm cancelling the browser question sends no disconnect request and shows no failure. -5. Confirm a made-up rejected request shows `We could not confirm the Strava disconnect. Please refresh this page before trying again.` +5. Confirm a made-up rejected request, or a fulfilled result that does not present exactly one field named `ok` set to `true`, shows `We could not confirm the Strava disconnect. Please refresh this page before trying again.` 6. Confirm the connected athlete and activity remain visible because the actual result is not known. 7. Confirm the Disconnect button becomes available again, but the instructions say to refresh before another attempt. 8. Confirm a later activity-load failure cannot replace the disconnect refresh instruction. 9. Confirm no made-up provider detail appears on the page or in browser console output. 10. Confirm a hostile rejected value is not inspected. -11. Confirm a successful made-up result still changes the page to `Connect Strava` and clears the old activity view. +11. Confirm only a made-up result that presents exactly one field named `ok` set to `true` changes the page to `Connect Strava` and clears the old activity view. 12. Record website publication, `runmprc.com`, Firebase, Strava, production-data, and live-behavior evidence as separate results. -**Expected result:** the reviewed source uses one fixed refresh-before-retry sentence for a rejected disconnect request. It does not inspect, display, or log the rejected value, and a later activity-load failure cannot replace that higher-priority instruction. It keeps the current connected view because the result is unknown, ends the busy state, and preserves the existing successful disconnect transition. This source slice does not prove that Strava access was revoked or that a retry is safe. +**Expected result:** the reviewed source uses one fixed refresh-before-retry sentence for a rejected disconnect request or a fulfilled result that does not present the exact confirmation fields. It does not keep or return the received result, invoke an `ok` field that performs code when read, display or log a rejected value, or let a later activity-load failure replace that higher-priority instruction. If reading the outer result or checking its structure fails, the source uses the same fixed result. It checks what the result presents, not how the object was created, and copies only the approved confirmation. It keeps the current connected view because the result is unknown, ends the busy state, and preserves the existing successful transition only for the exact confirmation. This source does not prove that Strava access was revoked or that a retry is safe. **Stop conditions:** any real member or Strava account; a request for a token, provider error, private account detail, or screenshot containing private values; a real disconnect or provider call; a production Firebase or Strava change; a raw detail on the page or in the console; an immediate retry without first refreshing; or a claim that source, tests, merge, or a green workflow proves the sentence or disconnect behavior is live. -**Success proof:** for source completion, record the exact #252 issue, reviewed pull request, merged commit, intended old-source failures, green synthetic tests, relevant full checks, and independent privacy review. For live availability, separately record the approved website publication, published revision, and a dated `runmprc.com` check using no real account. Record Firebase deployment, Strava/provider configuration, revoke actions, and production-data actions as **not performed** for this frontend-only change. +**Success proof:** for source completion, record the exact #252 and #610 issues, reviewed pull requests, merged commits, intended old-source failures, green synthetic tests, relevant full checks, and independent privacy review. For live availability, separately record the approved website publication, published revision, and a dated `runmprc.com` check using no real account. Record Firebase deployment, Strava/provider configuration, revoke actions, and production-data actions as **not performed** for these frontend-only changes. **Undo:** before publication, use one reviewed frontend revert or safe roll-forward. After publication, use the same protected website release path and verify the replacement revision on `runmprc.com`. Do not undo by disconnecting an account, changing a member record, editing Firebase, or changing a Strava setting. diff --git a/src/pages/account/Account.test.tsx b/src/pages/account/Account.test.tsx index 09e2b0dd..d2b4a3fc 100644 --- a/src/pages/account/Account.test.tsx +++ b/src/pages/account/Account.test.tsx @@ -1884,7 +1884,7 @@ describe('Strava authorization service boundary', () => { }); }); -describe('Strava disconnect empty-request service contract', () => { +describe('OAUTH-001B10 exact Strava disconnect confirmation service contract', () => { const functions = { name: 'synthetic-functions' }; beforeEach(() => { @@ -1892,13 +1892,18 @@ describe('Strava disconnect empty-request service contract', () => { (getFunctions as jest.Mock).mockReturnValue(functions); }); - test('sends one exact empty request and preserves the callable result', async () => { - const callable = jest.fn().mockResolvedValue({ data: { ok: true } }); + test('sends one exact empty request and returns a fresh frozen confirmation', async () => { + const received = Object.freeze({ ok: true }); + const callable = jest.fn().mockResolvedValue({ data: received }); (httpsCallable as jest.Mock).mockReturnValue(callable); - await expect(ActualStravaService.stravaDisconnect(app)) - .resolves.toEqual({ ok: true }); + const confirmation = await ActualStravaService.stravaDisconnect(app); + expect(confirmation).toEqual({ ok: true }); + expect(confirmation).not.toBe(received); + expect(Object.isFrozen(confirmation)).toBe(true); + expect(Object.getPrototypeOf(confirmation)).toBe(Object.prototype); + expect(Reflect.ownKeys(confirmation)).toEqual(['ok']); expect(getFunctions).toHaveBeenCalledWith(app); expect(httpsCallable).toHaveBeenCalledWith(functions, 'stravaDisconnect'); expect(callable).toHaveBeenCalledTimes(1); @@ -1907,6 +1912,203 @@ describe('Strava disconnect empty-request service contract', () => { expect(Object.getPrototypeOf(request)).toBe(Object.prototype); expect(Reflect.ownKeys(request)).toEqual([]); }); + + test('returns independent safe confirmations across repeated valid calls', async () => { + const received = { ok: true }; + const callable = jest.fn().mockResolvedValue({ data: received }); + (httpsCallable as jest.Mock).mockReturnValue(callable); + + const first = await ActualStravaService.stravaDisconnect(app); + const second = await ActualStravaService.stravaDisconnect(app); + + expect(first).toEqual({ ok: true }); + expect(second).toEqual({ ok: true }); + expect(first).not.toBe(second); + expect(first).not.toBe(received); + expect(second).not.toBe(received); + expect(Object.isFrozen(first)).toBe(true); + expect(Object.isFrozen(second)).toBe(true); + expect(callable).toHaveBeenCalledTimes(2); + expect(callable.mock.calls).toEqual([[{}], [{}]]); + }); + + test.each([ + ['undefined data', undefined], + ['null data', null], + ['a true primitive', true], + ['a false primitive', false], + ['a string', 'true'], + ['a number', 1], + ['a function', () => true], + ['a boxed Boolean', Object(true)], + ['a missing confirmation', {}], + ['a false confirmation', { ok: false }], + ['a string confirmation', { ok: 'true' }], + ['a numeric confirmation', { ok: 1 }], + ['a null confirmation', { ok: null }], + ['an array', [{ ok: true }]], + ['a Date', new Date(0)], + ['a null-prototype record', Object.assign(Object.create(null), { ok: true })], + ['a custom-prototype record', Object.assign(Object.create({}), { ok: true })], + ['an inherited confirmation', Object.create({ ok: true })], + ['an extra enumerable key', { ok: true, extra: true }], + [ + 'an extra non-enumerable key', + Object.defineProperty({ ok: true }, 'extra', { value: true }), + ], + ['an extra symbol key', { ok: true, [Symbol('extra')]: true }], + [ + 'a non-enumerable confirmation', + Object.defineProperty({}, 'ok', { value: true }), + ], + ])('rejects %s with one fixed result', async (_case, data) => { + const callable = jest.fn().mockResolvedValue({ data }); + (httpsCallable as jest.Mock).mockReturnValue(callable); + + await expect(ActualStravaService.stravaDisconnect(app)) + .rejects.toThrow('Invalid Strava disconnect response.'); + + expect(callable).toHaveBeenCalledTimes(1); + expect(callable).toHaveBeenCalledWith({}); + }); + + test('rejects an own confirmation getter without invoking it', async () => { + const okGetter = jest.fn(() => true); + const data = Object.defineProperty({}, 'ok', { + enumerable: true, + get: okGetter, + }); + const callable = jest.fn().mockResolvedValue({ data }); + (httpsCallable as jest.Mock).mockReturnValue(callable); + + await expect(ActualStravaService.stravaDisconnect(app)) + .rejects.toThrow('Invalid Strava disconnect response.'); + + expect(okGetter).not.toHaveBeenCalled(); + }); + + test('rejects extra and inherited getters without invoking them', async () => { + const extraGetter = jest.fn(() => 'private-extra-canary'); + const inheritedGetter = jest.fn(() => 'private-inherited-canary'); + const extra = Object.defineProperty({ ok: true }, 'extra', { + enumerable: true, + get: extraGetter, + }); + const inherited = Object.assign(Object.create(Object.defineProperty({}, 'extra', { + enumerable: true, + get: inheritedGetter, + })), { ok: true }); + const callable = jest.fn() + .mockResolvedValueOnce({ data: extra }) + .mockResolvedValueOnce({ data: inherited }); + (httpsCallable as jest.Mock).mockReturnValue(callable); + + await expect(ActualStravaService.stravaDisconnect(app)) + .rejects.toThrow('Invalid Strava disconnect response.'); + await expect(ActualStravaService.stravaDisconnect(app)) + .rejects.toThrow('Invalid Strava disconnect response.'); + + expect(extraGetter).not.toHaveBeenCalled(); + expect(inheritedGetter).not.toHaveBeenCalled(); + }); + + test.each([ + ['an undefined callable result', undefined], + ['a null callable result', null], + ['a callable result without data', {}], + ])('contains %s inside the fixed response boundary', async (_case, result) => { + const callable = jest.fn().mockResolvedValue(result); + (httpsCallable as jest.Mock).mockReturnValue(callable); + + await expect(ActualStravaService.stravaDisconnect(app)) + .rejects.toThrow('Invalid Strava disconnect response.'); + }); + + test('contains an exceptional callable data read inside the fixed response boundary', async () => { + const dataGetter = jest.fn(() => { + throw new Error('disconnect-data-getter-canary token=private-canary'); + }); + const result = Object.defineProperty({}, 'data', { + enumerable: true, + get: dataGetter, + }); + const callable = jest.fn().mockResolvedValue(result); + (httpsCallable as jest.Mock).mockReturnValue(callable); + + await expect(ActualStravaService.stravaDisconnect(app)) + .rejects.toThrow('Invalid Strava disconnect response.'); + + expect(dataGetter).toHaveBeenCalledTimes(1); + }); + + test.each([ + [ + 'prototype reflection', + () => new Proxy({ ok: true }, { + getPrototypeOf: () => { + throw new Error('disconnect-prototype-canary token=private-canary'); + }, + }), + ], + [ + 'own-key reflection', + () => new Proxy({ ok: true }, { + ownKeys: () => { + throw new Error('disconnect-keys-canary token=private-canary'); + }, + }), + ], + [ + 'descriptor reflection', + () => new Proxy({ ok: true }, { + getOwnPropertyDescriptor: () => { + throw new Error('disconnect-descriptor-canary token=private-canary'); + }, + }), + ], + [ + 'revoked Proxy reflection', + () => { + const { proxy, revoke } = Proxy.revocable({ ok: true }, {}); + revoke(); + return proxy; + }, + ], + ])('contains exceptional %s', async (_case, makeData) => { + const callable = jest.fn().mockResolvedValue({ data: makeData() }); + (httpsCallable as jest.Mock).mockReturnValue(callable); + + await expect(ActualStravaService.stravaDisconnect(app)) + .rejects.toThrow('Invalid Strava disconnect response.'); + }); + + test('projects but never retains a transparent Proxy that presents the exact contract', async () => { + const data = new Proxy({ ok: true }, {}); + const callable = jest.fn().mockResolvedValue({ data }); + (httpsCallable as jest.Mock).mockReturnValue(callable); + + const confirmation = await ActualStravaService.stravaDisconnect(app); + + expect(confirmation).toEqual({ ok: true }); + expect(confirmation).not.toBe(data); + expect(Object.isFrozen(confirmation)).toBe(true); + }); + + test('preserves a transport rejection without inspecting it', async () => { + const messageGetter = jest.fn(() => { + throw new Error('disconnect-transport-message-getter-canary'); + }); + const rejection = Object.defineProperty({}, 'message', { + configurable: true, + get: messageGetter, + }); + const callable = jest.fn().mockRejectedValue(rejection); + (httpsCallable as jest.Mock).mockReturnValue(callable); + + await expect(ActualStravaService.stravaDisconnect(app)).rejects.toBe(rejection); + + expect(messageGetter).not.toHaveBeenCalled(); + }); }); describe('Strava statistics empty-request service contract', () => { @@ -2772,6 +2974,38 @@ describe('Strava disconnect browser failure boundary', () => { consoleSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled()); }); + test('OAUTH-001B10 keeps the connected view when the actual service rejects a fulfilled non-confirmation', async () => { + const functions = { name: 'disconnect-composition-functions' }; + const callable = jest.fn().mockResolvedValue({ data: { ok: false } }); + const consoleSpies = ['debug', 'error', 'info', 'log', 'warn'] + .map((method) => jest.spyOn(console, method as any).mockImplementation(() => undefined)); + jest.spyOn(window, 'confirm').mockReturnValue(true); + (getFunctions as jest.Mock).mockReturnValue(functions); + (httpsCallable as jest.Mock).mockReturnValue(callable); + (stravaDisconnect as jest.Mock).mockImplementationOnce( + (firebaseApp) => ActualStravaService.stravaDisconnect(firebaseApp), + ); + + renderActualStravaSection(); + expect(await screen.findByText('Synthetic Morning Run')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Disconnect' })); + + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toBe(STRAVA_DISCONNECT_FAILURE); + expect(document.body).toHaveTextContent('Synthetic Athlete'); + expect(document.body).toHaveTextContent('Synthetic Morning Run'); + expect(document.body).not.toHaveTextContent('Invalid Strava disconnect response.'); + expect(screen.queryByRole('button', { name: 'Connect Strava' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Disconnect' })).toBeEnabled(); + expect(getFunctions).toHaveBeenCalledWith(app); + expect(httpsCallable).toHaveBeenCalledWith(functions, 'stravaDisconnect'); + expect(callable).toHaveBeenCalledTimes(1); + expect(callable).toHaveBeenCalledWith({}); + expect(stravaDisconnect).toHaveBeenCalledTimes(1); + expect(stravaDisconnect).toHaveBeenCalledWith(app); + consoleSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled()); + }); + test('does not inspect a hostile disconnect rejection', async () => { const messageGetter = jest.fn(() => { throw new Error('disconnect-message-getter-canary'); diff --git a/src/services/strava/stravaService.ts b/src/services/strava/stravaService.ts index ae49ffd2..8d0d7704 100644 --- a/src/services/strava/stravaService.ts +++ b/src/services/strava/stravaService.ts @@ -13,6 +13,10 @@ export type StravaAuthorizationChallenge = Readonly<{ expiresInSeconds: 600; }>; +export type StravaDisconnectConfirmation = Readonly<{ + ok: true; +}>; + export interface StravaConnection { provider: 'strava'; athleteId: number | null; @@ -92,6 +96,33 @@ function readStravaAuthorizationChallenge(value: unknown): StravaAuthorizationCh } } +function readStravaDisconnectConfirmation(value: unknown): StravaDisconnectConfirmation { + try { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('invalid'); + } + if (Object.getPrototypeOf(value) !== Object.prototype) { + throw new Error('invalid'); + } + const keys = Reflect.ownKeys(value); + if (keys.length !== 1 || keys[0] !== 'ok') { + throw new Error('invalid'); + } + const descriptor = Object.getOwnPropertyDescriptor(value, 'ok'); + if ( + descriptor === undefined + || descriptor.enumerable !== true + || !Object.prototype.hasOwnProperty.call(descriptor, 'value') + || descriptor.value !== true + ) { + throw new Error('invalid'); + } + return Object.freeze({ ok: true }); + } catch { + throw new Error('Invalid Strava disconnect response.'); + } +} + export function buildStravaAuthorizeUrl( clientId: string, redirectUri: string, @@ -145,14 +176,20 @@ export async function stravaFetchStats(app: FirebaseApp): Promise { +export async function stravaDisconnect( + app: FirebaseApp, +): Promise { const functions = getFunctions(app); - const callable = httpsCallable, { ok: boolean }>( + const callable = httpsCallable, unknown>( functions, 'stravaDisconnect', ); const result = await callable({}); - return result.data; + try { + return readStravaDisconnectConfirmation(result.data); + } catch { + throw new Error('Invalid Strava disconnect response.'); + } } export async function getStravaConnection(