diff --git a/app/api/[[...path]]/route.ts b/app/api/[[...path]]/route.ts new file mode 100644 index 0000000..a83f43e --- /dev/null +++ b/app/api/[[...path]]/route.ts @@ -0,0 +1,8 @@ +import { endpointNotFound } from '@/lib/endpoints' + +/** Any path under /api that is not an endpoint, answered in the error envelope rather than the site's HTML 404 page. */ +export const GET = endpointNotFound +export const POST = endpointNotFound +export const PUT = endpointNotFound +export const PATCH = endpointNotFound +export const DELETE = endpointNotFound diff --git a/app/api/v1/channels/[id]/close/route.ts b/app/api/v1/channels/[id]/close/route.ts index 11111ad..9ac7628 100644 --- a/app/api/v1/channels/[id]/close/route.ts +++ b/app/api/v1/channels/[id]/close/route.ts @@ -1,5 +1,6 @@ import { authenticate } from '@/lib/auth' import { closeChannel } from '@/lib/channels' +import { otherMethods } from '@/lib/endpoints' import { toErrorResponse } from '@/lib/http' import { getRedis } from '@/lib/redis' @@ -20,3 +21,5 @@ export async function POST(request: Request, context: RouteContext<'/api/v1/chan return toErrorResponse(error) } } + +export const { GET, PUT, PATCH, DELETE, OPTIONS } = otherMethods('/api/v1/channels/{id}/close') diff --git a/app/api/v1/channels/[id]/join/route.ts b/app/api/v1/channels/[id]/join/route.ts index 20c75b3..53469c7 100644 --- a/app/api/v1/channels/[id]/join/route.ts +++ b/app/api/v1/channels/[id]/join/route.ts @@ -1,4 +1,5 @@ import { authenticate } from '@/lib/auth' +import { otherMethods } from '@/lib/endpoints' import { readJson, toErrorResponse } from '@/lib/http' import { joinChannel, joinRequestSchema } from '@/lib/participants' import { getRedis } from '@/lib/redis' @@ -22,3 +23,5 @@ export async function POST(request: Request, context: RouteContext<'/api/v1/chan return toErrorResponse(error) } } + +export const { GET, PUT, PATCH, DELETE, OPTIONS } = otherMethods('/api/v1/channels/{id}/join') diff --git a/app/api/v1/channels/[id]/leave/route.ts b/app/api/v1/channels/[id]/leave/route.ts index aec6ad1..cd9c8c7 100644 --- a/app/api/v1/channels/[id]/leave/route.ts +++ b/app/api/v1/channels/[id]/leave/route.ts @@ -1,4 +1,5 @@ import { authenticateParticipant } from '@/lib/auth' +import { otherMethods } from '@/lib/endpoints' import { toErrorResponse } from '@/lib/http' import { leaveChannel } from '@/lib/participants' import { getRedis } from '@/lib/redis' @@ -22,3 +23,5 @@ export async function POST(request: Request, context: RouteContext<'/api/v1/chan return toErrorResponse(error) } } + +export const { GET, PUT, PATCH, DELETE, OPTIONS } = otherMethods('/api/v1/channels/{id}/leave') diff --git a/app/api/v1/channels/[id]/messages/route.ts b/app/api/v1/channels/[id]/messages/route.ts index de155c8..f913009 100644 --- a/app/api/v1/channels/[id]/messages/route.ts +++ b/app/api/v1/channels/[id]/messages/route.ts @@ -1,5 +1,6 @@ import { authenticate, authenticateParticipant } from '@/lib/auth' import { listParticipants, roster } from '@/lib/channels' +import { otherMethods } from '@/lib/endpoints' import { readJson, toErrorResponse } from '@/lib/http' import { lastSeq } from '@/lib/items' import { LIMITS } from '@/lib/limits' @@ -130,3 +131,5 @@ export async function POST( return toErrorResponse(error) } } + +export const { PUT, PATCH, DELETE, OPTIONS } = otherMethods('/api/v1/channels/{id}/messages') diff --git a/app/api/v1/channels/[id]/route.ts b/app/api/v1/channels/[id]/route.ts index d612975..b23ea5f 100644 --- a/app/api/v1/channels/[id]/route.ts +++ b/app/api/v1/channels/[id]/route.ts @@ -1,5 +1,6 @@ import { authenticate } from '@/lib/auth' import { channelView } from '@/lib/channels' +import { otherMethods } from '@/lib/endpoints' import { toErrorResponse } from '@/lib/http' import { getRedis } from '@/lib/redis' import { sweepChannel } from '@/lib/sweep' @@ -22,3 +23,5 @@ export async function GET(request: Request, context: RouteContext<'/api/v1/chann return toErrorResponse(error) } } + +export const { POST, PUT, PATCH, DELETE, OPTIONS } = otherMethods('/api/v1/channels/{id}') diff --git a/app/api/v1/channels/route.ts b/app/api/v1/channels/route.ts index e73debf..17c5084 100644 --- a/app/api/v1/channels/route.ts +++ b/app/api/v1/channels/route.ts @@ -1,4 +1,5 @@ import { createChannel, createChannelRequestSchema } from '@/lib/channels' +import { otherMethods } from '@/lib/endpoints' import { readJson, toErrorResponse } from '@/lib/http' import { limitChannelCreation } from '@/lib/rate-limit' import { getRedis } from '@/lib/redis' @@ -22,3 +23,5 @@ export async function POST(request: Request): Promise { return toErrorResponse(error) } } + +export const { GET, PUT, PATCH, DELETE, OPTIONS } = otherMethods('/api/v1/channels') diff --git a/cli/src/types.ts b/cli/src/types.ts index f7342f9..8000646 100644 --- a/cli/src/types.ts +++ b/cli/src/types.ts @@ -88,6 +88,7 @@ export type ApiErrorCode = | 'unauthorized' | 'gone' | 'not_found' + | 'method_not_allowed' | 'forbidden' | 'invalid_request' | 'channel_full' diff --git a/lib/auth.ts b/lib/auth.ts index 45e32ff..3c4ef62 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -22,6 +22,17 @@ export type AuthContext = { participant?: ParticipantRecord } +const TOKEN_SOURCE: Record = { + invite: 'the invite token is everything after the # in the channel URL', + participant: 'the participant token is the participant_token your join returned', + admin: 'the admin token is the admin_token returned when the channel was created', +} + +function tokenSources(accepted: readonly CredentialType[]): string { + const sources = accepted.map((credential) => TOKEN_SOURCE[credential]) + return sources.join('; ').replace(/^./, (first) => first.toUpperCase()) +} + /** Reads the bearer token. Query strings are never consulted: secrets do not belong in URLs. */ export function bearerToken(request: Request): string | undefined { const header = request.headers.get('authorization') @@ -70,22 +81,38 @@ export async function authenticate( ): Promise { const channel = await loadChannel(redis, channelId) const token = bearerToken(request) - if (!token) throw unauthorized('Missing Authorization: Bearer header.') - const accepted = typeof expected === 'string' ? [expected] : expected + if (!token) { + throw unauthorized( + 'Missing Authorization: Bearer header.', + `This endpoint takes the ${accepted.join(' or ')} token. ${tokenSources(accepted)}.`, + ) + } + for (const credential of accepted) { if (credential === 'invite' && tokenMatches(token, channel.invite_hash)) return { channel } if (credential === 'admin' && tokenMatches(token, channel.admin_hash)) return { channel } if (credential === 'participant') { const participant = await findParticipantByToken(redis, channel.id, token) if (participant?.left_at !== undefined) { - throw unauthorized('You have left this channel. Join again with the invite to continue.') + throw unauthorized( + 'You have left this channel. Join again with the invite to continue.', + 'This token will never work again, so do not retry it. A new join gives you a new one.', + ) } if (participant) return { channel, participant } } } - throw unauthorized(`Invalid ${accepted.join(' or ')} token for this channel.`) + throw unauthorized(`Invalid ${accepted.join(' or ')} token for this channel.`, invalidTokenHint(token, accepted)) +} + +function invalidTokenHint(token: string, accepted: readonly CredentialType[]): string { + const final = 'Tokens are never reissued, so retrying the same one cannot succeed.' + if (token === 'null' || token === 'undefined') { + return `The token sent was the word "${token}": the step that should have saved it failed and wrote that instead. ${final} ${tokenSources(accepted)}.` + } + return `${final} ${tokenSources(accepted)}.` } /** The participant-credential case, narrowed: callers get the participant, not an optional one. */ diff --git a/lib/endpoints.ts b/lib/endpoints.ts new file mode 100644 index 0000000..d2bbe57 --- /dev/null +++ b/lib/endpoints.ts @@ -0,0 +1,52 @@ +import { ApiError, toErrorResponse } from './http' + +type Method = 'GET' | 'POST' + +type Endpoint = { method: Method; path: string; does: string } + +export const ENDPOINTS: readonly Endpoint[] = [ + { method: 'POST', path: '/api/v1/channels', does: 'create a channel, no token' }, + { method: 'GET', path: '/api/v1/channels/{id}', does: 'read the channel, invite or participant token' }, + { method: 'POST', path: '/api/v1/channels/{id}/join', does: 'join, invite token' }, + { method: 'GET', path: '/api/v1/channels/{id}/messages', does: 'poll, participant or invite token' }, + { method: 'POST', path: '/api/v1/channels/{id}/messages', does: 'post, participant token' }, + { method: 'POST', path: '/api/v1/channels/{id}/leave', does: 'leave, participant token' }, + { method: 'POST', path: '/api/v1/channels/{id}/close', does: 'close the channel, admin token' }, +] + +const docs = (origin: string) => `The full guide is ${origin}/agent/curl.md.` + +const listing = (endpoints: readonly Endpoint[]) => + endpoints.map((endpoint) => `${endpoint.method} ${endpoint.path} (${endpoint.does})`).join('; ') + +export function endpointNotFound(request: Request): Response { + const url = new URL(request.url) + return toErrorResponse( + new ApiError(404, 'not_found', `There is no endpoint at ${request.method} ${url.pathname}.`, { + hint: `The endpoints are: ${listing(ENDPOINTS)}. ${docs(url.origin)}`, + }), + ) +} + +/** + * The handlers a route exports for the methods it does not take. Without them + * Next answers 405 with an empty body, which an agent reading `.error` gets + * nothing from. OPTIONS is exported too, or Next would list these refusals in + * its generated Allow header. + */ +export function otherMethods(path: string) { + const here = ENDPOINTS.filter((endpoint) => endpoint.path === path) + const allow = here.map((endpoint) => endpoint.method).join(', ') + + const refuse = (request: Request) => + toErrorResponse( + new ApiError(405, 'method_not_allowed', `${path} does not take ${request.method}.`, { + hint: `It takes ${listing(here)}. ${docs(new URL(request.url).origin)}`, + headers: { Allow: allow }, + }), + ) + + const options = () => new Response(null, { status: 204, headers: { Allow: allow } }) + + return { GET: refuse, POST: refuse, PUT: refuse, PATCH: refuse, DELETE: refuse, OPTIONS: options } +} diff --git a/lib/http.ts b/lib/http.ts index ff4b683..b49e85b 100644 --- a/lib/http.ts +++ b/lib/http.ts @@ -10,6 +10,7 @@ export type ApiErrorCode = | 'unauthorized' | 'gone' | 'not_found' + | 'method_not_allowed' | 'forbidden' | 'invalid_request' | 'channel_full' @@ -41,12 +42,15 @@ export class ApiError extends Error { } } -export const unauthorized = (message = 'Invalid or missing token for this channel.') => - new ApiError(401, 'unauthorized', message) +export const unauthorized = (message = 'Invalid or missing token for this channel.', hint?: string) => + new ApiError(401, 'unauthorized', message, hint === undefined ? {} : { hint }) export const forbidden = (message: string) => new ApiError(403, 'forbidden', message) -export const gone = (message = 'This channel has expired or been closed.') => new ApiError(410, 'gone', message) +export const gone = (message = 'This channel has expired or been closed.') => + new ApiError(410, 'gone', message, { + hint: 'This is final: the channel and its transcript are gone, and no retry or token will bring them back. Stop, tell your user, and ask for a new channel if the work goes on.', + }) /** JSON body for an error. The only shape the API ever returns for a failure. */ export function errorBody(error: ApiError): { error: { code: ApiErrorCode; message: string; hint?: string } } { @@ -60,7 +64,11 @@ export function errorBody(error: ApiError): { error: { code: ApiErrorCode; messa */ export function toErrorResponse(error: unknown): Response { const apiError = - error instanceof ApiError ? error : new ApiError(500, 'server_error', 'Something went wrong on this instance.') + error instanceof ApiError + ? error + : new ApiError(500, 'server_error', 'Something went wrong on this instance.', { + hint: 'The fault is in the instance, not in your request. Retry once after a few seconds; a post is safe to retry with the same client_id. If it keeps failing, the instance may be down.', + }) if (!(error instanceof ApiError)) { console.error(`unhandled: ${error instanceof Error ? `${error.name}: ${error.message}` : 'non-error thrown'}`) } diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts index edf238f..7ea1a30 100644 --- a/lib/rate-limit.ts +++ b/lib/rate-limit.ts @@ -42,6 +42,7 @@ export async function enforceLimit(redis: WaveRedis, limit: Limit): Promise 0 ? ttl : limit.windowSeconds throw new ApiError(429, 'rate_limited', `Too many requests. Try again in ${retryAfter} seconds.`, { headers: { 'Retry-After': String(retryAfter) }, + hint: `Nothing was done. Every request before then is refused too, so wait the full ${retryAfter}s and send once, rather than retrying in a loop.`, }) } diff --git a/tests/api-errors.test.ts b/tests/api-errors.test.ts index db67e53..b554935 100644 --- a/tests/api-errors.test.ts +++ b/tests/api-errors.test.ts @@ -29,6 +29,9 @@ vi.mock('@/lib/redis', async (importOriginal) => ({ const { POST: createRoute } = await import('@/app/api/v1/channels/route') const { POST: joinRoute } = await import('@/app/api/v1/channels/[id]/join/route') const { GET: pollRoute, POST: postRoute } = await import('@/app/api/v1/channels/[id]/messages/route') +const joinModule = await import('@/app/api/v1/channels/[id]/join/route') +const createModule = await import('@/app/api/v1/channels/route') +const catchAll = await import('@/app/api/[[...path]]/route') const origin = 'https://wave.example.com' @@ -218,3 +221,62 @@ describe('idempotent post', () => { }) }) + +/** + * A curl agent has no exit code to tell it a failure is final, so the body + * has to: every 401 and 410 says whether retrying can work and where the right + * token comes from. + */ +describe('hints on the failures that end a session', () => { + it('says a 410 is final', async () => { + const response = await pollRoute(new Request(`${origin}/x`), context('not-a-channel-id')) + expect(response.status).toBe(410) + expect((await response.json()).error.hint).toMatch(/final/) + }) + + it('names the token an endpoint takes when none was sent', async () => { + const channel = await openChannel() + const response = await joinRoute(post({ name: 'B', role: 'agent' }), context(channel.id)) + expect(response.status).toBe(401) + expect((await response.json()).error.hint).toContain('everything after the # in the channel URL') + }) + + it('says a wrong token cannot be retried', async () => { + const channel = await openChannel() + const response = await postRoute(post({ text: 'hi' }, 'not-a-real-token'), context(channel.id)) + expect((await response.json()).error.hint).toMatch(/never reissued/) + }) + + it('recognises the word null a failed join leaves in a token file', async () => { + const channel = await openChannel() + const response = await postRoute(post({ text: 'hi' }, 'null'), context(channel.id)) + expect((await response.json()).error.hint).toContain('the word "null"') + }) +}) + +describe('methods and paths that are not endpoints', () => { + it('answers a method a route does not take in the error envelope, with Allow', async () => { + const response = await joinModule.GET(new Request(`${origin}/api/v1/channels/x/join`)) + expect(response.status).toBe(405) + expect(response.headers.get('Allow')).toBe('POST') + const { error } = await response.json() + expect(error.code).toBe('method_not_allowed') + expect(error.hint).toContain('POST /api/v1/channels/{id}/join') + }) + + it('lists only the methods a route takes in OPTIONS', async () => { + const response = createModule.OPTIONS() + expect(response.status).toBe(204) + expect(response.headers.get('Allow')).toBe('POST') + }) + + it('answers an unknown path under /api with every endpoint', async () => { + const response = await catchAll.GET(new Request(`${origin}/api/channels`)) + expect(response.status).toBe(404) + const { error } = await response.json() + expect(error.code).toBe('not_found') + expect(error.message).toContain('GET /api/channels') + expect(error.hint).toContain('POST /api/v1/channels') + expect(error.hint).toContain(`${origin}/agent/curl.md`) + }) +})