From 0211706e96a3a2c5d2638b10b1c079581cebb339 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sun, 18 Oct 2026 06:08:16 -0400 Subject: [PATCH] Clarify Activity sign-in failures and preserve diagnostics --- SHA256SUMS | 6 ++-- activity/src/connection.ts | 30 +++++++++++++++-- activity/src/main.ts | 14 +++----- tests/activity-connection.test.ts | 56 +++++++++++++++++++++++++++++-- 4 files changed, 88 insertions(+), 18 deletions(-) diff --git a/SHA256SUMS b/SHA256SUMS index 43d89d3..0d2e4fe 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -20,8 +20,8 @@ ecd8294e24a1cd55cc1aa0b3c5d6cde68c956524cfb1d7707766b4f6287b5e3e activity/index 2c22d3a5e0aff57971932e24cbdf4dc93c12f589b060203cc20417fd97a75142 activity/legal/legal.css 6c8b74d4d57bff38a393ab038418c8f410627dc66721bc416905289110f151ac activity/legal/privacy.html 297c926eb462285e52e196af2d23f2ac70cb280324bfd30aeff74836e02f159c activity/legal/terms.html -e8b23240fdf9f8942c50da42f7b18ed1f4732764247eaaccba4c28d999e50f61 activity/src/connection.ts -6602d23db9ea01227e5e57b1f4c16942116fb3ce9100b0d2d865b6a12727cf79 activity/src/main.ts +81b6e414e8069db0699dd3fea09383ee82b772fa8d929bef60cbd730a9734cfb activity/src/connection.ts +fd8e9fa4ba6382219e4bb13cb415fdeea893eaaf3e58002b05dd71fbaf6a1a66 activity/src/main.ts 8ee444c30afb9d212532308df0f8beffd5c01c8f6e61e0fd1e05c607ecbac477 activity/src/onboarding.ts 66988416b7ab216ad9d3947ed528bfe9f2e45f74943971d506a686ff0b044508 activity/style.css 3fb468ea85cd082f252d85b34bb3da8b62a10ae4aa984b48ef93f0ecacc7017f data/scams/README.md @@ -440,7 +440,7 @@ f379be142cd234fb82c6b55ebb66e5ea94c184cb972cda576606d8dd872187ff starters/combi d1611476abf0bc8ab0e1d4ccfea0abaa232183aa144390d56a8cf00f7468d984 starters/custom-template.typecheck.json 79ba12d8b758c4bcabc7c79b357cebc584442d3f2a368c2142576ef6a95975dd starters/image-scam-analysis.typecheck.json 20868d9d3d3760a4f19cc5aa97ef6496dd3e076507d9eb1b333a8f5ea9691d1e starters/reviewed-scam-references.typecheck.json -03af3cd35cfdc8437f99c7bd900c164d426717185d88df51d845f4e1b22b5743 tests/activity-connection.test.ts +787e6f7eed60f8d56a71d00d1ddf13d20df3fb7047bf760737cc8a066ab1deac tests/activity-connection.test.ts 2257548bf1aa1de3113925dbdbd367db1211f5692ea6e0e69dae9830ef6ca611 tests/analytics.test.ts 977ac57a09795d86d5d5211ca91fa42db9e4f2b63442b0daadfb16025d42ef1c tests/compose.env f9c22e2e2b1b12d1463e433966978b3cffb6f57e6903aae9d97d2d47a13eaed7 tests/compose.validation.yml diff --git a/activity/src/connection.ts b/activity/src/connection.ts index 4cebba9..069dd4a 100644 --- a/activity/src/connection.ts +++ b/activity/src/connection.ts @@ -1,11 +1,35 @@ // Keep OAuth credentials only in memory for the current Activity instance. export class HttpError extends Error { - constructor(message: string, readonly status: number) { super(message); } + constructor(message: string, readonly status: number, readonly reference = '') { super(message); } +} +export async function apiResponse(response: Response): Promise { + const reference = (value: unknown) => typeof value === 'string' && /^[a-zA-Z0-9-]{1,80}$/.test(value) ? value : ''; + const headerReference = reference(response.headers.get('x-request-id')) || reference(response.headers.get('cf-ray')); + let data: unknown; + try { data = await response.json(); } + catch { throw new HttpError('The server returned an unreadable response', response.status, headerReference); } + if (!response.ok) { + const error = data && typeof data === 'object' && 'error' in data ? data.error : undefined; + const bodyReference = data && typeof data === 'object' && 'requestId' in data ? reference(data.requestId) : ''; + throw new HttpError(typeof error === 'string' ? error : 'Request failed', response.status, bodyReference || headerReference); + } + return data as T; } export function connectionError(stage: string, error: unknown): Error { - const detail = error instanceof HttpError ? ` (HTTP ${error.status}: ${error.message})` : + if (error instanceof HttpError && error.status === 403 && error.message === 'no_delegated_access') + return new Error('You need permission to use TypeCheck in this server. Ask a server owner or administrator to open TypeCheck → Permissions and grant access to your Discord role, then click Connect in Discord. Server owners and administrators have access automatically.'); + const detail = error instanceof HttpError ? ` (HTTP ${error.status}: ${error.message}${error.reference ? `; reference ${error.reference}` : ''})` : error && typeof error === 'object' && 'code' in error && typeof error.code === 'number' ? ` (Discord code ${error.code})` : ''; - return new Error(`${stage} failed${detail}. Click Connect in Discord to retry. If Discord continues rejecting authorization, close and reopen the Activity.`); + if (error instanceof HttpError && error.status >= 500) + return new Error(`TypeCheck could not complete sign-in because its server or an upstream service returned an error. Click Connect in Discord to retry. If it continues, share this error with TypeCheck support. ${stage}${detail}.`); + if (error instanceof HttpError) + return new Error(`${stage} failed${detail}. Click Connect in Discord to retry. If it continues, report this error to the TypeCheck administrator.`); + if (error instanceof TypeError || error instanceof SyntaxError) + return new Error(`${stage} failed: the TypeCheck server could not be reached or returned an unreadable response. Click Connect in Discord to retry. If it continues, report this error to the TypeCheck administrator.`); + const action = stage === 'Discord authorization' || stage === 'Discord authentication' + ? 'Click Connect in Discord to retry. If it continues, close and reopen the Activity.' + : 'Click Connect in Discord to retry. If it continues, report this error to the TypeCheck administrator.'; + return new Error(`Could not connect to TypeCheck. ${action} (${stage} failed${detail}.)`); } export function serializeConnection(run: () => Promise): () => Promise { let pending: Promise | undefined; diff --git a/activity/src/main.ts b/activity/src/main.ts index 46b223c..7d1cfb6 100644 --- a/activity/src/main.ts +++ b/activity/src/main.ts @@ -122,7 +122,7 @@ async function packsPage(parent: HTMLElement) { import { TEXT_STARTERS } from '../../shared/text-starters.js'; import { TEXT_LIMITS, parseText } from '../../shared/text.js'; import type { HistoricalScan } from '../../shared/historical.js'; -import { activityAuth, HttpError, serializeConnection } from './connection.js'; +import { activityAuth, apiResponse, HttpError, serializeConnection } from './connection.js'; import { timeoutPreset } from '../../shared/domain.js'; import { CONTENT_PRESETS, CONTENT_VERSION, parseContent } from '../../shared/content.js'; import { money, type AnalyticsReport, type Bucket, type Attempt, type Rate } from '../../shared/analytics.js'; @@ -187,7 +187,7 @@ function initializeAgentSetup() { window.addEventListener('pagehide',clearAgentSetup); const can = (cap: Capability) => authority?.capabilities.includes(cap) ?? false; const admin = () => !!(authority?.owner || authority?.administrator); -const pretty = (s: string) => s === 'severe_threshold_must_not_be_lower' ? 'Action floors must satisfy timeout <= kick <= ban.' : s === 'match_threshold_must_be_a_finite_number_from_0.5_to_1.0' ? 'Match threshold must be a finite number from 0.5 to 1.0 (inclusive).' : s.replaceAll('_', ' ').replaceAll('.', ' / '); +const pretty = (s: string) => s === 'severe_threshold_must_not_be_lower' ? 'Action floors must satisfy timeout <= kick <= ban.' : s === 'match_threshold_must_be_a_finite_number_from_0.5_to_1.0' ? 'Match threshold must be a finite number from 0.5 to 1.0 (inclusive).' : s.replaceAll('_', ' '); function h(tag: K, attrs: Record = {}, ...children: Child[]): HTMLElementTagNameMap[K] { const el = document.createElement(tag); for (const [key, value] of Object.entries(attrs)) { @@ -244,13 +244,9 @@ function table(headers: string[], rows: Child[][]) { return h('div', { class: 't function empty(title: string, detail: string) { return h('div', { class: 'empty' }, h('h3', {}, title), h('p', {}, detail)); } async function api(path: string, method = 'GET', body?: unknown, global = false): Promise { const response = await fetch(global ? path : `/api/g/${guild}/${path}`, { method, headers: { ...(session ? { Authorization: `Bearer ${session}` } : {}), ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}) }, body: body === undefined ? undefined : JSON.stringify(body) }); - const data = await response.json(); - if (!response.ok) { - if ([401,403].includes(response.status)) clearAgentSetup(); - if (response.status === 401) session = ''; - throw new HttpError(data.error ?? 'Request failed', response.status); - } - return data as T; + if (!response.ok && [401,403].includes(response.status)) clearAgentSetup(); + if (response.status === 401) session = ''; + return apiResponse(response); } async function blobRequest(path: string) { const r = await fetch(`/api/g/${guild}/${path}`, { headers: { Authorization: `Bearer ${session}` } }); if (!r.ok) throw new Error('Cannot retrieve file'); return r.blob(); } diff --git a/tests/activity-connection.test.ts b/tests/activity-connection.test.ts index 4253818..05e923e 100644 --- a/tests/activity-connection.test.ts +++ b/tests/activity-connection.test.ts @@ -1,6 +1,54 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { activityAuth, HttpError } from '../activity/src/connection.js'; +import { activityAuth, apiResponse, connectionError, HttpError } from '../activity/src/connection.js'; + +test('missing TypeCheck role grant explains access requirements instead of retrying Discord authorization', () => { + const error = connectionError('Authorization code exchange', new HttpError('no_delegated_access', 403)); + assert.match(error.message, /Server owners and administrators/); + assert.match(error.message, /TypeCheck → Permissions and grant access to your Discord role/); + assert.doesNotMatch(error.message, /close and reopen|Discord continues rejecting/); + assert.match(connectionError('Authorization code exchange', new HttpError('discord_http_403', 403)).message, /HTTP 403: discord_http_403/); +}); + +test('non-JSON proxy failures preserve HTTP status without displaying the response body', async () => { + const response = new Response('private upstream diagnostic', {status: 502, headers: {'cf-ray': 'abc123-IAD'}}); + await assert.rejects(apiResponse(response), (error: unknown) => { + assert.ok(error instanceof HttpError); + const message = connectionError('Authorization code exchange', error).message; + assert.match(message, /HTTP 502: The server returned an unreadable response/); + assert.match(message, /server or an upstream service returned an error/); + assert.match(message, /reference abc123-IAD/); + assert.doesNotMatch(message, /private upstream|Discord continues rejecting|need permission|grant access/); + return true; + }); +}); + +test('server sign-in errors preserve a support reference without treating administrators as ungranted members', async () => { + await assert.rejects(apiResponse(Response.json({error: 'internal_error', requestId: 'test-request-123'}, {status: 500})), (error: unknown) => { + assert.ok(error instanceof HttpError); + const message = connectionError('Authorization code exchange', error).message; + assert.match(message, /could not complete sign-in/); + assert.match(message, /HTTP 500: internal_error; reference test-request-123/); + assert.doesNotMatch(message, /need permission|grant access|close and reopen/); + return true; + }); +}); + +test('API response parsing preserves permission failures and successful session data', async () => { + await assert.rejects(apiResponse(Response.json({error: 'no_delegated_access'}, {status: 403})), (error: unknown) => { + assert.ok(error instanceof HttpError); + assert.equal(error.status, 403); + assert.match(connectionError('Authorization code exchange', error).message, /TypeCheck → Permissions/); + return true; + }); + assert.deepEqual(await apiResponse(Response.json({sessionToken: 'test-session'})), {sessionToken: 'test-session'}); +}); + +test('network failures are not described as Discord rejecting authorization', () => { + const message = connectionError('Authorization code exchange', new TypeError('Failed to fetch')).message; + assert.match(message, /server could not be reached/); + assert.doesNotMatch(message, /Discord continues rejecting/); +}); function flow() { const calls: string[] = []; @@ -34,12 +82,14 @@ test('expired OAuth token falls back using a fresh nonce and code', async () => test('plain SDK rejection explains the failed stage and retry remains possible', async () => { const f = flow(); f.authorizeError({code: 4001, message: 'SDK error'}); - await assert.rejects(f.connect(), /Discord authorization failed.*4001.*Connect in Discord/); + await assert.rejects(f.connect(), /Connect in Discord.*Discord authorization failed.*4001/); f.authorizeError(undefined); await f.connect(); assert.ok(f.calls.includes('authorize:nonce-2')); }); test('renewal permission or network failures do not start another OAuth flow', async () => { const f = flow(); await f.connect(); f.renewError(new HttpError('no_delegated_access', 403)); - await assert.rejects(f.connect(), /Session renewal failed.*403/); + await assert.rejects(f.connect(), /You need permission.*TypeCheck → Permissions/); + f.renewError(new TypeError('Failed to fetch')); + await assert.rejects(f.connect(), /Session renewal failed.*server could not be reached/); assert.equal(f.calls.filter(c => c.startsWith('authorize:')).length, 1); });