This file provides guidance to coding agents when working with code in this repository.
!! Never run build on frontend locally for testing purposes !!
Use the 1Password Environment loyal-frontend-devnet for local frontend devnet secrets. The environment is mounted at .env.1password, which is ignored by Git through .env*.
Do not write plaintext secrets to .env files, source files, command arguments, logs, or chat. When a command needs environment variables, use the 1Password CLI with the mounted env file:
op run --env-file=.env.1password -- sh -c '<command>'Keep shell variable expansion inside the sh -c subprocess so op run injects values before the command reads them.
Solana Telegram Transactions enables users to deposit SOL for any Telegram username, which can later be claimed by the verified account owner. It integrates Solana smart contracts with a Telegram mini-app interface.
bun dev # Run mini-app dev server (turbopack)
bun run build # Production build (Next.js)
bun lint # ESLint
bun db:generate # Generate Drizzle migrations from schema
bun db:migrate # Apply migrations
bun db:studio # Open Drizzle Studio GUIbun dev # Run Loyal web dev server (turbopack)
bun run build # Production build (Next.js)
bun run lint # Next.js lint
bun run ultracite # Biome/Ultracite checksnpx expo start --clear # Start Expo dev server (requires dev client)
npx expo lint # ESLint
npm test # Jest tests
npx eas build --profile development-simulator --platform ios # Build dev client (iOS sim)
npx eas build --profile development --platform ios # Build dev client (device)
npx eas build --profile preview --platform android # Preview APK
npx eas build --profile production --platform all # Production buildbun dev # Run admin dev server (turbopack)
bun run build # Production build (Next.js)
bun lint # Next.js lintanchor build # Compile programs
anchor deploy --provider.cluster devnet # Deploy to devnet
anchor deploy --provider.cluster localnet # Deploy to localnetRequires 3 terminals running simultaneously:
# Terminal 1: Start validator
mb-test-validator --reset
# Terminal 2: Start ephemeral validator
RUST_LOG=info ephemeral-validator \
--accounts-lifecycle ephemeral \
--remote-cluster development \
--remote-url http://127.0.0.1:8899 \
--remote-ws-url ws://127.0.0.1:8900 \
--rpc-port 7799
# Terminal 3: Run tests
EPHEMERAL_PROVIDER_ENDPOINT="http://localhost:7799" \
EPHEMERAL_WS_ENDPOINT="ws://localhost:7800" \
anchor test --provider.cluster localnet --skip-local-validator --skip-build --skip-deployDefault to no new TypeScript unit tests. Prefer typecheck, lint, focused
verifier scripts, manual smoke checks, or live read-only probes over bun:test,
Jest, Vitest, *.test.ts(x), *.spec.ts(x), or __tests__/ coverage.
Keep or add a TypeScript test only when it protects an external contract or invariant that would still compile while broken: money movement, auth/session boundaries, confirmed-chain writes, generated SDK or wire-format parity, public API discriminants, webhook retry behavior, DB ownership/idempotency/conflict behavior, signer/secret/storage boundaries, config parsing, or dangerous pure calculations.
Implement the core logic requested first. Only add or update tests after the behavior exists, and only when the test still passes this rubric:
A test earns +1 when it protects an external contract or invariant static
checks can miss, +1 when it asserts an observable side effect or invariant,
and +1 when TypeScript, lint, or a manual smoke check would not catch the
failure. Observable checks include write/no-write behavior, ordering, rejection
before mutation, retry/backoff, persisted state transitions, balance or amount
calculation, and instruction/account ordering.
Subtract 1 when the test mostly mirrors fields, defaults, copy, route
strings, source strings, Object.keys(...), typeof ..., or third-party
library behavior. Subtract 1 when it mainly asserts mocked JSON, Drizzle call
order, every .values() field, a full response body, or that a mocked
dependency received the same mock data.
Keep only tests scoring at least 2. For mixed files, delete or compress the
low-scoring blocks and keep only the contract/invariant checks.
Delete or compress tests that restate implementation shape. Do not keep tests
whose main value is asserting every mocked JSON field, every Drizzle .values()
field, object defaults, route-string builders, source substrings, copy that is
not a product or ops contract, typeof ... === "function", Object.keys(...)
mirrors, or third-party library behavior.
Route tests should assert status codes, branch discriminants, caller-consumed fields, and side effects only. Repository tests should assert persistence rules, conflict/idempotency behavior, ownership boundaries, and state transitions. Schema tests are allowed only for migrations, ownership boundaries, unique constraints, conflict behavior, or cross-database routing.
Live RPC/devnet tests must be opt-in smoke tests with explicit environment
requirements and no checked-in key material. They must not run in default
bun test suites.
bun run lint # prettier --check
bun run lint:fix # prettier -w
bun run build:auth-packages # build auth workspace packages
bun run build:db-packages # build shared DB workspace packages
bun run typecheck:auth-packages # typecheck auth workspace packages
bun run typecheck:db-packages # typecheck shared DB workspace packages
bun run guard:shared-boundaries # ensure shared packages stay app-env agnostic
bun run guard:admin-shared-schema # prevent admin-local schema duplication
bun run admin:dev # run admin dev server from repo root
bun run admin:lint # lint admin workspace from repo root
bun run admin:build # build admin workspace from repo root
bun run frontend:dev # run loyal web frontend from repo root
bun run frontend:lint # lint loyal web frontend from repo root
bun run frontend:build # build loyal web frontend from repo root./scripts/setup-git-hooks.sh- Run once per clone/worktree to enable repo hooks.
- Hooks enforce commit message format (
commit-msg) and run app/admin/frontend lint+build before push. - Temporary bypass (only when necessary):
SKIP_VERIFY=1 git push - CI note: app builds are intentionally not run in GitHub Actions; Vercel is the build/deploy gate.
| Path | Purpose |
|---|---|
/programs |
Anchor smart contracts. telegram-private-transfer handles deposit/claim/refund SOL transfers; telegram-verification handles on-chain Ed25519 Telegram signature verification. |
/app |
Next.js 15 Telegram mini-app frontend and API routes. |
/frontend |
Next.js 15 Loyal web frontend. |
/mobile |
Expo React Native mobile app. |
/admin |
Next.js 15 internal admin dashboard. |
/packages |
Internal shared workspace packages such as db-core, db-adapter-neon, auth-core, and shared. |
/sdk/private-transactions |
Publishable @loyal-labs/private-transactions NPM package. |
/workers |
Runtime services/workers. |
/tests |
Anchor test suite. |
/docs and /user-docs |
Internal engineering docs and Mintlify-hosted user-facing docs. |
| Program | Address |
|---|---|
telegram-private-transfer |
97FzQdWi26mFNR21AbQNg4KqofiCLqQydQfAvRQMcXhV |
telegram-verification |
9yiphKYd4b69tR1ZPP8rNwtMeUwWgjYXaXdEzyNziNhz |
The current /app/src architecture is a hybrid vertical-slice implementation. Feature boundaries are primarily expressed by route segments and feature-scoped component folders:
- Route slices:
/app/src/app/telegram/*and/app/src/app/api/* - UI slices:
/app/src/components/wallet,/app/src/components/summaries,/app/src/components/telegram - Shared cross-slice hooks/types:
/app/src/hooks,/app/src/types - Shared integration/domain modules:
/app/src/lib/*
Current slice mapping:
| Slice | Current files |
|---|---|
| Wallet | /app/src/app/telegram/wallet/page.tsx, /app/src/components/wallet/*, and Solana/Telegram wallet integrations in /app/src/lib/solana/* and /app/src/lib/telegram/mini-app/*. |
| Summaries | /app/src/app/telegram/summaries/*, /app/src/components/summaries/*, and /app/src/app/api/summaries/route.ts. |
| Telegram platform | /app/src/app/telegram/layout.tsx, /app/src/components/telegram/*, bot/API modules in /app/src/lib/telegram/*, and /app/src/app/api/telegram/*. |
Rules for all new feature work:
- Organize by feature first; do not introduce new horizontal folders by technical layer.
- Extend an existing slice in-place when behavior belongs to wallet/summaries/telegram/profile flows.
- Keep route handlers and page files as orchestration layers; move reusable business logic out of route/page files and into slice-owned modules.
- Avoid deep imports across slices (for example, wallet internals imported from summaries).
- Shared code must be stable and reused by multiple slices before promotion to
/app/src/lib.
For net-new, substantial features, prefer creating an explicit slice root:
/app/src/features/<feature-name>/
index.ts # public entrypoints only
ui/
server/
domain/
data/
integrations/
types.ts
Use /app/src/lib for cross-slice infrastructure and integration primitives. Existing shared modules include:
| Module | Purpose |
|---|---|
core/ |
HTTP utilities, Neon PostgreSQL + Drizzle ORM |
solana/rpc/ |
RPC connections (Helius for mainnet/devnet, localhost for localnet) |
solana/wallet/ |
Keypair management via Telegram Cloud Storage |
solana/deposits/ |
Deposit/claim/refund logic with PDAs |
solana/verification/ |
On-chain Telegram signature verification |
telegram/mini-app/ |
Client-side SDK wrappers, Cloud Storage, auth |
telegram/bot-api/ |
Server-side bot API (grammy) |
telegram/ |
User service, bot thread service, bot API handlers |
magicblock/ |
SOL/USD price feed via Pyth oracle |
redpill/ |
AI chat summaries |
- New feature-specific behavior should stay in its owning slice unless it is clearly shared.
- Promote code into
/app/src/libonly after it is proven reusable across multiple slices. - Refactor incrementally by slice (wallet, summaries, telegram, etc.), not by file type alone.
These complement the command list above and mirror guidance in mobile/CLAUDE.md.
Routes live in mobile/app through Expo Router. Reusable UI plus hook/service
logic lives in mobile/src. Keep network calls centralized in
mobile/src/services/api.ts, use the @/ alias for imports under
mobile/src, and keep shared cross-app summary types in @loyal-labs/shared.
Prepare mobile Earn deposit, withdrawal, autodeposit, and cleanup transactions on-device with the shared SDK. Backend mobile endpoints should return authenticated prepare contexts only; server-prepared transaction routes are legacy fallbacks for older app versions.
Client-exposed mobile env vars must use the EXPO_PUBLIC_ prefix. For cloud
builds, eas.json is the source of truth; .env is local-only. Keep fallback
behavior in mobile/src/config/env.ts aligned with production API
expectations.
Maintain the Metro monorepo and SVG transformer settings in
mobile/metro.config.js. Keep typed routes and New Architecture settings
compatible with the pinned Expo SDK/React Native versions.
For validation, lint mobile changes with cd mobile && npx expo lint. Run
cd mobile && npm test when touching shared mobile logic. Do not start the Expo
dev server unless the user explicitly requests it.
Admin DB code must use only @loyal-labs/db-core/schema and
@loyal-labs/db-adapter-neon. Keep admin DB wiring in
admin/src/lib/core/database.ts. Do not introduce admin/src/lib/generated/*,
admin/drizzle.config.ts, or /admin/schema; prefer shared schema/docs
references. Run bun run guard:admin-shared-schema for admin DB/schema
refactors.
For Vercel monorepo deploys, the admin Root Directory is admin (see
admin/vercel.json) and the Loyal web frontend Root Directory is frontend
(see frontend/vercel.json).
Deposit accounts and vaults use Program Derived Addresses with seeds
"deposit_v2", "username_deposit_v2", "vault", and "tg_session_v2".
User keypairs belong in Telegram Cloud Storage, never localStorage.
NEXT_PUBLIC_SOLANA_ENV selects the RPC environment (mainnet, devnet, or
localnet).
This repo reads from or writes to three database surfaces. Keep their purposes separate:
| Database surface | Runtime owner | Code entrypoint | Responsibility |
|---|---|---|---|
| App Neon database | App/admin product plane | /app/src/lib/core/database.ts, /frontend/src/lib/core/database.ts, shared schema in /packages/db-core/src/schema.ts |
Users, Telegram communities/messages/summaries, app wallet auth, smart-account records, sponsorship analytics, admin-readable app state |
Yield Neon database (loyal_yield) |
Yield optimization control plane | /frontend/src/lib/yield-optimization/yield-neon-client.server.ts |
Yield route policies, managed vault metadata, vault/reserve snapshots, rebalance decisions, confirmed user yield positions and deposit events |
Kamino Timescale database (kamino) |
Market data/read model | /frontend/src/lib/kamino/timescale-reserve-client.server.ts |
Read-only Kamino reserve history/latest reserve updates used to choose or forecast safe/no-fee earn targets |
Rules:
App/product tables belong in @loyal-labs/db-core/schema and use the app-local
getDatabase() wrapper. Yield optimizer tables in loyal_yield are not
app-product tables; keep their Drizzle models and repository methods under
/frontend/src/lib/yield-optimization. Kamino Timescale is read-only from this
app, so do not write user, policy, vault, or app state into Timescale.
Confirmed yield deposits and withdrawals are written only after chain
confirmation. Persist route policy/vault metadata in
loyal_yield.route_policies and loyal_yield.managed_vaults, then record
immutable deposit/withdrawal events and aggregate user positions in
loyal_yield.user_yield_position_deposits,
loyal_yield.user_yield_position_withdrawals, and
loyal_yield.user_yield_positions. Keep cross-database joins out of app code;
fetch from each database through its owning server module and join in typed
application code only when needed.
The Loyal web Earn UI lives under
/frontend/src/components/wallet-workspace/app-wallet-workspace.tsx and
/frontend/src/components/wallet-sidebar/earn-detail-view.tsx.
User-initiated Earn uses smart-account vault accountIndex = 1 and the
canonical Kamino USDC reserve. Older auto-yield planning docs may discuss
agent-managed vault 0; that is a separate flow.
packages/smart-account-vaults is the instruction-building boundary. Use
prepareEarnUsdcDeposit and prepareEarnUsdcWithdraw; do not duplicate Kamino
or Squads policy instruction shape in React. First Earn deposits initialize the
yield-routing ProgramInteraction policy. Top-up deposits must call
prepareEarnUsdcDeposit with initializeYieldRoutingPolicy: false once
overview policies show an existing Earn policy (state === "ProgramInteraction",
accountIndex === 1). The Earn vault index is always 1, but the policy seed
is the actual Squads policy seed returned by policy creation or persistence; do
not assume policy seed 1.
After wallet confirmation, the frontend posts to
/api/smart-accounts/yield-optimization/deposits/confirm or
/api/smart-accounts/yield-optimization/withdrawals/confirm. Those routes
validate authenticated wallet/session ownership, configured cluster
(NEXT_PUBLIC_SOLANA_ENV, devnet or mainnet/mainnet-beta), canonical
policy/vault/reserve metadata, confirmed signature status, and slot before
writing Yield Neon state.
The active Earn position read endpoint is
/api/smart-accounts/yield-optimization/position. It returns the active
aggregate position from loyal_yield.user_yield_positions for the authenticated
wallet, configured cluster, vault index 1, and canonical target reserve. The
workspace uses it to decide whether to show the active Earn view, set the
displayed principal, and cap partial/full withdrawals.
Schema conventions used in /packages/db-core/src/schema.ts:
| Convention | Rule |
|---|---|
| Primary keys | Use UUIDs with defaultRandom() for all tables. |
| Telegram IDs | Use bigint with { mode: "bigint" } for Telegram user/chat IDs. |
| Timestamps | Use timestamp("...", { withTimezone: true }) with .defaultNow().notNull(). |
| Relations | Define relations separately from tables with relations() so with: queries stay type-safe. |
| Type exports | Export both Table select types and InsertTable insert types through $inferSelect and $inferInsert. |
| Indexes | Use uniqueIndex for unique constraints and index for query optimization. |
For typed JSONB columns, use .$type<T>():
topics: jsonb("topics").$type<{ title: string; content: string }[]>().notNull();
encryptedContent: jsonb("encrypted_content")
.$type<EncryptedMessageContent>()
.notNull();Service layer patterns:
Use onConflictDoNothing for race-condition-safe idempotent operations:
const result = await db
.insert(table)
.values({ ... })
.onConflictDoNothing()
.returning({ id: table.id });
if (result.length === 0) {
/* query existing record */
}Check /app/src/lib/core/database.ts before choosing advanced DB APIs. The
current app uses drizzle-orm/neon-http, which does not support
db.transaction(). For atomic multi-statement writes, use db.batch([...]).
Use db.transaction() only if the project moves to a driver that supports it.
Prefer db.query.table.findFirst() with with: for relations over raw SQL.
In app code, import schema from @loyal-labs/db-core/schema. Keep Neon driver
wiring and env access in app code (/app/src/lib/core/database.ts). Shared
packages must not import app-only server config modules. Preserve Neon HTTP
semantics, including db.batch for atomic multi-write flows.
Use @/lib/encryption for sensitive data such as bot messages and personal
information:
import { encrypt, decrypt } from "@/lib/encryption";
const encrypted = await encrypt(JSON.stringify(data)); // returns { ciphertext, iv }
const decrypted = await decrypt(encrypted); // returns plaintext or nullUse the Drizzle query builder for typed reads:
const user = await db.query.users.findFirst({
where: eq(users.telegramId, telegramId),
with: { communityMemberships: true },
});Use onConflictDoNothing or onConflictDoUpdate to handle duplicate inserts.
Server/client boundaries are strict. Never import
@/lib/core/config/server from client code or shared barrels consumed by client
code. Keep server-only entrypoints isolated in dedicated modules such as
server.ts or *.server.ts, and import them only from server contexts. For
dual-use modules, keep index.ts client-safe and expose server-only helpers via
a separate server entrypoint. Components imported by app/src/app/layout.tsx
and other root wrappers must be SSR-safe. Browser-only SDKs and browser globals
must stay behind Client Components, dynamic client wrappers, useEffect, or
runtime guards.
After any change to app/src/app/layout.tsx, /app/src/app/**/layout.tsx, or
global provider trees, run cd app && bun run build. Verify production
build/_not-found prerender succeeds without ReferenceError: window is not defined, and check changed modules for top-level browser API usage.
If logs or stack traces reference code that no longer matches current file contents, restart the local dev server or worker process. Stale processes can keep executing old code after edits.
Never detach Drizzle or SDK methods that may rely on this, including
db.query.*.findFirst, findMany, and SDK instance methods. Prefer direct
calls from the owning object, such as db.query.communities.findFirst({...}).
If extraction is unavoidable, bind the method explicitly and add a regression
test that would fail without the binding.
Classify webhook handler work as either critical or best-effort. Critical
failures in ingest/storage/auth paths must bubble so upstream systems can
retry. Best-effort side effects such as reactions or analytics must not block
webhook acknowledgment; use fire-and-catch with structured logs.
Error logs should include structured context (chatId, messageId,
telegramUserId, updateId) without message text. Include normalized error
fields (errorName, errorMessage) plus stack/raw error data for debugging.
Tests should cover context-sensitive ORM mocks, ingest retry/idempotency after a
partial write failure, and non-blocking best-effort side effects.
After webhook or ingest changes, run cd app && bun lint, run targeted tests
for touched modules, run cd app && bun run build, and do a Telegram canary
that confirms new messages rows are written.
If cron summary stats suddenly show high skippedNotEnoughMessages for
historically active communities, assume an ingest regression until disproven.
Check webhook errors and message freshness first. Useful SQL:
-- Freshness by active community
SELECT c.chat_title, c.chat_id, MAX(m.created_at) AS latest_message_at, COUNT(m.id) AS total_messages
FROM communities c
LEFT JOIN messages m ON m.community_id = c.id
WHERE c.is_active = true
GROUP BY c.id, c.chat_title, c.chat_id
ORDER BY latest_message_at DESC NULLS LAST;-- 24h ingest volume by active community
SELECT c.chat_title, c.chat_id, COUNT(m.id) AS messages_24h
FROM communities c
LEFT JOIN messages m
ON m.community_id = c.id
AND m.created_at >= NOW() - INTERVAL '24 hours'
WHERE c.is_active = true
GROUP BY c.id, c.chat_title, c.chat_id
ORDER BY messages_24h DESC;Keep app/src/app/layout.tsx free of Telegram SDK/UI imports. Telegram
wrappers and providers belong under the app/src/app/telegram/* route scope.
Do not top-level import @telegram-apps/sdk or @telegram-apps/sdk-react from
modules that can enter server/root graphs such as /, /_not-found, metadata,
or shared root providers. If shared utilities need Telegram SDK access, load it
lazily inside runtime functions and guard with typeof window !== "undefined".
next/dynamic(..., { ssr: false }) is valid only in a Client Component.
Wallet key material must stay in Telegram Cloud Storage, with no local/session fallback. Cloud storage readiness can race during early app boot, so critical writes such as wallet keypair persistence need bounded retry/backoff before throwing.
After changing the files below, run cd app && bun run build and confirm no
prerender failures on /, /_not-found, or /telegram.
| File area | Why it is sensitive |
|---|---|
app/src/app/layout.tsx |
Root graph can pull browser-only code into prerender. |
app/src/app/telegram/layout.tsx |
Telegram route scope owns Telegram SDK wrappers. |
app/src/lib/telegram/mini-app/cloud-storage.ts |
Wallet keypair persistence depends on Cloud Storage readiness. |
app/src/components/telegram/* |
Components often touch Telegram browser APIs. |
app/src/lib/solana/wallet/wallet-keypair-logic.ts |
Keypair persistence must remain Cloud Storage-only. |
Manual smoke check after SDK/cloud-storage changes: open wallet in the Telegram
mini-app and confirm keypair persistence succeeds without
Failed to persist generated wallet keypair.
All branches MUST follow the Linear format: <issue-number-title>
Example: ask-328-fix-wrong-token-history-processing
To find the correct branch name for a Linear issue, use the issue identifier (e.g., ASK-123).
When asked to work on a new issue/branch:
- Create the worktree from the repo root:
git worktree add ../loyal-app-ASK-123 -b ASK-123-short-description main- Worktrees live as sibling directories to the main repo
- Always branch from
main(or ask if unclear)
cdinto the new worktree directory before doing any work
git worktree listWhen done with a branch:
git worktree remove ../loyal-app-ASK-123Or if already deleted the directory:
git worktree prune- NEVER switch branches in the main worktree to work on issues — always create a new worktree
- Each tmux session / Claude Code instance should operate in its own worktree
- Run
git worktree listif unsure which worktrees exist - After merging a PR, clean up the worktree
For every new Linear issue created through MCP, set status to Todo, choose an
explicit priority (Urgent, High, Normal, or Low), assign a concrete
owner, attach the current team cycle, and attach the most appropriate project.
Descriptions should be short but ready for implementation: include the
goal/context, key implementation ideas, key files/paths, and any docs, PRs, or
issues needed for follow-up work.
This project enforces Conventional Commits via commitlint with @commitlint/config-conventional. A CI workflow (.github/workflows/commit-style.yml) validates all commit messages in a PR and the PR title itself.
type(scope): description
Allowed types: feat, fix, chore, docs, style, refactor, perf, test, build, ci, revert
Scope is optional but encouraged — use the area of the codebase being changed (e.g., wallet, ui, og, sdk, ci, telegram).
feat(wallet): show SPL token transfers in activity
fix(sdk): restore delegation PDA helpers
chore(ci): enforce conventional commit style with commitlint
docs(sdk): refresh README for PER + auth usage
refactor(ui): extract pill button component
- NEVER add
Co-Authored-Bytrailers or any co-author attribution to commits - Keep the subject line under 100 characters
- Use imperative mood in the description ("add", not "added" or "adds")
- Do not end the subject line with a period
- Validate locally before pushing:
bun run commitlint:headcd app && bun run lint
PR titles must follow the same conventional commit format:
type(scope): description. Keep the PR body to a one- or two-sentence summary;
do not add templates or checklists. Merge only after the Vercel build/check is
successful, and use squash-and-merge. For monorepo deployments, configure the
admin Vercel Root Directory as admin and the Loyal web frontend Root Directory
as frontend.
| Tool | Version or rule |
|---|---|
| Package manager | Bun preferred |
| Anchor | 0.32.1 |
| Solana | 2.1.0 |
| ESLint | Enforces alphabetical imports with eslint-plugin-simple-import-sort |
Required for Telegram mini-app frontend (in /app/.env.local):
NEXT_PUBLIC_TELEGRAM_BOT_ID=<bot_id>
NEXT_PUBLIC_SOLANA_ENV=devnet # mainnet, devnet, or localnet
ASKLOYAL_TGBOT_KEY=<bot_token> # Telegram Bot API token only
TELEGRAM_SETUP_SECRET=<route_secret> # Bearer token for /api/telegram/setup-commands
REDPILL_AI_API_KEY=<api_key>
DATABASE_URL=postgresql://...
MESSAGE_ENCRYPTION_KEY=<base64-32-bytes> # For encrypted bot messagesOptional:
NEXT_PUBLIC_SERVER_HOST- API base URLDEPLOYMENT_PK- Gasless transaction keypair (base58)NEXT_PUBLIC_GAS_PUBLIC_KEY- Gasless payer public key
Core clients live in /app/src/lib/core:
r2-upload.ts(server-only):getCloudflareR2UploadClientFromEnv()cdn-url.ts:getCloudflareCdnUrlClientFromEnv()
Required for R2 upload:
CLOUDFLARE_R2_ACCOUNT_IDCLOUDFLARE_R2_ACCESS_KEY_IDCLOUDFLARE_R2_SECRET_ACCESS_KEYCLOUDFLARE_R2_BUCKET_NAME
Set at least one CDN base URL:
CLOUDFLARE_CDN_BASE_URL(preferred)NEXT_PUBLIC_CLOUDFLARE_CDN_BASE_URLCLOUDFLARE_R2_PUBLIC_DEV_URL(dev fallback)
Optional:
CLOUDFLARE_R2_S3_ENDPOINTCLOUDFLARE_R2_UPLOAD_PREFIX
Run targeted tests:
cd app
bun test src/lib/core/__tests__/object-path.test.ts src/lib/core/__tests__/cdn-url.test.ts src/lib/core/__tests__/r2-upload.test.ts