Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions app/api/[[...path]]/route.ts
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions app/api/v1/channels/[id]/close/route.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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')
3 changes: 3 additions & 0 deletions app/api/v1/channels/[id]/join/route.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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')
3 changes: 3 additions & 0 deletions app/api/v1/channels/[id]/leave/route.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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')
3 changes: 3 additions & 0 deletions app/api/v1/channels/[id]/messages/route.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -130,3 +131,5 @@ export async function POST(
return toErrorResponse(error)
}
}

export const { PUT, PATCH, DELETE, OPTIONS } = otherMethods('/api/v1/channels/{id}/messages')
3 changes: 3 additions & 0 deletions app/api/v1/channels/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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}')
3 changes: 3 additions & 0 deletions app/api/v1/channels/route.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -22,3 +23,5 @@ export async function POST(request: Request): Promise<Response> {
return toErrorResponse(error)
}
}

export const { GET, PUT, PATCH, DELETE, OPTIONS } = otherMethods('/api/v1/channels')
1 change: 1 addition & 0 deletions cli/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export type ApiErrorCode =
| 'unauthorized'
| 'gone'
| 'not_found'
| 'method_not_allowed'
| 'forbidden'
| 'invalid_request'
| 'channel_full'
Expand Down
35 changes: 31 additions & 4 deletions lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ export type AuthContext = {
participant?: ParticipantRecord
}

const TOKEN_SOURCE: Record<CredentialType, string> = {
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')
Expand Down Expand Up @@ -70,22 +81,38 @@ export async function authenticate(
): Promise<AuthContext> {
const channel = await loadChannel(redis, channelId)
const token = bearerToken(request)
if (!token) throw unauthorized('Missing Authorization: Bearer <token> header.')

const accepted = typeof expected === 'string' ? [expected] : expected
if (!token) {
throw unauthorized(
'Missing Authorization: Bearer <token> 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. */
Expand Down
52 changes: 52 additions & 0 deletions lib/endpoints.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
16 changes: 12 additions & 4 deletions lib/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type ApiErrorCode =
| 'unauthorized'
| 'gone'
| 'not_found'
| 'method_not_allowed'
| 'forbidden'
| 'invalid_request'
| 'channel_full'
Expand Down Expand Up @@ -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 } } {
Expand All @@ -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'}`)
}
Expand Down
1 change: 1 addition & 0 deletions lib/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export async function enforceLimit(redis: WaveRedis, limit: Limit): Promise<void
const retryAfter = ttl > 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.`,
})
}

Expand Down
62 changes: 62 additions & 0 deletions tests/api-errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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`)
})
})
Loading