Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
9364c96
refactor: replace manual portal popovers with Radix Popover (modal + …
EvanTechDev Jul 28, 2026
a58fab4
fix: scrollarea height constraint and popover anchor positioning
EvanTechDev Jul 28, 2026
4310e4c
fix: replace scrollarea with overflow-y-auto, reposition month popover
EvanTechDev Jul 28, 2026
e2e2582
fix: resolve year view popover/countdown bugs, remove mini calendar s…
EvanTechDev Jul 28, 2026
1ec13f5
feat: install ioredis dependency
EvanTechDev Jul 28, 2026
da4b145
feat: add Redis cache layer — client, keys, session, events modules
EvanTechDev Jul 28, 2026
3338589
feat: integrate Redis cache into auth session and calendar events API
EvanTechDev Jul 28, 2026
6b806f1
feat: add Redis cache — session, events, invalidation
EvanTechDev Jul 28, 2026
295d00f
fix: align Session type nullability with better-auth return types
EvanTechDev Jul 28, 2026
4f5e4f3
fix: cache all events by month regardless of date params + decrypt on…
EvanTechDev Jul 28, 2026
b323483
fix: replace ScrollArea with overflow-y-auto in search results dropdown
EvanTechDev Jul 28, 2026
9a7e890
feat: redirect unauthenticated /app users to /sign-up
EvanTechDev Jul 28, 2026
aad597d
feat: add MCP infrastructure with DB schema, auth module, and audit l…
EvanTechDev Jul 29, 2026
03d664d
feat: implement OAuth Device Code Grant flow for MCP
EvanTechDev Jul 29, 2026
832ff16
feat: implement MCP server with all calendar tools
EvanTechDev Jul 29, 2026
2d25e5d
feat: add MCP management API routes
EvanTechDev Jul 29, 2026
bf1e696
feat: add MCP settings UI components
EvanTechDev Jul 29, 2026
f16dc45
feat: add rate limiting and audit logging for MCP
EvanTechDev Jul 29, 2026
fd1e5c8
chore: fix lint warnings in MCP code
EvanTechDev Jul 29, 2026
c679377
fix: wrap useSearchParams in Suspense boundary on /oauth/authorize
EvanTechDev Jul 29, 2026
66654a7
fix: create fresh MCP transport per request, add CORS origins
EvanTechDev Jul 29, 2026
41cbba6
fix: implement OAuth Authorization Code Grant with PKCE for ChatGPT c…
EvanTechDev Jul 29, 2026
862ca41
fix: support application/x-www-form-urlencoded in OAuth token/authori…
EvanTechDev Jul 29, 2026
c90c548
fix: restrict MCP tool colors to enum, add English descriptions
EvanTechDev Jul 29, 2026
785b3af
refactor: replace hand-rolled UI with @zntr/ui library components in …
EvanTechDev Jul 30, 2026
198c20a
refactor: move OAuth auth check to proxy.ts, simplify authorize page UI
EvanTechDev Jul 30, 2026
2d5a8ce
feat: show redirect URI and scopes on OAuth authorize page, validate …
EvanTechDev Jul 30, 2026
66318f3
fix: remove thick ring border from OAuth page avatar
EvanTechDev Jul 30, 2026
834d451
fix: decrypt MCP tool responses, add countdown pagination, improve au…
EvanTechDev Jul 30, 2026
e8e59c1
fix: skip auth for GET/SSE requests in MCP handler
EvanTechDev Jul 30, 2026
55763b7
fix: query API keys by both old and new prefix formats
EvanTechDev Jul 30, 2026
a371108
chore: strict original auth prefix + original handler with GET skip-auth
EvanTechDev Jul 30, 2026
f0e81f2
feat: add OAuth dynamic client registration to MCP server
EvanTechDev Jul 31, 2026
cb0c8fc
feat: lowercase auth table names and add audit log retention
EvanTechDev Jul 31, 2026
698a96a
feat: add Astro web marketing site with theme-aware logo favicon
EvanTechDev Aug 1, 2026
1572865
fix: harden redis client and event cache date handling
EvanTechDev Aug 1, 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,5 @@ next-env.d.ts
.nx/

# session status (not committed)
STATUS.md
status.md
17 changes: 9 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ pnpm workspace + Turborepo. Package manager: `pnpm@11.5.2`.
apps/
calendar/ Next.js 16 web app (one-calendar) — main product
calendar-client/ Tauri + React + Vite desktop client
web/ Astro 7 marketing/site app (@astrojs/react + Tailwind v4)
packages/
ui/ @zntr/ui — shadcn/ui components (radix-nova style)
utils/ @zntr/utils — cn(), tailwind-merge, clsx
Expand All @@ -16,13 +17,13 @@ packages/

## Essential commands (run from root)

| Command | What it does |
|---|---|
| `pnpm dev` | Start all dev servers |
| `pnpm build` | Full build (i18n generate → mdx → next build) |
| `pnpm lint` | oxlint + eslint via turbo (both run per-package) |
| `pnpm type-check` | `tsc --noEmit` across all packages |
| `pnpm test` | `vitest run` across all packages |
| Command | What it does |
| ----------------- | ------------------------------------------------ |
| `pnpm dev` | Start all dev servers |
| `pnpm build` | Full build (i18n generate → mdx → next build) |
| `pnpm lint` | oxlint + eslint via turbo (both run per-package) |
| `pnpm type-check` | `tsc --noEmit` across all packages |
| `pnpm test` | `vitest run` across all packages |

Single-package: use `pnpm --filter <name> <script>`, e.g. `pnpm --filter one-calendar dev`.

Expand Down Expand Up @@ -77,4 +78,4 @@ Conventional commits enforced by commitlint (pre-commit hook): `feat`, `fix`, `d
- Component UI library (`@zntr/ui`) uses single-file components at `packages/ui/src/*.tsx` with barrel `index.ts`.
- `calednar-client` is a Tauri desktop app; its `build:tauri` script runs `tsc && vite build`.
- knip config in root `package.json` tracks known dead-code exceptions.
- Vercel cron runs daily via `vercel.json`: `GET /api/blob/check` at midnight UTC.
- Vercel cron runs daily via `vercel.json`: `GET /api/blob/check` at midnight UTC.
1 change: 1 addition & 0 deletions apps/calendar/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ BETTER_AUTH_URL=http://localhost:3000

# Optional
POSTGRES_URL=postgres://postgres:postgres@localhost:5432/onecalendar
REDIS_URL=rediss://default:password@your-region.upstash.io:6379
BETTER_AUTH_API_KEY=your-api-key
NEXT_PUBLIC_TURNSTILE_SITE_KEY=site-key
TURNSTILE_SECRET_KEY=secret-key
31 changes: 31 additions & 0 deletions apps/calendar/app/.well-known/oauth-authorization-server/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server'

const MCP_BASE_URL = process.env.BETTER_AUTH_URL || 'http://localhost:3000'

export async function GET() {
return NextResponse.json({
issuer: MCP_BASE_URL,
authorization_endpoint: `${MCP_BASE_URL}/oauth/authorize`,
token_endpoint: `${MCP_BASE_URL}/api/oauth/token`,
registration_endpoint: `${MCP_BASE_URL}/api/oauth/register`,
device_authorization_endpoint: `${MCP_BASE_URL}/api/oauth/device`,
response_types_supported: ['code'],
grant_types_supported: [
'authorization_code',
'urn:ietf:params:oauth:grant-type:device_code',
],
token_endpoint_auth_methods_supported: ['none'],
scopes_supported: [
'events:read',
'events:write',
'categories:read',
'categories:write',
'countdowns:read',
'countdowns:write',
'settings:read',
'settings:write',
'profile:read',
],
code_challenge_methods_supported: ['S256'],
})
}
22 changes: 22 additions & 0 deletions apps/calendar/app/.well-known/oauth-protected-resource/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { NextResponse } from 'next/server'

const MCP_BASE_URL = process.env.BETTER_AUTH_URL || 'http://localhost:3000'

export async function GET() {
return NextResponse.json({
resource: MCP_BASE_URL,
authorization_servers: [MCP_BASE_URL],
scopes_supported: [
'events:read',
'events:write',
'categories:read',
'categories:write',
'countdowns:read',
'countdowns:write',
'settings:read',
'settings:write',
'profile:read',
],
bearer_methods_supported: ['header'],
})
}
78 changes: 75 additions & 3 deletions apps/calendar/app/api/events/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ import { eq, and, gte, lte, inArray } from 'drizzle-orm'
import { encryptField, encryptJsonField } from '@/lib/field-crypto'
import crypto from 'crypto'
import { getAuthedUser, decryptEvent } from '@/lib/api-helpers'
import {
getCachedEvents,
setCachedEvents,
invalidateEventCache,
groupByMonth,
} from '@/lib/cache/events'
import { fullMonthRange } from '@/lib/cache/keys'

export const runtime = 'nodejs'

Expand Down Expand Up @@ -35,8 +42,21 @@ export const GET = async function GET(request: NextRequest) {
const filters = [eq(calendarEvents.userId, user.id)]

if (startDate && endDate) {
filters.push(gte(calendarEvents.startDate, new Date(startDate)))
filters.push(lte(calendarEvents.endDate, new Date(endDate)))
const cached = await getCachedEvents(user.id, startDate, endDate)
if (cached) {
let events = cached.map(decryptEvent)
if (categoryIds) {
const ids = categoryIds.split(',')
events = events.filter(
(e) => e.categoryId && ids.includes(e.categoryId),
)
}
return NextResponse.json({ events })
}

const range = fullMonthRange(startDate, endDate)
filters.push(gte(calendarEvents.startDate, range.start))
filters.push(lte(calendarEvents.endDate, range.end))
}

if (categoryIds) {
Expand All @@ -48,7 +68,22 @@ export const GET = async function GET(request: NextRequest) {
.from(calendarEvents)
.where(and(...filters))

return NextResponse.json({ events: results.map(decryptEvent) })
const grouped = groupByMonth(results)
for (const [ym, monthEvents] of grouped) {
await setCachedEvents(user.id, ym, monthEvents)
}

const decrypted = results.map(decryptEvent)

if (startDate && endDate) {
const start = new Date(startDate)
const end = new Date(endDate)
return NextResponse.json({
events: decrypted.filter((e) => e.startDate >= start && e.endDate <= end),
})
}

return NextResponse.json({ events: decrypted })
}

export const POST = async function POST(request: NextRequest) {
Expand All @@ -59,6 +94,24 @@ export const POST = async function POST(request: NextRequest) {
const body: EventInput = await request.json()
const id = body.id ?? crypto.randomUUID()

const isUpdate = !!body.id
if (isUpdate) {
const [old] = await getDb()
.select({
startDate: calendarEvents.startDate,
endDate: calendarEvents.endDate,
})
.from(calendarEvents)
.where(eq(calendarEvents.id, id))
if (old) {
await invalidateEventCache(
user.id,
old.startDate.toISOString(),
old.endDate.toISOString(),
)
}
}

const [event] = await getDb()
.insert(calendarEvents)
.values({
Expand Down Expand Up @@ -93,6 +146,8 @@ export const POST = async function POST(request: NextRequest) {
})
.returning()

await invalidateEventCache(user.id, body.startDate, body.endDate)

return NextResponse.json({ event: decryptEvent(event) })
}

Expand All @@ -106,9 +161,26 @@ export const DELETE = async function DELETE(request: NextRequest) {
if (!id)
return NextResponse.json({ error: 'Missing event id' }, { status: 400 })

const [old] = await getDb()
.select({
startDate: calendarEvents.startDate,
endDate: calendarEvents.endDate,
})
.from(calendarEvents)
.where(and(eq(calendarEvents.id, id), eq(calendarEvents.userId, user.id)))

if (!old)
return NextResponse.json({ error: 'Event not found' }, { status: 404 })

await getDb()
.delete(calendarEvents)
.where(and(eq(calendarEvents.id, id), eq(calendarEvents.userId, user.id)))

await invalidateEventCache(
user.id,
old.startDate.toISOString(),
old.endDate.toISOString(),
)

return NextResponse.json({ success: true })
}
78 changes: 78 additions & 0 deletions apps/calendar/app/api/mcp/api-keys/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from 'next/server'
import { getAuthedUser } from '@/lib/api-helpers'
import {
generateApiKey,
listApiKeys,
revokeApiKey,
updateApiKeyScopes,
} from '@/lib/mcp/auth'

export const runtime = 'nodejs'

export async function GET() {
const user = await getAuthedUser()
if (!user)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

const keys = await listApiKeys(user.id)
return NextResponse.json({ keys })
}

export async function POST(request: NextRequest) {
const user = await getAuthedUser()
if (!user)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

const body = await request.json().catch(() => ({}))
const { name, scopes } = body as { name?: string; scopes?: string[] }

if (!name || typeof name !== 'string') {
return NextResponse.json({ error: 'Name is required' }, { status: 400 })
}

const key = await generateApiKey(name, user.id, scopes ?? [])

return NextResponse.json({
key,
message: 'Save this key now — it will not be shown again',
})
}

export async function PUT(request: NextRequest) {
const user = await getAuthedUser()
if (!user)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

const body = await request.json().catch(() => ({}))
const { id, scopes } = body as { id?: string; scopes?: string[] }

if (!id) {
return NextResponse.json({ error: 'Key ID is required' }, { status: 400 })
}

if (scopes) {
await updateApiKeyScopes(id, user.id, scopes)
}

return NextResponse.json({ success: true })
}

export async function DELETE(request: NextRequest) {
const user = await getAuthedUser()
if (!user)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

const body = await request.json().catch(() => ({}))
const { id } = body as { id?: string }

if (!id) {
return NextResponse.json({ error: 'Key ID is required' }, { status: 400 })
}

const revoked = await revokeApiKey(id, user.id)
if (!revoked) {
return NextResponse.json({ error: 'Key not found' }, { status: 404 })
}

return NextResponse.json({ success: true })
}
30 changes: 30 additions & 0 deletions apps/calendar/app/api/mcp/audit-logs/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server'
import { getAuthedUser } from '@/lib/api-helpers'
import { getAuditLogs, getAuditLogsCount } from '@/lib/mcp/audit'

export const runtime = 'nodejs'

export async function GET(request: NextRequest) {
const user = await getAuthedUser()
if (!user)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

const { searchParams } = request.nextUrl
const page = Number(searchParams.get('page') ?? 1)
const limit = Math.min(Number(searchParams.get('limit') ?? 50), 100)

const [logs, total] = await Promise.all([
getAuditLogs(user.id, limit, (page - 1) * limit),
getAuditLogsCount(user.id),
])

return NextResponse.json({
logs,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
})
}
53 changes: 53 additions & 0 deletions apps/calendar/app/api/mcp/authorized-apps/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from 'next/server'
import { getAuthedUser } from '@/lib/api-helpers'
import { getDb } from '@/lib/drizzle/client'
import { mcpTokens } from '@/lib/drizzle/schema'
import { eq, and, gte } from 'drizzle-orm'

export const runtime = 'nodejs'

export async function GET() {
const user = await getAuthedUser()
if (!user)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

const db = await getDb()
const apps = await db
.select({
id: mcpTokens.id,
clientId: mcpTokens.clientId,
clientName: mcpTokens.clientName,
scopes: mcpTokens.scopes,
createdAt: mcpTokens.createdAt,
expiresAt: mcpTokens.expiresAt,
isRevoked: mcpTokens.isRevoked,
})
.from(mcpTokens)
.where(
and(eq(mcpTokens.userId, user.id), gte(mcpTokens.expiresAt, new Date())),
)
.orderBy(mcpTokens.createdAt)

return NextResponse.json({ apps })
}

export async function DELETE(request: NextRequest) {
const user = await getAuthedUser()
if (!user)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

const body = await request.json().catch(() => ({}))
const { id } = body as { id?: string }

if (!id) {
return NextResponse.json({ error: 'Token ID is required' }, { status: 400 })
}

const db = await getDb()
await db
.update(mcpTokens)
.set({ isRevoked: true })
.where(and(eq(mcpTokens.id, id), eq(mcpTokens.userId, user.id)))

return NextResponse.json({ success: true })
}
Loading