From b89690a9f2e9aaa61e9f81d33cf030defea7a99a Mon Sep 17 00:00:00 2001 From: ginnoir Date: Wed, 6 May 2026 00:05:50 -0500 Subject: [PATCH] Initial scaffold: tooling, plan, task briefs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pnpm 10 workspace + TypeScript strict + ESLint flat + Prettier - CLAUDE.md as canonical brief - docs/tasks/ — 22 task briefs broken out by phase for sub-sessions - docs/decisions/ — ADR scaffold Implements task 01 (repo-init). --- .editorconfig | 12 + .env.example | 25 + .gitattributes | 9 + .gitignore | 43 ++ .nvmrc | 1 + .prettierignore | 5 + .prettierrc.json | 9 + CLAUDE.md | 165 +++++ README.md | 7 + docs/decisions/README.md | 25 + docs/tasks/01-repo-init.md | 42 ++ docs/tasks/02-nextjs-skeleton.md | 38 ++ docs/tasks/03-drizzle-postgres.md | 38 ++ docs/tasks/04-module-loader.md | 72 +++ docs/tasks/05-compose-caddy.md | 63 ++ docs/tasks/06-authentik-oidc.md | 53 ++ docs/tasks/07-household-seed.md | 29 + docs/tasks/10-calendar-module.md | 57 ++ docs/tasks/11-lists-module.md | 48 ++ docs/tasks/12-notes-module.md | 30 + docs/tasks/20-dashboard.md | 33 + docs/tasks/21-quick-add.md | 26 + docs/tasks/22-activity-log.md | 26 + docs/tasks/30-share-links.md | 32 + docs/tasks/31-share-viewer.md | 27 + docs/tasks/40-web-push.md | 28 + docs/tasks/41-reminders.md | 28 + docs/tasks/42-notification-bus.md | 28 + docs/tasks/50-pwa-shell.md | 28 + docs/tasks/51-offline.md | 28 + docs/tasks/60-backups.md | 27 + docs/tasks/61-rate-limit.md | 24 + docs/tasks/62-logging.md | 27 + docs/tasks/README.md | 68 ++ eslint.config.mjs | 33 + package.json | 26 + pnpm-lock.yaml | 990 ++++++++++++++++++++++++++++++ pnpm-workspace.yaml | 2 + src/placeholder.ts | 1 + tsconfig.json | 29 + 40 files changed, 2282 insertions(+) create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .nvmrc create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 docs/decisions/README.md create mode 100644 docs/tasks/01-repo-init.md create mode 100644 docs/tasks/02-nextjs-skeleton.md create mode 100644 docs/tasks/03-drizzle-postgres.md create mode 100644 docs/tasks/04-module-loader.md create mode 100644 docs/tasks/05-compose-caddy.md create mode 100644 docs/tasks/06-authentik-oidc.md create mode 100644 docs/tasks/07-household-seed.md create mode 100644 docs/tasks/10-calendar-module.md create mode 100644 docs/tasks/11-lists-module.md create mode 100644 docs/tasks/12-notes-module.md create mode 100644 docs/tasks/20-dashboard.md create mode 100644 docs/tasks/21-quick-add.md create mode 100644 docs/tasks/22-activity-log.md create mode 100644 docs/tasks/30-share-links.md create mode 100644 docs/tasks/31-share-viewer.md create mode 100644 docs/tasks/40-web-push.md create mode 100644 docs/tasks/41-reminders.md create mode 100644 docs/tasks/42-notification-bus.md create mode 100644 docs/tasks/50-pwa-shell.md create mode 100644 docs/tasks/51-offline.md create mode 100644 docs/tasks/60-backups.md create mode 100644 docs/tasks/61-rate-limit.md create mode 100644 docs/tasks/62-logging.md create mode 100644 docs/tasks/README.md create mode 100644 eslint.config.mjs create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 src/placeholder.ts create mode 100644 tsconfig.json diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..1014ba7 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1714092 --- /dev/null +++ b/.env.example @@ -0,0 +1,25 @@ +# Public app URL (used for OIDC redirect URIs, share links, etc.) +NEXT_PUBLIC_APP_URL=https://fam.ginnoir.com + +# Postgres +DATABASE_URL=postgres://famapp:famapp@localhost:5432/famapp + +# Auth.js +AUTH_SECRET=replace-with-openssl-rand-base64-32 + +# OIDC (Authentik) +AUTH_OIDC_ISSUER=https://auth.ginnoir.com/application/o/famapp/ +AUTH_OIDC_CLIENT_ID=replace-me +AUTH_OIDC_CLIENT_SECRET=replace-me + +# Web Push (generate with: pnpm vapid:generate) +VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= +VAPID_SUBJECT=mailto:you@example.com + +# ntfy (optional fallback channel; leave blank to disable) +NTFY_URL= +NTFY_TOPIC= + +# Logging +LOG_LEVEL=info diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ef264ac --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +* text=auto eol=lf +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.webp binary +*.woff binary +*.woff2 binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4960f0b --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# Dependencies +node_modules/ +.pnpm-store/ + +# Build output +.next/ +out/ +dist/ +build/ +*.tsbuildinfo + +# Env +.env +.env.local +.env.*.local +.env.production + +# Logs +*.log +npm-debug.log* +pnpm-debug.log* + +# OS +.DS_Store +Thumbs.db +desktop.ini + +# Editors +.vscode/* +!.vscode/extensions.json +!.vscode/settings.json.example +.idea/ + +# Coverage / test artifacts +coverage/ +playwright-report/ +test-results/ + +# Drizzle generated artifacts (migrations themselves are committed) +drizzle/meta/ + +# Backups +deploy/backups/data/ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..5fe82c0 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +node_modules +.next +dist +drizzle +pnpm-lock.yaml diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..672d96f --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,9 @@ +{ + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..fc94068 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,165 @@ +# famapp + +Self-hosted family coordination web app for Matt and his wife. Replaces a commercial family app with a shared calendar, shopping/task lists, and notes/reminders. Designed to be **deliberately extensible** — Matt expects to bolt on niche features over time, so the architecture treats every feature as a module. + +This file is the canonical brief. Read it at the start of every session before making changes. Sub-task briefs in [`docs/tasks/`](docs/tasks/) reference this file; do not duplicate its contents there. + +--- + +## Goals + +- Replace current family app: shared calendar, shopping list, task list, notes/reminders. +- Self-hosted on Matt's home server, accessible from outside the house with auth. +- Wife-friendly: installable PWA, passkey/SSO login, no extra apps to install for notifications. +- "Share anything" — most entities can produce a temporary public link for outsiders. +- Architecture optimized for adding many small features later without core changes. + +--- + +## Stack (decided) + +| Layer | Choice | Notes | +| ------------------ | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Frontend + backend | Next.js 15 App Router + TypeScript | Single Node process, server actions for mutations | +| UI | Tailwind + shadcn/ui | | +| DB | Postgres 16 | Dedicated container per app (Matt's rule) | +| ORM | Drizzle | Lightweight, SQL-friendly, good for evolving schema | +| Auth/SSO | **Authentik** at `auth.ginnoir.com` | Picked over Pocket-ID because it ships forward-auth (Proxy Provider) for the rest of Matt's stack (Sonarr/Radarr/Prowlarr/NZBGet/qBittorrent/Tautulli/Overseerr/FreshRSS/ntfy) which has no native OIDC. famapp itself uses OIDC. | +| Push | Web Push (VAPID) | Works in installed PWAs incl. iOS Safari 16.4+. ntfy stays optional. | +| Realtime | Postgres `LISTEN/NOTIFY` → SSE | Simpler than WebSockets, fine for two users | +| Reverse proxy | Caddy (existing) | `fam.ginnoir.com` → famapp:3000, `auth.ginnoir.com` → authentik | +| Package manager | pnpm | Fast, content-addressed store, strict deps | +| Runtime | Node 22 LTS | In `node:22-alpine` container | +| Host | Ubuntu Server 24.04 x64, Docker + Compose | Existing self-host stack | + +Domain: `fam.ginnoir.com`. SSO: `auth.ginnoir.com`. + +--- + +## Architecture: extensibility-first + +Every feature is a self-contained **module** under `src/modules//`. A module declares its tables, routes, dashboard contributions, quick-add actions, and supported share/reminder/search behaviors via a manifest. Core services (sharing, push, activity log, search, reminders) operate generically against any registered entity — adding a new module should not require touching core code. + +``` +src/ + modules/ + _core/ # auth, household, sharing, push, activity log, reminders + calendar/ + schema.ts # drizzle tables + server/ # server actions + queries + components/ + routes.ts # registers /calendar pages + API + dashboard.tsx # widget contributed to dashboard + manifest.ts # name, icon, nav, entity types, share/reminder handlers + lists/ + notes/ + app/ # Next.js routes — thin, mounts modules + lib/ +``` + +### Core primitives every module gets + +- **Entity registry.** Modules declare entity types; share-link, activity log, search, reminders all work against any registered entity. +- **Dashboard contribution API.** Each module exports a widget + priority. Dashboard composes whatever's installed. +- **Quick-add registry.** Modules register quick actions for the dashboard's `+` menu. +- **Share-link service.** `createShareLink(entityType, entityId, { expiresAt, capabilities })` → `fam.ginnoir.com/s/`. Generic. +- **Notification bus.** `notify(userId, { title, body, url })` fans out to web push + in-app + (optional) ntfy. +- **Permissions.** Household-scoped by default. Share-tokens grant scoped read/write per entity. +- **Feature flags.** Env/db-driven for staging experiments. + +**Rule for contributors (including future Sonnet sessions):** if a feature requires a change to `_core` to support a new entity type, that's a smell — extend the registry instead. + +--- + +## Data model (v1) + +- `users`, `households`, `household_members` +- `calendar_events` — title, start, end, all_day, location, notes, color, owner. `rrule` text column reserved (no recurrence in v1). `external_source`/`external_id` nullable for future Google/Apple sync. +- `lists` (type enum: `shopping` | `task` | future), `list_items` +- `notes` — title, body, pinned, remind_at +- `share_links` — entity_type, entity_id, token, capabilities jsonb, expires_at +- `activity_log` — entity_type, entity_id, actor, action, payload jsonb +- `push_subscriptions` +- `reminders` — entity_type, entity_id, fire_at, channel. Generic; used by notes/events/anything. + +All entity tables include `household_id`. All mutations are scoped to the caller's household. + +--- + +## Pages (v1) + +- `/` Dashboard — today + next 3 days events, top of shopping list, open tasks, pinned notes, recent activity, quick-add `+` +- `/calendar` — month/week/day, drag-create +- `/lists/shopping`, `/lists/tasks` — defaults; create more +- `/notes` +- `/s/` — public share viewer +- `/settings` — household, push toggles, share-link management + +--- + +## Deploy shape + +```yaml +# compose.yaml (sketch — final lives in /deploy) +services: + famapp: # node:22-alpine, Next.js + famapp-db: # postgres:16 + authentik-server: + authentik-worker: + authentik-db: # postgres:16 (separate per Matt's rule) + authentik-redis: +``` + +Caddy: `fam.ginnoir.com` → `famapp:3000`. `auth.ginnoir.com` → `authentik-server:9000`. + +Backups: nightly `pg_dump` to a host volume. Out of scope for phase 1 but reserve the cron slot. + +--- + +## Build phases + +1. **Scaffold** — repo init, Next.js + Drizzle + Tailwind + shadcn, module loader, compose stack, Caddy snippet, Authentik wired up, seeded household with both users. +2. **Core modules** — calendar, lists, notes (CRUD only). +3. **Dashboard + quick-add + activity log.** +4. **Sharing** — share-link service + `/s/` viewer. +5. **Push notifications + reminders.** +6. **PWA polish** — manifest, icons, offline shell, install prompt. +7. **Hardening** — backups (pg_dump cron), rate limiting on share links, logging. + +Tasks for each phase live in [`docs/tasks/`](docs/tasks/). Sub-sessions should pick up a task file, follow its scope, and stop at its acceptance criteria. + +--- + +## Conventions + +- **TypeScript strict.** No `any` without a written reason. +- **Server actions** for mutations; route handlers only for webhooks/SSE/share-link viewer. +- **Drizzle migrations** committed under `drizzle/`. Never edit a shipped migration — add a new one. +- **No comments** unless the _why_ is non-obvious. Names should carry intent. +- **Module isolation.** A module imports from `_core` and `lib/` only — never from a sibling module. +- **One module = one PR/commit boundary** when possible. +- **Tests:** Vitest for units (where it pays off), Playwright for one happy-path E2E per module. Don't write tests for trivial CRUD. +- **Secrets** via `.env` (gitignored) and `.env.example` (committed, no values). + +--- + +## Out of scope for v1 (do not build) + +- ICS export / Google / Apple / Outlook calendar sync (data model leaves room) +- Recurrence editor (column reserved, no UI) +- Native iOS/Android app (PWA only; architecture must not preclude it) +- Migration from any existing app (starting fresh) +- Forward-auth wiring for the rest of Matt's stack (Authentik is installed; wiring the \*arr apps is a separate later task) +- Multi-tenant / multi-household (schema has `household_id` but UI is single-household) + +--- + +## Where things live + +- **This brief:** `CLAUDE.md` +- **Task briefs for sub-sessions:** `docs/tasks/NN-name.md` +- **Architecture decisions worth preserving:** `docs/decisions/NNNN-title.md` (lightweight ADR — only when a non-obvious choice is made) +- **App code:** `src/` +- **Drizzle migrations:** `drizzle/` +- **Deploy:** `deploy/` (compose.yaml, Caddyfile snippet, Authentik bootstrap) +- **Memory (cross-session):** `C:\Users\MattC\.claude\projects\C--Users-MattC-Documents-famapp\memory\` diff --git a/README.md b/README.md new file mode 100644 index 0000000..5d264ec --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# famapp + +Self-hosted family coordination: shared calendar, lists, notes. + +- **Plan & architecture:** [`CLAUDE.md`](CLAUDE.md) +- **Task briefs (for sub-sessions):** [`docs/tasks/`](docs/tasks/) +- **Architecture decisions:** [`docs/decisions/`](docs/decisions/) diff --git a/docs/decisions/README.md b/docs/decisions/README.md new file mode 100644 index 0000000..70c8589 --- /dev/null +++ b/docs/decisions/README.md @@ -0,0 +1,25 @@ +# Architecture decisions + +Lightweight ADRs. Add one only when a non-obvious choice is made and the reasoning would be hard to reconstruct later. Keep each under ~300 words. + +## Format + +``` +# NNNN — Title + +Date: YYYY-MM-DD +Status: accepted | superseded by NNNN + +## Context +What problem we faced. + +## Decision +What we chose. + +## Consequences +What this costs us, what it buys us. +``` + +## Index + +- (none yet — add as decisions are made) diff --git a/docs/tasks/01-repo-init.md b/docs/tasks/01-repo-init.md new file mode 100644 index 0000000..002a45c --- /dev/null +++ b/docs/tasks/01-repo-init.md @@ -0,0 +1,42 @@ +# 01 — Repo init & tooling + +## Goal + +Initialize the famapp repo with pnpm, TypeScript strict, ESLint/Prettier, and a clean baseline commit. + +## Why + +Every later task assumes the repo, lockfile, and lint config exist. + +## Depends on + +None. + +## Scope + +- `git init` in `C:\Users\MattC\Documents\famapp` with `main` as default branch. +- `package.json` (`"packageManager": "pnpm@"`, `"engines": { "node": ">=22" }`). +- `pnpm-workspace.yaml` (single root for now; leave room for workspaces). +- `tsconfig.json` — strict, `moduleResolution: "bundler"`, paths alias `@/* → src/*`. +- ESLint (flat config) + Prettier with sensible defaults; `eslint-config-next` deferred to task 02. +- `.gitignore` (Node, Next.js, env files, OS junk). +- `.env.example` with placeholders for: `DATABASE_URL`, `AUTH_OIDC_ISSUER`, `AUTH_OIDC_CLIENT_ID`, `AUTH_OIDC_CLIENT_SECRET`, `AUTH_SECRET`, `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `NEXT_PUBLIC_APP_URL`. +- `README.md` — short pointer to `CLAUDE.md` and `docs/tasks/`. +- Editor: `.editorconfig`, `.nvmrc` (`22`). + +## Out of scope + +- Installing Next.js, Drizzle, Tailwind (those are in 02 and 03). +- Husky / lint-staged (defer; small two-person project doesn't need it yet). + +## Acceptance criteria + +- [ ] `pnpm install` succeeds on a clean clone. +- [ ] `pnpm lint` and `pnpm format:check` run (even if no files yet) and exit 0. +- [ ] `tsc --noEmit` exits 0. +- [ ] `git log` shows exactly one initial commit. + +## Notes + +- Use the latest pnpm 10.x. +- Flat ESLint config (`eslint.config.mjs`) — don't use the legacy `.eslintrc`. diff --git a/docs/tasks/02-nextjs-skeleton.md b/docs/tasks/02-nextjs-skeleton.md new file mode 100644 index 0000000..ec86441 --- /dev/null +++ b/docs/tasks/02-nextjs-skeleton.md @@ -0,0 +1,38 @@ +# 02 — Next.js app skeleton + +## Goal + +Stand up a minimal Next.js 15 (App Router) app with Tailwind and shadcn/ui, rendering a placeholder dashboard. + +## Why + +Provides the shell the modules will plug into. + +## Depends on + +- 01 (repo init) + +## Scope + +- Install Next.js 15 + React 19 into the existing repo (do **not** use `create-next-app` to overwrite — add deps manually or with `--use-pnpm` into the existing dir). +- App Router under `src/app/` with a root layout and a `/` page that renders "famapp" plus a stub "Dashboard coming soon". +- Tailwind v4 (or latest stable) configured against `src/**`. +- shadcn/ui initialized; install `button`, `card`, `input`, `dialog` to start. +- Path alias `@/*` working at runtime + in the editor. +- `next.config.ts` with `reactStrictMode: true` and `output: "standalone"` (required for the Docker image in task 05). + +## Out of scope + +- Auth, DB, modules. Just the shell. +- Custom theming beyond shadcn defaults. + +## Acceptance criteria + +- [ ] `pnpm dev` serves `http://localhost:3000` showing the placeholder page. +- [ ] `pnpm build` succeeds with `output: "standalone"` producing `.next/standalone/`. +- [ ] `pnpm lint` (now extending `next/core-web-vitals`) passes. +- [ ] No `any` introduced. + +## Notes + +- shadcn CLI writes to `src/components/ui/`. Keep it there; modules will import from it. diff --git a/docs/tasks/03-drizzle-postgres.md b/docs/tasks/03-drizzle-postgres.md new file mode 100644 index 0000000..4fc6716 --- /dev/null +++ b/docs/tasks/03-drizzle-postgres.md @@ -0,0 +1,38 @@ +# 03 — Drizzle + Postgres setup + +## Goal + +Wire Drizzle ORM to a Postgres 16 instance, with migrations and a working `db` client. + +## Depends on + +- 02 (Next.js skeleton) + +## Scope + +- Install `drizzle-orm`, `drizzle-kit`, `postgres` (the `postgres` driver, not `pg`). +- `drizzle.config.ts` pointing at `src/modules/**/schema.ts` and writing to `./drizzle/`. +- `src/lib/db.ts` exporting a singleton `db` client built from `DATABASE_URL`. +- Initial schema in `src/modules/_core/schema.ts`: + - `users` (id uuid pk, email unique, display_name, created_at) + - `households` (id uuid pk, name, created_at) + - `household_members` (household_id, user_id, role enum: `owner` | `member`, pk on both) +- pnpm scripts: `db:generate`, `db:migrate`, `db:studio`. +- `docker-compose.dev.yaml` at repo root with a `famapp-db` service for local dev (Postgres 16, exposed on 5432, named volume). + +## Out of scope + +- Module-specific tables (calendar/lists/notes ship in their own tasks). +- Seed data (in task 07). + +## Acceptance criteria + +- [ ] `docker compose -f docker-compose.dev.yaml up -d` starts Postgres locally. +- [ ] `pnpm db:generate && pnpm db:migrate` produces `drizzle/0000_*.sql` and applies it. +- [ ] A throwaway script can `import { db } from "@/lib/db"` and `select` from `users` (returning empty). +- [ ] `tsc --noEmit` passes. + +## Notes + +- Use the `postgres` driver — works in both Node and edge-light contexts and is what Drizzle docs prefer for serverless-shaped apps. +- Keep the dev compose file separate from the production `deploy/compose.yaml` (task 05). diff --git a/docs/tasks/04-module-loader.md b/docs/tasks/04-module-loader.md new file mode 100644 index 0000000..6d976cc --- /dev/null +++ b/docs/tasks/04-module-loader.md @@ -0,0 +1,72 @@ +# 04 — Module loader & registry + +## Goal + +Implement the module system that everything else hangs off: each module declares a manifest, the loader composes them at startup, and core services iterate the registry instead of hardcoding entity types. + +## Why + +This is the load-bearing extensibility piece. Get this wrong and every later module needs core changes. + +## Depends on + +- 03 (DB) + +## Scope + +### Module manifest type (`src/modules/_core/module.ts`) + +```ts +export type ModuleManifest = { + id: string; // "calendar", "lists", "notes" + name: string; // human-readable + nav?: { href: string; label: string; icon?: string }; + entities: EntityTypeRegistration[]; // see below + dashboardWidgets?: DashboardWidget[]; + quickAdds?: QuickAddAction[]; +}; + +export type EntityTypeRegistration = { + type: string; // "calendar.event", "lists.item", "notes.note" + label: { singular: string; plural: string }; + share?: ShareCapabilities; // null = not shareable + reminder?: ReminderCapabilities; // null = not remindable + search?: SearchAdapter; // null = not searchable + resolveUrl: (id: string) => string; // canonical app URL + loadForShare?: (id: string) => Promise; // payload for /s/ +}; +``` + +### Registry (`src/modules/_core/registry.ts`) + +- `registerModule(manifest)` and `getRegistry()` returning frozen views. +- `getEntityType(type)` lookup. +- Import-time side-effect free: modules are listed and registered explicitly in `src/modules/index.ts`. + +### Loader (`src/modules/index.ts`) + +- Imports each module's manifest and calls `registerModule`. Order is deterministic. +- Stub modules for now: `calendar`, `lists`, `notes` each export an empty-but-valid manifest so the loader has something to register. + +### Wiring + +- Root layout reads the registry to render nav. +- A throwaway `/debug/registry` page (dev-only) dumps the loaded registry as JSON. + +## Out of scope + +- Real implementations of share / reminder / search adapters (later phases). +- Dynamic plugin loading from disk. Modules are statically imported. + +## Acceptance criteria + +- [ ] `src/modules/index.ts` registers three stub modules. +- [ ] Nav renders entries from the registry, not from a hardcoded list. +- [ ] `/debug/registry` shows all three module manifests in dev. +- [ ] Adding a fourth stub module only requires creating its folder + adding one line to `src/modules/index.ts`. +- [ ] All types exported from `_core` so other modules can import without circulars. + +## Notes + +- Frozen objects (`Object.freeze`) on registry exposure are cheap insurance against accidental mutation. +- Resist the urge to make this a fancy DI container. A plain map is enough. diff --git a/docs/tasks/05-compose-caddy.md b/docs/tasks/05-compose-caddy.md new file mode 100644 index 0000000..651c685 --- /dev/null +++ b/docs/tasks/05-compose-caddy.md @@ -0,0 +1,63 @@ +# 05 — Compose stack & Caddy + +## Goal + +Produce the production deploy artifacts: Dockerfile, `deploy/compose.yaml`, Caddy snippet, `.env.production.example`. + +## Depends on + +- 02 (so `output: "standalone"` exists) +- 03 (so DB service is defined) + +## Scope + +### Dockerfile (multi-stage) + +- Stage 1: `node:22-alpine` + pnpm, install deps, build (`pnpm build`). +- Stage 2: `node:22-alpine` runtime, copy `.next/standalone`, `.next/static`, `public/`. Run as non-root. `CMD ["node", "server.js"]`. + +### `deploy/compose.yaml` + +Services: + +- `famapp` (built from Dockerfile) +- `famapp-db` (postgres:16, volume `famapp_db_data`) +- `authentik-server`, `authentik-worker`, `authentik-db` (postgres:16, volume `authentik_db_data`), `authentik-redis` — leave fully configured but task 06 will tune env + +All services on a `famapp_net` network. famapp depends on famapp-db. No host port exposure for the DBs/redis. + +### Caddy snippet (`deploy/Caddyfile.snippet`) + +``` +fam.ginnoir.com { + reverse_proxy famapp:3000 +} + +auth.ginnoir.com { + reverse_proxy authentik-server:9000 +} +``` + +Comment at the top: "Include from main Caddyfile or paste into the existing one." + +### `.env.production.example` + +All vars needed by the compose stack, with comments explaining each. + +## Out of scope + +- Actually running the stack against ginnoir.com (Matt does that). +- Backups, log shipping (later tasks). + +## Acceptance criteria + +- [ ] `docker build -t famapp .` succeeds. +- [ ] `docker compose -f deploy/compose.yaml config` validates without errors. +- [ ] famapp container starts against famapp-db when given a populated `.env`. +- [ ] Image size under ~300 MB. +- [ ] No secrets committed; only `.env.production.example`. + +## Notes + +- Use `pnpm fetch` + `pnpm install --offline` in the build stage for cache locality. +- `output: "standalone"` (set in task 02) means we copy `.next/standalone/server.js`, not run `next start`. diff --git a/docs/tasks/06-authentik-oidc.md b/docs/tasks/06-authentik-oidc.md new file mode 100644 index 0000000..e851062 --- /dev/null +++ b/docs/tasks/06-authentik-oidc.md @@ -0,0 +1,53 @@ +# 06 — Authentik install + OIDC integration + +## Goal + +Bring up Authentik in the compose stack and integrate famapp as an OIDC client. Logging in at `fam.ginnoir.com` redirects to `auth.ginnoir.com`, returns, and creates a session. + +## Depends on + +- 04 (registry, so we have a `users` table to map into) +- 05 (compose stack) + +## Scope + +### Authentik bootstrap + +- Tune env in `deploy/compose.yaml` for authentik-server/worker/db/redis per Authentik's official docs. +- `deploy/authentik/README.md` — manual one-time steps Matt runs after first boot: + 1. Visit `auth.ginnoir.com/if/flow/initial-setup/`, set the akadmin password. + 2. Create an OIDC Provider for famapp (RS256, redirect URI `https://fam.ginnoir.com/api/auth/callback/authentik`). + 3. Create an Application bound to it; copy client ID + secret into famapp's `.env`. + 4. Create the two user accounts (Matt + wife) with passkeys. + +### famapp OIDC client + +- Install `next-auth@beta` (v5) — Auth.js. +- `src/lib/auth.ts` configures Auth.js with a generic OIDC provider pointed at `AUTH_OIDC_ISSUER`. +- On first login, upsert into `users` table by email; create-or-attach to the single seeded household (task 07 owns the seeding; this task assumes it). +- Middleware (`src/middleware.ts`) protects everything except `/s/*` (share viewer), `/api/auth/*`, and static assets. +- A signed-in user is available via a `getCurrentUser()` server helper. + +### Login UX + +- `/login` page with a single "Sign in with SSO" button. +- After login, redirect to `/`. + +## Out of scope + +- Forward-auth / Outpost wiring for the rest of Matt's stack (separate later task). +- Account self-service in famapp (Authentik owns identity). +- Automating Authentik provider creation via Terraform/blueprints — manual is fine for now; document it well. + +## Acceptance criteria + +- [ ] Hitting `/` while signed out redirects to `/login`. +- [ ] Signing in with an Authentik account creates/updates a row in `users` and returns to `/`. +- [ ] `getCurrentUser()` works in server components and server actions. +- [ ] `/s/` is reachable signed-out (placeholder is fine — real viewer is task 31). +- [ ] Auth secret comes from `AUTH_SECRET` env, not committed. + +## Notes + +- Authentik's OIDC issuer URL takes the form `https://auth.ginnoir.com/application/o//`. Document this in the README so Matt copies it correctly. +- Use Auth.js's database session strategy with the Drizzle adapter so the `users` row is the source of truth. diff --git a/docs/tasks/07-household-seed.md b/docs/tasks/07-household-seed.md new file mode 100644 index 0000000..fd06670 --- /dev/null +++ b/docs/tasks/07-household-seed.md @@ -0,0 +1,29 @@ +# 07 — Household seeding & session + +## Goal + +On first boot, create the single household and attach Matt + wife to it. Every authenticated request resolves a `householdId` cheaply. + +## Depends on + +- 06 (auth) + +## Scope + +- Migration / seed script (`pnpm db:seed`) that inserts one household named "Home" if none exists. +- On first OIDC login, attach the user to the seeded household as `owner` (Matt) or `member` (everyone else). Idempotent. +- `getCurrentSession()` server helper returns `{ user, household, role }`. Throws if no household membership. +- All later modules' queries take `householdId` as the scope key. +- `/settings/household` page showing household name, member list, and a "rename household" form (owner only). + +## Out of scope + +- Multi-household UI (schema supports it; UI is single-household only). +- Invite flows (Authentik provisions accounts; we just attach them). + +## Acceptance criteria + +- [ ] `pnpm db:seed` is idempotent — running twice does not create duplicate households. +- [ ] First user to sign in becomes `owner`; subsequent users become `member`. +- [ ] `getCurrentSession()` returns a typed object with `household.id`. +- [ ] `/settings/household` renders for both roles; rename only works for owner. diff --git a/docs/tasks/10-calendar-module.md b/docs/tasks/10-calendar-module.md new file mode 100644 index 0000000..10f33bb --- /dev/null +++ b/docs/tasks/10-calendar-module.md @@ -0,0 +1,57 @@ +# 10 — Calendar module + +## Goal + +Implement the `calendar` module: shared events with month/week/day views and CRUD. + +## Depends on + +- 04 (module loader), 07 (household) + +## Scope + +### Schema (`src/modules/calendar/schema.ts`) + +- `calendar_events`: + - `id` uuid pk + - `household_id` fk + - `title` text + - `start_at` timestamptz, `end_at` timestamptz + - `all_day` boolean + - `location` text nullable + - `notes` text nullable + - `color` text nullable + - `owner_id` fk users + - `rrule` text nullable (reserved; no UI) + - `external_source` text nullable, `external_id` text nullable (reserved) + - `created_at`, `updated_at` + +### Server (`src/modules/calendar/server/`) + +- `listEvents({ from, to })` — household-scoped, range query. +- `createEvent`, `updateEvent`, `deleteEvent` — server actions, validate with Zod. + +### UI (`src/modules/calendar/components/`) + +- `/calendar` page with month / week / day toggle. Recommended lib: **FullCalendar** (`@fullcalendar/react` + day/week/month plugins) — handles the heavy lifting; we own only the data layer. +- Click a day cell → create-event dialog. Click an event → edit dialog. Drag-resize updates `end_at`. + +### Manifest + +- Registers entity type `calendar.event` with `share` enabled (read-only by default), `reminder` enabled, `search` enabled (title + notes + location). +- Registers nav entry `/calendar`. +- Dashboard widget: next 3 days (built in task 20, but expose the data fetcher here). +- Quick-add: "New event" → opens create dialog. + +## Out of scope + +- Recurrence UI (rrule column reserved, ignored on read). +- External calendar sync. +- Free/busy aggregation across users. + +## Acceptance criteria + +- [ ] CRUD works end-to-end with optimistic updates. +- [ ] All queries scoped to `household_id`; cross-household leakage is impossible (verify with a second seeded household in a test). +- [ ] Manifest registers all four capabilities (entity, nav, widget data, quick-add). +- [ ] One Playwright happy-path test: create → see on calendar → edit → delete. diff --git a/docs/tasks/11-lists-module.md b/docs/tasks/11-lists-module.md new file mode 100644 index 0000000..ee933f6 --- /dev/null +++ b/docs/tasks/11-lists-module.md @@ -0,0 +1,48 @@ +# 11 — Lists module (shopping + task) + +## Goal + +Implement the `lists` module supporting two list types out of the box (`shopping`, `task`), with the schema designed so additional types can be added later without migration. + +## Depends on + +- 04, 07 + +## Scope + +### Schema + +- `lists`: `id`, `household_id`, `type` (text — not an enum, to allow extension), `name`, `archived`, `created_at`. +- `list_items`: `id`, `list_id`, `text`, `done` boolean, `qty` text nullable, `notes` text nullable, `due_at` timestamptz nullable, `assignee_id` fk users nullable, `position` int (for ordering), `created_at`, `updated_at`. + +Seed: one default list of each type per household on first access (idempotent). + +### Server + +- `listLists({ type? })`, `getList(id)`, `createList`, `renameList`, `archiveList`. +- `addItem`, `toggleItem`, `updateItem`, `deleteItem`, `reorderItems`. + +### UI + +- `/lists` index showing all lists grouped by type. +- `/lists/[id]` — fast keyboard-driven entry: focus stays in input, Enter adds, checkbox toggles, swipe-left (mobile) deletes. +- Realtime: SSE subscription on the list id; updates from the other user appear without refresh. (Implementation detail: Postgres `LISTEN/NOTIFY` channel `list:`; thin SSE route handler bridges it. Document the pattern in `docs/decisions/`.) + +### Manifest + +- Entity type `lists.list` (shareable read+write via share token), `lists.item` (not directly shareable; inherits via list). +- Quick-adds: "Add to shopping", "Add to tasks" (each adds a single item to the default list of that type). +- Dashboard widget: top 5 unchecked shopping items + open tasks assigned to me. + +## Out of scope + +- Reordering across lists. +- Per-item images. +- Recurring tasks (use reminders module later if needed). + +## Acceptance criteria + +- [ ] Both default lists are auto-seeded. +- [ ] Adding an item on one device appears on the other within ~1s via SSE. +- [ ] `type` is `text` not `enum` — adding a third type works without a migration. +- [ ] Playwright: add item → check it off → archive list. diff --git a/docs/tasks/12-notes-module.md b/docs/tasks/12-notes-module.md new file mode 100644 index 0000000..29daa25 --- /dev/null +++ b/docs/tasks/12-notes-module.md @@ -0,0 +1,30 @@ +# 12 — Notes module + +## Goal + +Lightweight shared notes with optional reminders. + +## Depends on + +- 04, 07 + +## Scope + +- Schema: `notes` (`id`, `household_id`, `author_id`, `title`, `body` markdown, `pinned` bool, `remind_at` timestamptz nullable, `created_at`, `updated_at`). +- `/notes` index: pinned first, then by updated_at desc. +- `/notes/[id]` editor — minimal markdown (use `@uiw/react-md-editor` or similar; live preview optional). +- `remind_at` writes a row to `reminders` (table comes from `_core`; reminder firing comes from task 41). +- Manifest: shareable, remindable, searchable (title + body). +- Dashboard widget: pinned notes. + +## Out of scope + +- Rich text / WYSIWYG. +- Attachments (separate later module). +- Collaborative editing. + +## Acceptance criteria + +- [ ] Create / edit / pin / delete works. +- [ ] Setting `remind_at` creates a `reminders` row scoped to entity `notes.note`. +- [ ] Markdown renders safely (no raw HTML injection). diff --git a/docs/tasks/20-dashboard.md b/docs/tasks/20-dashboard.md new file mode 100644 index 0000000..7186c00 --- /dev/null +++ b/docs/tasks/20-dashboard.md @@ -0,0 +1,33 @@ +# 20 — Dashboard composition + +## Goal + +The `/` landing page composes widgets contributed by installed modules. No hardcoded widget list. + +## Depends on + +- 10, 11, 12 + +## Scope + +- `DashboardWidget` type already in `_core` (from task 04). Each widget exports `{ id, priority, render }`. +- `/` reads `getRegistry().dashboardWidgets`, sorts by priority desc, renders them in a responsive grid (1 col mobile, 2-3 cols desktop). +- Widgets implemented: + - **calendar.upcoming** — next 3 days of events + - **lists.shopping** — top 5 unchecked items in default shopping list + - **lists.tasks** — my open tasks (assigned to me, due soon first) + - **notes.pinned** — pinned notes + - **core.activity** — recent activity feed (depends on task 22; until then render a stub) +- Skeleton loaders while widgets load (each widget is a server component with `loading.tsx`-equivalent Suspense boundary). +- "+" floating action button opens the quick-add menu (task 21). + +## Out of scope + +- User-configurable widget order or hide/show (defer; pin a sane default order). +- Drag-rearrange. + +## Acceptance criteria + +- [ ] Removing a module from `src/modules/index.ts` cleanly removes its widgets — no errors, no blank slot. +- [ ] Widgets load in parallel (verify in network tab — no waterfall). +- [ ] Mobile layout (375px) is usable without horizontal scroll. diff --git a/docs/tasks/21-quick-add.md b/docs/tasks/21-quick-add.md new file mode 100644 index 0000000..701cd3d --- /dev/null +++ b/docs/tasks/21-quick-add.md @@ -0,0 +1,26 @@ +# 21 — Quick-add registry + +## Goal + +A single global `+` button on the dashboard (and a `cmd+k` palette anywhere) that lists actions contributed by modules. + +## Depends on + +- 20 + +## Scope + +- `QuickAddAction` type: `{ id, label, icon?, shortcut?, run: () => void | Promise }`. +- Modules register via manifest. Registry exposes a sorted list. +- UI: floating `+` on dashboard opens a sheet of actions; `cmd/ctrl+k` opens a command-palette-style modal (use `cmdk` package). +- Default actions: New event, Add to shopping, Add to tasks, New note. + +## Out of scope + +- Fuzzy search across data (search adapter is a later concern). +- Action history / recents. + +## Acceptance criteria + +- [ ] Adding a new module that registers a quick-add appears in both the FAB sheet and the cmd-k palette without touching core. +- [ ] Keyboard navigation works in the palette (arrows + enter + escape). diff --git a/docs/tasks/22-activity-log.md b/docs/tasks/22-activity-log.md new file mode 100644 index 0000000..d6b60f7 --- /dev/null +++ b/docs/tasks/22-activity-log.md @@ -0,0 +1,26 @@ +# 22 — Activity log + +## Goal + +Generic activity feed every module writes to and the dashboard reads from. + +## Depends on + +- 10, 11, 12 + +## Scope + +- Schema: `activity_log` (`id`, `household_id`, `entity_type`, `entity_id`, `actor_id`, `action`, `payload` jsonb, `created_at`). Index on `(household_id, created_at desc)`. +- `_core` exports `logActivity({ entityType, entityId, action, payload })`. Reads `householdId` and `actorId` from current session. +- Calendar/lists/notes modules call it on create/update/delete. +- Dashboard widget `core.activity` shows last 20 entries with human-readable rendering. Each entity type's manifest provides a `renderActivity(entry)` function. + +## Out of scope + +- Per-user activity filters. +- Retention / pruning (no growth concerns at two-user scale). + +## Acceptance criteria + +- [ ] Every CRUD across the three core modules writes a row. +- [ ] Widget renders entries via the registry — no `if (entityType === ...)` branches in the widget itself. diff --git a/docs/tasks/30-share-links.md b/docs/tasks/30-share-links.md new file mode 100644 index 0000000..7cec60e --- /dev/null +++ b/docs/tasks/30-share-links.md @@ -0,0 +1,32 @@ +# 30 — Share-link service + +## Goal + +A generic service that issues temporary, scoped, revocable share tokens for any registered entity. + +## Depends on + +- 04 (registry), 07 (household) + +## Scope + +- Schema: `share_links` (`id`, `household_id`, `entity_type`, `entity_id`, `token` unique, `capabilities` jsonb (e.g. `{ read: true, write: false }`), `created_by`, `expires_at` nullable, `revoked_at` nullable, `created_at`). +- `_core/share.ts`: + - `createShareLink(entityType, entityId, opts)` → `{ url, token, expiresAt }`. + - `resolveShareToken(token)` → `{ entityType, entityId, capabilities } | null` (rejects expired/revoked). + - `revokeShareLink(id)`. +- Verifies the entity type is registered and shareable per its manifest's `share` capability declaration. +- Token format: 32-byte URL-safe base64. Stored hashed (sha-256) — only the URL contains the raw token. + +## Out of scope + +- The viewer page (task 31). +- Rate limiting (task 61). +- Per-recipient access logs. + +## Acceptance criteria + +- [ ] Creating a link for an entity type with `share: undefined` throws. +- [ ] Tokens are stored hashed; raw token only returned at creation. +- [ ] Expired/revoked tokens return null. +- [ ] `/settings` shows active share links per entity with revoke buttons. diff --git a/docs/tasks/31-share-viewer.md b/docs/tasks/31-share-viewer.md new file mode 100644 index 0000000..56b147f --- /dev/null +++ b/docs/tasks/31-share-viewer.md @@ -0,0 +1,27 @@ +# 31 — Public share viewer (`/s/`) + +## Goal + +Unauthenticated route that resolves a share token and renders the entity using its manifest's `loadForShare` adapter. + +## Depends on + +- 30 + +## Scope + +- `/s/[token]/page.tsx` — server component. Calls `resolveShareToken`. If valid, calls the entity's `loadForShare(id)` and renders a per-type viewer component (each module exports a ``). +- If the token grants write capability, render an editable view (initially only relevant for lists — recipient can check items off). +- Friendly error page for invalid/expired/revoked tokens. +- Add `noindex` headers on the route. + +## Out of scope + +- Auth on the share page (deliberately public; security is the unguessable token). +- Comments / reactions from anonymous viewers. + +## Acceptance criteria + +- [ ] Calendar event share renders title/time/location/notes — read-only. +- [ ] Shopping list share with `write: true` lets a recipient toggle items; toggles round-trip back into the household (with `actor_id = null` in activity log, action `share.toggle`). +- [ ] Middleware does not gate `/s/*`. diff --git a/docs/tasks/40-web-push.md b/docs/tasks/40-web-push.md new file mode 100644 index 0000000..9d6786d --- /dev/null +++ b/docs/tasks/40-web-push.md @@ -0,0 +1,28 @@ +# 40 — Web Push (VAPID) + +## Goal + +Subscribe browsers to web push and deliver notifications to them. + +## Depends on + +- 06 (auth), 50 (PWA shell — needed for iOS to allow push). If 50 isn't done, this task can land but iOS won't fire until then. + +## Scope + +- `pnpm add web-push` for server, use built-in `PushManager` on client. +- VAPID keys generated once (script: `pnpm vapid:generate`), stored in `.env` as `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY`. Document in README. +- Schema: `push_subscriptions` (`id`, `user_id`, `endpoint` unique, `p256dh`, `auth`, `user_agent`, `created_at`). +- Service worker registers on app load; on user opt-in (button in `/settings`), prompts permission and stores subscription via server action. +- Server helper `sendPush(userId, { title, body, url })` iterates subscriptions, removes any returning 404/410. + +## Out of scope + +- Notification preferences per category (defer). +- ntfy fallback (task 42). + +## Acceptance criteria + +- [ ] Opt-in flow works in Chrome desktop and mobile, plus iOS Safari (when PWA installed). +- [ ] Stale subscriptions are pruned on send failure. +- [ ] Sending a test notification from `/settings` reaches all of the current user's subscribed devices. diff --git a/docs/tasks/41-reminders.md b/docs/tasks/41-reminders.md new file mode 100644 index 0000000..b323c52 --- /dev/null +++ b/docs/tasks/41-reminders.md @@ -0,0 +1,28 @@ +# 41 — Reminders engine + +## Goal + +A generic, entity-agnostic reminder system that fires at `fire_at` and routes through the notification bus. + +## Depends on + +- 40 (push), 42 (bus) + +## Scope + +- Schema: `reminders` (`id`, `household_id`, `entity_type`, `entity_id`, `fire_at` timestamptz, `channel` text default `'auto'`, `fired_at` timestamptz nullable, `created_by`). +- Worker: a long-running tick (every 30s) that selects due un-fired reminders, calls `notify(...)`, marks `fired_at`. Runs in the same Node process via `setInterval` guarded by a Postgres advisory lock to allow future horizontal scaling. +- `_core/reminders.ts` exposes `scheduleReminder(...)`, `cancelReminder(id)`, `listReminders(entityType, entityId)`. +- Notes module's `remind_at` field calls `scheduleReminder` on save and `cancelReminder` when cleared. +- Calendar module: optional "remind me" on event create (default 30 min before). + +## Out of scope + +- Recurring reminders (use rrule later). +- Snooze. + +## Acceptance criteria + +- [ ] A note with `remind_at = now + 1min` fires within 90s and triggers a push notification. +- [ ] Editing the note's `remind_at` updates the existing reminder, not creates duplicates. +- [ ] Worker survives DB blips (caught + logged, not crashed). diff --git a/docs/tasks/42-notification-bus.md b/docs/tasks/42-notification-bus.md new file mode 100644 index 0000000..92b448c --- /dev/null +++ b/docs/tasks/42-notification-bus.md @@ -0,0 +1,28 @@ +# 42 — Notification bus + ntfy adapter + +## Goal + +Single `notify()` entrypoint that fans out to web push, in-app inbox, and (optionally) ntfy. + +## Depends on + +- 40 + +## Scope + +- `_core/notify.ts` — `notify(userId, { title, body, url, channels? })`. +- Channels: `push`, `inapp`, `ntfy`. Default: `['push', 'inapp']`. +- In-app inbox: `notifications` table (`id`, `user_id`, `title`, `body`, `url`, `read_at`, `created_at`); bell icon in header with unread count and dropdown. +- ntfy adapter: if `NTFY_URL` and `NTFY_TOPIC` env are set, also POSTs there. Off by default; Matt opts in. +- Per-user toggles in `/settings` for each channel. + +## Out of scope + +- Email notifications. +- Per-event-type routing rules (Matt can add later as a module). + +## Acceptance criteria + +- [ ] `notify()` with default channels delivers push + in-app and ignores ntfy. +- [ ] Setting `NTFY_URL`+`NTFY_TOPIC` and toggling on in settings adds ntfy delivery. +- [ ] Unread count is accurate and clears on read. diff --git a/docs/tasks/50-pwa-shell.md b/docs/tasks/50-pwa-shell.md new file mode 100644 index 0000000..8596c38 --- /dev/null +++ b/docs/tasks/50-pwa-shell.md @@ -0,0 +1,28 @@ +# 50 — Manifest, icons, install prompt + +## Goal + +Make famapp installable as a PWA on iOS and Android. + +## Depends on + +- 02 + +## Scope + +- `public/manifest.webmanifest` with name, short_name, theme_color, background_color, display: `standalone`, start_url `/`. +- App icons (192, 384, 512, maskable). Source SVG committed; export script in `scripts/`. +- `apple-touch-icon` link in root layout. +- Service worker registration (next-pwa or hand-rolled — prefer hand-rolled to avoid coupling to a library that lags Next.js releases). +- Install prompt: a small banner on first visit on supported browsers, dismissible. + +## Out of scope + +- Offline caching (task 51). +- Background sync. + +## Acceptance criteria + +- [ ] Lighthouse "Installable" check passes. +- [ ] Installing on iOS 16.4+ home screen produces a standalone window with no Safari chrome. +- [ ] App icon shows correctly on Android home screen with maskable variant. diff --git a/docs/tasks/51-offline.md b/docs/tasks/51-offline.md new file mode 100644 index 0000000..b02ad17 --- /dev/null +++ b/docs/tasks/51-offline.md @@ -0,0 +1,28 @@ +# 51 — Offline shell + service worker caching + +## Goal + +The app shell loads without network; cached data is shown with a "stale" indicator while fresh data loads. + +## Depends on + +- 50 + +## Scope + +- Service worker uses Workbox-style strategies (hand-rolled, see task 50 rationale): + - **App shell** (HTML, JS, CSS): stale-while-revalidate. + - **API GETs**: network-first with 2s timeout, fall back to cache. + - **Mutations**: never cache; if offline, show a clear "you're offline" toast. +- Offline page for navigations that have no cache. +- Version SW on every build; show a "new version available, refresh" toast when a new SW takes control. + +## Out of scope + +- Background sync of queued mutations (defer; usually more trouble than worth at this scale). + +## Acceptance criteria + +- [ ] In Chrome DevTools "Offline", reloading the app shows the cached dashboard with a stale-indicator. +- [ ] Attempting to mutate while offline shows a clear error, doesn't silently drop. +- [ ] Deploying a new build prompts active sessions to refresh. diff --git a/docs/tasks/60-backups.md b/docs/tasks/60-backups.md new file mode 100644 index 0000000..5d3dc41 --- /dev/null +++ b/docs/tasks/60-backups.md @@ -0,0 +1,27 @@ +# 60 — Postgres backups (pg_dump cron) + +## Goal + +Nightly compressed pg_dump of `famapp-db` (and `authentik-db`) to a host volume, with a retention policy. + +## Depends on + +- 05 + +## Scope + +- `deploy/backups/` directory committed. +- Add a `famapp-backup` service to compose: small Alpine container with `postgresql-client` + `cron` + a script that runs `pg_dump -Fc` against both DBs into `/backups//.dump`. +- Retention: keep last 14 daily, last 8 weekly, last 6 monthly. Pure shell, no fancy tooling. +- Document restore procedure in `deploy/backups/README.md`. + +## Out of scope + +- Off-site replication (Matt's call; he can rsync `/backups` elsewhere). +- Encryption at rest (handled at the disk layer). + +## Acceptance criteria + +- [ ] After a day, dump files exist for both DBs. +- [ ] `restore.sh ` script exists and works against a fresh DB. +- [ ] Retention script keeps the right counts. diff --git a/docs/tasks/61-rate-limit.md b/docs/tasks/61-rate-limit.md new file mode 100644 index 0000000..f7908e5 --- /dev/null +++ b/docs/tasks/61-rate-limit.md @@ -0,0 +1,24 @@ +# 61 — Rate limiting on share links + +## Goal + +Prevent token brute-forcing on `/s/`. + +## Depends on + +- 31 + +## Scope + +- IP + token-prefix bucket. Reject after N failed lookups per minute per IP. In-memory LRU is fine for one Node process; document switch to Redis if multi-process arrives. +- Failed `resolveShareToken` calls increment the bucket; successful resolves do not. +- Generic `lib/rate-limit.ts` so other endpoints can use the same primitive. + +## Out of scope + +- Captcha. + +## Acceptance criteria + +- [ ] 50 bad tokens from one IP in a minute returns 429 thereafter. +- [ ] Legitimate access from another IP unaffected. diff --git a/docs/tasks/62-logging.md b/docs/tasks/62-logging.md new file mode 100644 index 0000000..29790ca --- /dev/null +++ b/docs/tasks/62-logging.md @@ -0,0 +1,27 @@ +# 62 — Structured logging + +## Goal + +JSON logs for the app, easy to grep and ship later. + +## Depends on + +- 02 + +## Scope + +- `pino` for app logs; pretty-print only in dev. +- Log levels via `LOG_LEVEL` env (default `info`). +- Request log middleware (method, path, status, duration, userId if present). +- Errors: capture stack + cause, never log secrets. + +## Out of scope + +- Log shipping (Loki/etc). +- Sentry/error tracking. + +## Acceptance criteria + +- [ ] Production logs are single-line JSON. +- [ ] No secret values appear anywhere in the log stream during a typical session. +- [ ] Request logs include duration and userId when authenticated. diff --git a/docs/tasks/README.md b/docs/tasks/README.md new file mode 100644 index 0000000..8b7d4a8 --- /dev/null +++ b/docs/tasks/README.md @@ -0,0 +1,68 @@ +# Task briefs + +Each file here is a self-contained brief for a sub-session (typically Sonnet) to execute one chunk of work. Briefs assume the executor has read [`/CLAUDE.md`](../../CLAUDE.md) — do not restate the stack or architecture there. + +## How to use a brief + +1. Read `/CLAUDE.md` first. +2. Read the task file end-to-end. +3. Stop when **all** acceptance criteria pass. Do not bolt on extra scope. +4. If a brief turns out to be wrong, update the brief in the same commit as the code. + +## Brief format + +Every task file has these sections: + +- **Goal** — one sentence. +- **Why** — what this unlocks; how it fits the larger plan. +- **Depends on** — task numbers that must be done first. +- **Scope** — bullet list of what to build. +- **Out of scope** — explicit non-goals to prevent drift. +- **Acceptance criteria** — checkable list. Done = all checked. +- **Notes** — gotchas, recommended libs, sketches. + +## Phase index + +### Phase 1 — Scaffold + +- [01 — Repo init & tooling](01-repo-init.md) +- [02 — Next.js app skeleton](02-nextjs-skeleton.md) +- [03 — Drizzle + Postgres setup](03-drizzle-postgres.md) +- [04 — Module loader & registry](04-module-loader.md) +- [05 — Compose stack & Caddy](05-compose-caddy.md) +- [06 — Authentik install + OIDC integration](06-authentik-oidc.md) +- [07 — Household seeding & session](07-household-seed.md) + +### Phase 2 — Core modules + +- [10 — Calendar module](10-calendar-module.md) +- [11 — Lists module (shopping + task)](11-lists-module.md) +- [12 — Notes module](12-notes-module.md) + +### Phase 3 — Dashboard & UX + +- [20 — Dashboard composition](20-dashboard.md) +- [21 — Quick-add registry](21-quick-add.md) +- [22 — Activity log](22-activity-log.md) + +### Phase 4 — Sharing + +- [30 — Share-link service](30-share-links.md) +- [31 — Public share viewer (`/s/`)](31-share-viewer.md) + +### Phase 5 — Notifications & reminders + +- [40 — Web Push (VAPID)](40-web-push.md) +- [41 — Reminders engine](41-reminders.md) +- [42 — Notification bus + ntfy adapter](42-notification-bus.md) + +### Phase 6 — PWA polish + +- [50 — Manifest, icons, install prompt](50-pwa-shell.md) +- [51 — Offline shell + service worker caching](51-offline.md) + +### Phase 7 — Hardening + +- [60 — Postgres backups (pg_dump cron)](60-backups.md) +- [61 — Rate limiting on share links](61-rate-limit.md) +- [62 — Structured logging](62-logging.md) diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..6c6b2c2 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,33 @@ +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; +import globals from "globals"; + +export default tseslint.config( + { + ignores: [ + "node_modules/**", + ".next/**", + "dist/**", + "drizzle/**", + "*.config.js", + "*.config.mjs", + "*.config.cjs", + ], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + languageOptions: { + globals: { + ...globals.node, + ...globals.browser, + }, + }, + rules: { + "@typescript-eslint/no-unused-vars": [ + "error", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, + ], + }, + }, +); diff --git a/package.json b/package.json new file mode 100644 index 0000000..2d03717 --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "famapp", + "version": "0.0.0", + "private": true, + "type": "module", + "packageManager": "pnpm@10.33.3", + "engines": { + "node": ">=22" + }, + "scripts": { + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "format": "prettier --write .", + "format:check": "prettier --check .", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^22.9.0", + "eslint": "^9.15.0", + "globals": "^15.12.0", + "prettier": "^3.3.3", + "typescript": "^5.6.3", + "typescript-eslint": "^8.15.0" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..5cfe107 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,990 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@9.39.4) + '@types/node': + specifier: ^22.9.0 + version: 22.19.17 + eslint: + specifier: ^9.15.0 + version: 9.39.4 + globals: + specifier: ^15.12.0 + version: 15.15.0 + prettier: + specifier: ^3.3.3 + version: 3.8.3 + typescript: + specifier: ^5.6.3 + version: 5.9.3 + typescript-eslint: + specifier: ^8.15.0 + version: 8.59.2(eslint@9.39.4)(typescript@5.9.3) + +packages: + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@22.19.17': + resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} + + '@typescript-eslint/eslint-plugin@8.59.2': + resolution: {integrity: sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.59.2 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.59.2': + resolution: {integrity: sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.59.2': + resolution: {integrity: sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.59.2': + resolution: {integrity: sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.59.2': + resolution: {integrity: sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.59.2': + resolution: {integrity: sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.59.2': + resolution: {integrity: sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.59.2': + resolution: {integrity: sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.59.2': + resolution: {integrity: sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.59.2': + resolution: {integrity: sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + brace-expansion@1.1.14: + resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} + + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@15.15.0: + resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} + engines: {node: '>=18'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.8.3: + resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} + engines: {node: '>=14'} + hasBin: true + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.59.2: + resolution: {integrity: sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +snapshots: + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': + dependencies: + eslint: 9.39.4 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@10.0.1(eslint@9.39.4)': + optionalDependencies: + eslint: 9.39.4 + + '@eslint/js@9.39.4': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@types/estree@1.0.8': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@22.19.17': + dependencies: + undici-types: 6.21.0 + + '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.59.2 + '@typescript-eslint/type-utils': 8.59.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.2 + eslint: 9.39.4 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.59.2 + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.2 + debug: 4.4.3 + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.59.2(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@5.9.3) + '@typescript-eslint/types': 8.59.2 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.59.2': + dependencies: + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/visitor-keys': 8.59.2 + + '@typescript-eslint/tsconfig-utils@8.59.2(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.2(eslint@9.39.4)(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.4 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.59.2': {} + + '@typescript-eslint/typescript-estree@8.59.2(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.59.2(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@5.9.3) + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/visitor-keys': 8.59.2 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.7.4 + tinyglobby: 0.2.16 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@typescript-eslint/scope-manager': 8.59.2 + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.59.2': + dependencies: + '@typescript-eslint/types': 8.59.2 + eslint-visitor-keys: 5.0.1 + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + brace-expansion@1.1.14: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + + callsites@3.1.0: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + concat-map@0.0.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + escape-string-regexp@4.0.0: {} + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.4: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globals@15.15.0: {} + + has-flag@4.0.0: {} + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + isexe@2.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.5 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.14 + + ms@2.1.3: {} + + natural-compare@1.4.0: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + picomatch@4.0.4: {} + + prelude-ls@1.2.1: {} + + prettier@3.8.3: {} + + punycode@2.3.1: {} + + resolve-from@4.0.0: {} + + semver@7.7.4: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + strip-json-comments@3.1.1: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.59.2(eslint@9.39.4)(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.2(eslint@9.39.4)(typescript@5.9.3) + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + yocto-queue@0.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..ccbac80 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - "." diff --git a/src/placeholder.ts b/src/placeholder.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/src/placeholder.ts @@ -0,0 +1 @@ +export {}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..301a281 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "allowJs": false, + "noEmit": true, + "jsx": "preserve", + "incremental": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*", "*.ts", "*.mts", "*.cts"], + "exclude": ["node_modules", ".next", "dist", "drizzle"] +}