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
34 changes: 21 additions & 13 deletions db/test/live-share.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,8 @@ test('live share routes', async (t) => {
const answer = await refresh(deps, shareId, cardId);

assert.equal(answer.status, 404, name);
assert.deepEqual(answer.body, { error: 'not_found' }, name);
assert.equal(answer.body.code, 'not_found', name);
assert.equal(answer.body.retryable, false, name);
assert.equal((await download(deps, shareId, cardId)).status, 404, name);
}

Expand Down Expand Up @@ -279,7 +280,7 @@ test('live share routes', async (t) => {

assert.equal(waiting.status, 503);
assert.equal(waiting.headers.get('retry-after'), '2');
assert.deepEqual(await waiting.json(), { status: 'pending', retry_after_s: 2 });
assert.equal((await waiting.json()).retry_after_s, 2);
});

await t.test('no credential: an error answer and no back-end call', async () => {
Expand All @@ -295,7 +296,7 @@ test('live share routes', async (t) => {
const file = await download(unset, id, 'data');

assert.equal(file.status, 502);
assert.deepEqual(await file.json(), { status: 'error', code: 'live_unavailable' });
assert.equal((await file.json()).code, 'live_unavailable');
assert.equal(stub.calls.length, 0);
assert.deepEqual(await rows(id), []);
});
Expand Down Expand Up @@ -562,17 +563,23 @@ test('live share routes', async (t) => {

assert.equal(waiting.status, 503);
assert.equal(waiting.headers.get('retry-after'), '9');
assert.deepEqual(await waiting.json(), { status: 'pending', retry_after_s: 9 });
const pending = await waiting.json();

assert.equal(waiting.headers.get('content-type'), 'application/problem+json');
assert.equal(pending.code, 'refresh_pending');
assert.equal(pending.retryable, true);
assert.equal(pending.retry_after_s, 9);

const failed = over({ refreshDerived: (call) => aiRefusal('recipe_missing', call) });
const none = await download(failed.deps, id, 'derived');

assert.equal(none.status, 502);
assert.deepEqual(await none.json(), {
status: 'error',
code: 'recipe_missing',
retry_after_s: 30,
});
const missing = await none.json();

assert.equal(none.headers.get('content-type'), 'application/problem+json');
assert.equal(missing.code, 'recipe_missing');
assert.equal(missing.retryable, false);
assert.equal(missing.retry_after_s, 30);

await age(id);

Expand All @@ -587,13 +594,13 @@ test('live share routes', async (t) => {
const badFormat = await download(refusing.deps, id, 'data', 'format=xlsx');

assert.equal(badFormat.status, 400);
assert.deepEqual(await badFormat.json(), { status: 'error', code: 'request_invalid' });
assert.equal((await badFormat.json()).code, 'request_invalid');

// A refusal of the server's own credential is not the caller's error.
const badCredential = await download(refusing.deps, id, 'data', 'format=csv');

assert.equal(badCredential.status, 502);
assert.deepEqual(await badCredential.json(), { status: 'error', code: 'unauthorized' });
assert.equal((await badCredential.json()).code, 'unauthorized');
});

await t.test('stored answers go with the share and with a switch to snapshot', async () => {
Expand Down Expand Up @@ -683,7 +690,7 @@ test('live share routes', async (t) => {
]);

assert.equal(res.status, 502);
assert.deepEqual(await res.json(), { status: 'error', code: 'backend_timeout' });
assert.equal((await res.json()).code, 'backend_timeout');
});

await t.test('a share deleted mid-refresh answers 404, not a foreign-key failure', async () => {
Expand Down Expand Up @@ -722,7 +729,8 @@ test('live share routes', async (t) => {

const answer = await refresh({ ...deps, db: racing }, id, 'data');

assert.deepEqual(answer, { status: 404, body: { error: 'not_found' } });
assert.equal(answer.status, 404);
assert.equal(answer.body.code, 'not_found');
assert.deepEqual(await rows(id), []);
});

Expand Down
2 changes: 1 addition & 1 deletion docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ cd <website checkout>
pnpm install
```

A new package version packs under a new file name. Point the `@developmentfinance/client` entry in `package.json` at it before re-locking. Commit the tarball and the lockfile together.
A new package version packs under a new file name. Point the `@developmentfinance/client` entry in `package.json` at it before re-locking. A rebuilt tarball keeps its file name, so a local re-vendor needs the client's directory under `node_modules/.pnpm` deleted before `pnpm install --frozen-lockfile` picks it up. Commit the tarball and the lockfile together.

## Conventions

Expand Down
2 changes: 1 addition & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 8 additions & 5 deletions src/app/api/conversations/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
upsertConversation,
} from '@/lib/chat/conversationStore';
import type { SavedConversation } from '@/lib/chat/history';
import { missingProblem, requestInvalidProblem, unauthorizedProblem } from '@/lib/proxy/problem';

type Params = { params: Promise<{ id: string }> };

Expand All @@ -17,14 +18,14 @@ export const GET = async (_request: Request, { params }: Params) => {
const user = await getUser();

if (!user) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
return unauthorizedProblem();
}

const { id } = await params;
const saved = await getConversation(user.id, id);

if (!saved) {
return NextResponse.json({ error: 'not_found' }, { status: 404 });
return missingProblem('There is no such conversation.');
}

return NextResponse.json({ conversation: saved });
Expand All @@ -34,7 +35,7 @@ export const PUT = async (request: Request, { params }: Params) => {
const user = await getUser();

if (!user) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
return unauthorizedProblem();
}

const { id } = await params;
Expand All @@ -46,7 +47,9 @@ export const PUT = async (request: Request, { params }: Params) => {
typeof body.title !== 'string' ||
!Array.isArray(body.messages)
) {
return NextResponse.json({ error: 'bad_request' }, { status: 400 });
return requestInvalidProblem(
'The body needs the conversation id from the path, a title, and a list of messages.',
);
}

await upsertConversation(user.id, body);
Expand All @@ -58,7 +61,7 @@ export const DELETE = async (_request: Request, { params }: Params) => {
const user = await getUser();

if (!user) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
return unauthorizedProblem();
}

const { id } = await params;
Expand Down
3 changes: 2 additions & 1 deletion src/app/api/conversations/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import { NextResponse } from 'next/server';

import { getUser } from '@/lib/auth/session';
import { listConversations, searchConversations } from '@/lib/chat/conversationStore';
import { unauthorizedProblem } from '@/lib/proxy/problem';

// GET /api/conversations → the signed-in user's history (list columns only)
// GET /api/conversations?q=… → the ones whose title or transcript match
export const GET = async (request: Request) => {
const user = await getUser();

if (!user) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
return unauthorizedProblem();
}

const q = new URL(request.url).searchParams.get('q')?.trim() ?? '';
Expand Down
24 changes: 18 additions & 6 deletions src/app/api/download-pin/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type { NextRequest } from 'next/server';

import { readBoundedText } from '@/lib/proxy/boundedBody';
import { clientIp } from '@/lib/proxy/forward';
import { problem, rateLimitedProblem, requestInvalidProblem } from '@/lib/proxy/problem';
import { rateHeaders } from '@/lib/proxy/rateHeaders';
import { hit } from '@/lib/proxy/rateLimit';
import { MAX_DOWNLOAD_PIN_BODY, parseDownloadPin, pinVersionFrame } from '@/lib/search/snapshot';
import { needsPin, pinVersionOnce } from '@/lib/search/versionPin';
Expand All @@ -23,13 +25,13 @@ const CAPTURE_POLL_DEADLINE_MS = 50_000;
// 413 as soon as the count passes it, chunked or not, and a malformed one
// fails parseDownloadPin. A pin the door refuses, or cannot take right now, is
// logged with the door's detail and left unpinned for a later download to
// retry.
// retry. Every refusal is a problem document (src/lib/proxy/problem).
export const POST = async (req: NextRequest) => {
// A pin can mint, so it has its own small allowance.
const verdict = await hit('pin', clientIp(req));

if (!verdict.allowed) {
return Response.json({ error: 'rate_limited' }, { status: 429 });
return rateLimitedProblem(verdict, rateHeaders(verdict));
}

const text = await readBoundedText(req.body, MAX_DOWNLOAD_PIN_BODY).catch((error: unknown) => {
Expand All @@ -42,17 +44,22 @@ export const POST = async (req: NextRequest) => {
});

if (text === undefined) {
return Response.json({ error: 'bad_request' }, { status: 400 });
return requestInvalidProblem('The request body could not be read.');
}

if (text === null) {
return Response.json({ error: 'payload_too_large' }, { status: 413 });
return problem({
status: 413,
code: 'payload_too_large',
detail: `The request body is over ${MAX_DOWNLOAD_PIN_BODY} bytes.`,
retryable: false,
});
}

const body = parseDownloadPin(text);

if (!body) {
return Response.json({ error: 'bad_request' }, { status: 400 });
return requestInvalidProblem('The request body is not a download pin.');
}

if (!needsPin(body)) {
Expand Down Expand Up @@ -86,6 +93,11 @@ export const POST = async (req: NextRequest) => {
cause: cause instanceof Error ? cause.message : undefined,
});

return Response.json({ error: 'pin_refused' }, { status: 502 });
return problem({
status: 502,
code: 'pin_refused',
detail: 'The version could not be pinned. A later download tries again.',
retryable: false,
});
}
};
16 changes: 12 additions & 4 deletions src/app/api/events/route.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { NextResponse } from 'next/server';

import { getSession } from '@/lib/auth/session';
import { fitsProps } from '@/lib/events/limits';
import { fitsProps, MAX_PROPS_BYTES } from '@/lib/events/limits';
import { EVENT_NAMES, type EventName, track } from '@/lib/events/track';
import { problem, requestInvalidProblem } from '@/lib/proxy/problem';

/**
* Usage-event intake (POST /api/events) for the browser side of
* src/lib/events. Body: `{ name, props?, visitorId?, page? }`. The name must
* be one the site emits; props are capped so a stray payload can't bloat
* the table. Always answers 204 — the page never waits on analytics.
* the table. Answers 204 for an event taken, and a problem document
* (`request_invalid`, `payload_too_large`) for one refused; the page never
* waits on either.
*/
const MAX_ID = 64;
const MAX_PAGE = 300;
Expand All @@ -21,7 +24,7 @@ export const POST = async (request: Request) => {
const name = body?.name;

if (!(EVENT_NAMES as readonly unknown[]).includes(name)) {
return NextResponse.json({ error: 'unknown event' }, { status: 400 });
return requestInvalidProblem('The event name is not one the site records.', '/name');
}

const props =
Expand All @@ -30,7 +33,12 @@ export const POST = async (request: Request) => {
: {};

if (!fitsProps(props)) {
return NextResponse.json({ error: 'props too large' }, { status: 413 });
return problem({
status: 413,
code: 'payload_too_large',
detail: `The event props are over ${MAX_PROPS_BYTES} bytes.`,
retryable: false,
});
}

const session = await getSession();
Expand Down
5 changes: 3 additions & 2 deletions src/app/api/roadmap/votes/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { NextResponse } from 'next/server';
import { UPCOMING } from '@/components/organisms/Roadmap/roadmapData';
import { getUser } from '@/lib/auth/session';
import { db } from '@/lib/db';
import { requestInvalidProblem, unauthorizedProblem } from '@/lib/proxy/problem';
import { roadmapVote } from '../../../../../db/schema';

// Only entries still under consideration take votes.
Expand Down Expand Up @@ -39,14 +40,14 @@ export const POST = async (request: Request) => {
const user = await getUser();

if (!user) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
return unauthorizedProblem();
}

const body = (await request.json().catch(() => null)) as { entryId?: unknown } | null;
const entryId = typeof body?.entryId === 'string' ? body.entryId : '';

if (!VOTABLE.has(entryId)) {
return NextResponse.json({ error: 'bad_request' }, { status: 400 });
return requestInvalidProblem('The entry does not take votes.', '/entryId');
}

const where = and(eq(roadmapVote.userId, user.id), eq(roadmapVote.entryId, entryId));
Expand Down
25 changes: 19 additions & 6 deletions src/app/api/share/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,20 @@ import { NextResponse } from 'next/server';
import { getUser } from '@/lib/auth/session';
import { deleteShare, snapshotShare } from '@/lib/chat/shareStore';
import type { OwnerWriteResult } from '@/lib/chat/shareWrites';
import {
missingProblem,
problem,
requestInvalidProblem,
unauthorizedProblem,
} from '@/lib/proxy/problem';
import { getSearchShare } from '@/lib/search/searchShare';

type Context = { params: Promise<{ id: string }> };

const SHARE_ID = /^[A-Za-z0-9]{8}$/;

const noSuchShare = (): Response => missingProblem('There is no such share.');

// GET /api/share/<id> → a search share's references ({ mode, query, refs }).
// Public, like the link itself; conversation shares render server-side on
// /s/<id> and never come through here.
Expand All @@ -17,7 +25,7 @@ export const GET = async (_request: Request, { params }: Context) => {
const found = SHARE_ID.test(id) ? await getSearchShare(id) : null;

if (!found) {
return NextResponse.json({ error: 'not_found' }, { status: 404 });
return noSuchShare();
}

return NextResponse.json(found, { headers: { 'Cache-Control': 'no-store' } });
Expand All @@ -29,23 +37,28 @@ export const GET = async (_request: Request, { params }: Context) => {
const ownerWrite = async (
{ params }: Context,
write: (id: string, userId: string) => Promise<OwnerWriteResult>,
done: (id: string) => NextResponse,
done: (id: string) => Response,
) => {
const user = await getUser();

if (!user) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
return unauthorizedProblem();
}

const { id } = await params;
const result = SHARE_ID.test(id) ? await write(id, user.id) : 'not_found';

if (result === 'not_found') {
return NextResponse.json({ error: 'not_found' }, { status: 404 });
return noSuchShare();
}

if (result === 'unsupported_kind') {
return NextResponse.json({ error: 'unsupported_kind' }, { status: 400 });
return problem({
status: 400,
code: 'unsupported_kind',
detail: 'Only a conversation, canvas, or card share can be changed here.',
retryable: false,
});
}

return done(id);
Expand All @@ -58,7 +71,7 @@ export const PATCH = async (request: Request, context: Context) => {
const body = (await request.json().catch(() => null)) as { mode?: unknown } | null;

if (body?.mode !== 'snapshot') {
return NextResponse.json({ error: 'invalid_mode' }, { status: 400 });
return requestInvalidProblem('A share can only be switched to `snapshot`.', '/mode');
}

return ownerWrite(context, snapshotShare, (id) => NextResponse.json({ id, mode: 'snapshot' }));
Expand Down
Loading
Loading