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
19 changes: 17 additions & 2 deletions package-lock.json

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

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"dependencies": {
"hono": "^4.12.25",
"react": "^19.2.7",
"react-dom": "^19.2.7"
"react-dom": "^19.2.7",
"valibot": "^1.4.2"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
Expand Down
16 changes: 16 additions & 0 deletions server/__tests__/csv-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,20 @@ describe('parseCsvRows', () => {
it('returns an empty array for an empty string', () => {
expect(parseCsvRows('')).toEqual([]);
});

it('keeps a newline inside a quoted field as one cell in one row', () => {
const text = 'Name,Notes\nJane,"line one\nline two"';
expect(parseCsvRows(text)).toEqual([
['Name', 'Notes'],
['Jane', 'line one\nline two'],
]);
});

it('keeps a CRLF inside a quoted field as one cell in one row', () => {
const text = 'Name,Notes\r\nJane,"line one\r\nline two",done';
expect(parseCsvRows(text)).toEqual([
['Name', 'Notes'],
['Jane', 'line one\r\nline two', 'done'],
]);
});
});
70 changes: 42 additions & 28 deletions server/lib/csv.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,56 @@
/**
* Minimal RFC4180-ish CSV row parser: splits on commas outside quotes, un-escapes `""` to `"`
* inside a quoted field. Not a general CSV library — this codebase's only CSV use is the fixed
* 4-column client/pet import format (see
* docs/superpowers/specs/2026-07-10-csv-client-import-design.md), so a small hand-rolled parser
* avoids a dependency for a narrow, well-defined need.
* Minimal RFC4180-ish CSV parser tailored to this codebase's only CSV use: the fixed
* client/pet import format (see docs/superpowers/specs/2026-07-10-csv-client-import-design.md),
* whose free-text fields (e.g. a quoted Client Name) may contain newlines pasted from a spreadsheet.
*
* It runs a single-pass state machine over the whole input string (not line-by-line), so a
* quoted field spanning multiple physical lines stays in one cell / one row:
* - `"` outside a quoted field opens one; inside a quoted field `""` is a literal `"` and a
* lone `"` closes the field.
* - `,` outside quotes ends a cell.
* - `\r\n`, lone `\r`, and lone `\n` outside quotes each end a row (a CRLF is one row break).
* - Any newline inside a quoted field is kept as a literal character in the cell.
*
* A hand-rolled parser avoids a dependency for this narrow, well-defined need.
*/
export function parseCsvRows(text: string): string[][] {
if (text === '') return [];
const rows: string[][] = [];
const lines = text.split(/\r\n|\r|\n/);
for (const line of lines) {
const cells: string[] = [];
let cell = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (inQuotes) {
if (ch === '"') {
if (line[i + 1] === '"') {
cell += '"';
i++;
} else {
inQuotes = false;
}
let cells: string[] = [];
let cell = '';
let inQuotes = false;

for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (inQuotes) {
if (ch === '"') {
if (text[i + 1] === '"') {
cell += '"';
i++;
} else {
cell += ch;
inQuotes = false;
}
} else if (ch === '"') {
inQuotes = true;
} else if (ch === ',') {
cells.push(cell);
cell = '';
} else {
cell += ch;
}
} else if (ch === '"') {
inQuotes = true;
} else if (ch === ',') {
cells.push(cell);
cell = '';
} else if (ch === '\r' || ch === '\n') {
// Row terminator: \r\n counts as a single break.
if (ch === '\r' && text[i + 1] === '\n') i++;
cells.push(cell);
cell = '';
rows.push(cells);
cells = [];
} else {
cell += ch;
}
cells.push(cell);
rows.push(cells);
}

cells.push(cell);
rows.push(cells);
return rows;
}
38 changes: 28 additions & 10 deletions server/routes/auth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Hono } from 'hono';
import * as v from 'valibot';
import {
consumeLoginCode,
createLoginCode,
Expand All @@ -12,6 +13,23 @@ import type { AppEnv } from '../types';

const CODE_TTL_MS = 10 * 60 * 1000;

// --- Reference valibot pattern ---
// This file is the reference for validating request bodies with valibot: declare a schema, then
// `safeParse` once to both validate and narrow types (replacing hand-rolled `typeof` guards + casts).
// Other routes should follow this shape. Keep schemas small and inline — no shared factory.
//
// The email schema reuses the repo's EMAIL_RE via a regex pipe (not valibot's own email heuristic)
// so validation stays byte-for-byte identical to the previous hand-check. It also trims + lowercases
// before the regex, matching the old `body.email.trim().toLowerCase()` normalization.
const IdentifyBody = v.object({
email: v.pipe(v.string(), v.trim(), v.toLowerCase(), v.regex(EMAIL_RE)),
});
// codeId is intentionally NOT trimmed (matches prior behavior); code is trimmed before consuming.
const VerifyBody = v.object({
codeId: v.string(),
code: v.pipe(v.string(), v.trim()),
});

function generateCode(): string {
const n = crypto.getRandomValues(new Uint32Array(1))[0] % 1_000_000;
return n.toString().padStart(6, '0');
Expand All @@ -20,9 +38,10 @@ function generateCode(): string {
export const authRoutes = new Hono<AppEnv>()
.post('/:slug/identify', async (c) => {
const tenant = c.get('tenant');
const body = await c.req.json<{ email?: unknown }>().catch(() => ({}) as { email?: unknown });
const email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : '';
if (!EMAIL_RE.test(email)) return c.json({ error: 'Enter a valid email.' }, 400);
const raw = await c.req.json<unknown>().catch(() => ({}));
const parsed = v.safeParse(IdentifyBody, raw);
if (!parsed.success) return c.json({ error: 'Enter a valid email.' }, 400);
const { email } = parsed.output;

// Invite-only: only customers the provider has added may receive a code. Do NOT auto-create.
const user = await getEndUserByEmail(c.env.PAWBOOK_DB, tenant.Id, email);
Expand Down Expand Up @@ -52,17 +71,16 @@ export const authRoutes = new Hono<AppEnv>()

.post('/:slug/verify', async (c) => {
const tenant = c.get('tenant');
const body = await c.req
.json<{ codeId?: unknown; code?: unknown }>()
.catch(() => ({}) as { codeId?: unknown; code?: unknown });
if (typeof body.codeId !== 'string' || typeof body.code !== 'string')
return c.json({ error: 'Code required.' }, 400);
const raw = await c.req.json<unknown>().catch(() => ({}));
const parsed = v.safeParse(VerifyBody, raw);
if (!parsed.success) return c.json({ error: 'Code required.' }, 400);
const { codeId, code } = parsed.output;

const endUserId = await consumeLoginCode(
c.env.PAWBOOK_DB,
tenant.Id,
body.codeId,
body.code.trim(),
codeId,
code,
new Date().toISOString(),
);
if (!endUserId) return c.json({ error: 'That code is wrong or expired — try again.' }, 401);
Expand Down
Loading