|
| 1 | +# ПодписачЪ |
| 2 | + |
| 3 | +Self-hosted attribution for Telegram and MAX channel subscriptions. The repo is a pnpm/Turborepo monorepo with a Nuxt admin app, a separate bot worker, and a shared contracts package; both runtimes write to one PostgreSQL database through Prisma ([wiki/overview.md](wiki/overview.md)). |
| 4 | + |
| 5 | +**Stack:** Node 22 · Nuxt 4 SPA + Nitro · grammY (Telegram) + MAX polling · PostgreSQL 16 + Prisma 7 · `@ps/shared` (Zod, AES-256-GCM) · pnpm + Turborepo · Docker Compose |
| 6 | + |
| 7 | +> Auto-generated from `wiki/`. Edit the wiki, not this file. Manual edits are overwritten on next derive. |
| 8 | +
|
| 9 | +## Critical invariants |
| 10 | + |
| 11 | +Violating any of these causes data loss, downtime, or silent corruption. Read before every code change. |
| 12 | + |
| 13 | +1. **Never run `docker compose down -v`** — the `pgdata` named volume is the only durable store for Settings, encrypted tokens, channels, visits, subscribers, reports, and conversions (`docker-compose.yml:50-62`, [wiki/gotchas.md](wiki/gotchas.md#docker-compose-down--v-deletes-the-only-durable-data-volume)). |
| 14 | +2. **`Settings.internalApiSecret` is both auth credential and AES key material.** Rotation must re-encrypt every encrypted bot/integration field in the same migration; otherwise bot startup token decryption breaks (`packages/shared/src/crypto.ts:8-55`, `apps/bot/src/config/index.ts:49-61`, [wiki/gotchas.md](wiki/gotchas.md#rotating-internalapisecret-can-make-encrypted-tokens-unreadable)). |
| 15 | +3. **Public report cookie check is presence-only (P0 bug).** A forged `report-session-<token>=1` cookie unlocks password-protected reports — fix before extending report visibility (`apps/web/server/api/reports/[token].get.ts:20-29`, [wiki/active-tasks.md](wiki/active-tasks.md#harden-public-report-password-sessions)). |
| 16 | +4. **GA `apiSecret` is stored as plaintext JSON in `Integration.config` (P0 bug).** Use `encrypt(value, settings.internalApiSecret)` like Yandex tokens (`apps/web/server/api/integrations/ga/index.post.ts:10-23`, [wiki/gotchas.md](wiki/gotchas.md#google-analytics-api-secrets-are-stored-in-plaintext-json)). |
| 17 | +5. **Schema changes require a checked-in Prisma migration.** `migrate deploy` runs in `apps/web/docker-entrypoint.sh:10-12`; editing only `schema.prisma` will not apply in production ([wiki/gotchas.md](wiki/gotchas.md#migration-sql-is-the-real-deploy-artifact-not-only-schemaprisma)). |
| 18 | +6. **Admin APIs are session-protected by default; `/api/internal/**` requires Bearer `internalApiSecret`.** New routes outside `PUBLIC_PREFIXES` get auth automatically (`apps/web/server/middleware/auth.ts:3-29`, `apps/web/server/middleware/internal.ts:3-16`). |
| 19 | +7. **Telegram = exact attribution by invite-link URL (confidence 1.0). MAX = probabilistic by fingerprint/IP within `Settings.maxCorrelationWindowSec`.** Do not unify these matchers (`apps/bot/src/attribution/correlator.ts:13-37`, [wiki/decisions.md](wiki/decisions.md#adr-007-attribute-subscriptions-by-platform-specific-matching-strategies)). |
| 20 | +8. **Public report visibility filtering happens server-side in `getReportData()`.** Never query report-only fields outside this utility (`apps/web/server/utils/reportData.ts:103-190`). |
| 21 | + |
| 22 | +Full ADRs: [wiki/decisions.md](wiki/decisions.md) (11) |
| 23 | +Full sharp edges: [wiki/gotchas.md](wiki/gotchas.md) (4 critical, 7 high) |
| 24 | + |
| 25 | +## Verify changes |
| 26 | + |
| 27 | +```bash |
| 28 | +pnpm install # workspace install |
| 29 | +pnpm typecheck # turbo: tsc --noEmit across web, bot, shared |
| 30 | +pnpm lint # turbo lint (web lint script is a placeholder — see active-tasks) |
| 31 | +pnpm test # turbo test: vitest in web, bot, packages/shared |
| 32 | +pnpm build # turbo build: writes .output/** for web, dist/** for bot |
| 33 | +pnpm db:generate # regenerate Prisma client after schema.prisma edit |
| 34 | +``` |
| 35 | + |
| 36 | +Tests live in `apps/web/tests/`, `apps/bot/tests/`, `packages/shared/tests/` — utilities, attribution dispatch/matching, shared crypto, shared validation. Route- and job-level integration coverage is a known gap ([wiki/active-tasks.md](wiki/active-tasks.md#add-ci-quality-test-coverage-for-api-and-jobs)). |
| 37 | + |
| 38 | +## Code style |
| 39 | + |
| 40 | +- Validate every external HTTP payload with a Zod schema from `@ps/shared/validation` before any DB write (`packages/shared/src/validation.ts:1-111`). |
| 41 | +- Encrypt secrets with `encrypt(value, settings.internalApiSecret)` from `@ps/shared/crypto`; never store bot/integration secrets in plain JSON. |
| 42 | +- Use the shared Prisma utility (`apps/web/server/utils/prisma.ts`, `apps/bot/src/utils/prisma.ts`) — Prisma 7 needs `PrismaPg` adapter; do not revert to `datasourceUrl`. |
| 43 | +- Keep `Settings` as singleton `id=1`; every lookup uses `where: { id: 1 }`. |
| 44 | +- Use Nuxt auto-import-prefixed component names (`SetupStepPassword`, not `StepPassword`) ([wiki/gotchas.md](wiki/gotchas.md#setup-component-names-must-use-nuxt-auto-import-prefixes)). |
| 45 | +- Add explicit `timeout` / `AbortSignal.timeout()` to outbound `$fetch` calls (Telegram, MAX, Yandex) — defaults are unbounded. |
| 46 | +- `attributionConfidence`: 1.0 Telegram exact, 0.5 Telegram fallback, 0.80 MAX fingerprint, 0.70 MAX IP (`packages/shared/src/constants.ts:34-40`). |
| 47 | + |
| 48 | +## Project map |
| 49 | + |
| 50 | +``` |
| 51 | +apps/ |
| 52 | + web/ # Nuxt 4 SPA + Nitro API, public port 3000 |
| 53 | + nuxt.config.ts # ssr: false; transpile @ps/shared, jsonwebtoken |
| 54 | + pages/, components/ # admin UI, setup wizard, reports |
| 55 | + server/api/ # Nitro routes (track, links, setup, reports, internal) |
| 56 | + server/middleware/ # auth.ts (session), internal.ts (Bearer) |
| 57 | + server/utils/ # prisma, session, reportData, ymClient |
| 58 | + docker-entrypoint.sh # wait pg → migrate deploy → upsert Settings → start |
| 59 | + bot/ # Telegram + MAX worker, internal port 3001 |
| 60 | + src/index.ts # startup: load Settings, polling, jobs |
| 61 | + src/api/internal.ts # Bearer-auth HTTP API (link/create, bot/start) |
| 62 | + src/telegram/ # grammY bot, member updates, linkService |
| 63 | + src/max/ # marker-based long polling |
| 64 | + src/attribution/ # correlator, telegramMatcher, maxMatcher |
| 65 | + src/jobs/ # linkCleanup, statsSync, conversionRetry |
| 66 | +packages/ |
| 67 | + shared/ # @ps/shared: types, constants, validation, crypto |
| 68 | +prisma/ |
| 69 | + schema.prisma # PostgreSQL model; Settings singleton id=1 |
| 70 | + migrations/ # checked-in SQL — the real deploy artifact |
| 71 | +docker-compose.yml # app, bot, postgres + pgdata volume |
| 72 | +``` |
| 73 | + |
| 74 | +## Key symbols |
| 75 | + |
| 76 | +| Symbol | Location | Role | |
| 77 | +|---|---|---| |
| 78 | +| `POST /api/track` | `apps/web/server/api/track/index.post.ts:1-91` | Tracking ingest: validates, hashes IP, creates Visit, requests TG invite link | |
| 79 | +| `POST /api/links` | `apps/web/server/api/links/index.post.ts:1-63` | Manual invite-link creation; rejects non-Telegram channels | |
| 80 | +| `auth` middleware | `apps/web/server/middleware/auth.ts:3-29` | Session-gates `/api/**` except public prefixes | |
| 81 | +| `internal` middleware | `apps/web/server/middleware/internal.ts:3-16` | Bearer `internalApiSecret` for `/api/internal/**` | |
| 82 | +| `getReportData()` | `apps/web/server/utils/reportData.ts:41-191` | Server-side visibility filter for public reports | |
| 83 | +| `ymClient` | `apps/web/server/utils/ymClient.ts:20-131` | Yandex Metrika OAuth refresh + offline conversion upload | |
| 84 | +| Bot internal API | `apps/bot/src/api/internal.ts:12-62` | `/internal/link/create`, `/internal/bot/start`, `/internal/bot/status` | |
| 85 | +| `correlator` | `apps/bot/src/attribution/correlator.ts:6-37` | Routes joins to Telegram/MAX matchers, returns `{visitId, confidence, method}` | |
| 86 | +| `linkService` | `apps/bot/src/telegram/services/linkService.ts:36-113` | Auto links: `member_limit=1` + TTL; manual links: no limits | |
| 87 | +| `startMaxPolling` | `apps/bot/src/max/poller.ts:10-60` | Marker-based MAX polling (marker not yet persisted) | |
| 88 | +| `conversionRetry` job | `apps/bot/src/jobs/conversionRetry.ts:10-158` | Every 10 min, retries pending/failed conversions, max 3 attempts | |
| 89 | +| `encrypt` / `decrypt` | `packages/shared/src/crypto.ts:1-55` | AES-256-GCM, format `salt:iv:tag:ciphertext`, key derived from secret | |
| 90 | +| `trackPayloadSchema` | `packages/shared/src/validation.ts:7-21` | Zod contract for `/api/track` body | |
| 91 | + |
| 92 | +## Tools |
| 93 | + |
| 94 | +No code-intelligence index configured — use Read/Grep/Glob directly, then fall back to wiki pages for architecture context. |
| 95 | + |
| 96 | +## Wiki protocol |
| 97 | + |
| 98 | +**Always check `wiki/` before answering questions about this project's architecture, patterns, or decisions.** |
| 99 | + |
| 100 | +Every architectural choice, every sharp edge, every in-flight task lives in `wiki/`. The wiki is the single source of truth; this file is a pointer. |
| 101 | + |
| 102 | +### Query order |
| 103 | + |
| 104 | +1. **Wiki semantic search (QMD)** — `qmd query "<concept>" -c podpisach --no-rerank --json` returns top-N wiki pages by hybrid lex+vec match. **Default first step** for any question about architecture, decisions, gotchas, or how something works. HTTP for tools without shell: `GET http://127.0.0.1:9092/wiki/search/podpisach?q=...&limit=10&mode=hybrid`. Falls back silently to grep if QMD CLI is missing. |
| 105 | +2. **Direct read** — when QMD points to a page, open it for full context (`wiki/decisions.md`, `wiki/gotchas.md`, etc.). The catalog is `wiki/index.md`. |
| 106 | +3. **Grep fallback** — `rg -n "<term>" wiki/` when QMD unavailable. |
| 107 | +4. **Code last** — only for line-level details the wiki didn't cover. |
| 108 | + |
| 109 | +### Wiki pages |
| 110 | + |
| 111 | +- [overview.md](wiki/overview.md) — project overview, stack, quick start |
| 112 | +- [architecture.md](wiki/architecture.md) — C4 containers, runtime views, invariants |
| 113 | +- [decisions.md](wiki/decisions.md) — 11 ADRs with commit evidence |
| 114 | +- [gotchas.md](wiki/gotchas.md) — sharp edges (Critical / High / Medium / Low) |
| 115 | +- [data-model.md](wiki/data-model.md) — Prisma entities and relationships |
| 116 | +- [deployment.md](wiki/deployment.md) — Docker Compose runbook + troubleshooting |
| 117 | +- [active-areas.md](wiki/active-areas.md) — modules under recent git activity |
| 118 | +- [active-tasks.md](wiki/active-tasks.md) — pipeline tasks snapshot |
| 119 | +- [gaps.md](wiki/gaps.md) — undocumented code, open questions |
| 120 | +- [components/](wiki/components/) — per-area reference (api, attribution, bot, config, integrations, jobs, max, shared, telegram, web) |
| 121 | + |
| 122 | +Every page has YAML frontmatter with `confidence` (high/medium/low) and `sources:` listing `file:line` evidence. Claims without sources are `low` confidence. |
| 123 | + |
| 124 | +### Planning protocol |
| 125 | + |
| 126 | +Before any non-trivial change: |
| 127 | + |
| 128 | +1. Query wiki — start with `wiki/index.md`, then relevant pages. |
| 129 | +2. Synthesize "Past Knowledge": relevant decisions / applicable patterns / known gotchas / reusable components. |
| 130 | +3. Check `/home/ubuntu/wiki-master/` for cross-project patterns. |
| 131 | +4. Only then plan. |
| 132 | + |
| 133 | +Bug fixes too — `gotchas.md` often names the exact bug you're about to re-encounter. |
| 134 | + |
| 135 | +### Save-on-session-end |
| 136 | + |
| 137 | +After non-trivial work (feature, bug fix, refactor): |
| 138 | + |
| 139 | +1. **Update touched wiki pages** — if behavior in `components/*.md` or `decisions.md` changed, update; `sources:` must still resolve to `file:line`. |
| 140 | +2. **Append to `wiki/log.md`** — timestamp, what was done, pages touched, new gaps. |
| 141 | +3. **Log new gaps** to `wiki/gaps.md`. |
| 142 | +4. **Flag cross-project patterns** under `## cross-project candidates` in `gaps.md`. |
| 143 | + |
| 144 | +Trivial sessions (typo, one-liner) skip this. |
| 145 | + |
| 146 | +### Wiki update flow |
| 147 | + |
| 148 | +Wiki re-derives automatically after every successful dev-orchestrator task, on the WikiFreshness scheduler tick (~5 min), and during the nightly LINT pass. |
| 149 | + |
| 150 | +Manual triggers: |
| 151 | +- `POST http://127.0.0.1:9092/wiki/ingest/podpisach?diffMode=worktree` — uncommitted changes |
| 152 | +- `POST http://127.0.0.1:9092/wiki/ingest/podpisach?diffMode=committed` — new commits since last ingest |
| 153 | +- `POST http://127.0.0.1:9092/entity/wiki/podpisach` — full INIT rewrite (~10 min) |
| 154 | + |
| 155 | +Pipeline runs: wiki updater → QMD re-embed → re-derives this CLAUDE.md / PROJECT.md / README.md. |
| 156 | +**Never edit derived files manually** — they get overwritten on next derive. Edit `wiki/` instead. |
| 157 | + |
| 158 | +### Master wiki |
| 159 | + |
| 160 | +`/home/ubuntu/wiki-master/` — cross-project knowledge. Sync runs automatically on every wiki change. |
| 161 | + |
| 162 | +## Behavioral principles |
| 163 | + |
| 164 | +**At the start of any non-trivial task, load `Skill("karpathy-guidelines")`** — mandatory reading. |
| 165 | + |
| 166 | +1. **Think Before Coding** — surface assumptions, ask when unclear. |
| 167 | +2. **Simplicity First** — minimum code for the ask, no speculative abstractions. |
| 168 | +3. **Surgical Changes** — touch only what you must; match existing style. |
| 169 | +4. **Goal-Driven Execution** — verifiable goals, plan multi-step work with checkpoints. |
| 170 | + |
| 171 | +Trivial edits skip the skill load. |
| 172 | + |
| 173 | +## Derive lineage |
| 174 | + |
| 175 | +Built from: `wiki/index.md`, `wiki/overview.md`, `wiki/architecture.md`, `wiki/decisions.md`, `wiki/gotchas.md`, `wiki/active-tasks.md`, `wiki/deployment.md`. |
| 176 | + |
| 177 | +Derive date: `2026-04-29` · snapshot: `9eecf2f5` |
0 commit comments