Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
bc5baa5
♻️ mcp: migrate to Eden Treaty + scoped tools (admin, flag)
May 16, 2026
a9713ac
✨ cli: thin Eden Treaty wrapper for user + admin API
May 16, 2026
4b0446a
🛂 cli: admin command tree (stats, users, packs, catalog, trails, anal…
May 16, 2026
4f1d25e
🩹 mcp,cli: biome auto-fixes (formatting, useMaxParams, import order)
May 16, 2026
adfde8f
🚸 cli,mcp,env: satisfy pre-push lints
May 16, 2026
bd2e48b
♻️ guards: consolidate narrow helpers under to* naming
May 16, 2026
2d70fab
🩹 cli: load config when ~/.packrat/config.json is missing
May 16, 2026
1cb8d84
🛂 api: relax query schemas so Treaty sees them as truly optional
May 16, 2026
5d8ab30
🚸 cli,mcp: drop workarounds now that API schemas are Treaty-friendly
May 16, 2026
ffc929e
✨ api: GET /weather/by-name?q=X — search + forecast in one call
May 16, 2026
4e79c18
✨ api: GET /packs/:packId/weight-breakdown — per-category totals
May 16, 2026
2fcc4c4
✨ api: POST /catalog/compare — multi-item side-by-side comparison
May 16, 2026
089af14
✨ api: POST /packs/:packId/items/from-catalog — hydrate item from cat…
May 16, 2026
df0cfac
✨ api: server-side ID minting (optional, preserves offline-first)
May 16, 2026
31a0a69
🛂 api: POST /admin/login — body-credential variant of /admin/token
May 16, 2026
ba03da9
🩹 fix typecheck regressions from PR review
May 16, 2026
dda500c
🩹 fix admin SPA callsites broken by includeDeleted boolean coercion
May 16, 2026
61536f6
🔒 fix(api): security/correctness issues from PR review
May 16, 2026
e42a053
🩹 fix(cli): unwrap paginated/nested response shapes
May 16, 2026
ec8ea7f
🚸 polish PR review feedback — passwords, config, network errors, MCP doc
May 16, 2026
279b217
🩹 cli: import toRecord in admin/packs.ts
May 16, 2026
67c74cf
Merge remote-tracking branch 'origin/development' into worktree-mcp-c…
May 17, 2026
2800282
🩹 address CodeRabbit review on PR #2433 — fixes for real bugs
May 17, 2026
78b71a9
Merge remote-tracking branch 'origin/development' into worktree-mcp-c…
May 17, 2026
83c9992
🐛 fix: drop ★ from landing OG image + harden font fallback
May 17, 2026
df70874
🚨 lint: swap raw typeof for @packrat/guards isString in OG fetch shim
May 17, 2026
29d44a5
🔒 security: parse URL hostname for font intercept (CodeQL)
May 17, 2026
e0dcc36
⬆️ chore: pin bun 1.3.14 via .bun-version + packageManager + engines
May 17, 2026
024938c
🔀 merge: development into worktree-mcp-cli-eden (post-#2414 schema re…
May 17, 2026
9b71b69
🚚 schemas: move AddPackItemFromCatalogBodySchema into @packrat/schemas
May 17, 2026
67e6afe
⏪ revert: T8/T9 — defer client-vs-server ID split to feat/client-uuid…
andrew-bierman May 17, 2026
575e103
Merge remote-tracking branch 'origin/development' into worktree-mcp-c…
andrew-bierman May 17, 2026
56ad9ea
🔥 fix: typecheck failures after T9 revert
andrew-bierman May 17, 2026
db2a0bc
🔀 chore(cli): use uuid npm package (v7) instead of Bun.randomUUIDv7
andrew-bierman May 17, 2026
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
10 changes: 7 additions & 3 deletions apps/admin/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,12 @@ export async function getUsers({
q?: string;
includeDeleted?: boolean;
} = {}): Promise<PaginatedResponse<AdminUser>> {
// users-list no longer accepts includeDeleted — Better Auth doesn't support
// user soft-delete, so the field was dead code. Caller-supplied value is
// ignored.
void includeDeleted;
const { data, error } = await adminClient['users-list'].get({
query: { limit, offset, q, includeDeleted: includeDeleted ? 'true' : undefined },
query: { limit, offset, q },
});
if (error) throwOnError(error);
return unwrap(data, 'users');
Expand Down Expand Up @@ -138,7 +142,7 @@ export async function getPacks({
includeDeleted?: boolean;
} = {}): Promise<PaginatedResponse<AdminPack>> {
const { data, error } = await adminClient['packs-list'].get({
query: { limit, offset, q, includeDeleted: includeDeleted ? 'true' : undefined },
query: { limit, offset, q, includeDeleted },
});
if (error) throwOnError(error);
return unwrap(data, 'packs');
Expand Down Expand Up @@ -315,7 +319,7 @@ export async function getTrailConditions({
includeDeleted?: boolean;
} = {}): Promise<PaginatedResponse<TrailConditionReport>> {
const { data, error } = await adminClient.trails.conditions.get({
query: { q, limit, offset, includeDeleted: includeDeleted ? 'true' : undefined },
query: { q, limit, offset, includeDeleted },
});
if (error) throwOnError(error);
return unwrap(data, 'trailConditions');
Expand Down
2 changes: 1 addition & 1 deletion apps/expo/features/packs/hooks/usePackGapAnalysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { apiClient } from 'expo-app/lib/api/packrat';
export interface GapAnalysisRequest {
destination?: string;
tripType?: string;
duration?: string;
duration?: number;
startDate?: string;
endDate?: string;
}
Expand Down
19 changes: 19 additions & 0 deletions apps/guides/scripts/generate-og-images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,30 @@

import fs from 'node:fs';
import path from 'node:path';
import { isString } from '@packrat/guards';
import { ImageResponse } from 'next/og';
import { createElement } from 'react';
import { getAllPosts } from '../lib/mdx-static';
import { getGuidesOgImageElement, getPostOgImageElement, OG_IMAGE_SIZE } from '../lib/og-image';

// @vercel/og auto-fetches Google Fonts when it encounters glyphs outside its
// bundled Latin coverage. CF Pages' build network occasionally returns 4xx for
// fonts.googleapis.com, killing the build. Intercept and return an empty 404
// so loadGoogleFont gives up cleanly and ImageResponse falls back to bundled.
const FONT_HOSTS = new Set(['fonts.googleapis.com', 'fonts.gstatic.com']);
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const href = isString(input) ? input : input instanceof URL ? input.href : input.url;
try {
if (FONT_HOSTS.has(new URL(href).hostname)) {
return new Response(null, { status: 404 });
}
} catch {
// Not a parseable absolute URL — fall through to the real fetch.
}
return originalFetch(input, init);
}) as typeof fetch;

const PUBLIC_DIR = path.join(import.meta.dir, '..', 'public');
const OG_DIR = path.join(PUBLIC_DIR, 'og');

Expand Down
2 changes: 1 addition & 1 deletion apps/landing/lib/og-image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export function getLandingOgImageElement(): ReactElement {
gap: '48px',
}}
>
{['10K+ Users', '4.8 Rating', '100% Free'].map((stat) => (
{['10K+ Users', '4.8/5 Rating', '100% Free'].map((stat) => (
<div
key={stat}
style={{
Expand Down
19 changes: 19 additions & 0 deletions apps/landing/scripts/generate-og-images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,29 @@

import fs from 'node:fs';
import path from 'node:path';
import { isString } from '@packrat/guards';
import { ImageResponse } from 'next/og';
import { createElement } from 'react';
import { getLandingOgImageElement, OG_IMAGE_SIZE } from '../lib/og-image';

// @vercel/og auto-fetches Google Fonts when it encounters glyphs outside its
// bundled Latin coverage. CF Pages' build network occasionally returns 4xx for
// fonts.googleapis.com, killing the build. Intercept and return an empty 404
// so loadGoogleFont gives up cleanly and ImageResponse falls back to bundled.
const FONT_HOSTS = new Set(['fonts.googleapis.com', 'fonts.gstatic.com']);
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const href = isString(input) ? input : input instanceof URL ? input.href : input.url;
try {
if (FONT_HOSTS.has(new URL(href).hostname)) {
return new Response(null, { status: 404 });
}
} catch {
// Not a parseable absolute URL — fall through to the real fetch.
}
return originalFetch(input, init);
}) as typeof fetch;

const PUBLIC_DIR = path.join(import.meta.dir, '..', 'public');

async function generateOgImages(): Promise<void> {
Expand Down
11 changes: 10 additions & 1 deletion bun.lock

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

8 changes: 4 additions & 4 deletions packages/api/src/routes/admin/analytics/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ export const catalogAnalyticsRoutes = new Elysia({ prefix: '/catalog' })
},
{
query: z.object({
limit: z.coerce.number().int().min(1).max(100).optional().default(25),
limit: z.coerce.number().int().min(1).max(100).optional(),
}),
response: { 200: z.array(BrandRowSchema), ...AdminErrorResponses },
detail: { tags: ['Admin'], summary: 'Top gear brands' },
Expand Down Expand Up @@ -227,7 +227,7 @@ export const catalogAnalyticsRoutes = new Elysia({ prefix: '/catalog' })
},
{
query: z.object({
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
limit: z.coerce.number().int().min(1).max(200).optional(),
}),
response: { 200: EtlResponseSchema, ...AdminErrorResponses },
detail: { tags: ['Admin'], summary: 'ETL pipeline history' },
Expand Down Expand Up @@ -309,7 +309,7 @@ export const catalogAnalyticsRoutes = new Elysia({ prefix: '/catalog' })
},
{
query: z.object({
limit: z.coerce.number().int().min(1).max(100).optional().default(20),
limit: z.coerce.number().int().min(1).max(100).optional(),
}),
response: { 200: EtlFailureSummarySchema, ...AdminErrorResponses },
detail: { tags: ['Admin'], summary: 'Top ETL validation failure patterns' },
Expand Down Expand Up @@ -372,7 +372,7 @@ export const catalogAnalyticsRoutes = new Elysia({ prefix: '/catalog' })
{
params: z.object({ jobId: z.string().uuid() }),
query: z.object({
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
limit: z.coerce.number().int().min(1).max(200).optional(),
}),
response: { 200: EtlJobFailuresSchema, ...AdminErrorResponses },
detail: { tags: ['Admin'], summary: 'Validation failures for a specific ETL job' },
Expand Down
73 changes: 64 additions & 9 deletions packages/api/src/routes/admin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { verifyCFAccessRequest } from '@packrat/api/middleware/cfAccess';
import { timingSafeEqual } from '@packrat/api/utils/auth';
import { getEnv } from '@packrat/api/utils/env-validation';
import { catalogItems, packs, users } from '@packrat/db';
import { assertAllDefined } from '@packrat/guards';
import { assertAllDefined, queryBoolean } from '@packrat/guards';
import {
AdminCatalogListSchema,
AdminErrorResponses,
Expand All @@ -26,6 +26,13 @@ const ADMIN_TOKEN_TTL_SECONDS = 3600; // 1 hour
const ADMIN_JWT_ISSUER = 'packrat-api';
const ADMIN_JWT_AUDIENCE = 'packrat-admin';

function checkAdminCredentials(username: string, password: string): boolean {
const env = getEnv();
const userOk = timingSafeEqual(username, env.ADMIN_USERNAME);
const passOk = timingSafeEqual(password, env.ADMIN_PASSWORD);
return userOk && passOk;
}

function basicAuthGuard(request: Request): { authorized: true } | { authorized: false } {
const header = request.headers.get('authorization') ?? '';
if (!header.startsWith('Basic ')) return { authorized: false };
Expand All @@ -36,10 +43,7 @@ function basicAuthGuard(request: Request): { authorized: true } | { authorized:
if (sep === -1) return { authorized: false };
const username = decoded.slice(0, sep);
const password = decoded.slice(sep + 1);
const env = getEnv();
const userOk = timingSafeEqual(username, env.ADMIN_USERNAME);
const passOk = timingSafeEqual(password, env.ADMIN_PASSWORD);
if (userOk && passOk) return { authorized: true };
if (checkAdminCredentials(username, password)) return { authorized: true };
} catch {
return { authorized: false };
}
Expand Down Expand Up @@ -131,6 +135,50 @@ export const adminRoutes = new Elysia({ prefix: '/admin' })
allowedHeaders: ['Authorization', 'Content-Type'],
}),
)
// Login (body-credential variant) — same credential semantics as /token,
// but takes `{ username, password }` in the JSON body. Typed clients (MCP,
// CLI, Eden Treaty) can hit this without overriding the Authorization
// header. The Basic-auth /token route remains for the admin SPA.
.post(
'/login',
async ({ body, request }) => {
const env = getEnv();
if (env.TOKEN_RATE_LIMITER) {
const ip = request.headers.get('cf-connecting-ip') ?? 'unknown';
const { success } = await env.TOKEN_RATE_LIMITER.limit({ key: ip });
if (!success) return status(429, { error: 'Too many requests' });
}
const { CF_ACCESS_TEAM_DOMAIN, CF_ACCESS_AUD } = env;
if (CF_ACCESS_TEAM_DOMAIN && CF_ACCESS_AUD) {
const cfIdentity = await verifyCFAccessRequest(request, {
teamDomain: CF_ACCESS_TEAM_DOMAIN,
aud: CF_ACCESS_AUD,
});
if (!cfIdentity) return status(401, { error: 'CF Access authentication required' });
}
if (!checkAdminCredentials(body.username, body.password)) {
return status(401, { error: 'Invalid username or password' });
}
const token = await issueAdminJwt(body.username);
return { token, expiresIn: ADMIN_TOKEN_TTL_SECONDS };
},
{
body: z.object({
username: z.string().min(1),
password: z.string().min(1),
}),
response: {
200: z.object({ token: z.string(), expiresIn: z.number() }),
401: z.object({ error: z.string() }),
429: z.object({ error: z.string() }),
},
detail: {
tags: ['Admin'],
summary: 'Exchange JSON credentials for a short-lived admin JWT',
},
},
)

// Token exchange — must be registered BEFORE the auth guard so the admin
// SPA can exchange Basic credentials for a short-lived JWT.
.post(
Expand Down Expand Up @@ -180,7 +228,9 @@ export const adminRoutes = new Elysia({ prefix: '/admin' })
},
)
.onBeforeHandle(async ({ request, path }) => {
if (path === '/api/admin/token') return;
// Credential-exchange routes own their own auth gating (Basic for /token,
// JSON body for /login). Skip the bearer guard for both.
if (path === '/api/admin/token' || path === '/api/admin/login') return;
if (request.method === 'OPTIONS') return;
const ok = await adminAuthGuard(request);
if (!ok) return status(401, { error: 'Unauthorized' });
Expand Down Expand Up @@ -275,7 +325,6 @@ export const adminRoutes = new Elysia({ prefix: '/admin' })
limit: z.coerce.number().int().positive().max(100).optional(),
offset: z.coerce.number().int().min(0).optional(),
q: z.string().optional(),
includeDeleted: z.string().optional(),
}),
response: { 200: AdminUsersListSchema, ...AdminErrorResponses },
detail: { tags: ['Admin'], summary: 'List users' },
Expand All @@ -291,6 +340,7 @@ export const adminRoutes = new Elysia({ prefix: '/admin' })
const limit = Number(query.limit ?? 100);
const offset = Number(query.offset ?? 0);
const search = query.q;
const includeDeleted = query.includeDeleted ?? false;
const searchFilter = search
? or(
ilike(packs.name, `%${search}%`),
Expand All @@ -299,7 +349,9 @@ export const adminRoutes = new Elysia({ prefix: '/admin' })
ilike(users.email, `%${search}%`),
)
: undefined;
const whereClause = and(eq(packs.deleted, false), searchFilter);
const whereClause = includeDeleted
? searchFilter
: and(eq(packs.deleted, false), searchFilter);

const [packsList, [totalRow]] = await Promise.all([
db
Expand Down Expand Up @@ -349,7 +401,10 @@ export const adminRoutes = new Elysia({ prefix: '/admin' })
limit: z.coerce.number().int().positive().max(100).optional(),
offset: z.coerce.number().int().min(0).optional(),
q: z.string().optional(),
includeDeleted: z.string().optional(),
// queryBoolean() instead of z.coerce.boolean() — the latter treats
// any non-empty string as truthy, so ?includeDeleted=false would
// wrongly include soft-deleted rows.
includeDeleted: queryBoolean(),
}),
response: { 200: AdminPacksListSchema, ...AdminErrorResponses },
detail: { tags: ['Admin'], summary: 'List packs' },
Expand Down
10 changes: 6 additions & 4 deletions packages/api/src/routes/admin/trails.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createDb, createOsmDb } from '@packrat/api/db';
import { trailConditionReports, users } from '@packrat/db';
import { queryBoolean } from '@packrat/guards';
import {
AdminErrorResponses,
SuccessSchema,
Expand Down Expand Up @@ -243,7 +244,7 @@ export const adminTrailsRoutes = new Elysia({ prefix: '/trails' })
const limit = query.limit ?? 50;
const offset = query.offset ?? 0;
const search = query.q;
const includeDeleted = query.includeDeleted === 'true';
const includeDeleted = query.includeDeleted ?? false;

try {
const deletedFilter = includeDeleted ? undefined : eq(trailConditionReports.deleted, false);
Expand Down Expand Up @@ -300,9 +301,10 @@ export const adminTrailsRoutes = new Elysia({ prefix: '/trails' })
{
query: z.object({
q: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).optional().default(50),
offset: z.coerce.number().int().min(0).optional().default(0),
includeDeleted: z.string().optional(),
// Handler defaults limit to 50, offset to 0; keep schema truly optional.
limit: z.coerce.number().int().min(1).max(100).optional(),
offset: z.coerce.number().int().min(0).optional(),
includeDeleted: queryBoolean(),
}),
response: { 200: TrailConditionsListSchema, ...AdminErrorResponses },
detail: { tags: ['Admin'], summary: 'List all trail condition reports' },
Expand Down
Loading
Loading