diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index ef5eea5..e1dfb06 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -32,7 +32,7 @@
"expo-status-bar": "~57.0.1",
"expo-symbols": "~57.0.2",
"expo-web-browser": "~57.0.2",
- "lucide-react-native": "^1.35.0",
+ "lucide-react-native": "^1.37.0",
"react": "19.2.3",
"react-native": "0.86.2",
"react-native-appwrite": "^0.34.0",
diff --git a/apps/web/.env.local.example b/apps/web/.env.local.example
index d940dfe..be84d5b 100644
--- a/apps/web/.env.local.example
+++ b/apps/web/.env.local.example
@@ -117,6 +117,12 @@ APPWRITE_MODERATOR_TEAM_ID=
# OPTIONAL: Server Configuration
# ==============================================================================
# Public URL of your application (for OAuth redirects, etc.)
+#
+# NOTE: Password reset and email verification links are built from this value,
+# and Appwrite only sends them to hosts registered as "Platforms" for this
+# project. If you develop on http://127.0.0.1:3000 but Appwrite knows
+# http://localhost:3000, those links 400 and the flow silently breaks.
+# Register EVERY host you use (localhost, 127.0.0.1, LAN IP, dev domain).
SERVER_URL=http://localhost:3000
# Instance name displayed in mobile app and about pages
@@ -182,34 +188,6 @@ TENOR_API_KEY=
TENOR_CLIENT_KEY=firepit-web
TENOR_LOCALE=en_US
-# ==============================================================================
-# OPTIONAL: New Relic APM (Application Performance Monitoring)
-# ==============================================================================
-# Configure New Relic for application monitoring and log ingestion.
-# These are server-side only (no NEXT_PUBLIC prefix needed).
-# Get these from your New Relic account: https://newrelic.com
-
-# Your New Relic license key
-# Find in: New Relic > Account Settings > API Keys > License Keys
-NEW_RELIC_LICENSE_KEY=
-
-# Application name as it appears in New Relic
-# Example: firepit-production, firepit-staging, etc.
-NEW_RELIC_APP_NAME=
-
-# ==============================================================================
-# OPTIONAL: Telemetry Provider Routing
-# ==============================================================================
-# Controls where server-side telemetry from src/lib/newrelic-utils.ts is sent.
-# Allowed values: newrelic, posthog, both, none
-# Default: newrelic
-TELEMETRY_PROVIDER=newrelic
-
-# Controls where client-side telemetry from src/lib/client-telemetry.ts is sent.
-# Allowed values: newrelic, posthog, both, none
-# Default: newrelic
-NEXT_PUBLIC_TELEMETRY_PROVIDER=newrelic
-
# Enable PostHog forwarding during tests (normally disabled)
# ENABLE_POSTHOG_IN_TESTS=false
diff --git a/apps/web/CHANGELOG.md b/apps/web/CHANGELOG.md
index a9b15b8..3feab8d 100644
--- a/apps/web/CHANGELOG.md
+++ b/apps/web/CHANGELOG.md
@@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [2.1.0] - 2026-08-29
+
+### ✨ Features
+
+- **Password reset** - Request a reset link from the sign-in page and set a new password from a secure email link
+- **Change email** - Update your account email from Settings (with current-password confirmation and re-verification when enabled)
+- **Session management** - View all signed-in devices in Settings and revoke any of them (or sign out everywhere except the current device)
+- **Remember me** - Optional persistent sign-in; uncheck to use a session-only cookie that clears when the browser closes
+- **Signup control** - Admins can set the instance policy to open, individual approval, or no signups, and approve/reject pending signups from the admin panel
+- **Deactivate & delete account** - Temporarily deactivate your account (auto-reactivates on next sign-in) or permanently delete it from a new Danger Zone section
+- **Deleted User tombstones** - Deleted accounts show as "Deleted User" and their user ID is permanently reserved so it can never be reused
+
+### ⚙️ Improvements
+
+- Sign-in now refuses accounts awaiting approval and reactivates deactivated accounts automatically on success
+- Admin panel exposes the current signup policy and a pending-approvals queue
+- **Telemetry simplified to PostHog only** - Removed all New Relic plumbing, dependencies, and config; server and client telemetry now route exclusively to PostHog (smaller installs, less startup overhead)
+- Added `z.compile()` to one usage of zod in codebase to improve performance
+- Updated `bun test` wiring to improve passing tests to 818 up from 717 previously. (Part of bun dep bump)
+- **Account safety polish** - Deleting your account now opens a confirmation dialog with a full consequences summary and password confirmation; a persistent warning (with one-click resend) reminds you to confirm a new email before signing out; the remember-me checkbox preference and password-reset resend (with a 30s cooldown) are new conveniences on the sign-in page
+
+### 🐛 Fixes
+
+- **Session devices now identified** - The session list showed "Unknown device" and "signed in by unknown" because it read nested fields the Appwrite API doesn't return. It now maps the real device, browser, OS, and sign-in date
+- Fix TDZ in `setup-appwrite.ts` (`[error] Cannot access 'now' before initalization`).
+- Bumped zod to `4.5.4`, and bumped bun to `1.4.0` for performance improvements
+
## [2.0.3] - 2026-08-15
### 🐛 Fixes
diff --git a/apps/web/bunfig.toml b/apps/web/bunfig.toml
index 0715236..c6fc1ed 100644
--- a/apps/web/bunfig.toml
+++ b/apps/web/bunfig.toml
@@ -3,6 +3,6 @@ linker = "isolated"
[test]
root="src/__tests__/"
-preload="./src/__tests__/setup.ts"
+preload=["./src/__tests__/setup.ts", "./happydom.ts"]
pathIgnorePatterns="src/__tests__/__helpers__/**"
timeout=10000
\ No newline at end of file
diff --git a/apps/web/docs/TELEMETRY.md b/apps/web/docs/TELEMETRY.md
index 625b0eb..372b621 100644
--- a/apps/web/docs/TELEMETRY.md
+++ b/apps/web/docs/TELEMETRY.md
@@ -1,34 +1,11 @@
-# Telemetry Providers
+# Telemetry
-Firepit supports telemetry routing to New Relic, PostHog, both, or neither.
+Firepit sends all telemetry to PostHog.
-This is implemented in:
+- Server helpers: `src/lib/posthog-utils.ts`
+- Client helpers: `src/lib/client-logger.ts`
-- Server telemetry helpers: `src/lib/newrelic-utils.ts`
-- Client telemetry helpers: `src/lib/client-telemetry.ts`
-
-## Provider Configuration
-
-### Server-side provider
-
-- Env var: `TELEMETRY_PROVIDER`
-- Allowed values: `newrelic`, `posthog`, `both`, `none`
-- Default: `newrelic`
-
-### Client-side provider
-
-- Env var: `NEXT_PUBLIC_TELEMETRY_PROVIDER`
-- Allowed values: `newrelic`, `posthog`, `both`, `none`
-- Default: `newrelic`
-
-## Credentials
-
-### New Relic
-
-- `NEW_RELIC_LICENSE_KEY`
-- `NEW_RELIC_APP_NAME`
-
-### PostHog
+## Server-side capture
Server-side capture (`posthog-node`) prefers:
@@ -40,11 +17,18 @@ Fallback compatibility keys:
- `NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN`
- `NEXT_PUBLIC_POSTHOG_HOST`
-Client-side capture (`posthog-js`) uses existing browser initialization in `instrumentation-client.ts`.
+Structured logs are also forwarded through the OTLP log pipeline to the
+PostHog logs endpoint (`POSTHOG_LOGS_HOST`, defaults to the ingest host).
-## PostHog Privacy And Bandwidth Controls
+Process-level hooks (uncaught exceptions, unhandled rejections, flush on
+shutdown) are registered in `instrumentation.ts`.
-Client-side PostHog behavior is configured by these env vars:
+## Client-side capture
+
+Client capture (`posthog-js`) uses existing browser initialization in
+`instrumentation-client.ts`.
+
+## PostHog Privacy And Bandwidth Controls
- `NEXT_PUBLIC_POSTHOG_AUTOCAPTURE` (recommended `false`)
- `NEXT_PUBLIC_POSTHOG_SESSION_RECORDING` (recommended `false`)
@@ -98,45 +82,23 @@ If you use Next.js rewrites as a PostHog proxy path, configure:
If you set `NEXT_PUBLIC_POSTHOG_HOST` directly to your own reverse-proxy domain,
you can disable rewrites with `POSTHOG_REWRITE_ENABLED=false`.
-## Telemetry Matrix
+## Server Events
-### Server helper mapping
+Events emitted by `src/lib/posthog-utils.ts`:
-| Helper | New Relic output | PostHog output |
-| ------------------------------------ | ------------------------------------------ | ---------------------------------------------------------------------------------- |
-| `recordEvent(eventType, attributes)` | `recordCustomEvent(eventType, attributes)` | capture event `eventType` with `attributes` |
-| `recordMetric(name, value)` | `recordMetric(name, value)` | capture event `metric_recorded` with `{ metricName: name, value }` |
-| `incrementMetric(name, value)` | `incrementMetric(name, value)` | capture event `metric_incremented` with `{ metricName: name, incrementBy: value }` |
-| `recordError(error, attrs)` | `noticeError(error, attrs)` | capture event `error_recorded` with normalized error fields plus attrs |
-| `logger.info/warn/error/debug` | `ApplicationLog` custom event | capture event `application_log` |
+| Helper | PostHog output |
+| ------------------------------------ | ---------------------------------------------------------------------------------- |
+| `recordEvent(eventType, attributes)` | capture event `eventType` with `attributes` |
+| `recordMetric(name, value)` | capture event `metric_recorded` with `{ metricName: name, value }` |
+| `recordError(error, attrs)` | `captureException` with normalized error fields plus attrs |
+| `logger.info/warn/error/debug` | capture event `application_log` plus OTLP log record |
-### Client helper mapping
-
-| Helper | New Relic output | PostHog output |
-| ---------------------------------------- | ----------------------------------- | -------------------------------------------------------------------------------- |
-| `recordClientAction(action, attributes)` | `addPageAction(action, attributes)` | `capture(action, attributes)` |
-| `recordClientError(error, attributes)` | `noticeError(error, attributes)` | `captureException(error, attributes)` or fallback `capture("client_error", ...)` |
+Client helpers emit `log_info`, `log_warn`, `log_error`, `client_error` events
+and use `captureException` for rich client errors.
## Digest Telemetry Example
Digest generation in `src/lib/inbox.ts` emits:
-- Metric: `Custom/InboxDigest/DurationMs`
-- Metric: `Custom/InboxDigest/ReturnedItems`
-- Metric: `Custom/InboxDigest/TotalUnread`
- Event: `InboxDigestGenerated`
-
-Under provider routing:
-
-- `newrelic`: only New Relic receives these
-- `posthog`: only PostHog receives mapped equivalents
-- `both`: both providers receive telemetry
-- `none`: no provider receives telemetry
-
-## Client Parity Coverage
-
-The following client paths now route through provider-aware helpers instead of direct New Relic-only calls:
-
-- `src/lib/client-logger.ts`
-- `src/app/error.tsx`
-- `src/app/global-error.tsx`
+- Metrics: `Custom/InboxDigest/DurationMs`, `Custom/InboxDigest/ReturnedItems`, `Custom/InboxDigest/TotalUnread`
\ No newline at end of file
diff --git a/apps/web/happydom.ts b/apps/web/happydom.ts
new file mode 100644
index 0000000..7f712d0
--- /dev/null
+++ b/apps/web/happydom.ts
@@ -0,0 +1,3 @@
+import { GlobalRegistrator } from "@happy-dom/global-registrator";
+
+GlobalRegistrator.register();
diff --git a/apps/web/instrumentation.ts b/apps/web/instrumentation.ts
index 6115ed6..edeaeea 100644
--- a/apps/web/instrumentation.ts
+++ b/apps/web/instrumentation.ts
@@ -2,91 +2,30 @@
* Next.js Instrumentation Hook
*
* This file is automatically loaded by Next.js on both server and edge runtimes.
- * It initializes server telemetry hooks for Node.js runtime only.
+ * It registers PostHog telemetry hooks for the Node.js runtime only.
*
* Documentation: https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation
*/
-const instrumentationLogger = {
- error(message: string, attributes?: Record) {
- const payload = attributes
- ? `${message} ${JSON.stringify(attributes)}`
- : `${message}`;
- const error = new Error(payload);
-
- if (typeof globalThis.reportError === "function") {
- globalThis.reportError(error);
- return;
- }
-
- console.error(error);
- },
-};
-
export async function register() {
- if (process.env.NEXT_RUNTIME === "nodejs") {
- const newrelicLicenseKey = process.env.NEW_RELIC_LICENSE_KEY;
- const newrelicAppName = process.env.NEW_RELIC_APP_NAME;
-
- try {
- const {
- initNewRelic,
- registerPostHogLoggerProvider,
- registerPostHogProcessHandlers,
- } = await import("./src/lib/newrelic-utils");
- registerPostHogLoggerProvider();
- registerPostHogProcessHandlers();
-
- // Kick off New Relic initialization once at startup so sync
- // dispatch paths can use the agent without async triggers.
- if (newrelicLicenseKey && newrelicAppName) {
- await initNewRelic();
- }
- } catch (error) {
- // PostHog runtime hooks are optional and should not block startup.
- instrumentationLogger.error(
- "[PostHog] Failed to register process handlers",
- {
- error:
- error instanceof Error
- ? {
- message: error.message,
- name: error.name,
- stack: error.stack,
- }
- : String(error),
- },
- );
- }
-
- // Only initialize if both license key and app name are provided
- if (newrelicLicenseKey && newrelicAppName) {
- try {
- // Dynamic import to avoid loading New Relic on Edge runtime
- // New Relic will automatically load the newrelic.cjs config file
- const newrelic = await import("newrelic");
+ if (process.env.NEXT_RUNTIME !== "nodejs") {
+ return;
+ }
- // Return the newrelic instance for potential use
- return newrelic;
- } catch (error) {
- // If New Relic fails to initialize, log the error but don't crash the app
- console.error(
- "[New Relic] Failed to initialize:",
- error instanceof Error ? error.message : String(error),
- );
- }
+ try {
+ const {
+ registerPostHogLoggerProvider,
+ registerPostHogProcessHandlers,
+ } = await import("./src/lib/posthog-utils");
+ registerPostHogLoggerProvider();
+ registerPostHogProcessHandlers();
+ } catch (error) {
+ // PostHog runtime hooks are optional and should not block startup.
+ const payload = error instanceof Error ? `${error.message} ${error.stack ?? ""}` : String(error);
+ if (typeof globalThis.reportError === "function") {
+ globalThis.reportError(new Error(payload));
} else {
- // If credentials are missing, log a warning but don't fail
- if (!newrelicLicenseKey) {
- console.warn(
- "[New Relic] NEW_RELIC_LICENSE_KEY not found - APM monitoring disabled",
- );
- }
- if (!newrelicAppName) {
- console.warn(
- "[New Relic] NEW_RELIC_APP_NAME not found - APM monitoring disabled",
- );
- }
+ console.error(payload);
}
}
-}
+}
\ No newline at end of file
diff --git a/apps/web/knip.json b/apps/web/knip.json
index 4161172..191eea2 100644
--- a/apps/web/knip.json
+++ b/apps/web/knip.json
@@ -1,7 +1,6 @@
{
"$schema": "https://unpkg.com/knip@6/schema.json",
"entry": [
- "newrelic.cjs",
"public/sw.js",
"scripts/test-server-auth.ts"
],
diff --git a/apps/web/newrelic.cjs b/apps/web/newrelic.cjs
deleted file mode 100644
index 4aab4fb..0000000
--- a/apps/web/newrelic.cjs
+++ /dev/null
@@ -1,268 +0,0 @@
-/**
- * New Relic Configuration
- *
- * This file configures New Relic APM for comprehensive monitoring including:
- * - Application performance monitoring (APM)
- * - Error tracking
- * - Transaction tracing
- * - Custom events and metrics
- * - Browser monitoring
- *
- * Documentation: https://docs.newrelic.com/docs/apm/agents/nodejs-agent/installation-configuration/nodejs-agent-configuration/
- */
-
-'use strict'
-
-const process = require('process');
-
-/**
- * New Relic agent configuration.
- *
- * See lib/config/default.js in the agent distribution for a more complete
- * description of configuration variables and their potential values.
- */
-exports.config = {
- /**
- * Application name(s) - can be a string or array for multiple app names
- * This is how your app will appear in New Relic
- */
- app_name: [process.env.NEW_RELIC_APP_NAME || 'firepit-qpc'],
-
- /**
- * Your New Relic license key
- */
- license_key: process.env.NEW_RELIC_LICENSE_KEY || '',
-
- /**
- * Logging configuration
- */
- logging: {
- /**
- * Level at which to log. Options: trace, debug, info, warn, error, fatal
- * Use 'info' for production
- */
- level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
-
- /**
- * Where to write log data
- */
- filepath: 'stdout',
-
- /**
- * Whether to collect and send logs to New Relic
- */
- enabled: true,
- },
-
- /**
- * Allow all data to be sent to New Relic
- */
- allow_all_headers: true,
-
- /**
- * Attributes configuration - control what data is captured
- */
- attributes: {
- /**
- * Enable attribute capture globally
- */
- enabled: true,
-
- /**
- * Attributes to exclude from all destinations
- */
- exclude: [
- 'request.headers.cookie',
- 'request.headers.authorization',
- 'request.headers.x-api-key',
- ],
- },
-
- /**
- * Application logging configuration
- * Forward application logs to New Relic
- */
- application_logging: {
- enabled: true,
-
- /**
- * Forward logs to New Relic
- */
- forwarding: {
- enabled: true,
- max_samples_stored: 10000,
- },
-
- /**
- * Local log decoration (add New Relic metadata to logs)
- */
- local_decorating: {
- enabled: true,
- },
-
- /**
- * Metrics derived from logs
- */
- metrics: {
- enabled: true,
- },
- },
-
- /**
- * Error collector configuration
- */
- error_collector: {
- enabled: true,
-
- /**
- * Ignore specific error status codes
- * 404s are usually not errors we care about
- */
- ignore_status_codes: [404],
-
- /**
- * Maximum number of errors to send per harvest cycle
- */
- max_event_samples_stored: 100,
-
- /**
- * Capture error attributes
- */
- attributes: {
- enabled: true,
- },
- },
-
- /**
- * Transaction tracer configuration
- */
- transaction_tracer: {
- enabled: true,
-
- /**
- * Threshold for when a transaction is considered slow (in seconds)
- */
- transaction_threshold: 'apdex_f',
-
- /**
- * Maximum number of slow queries to collect per harvest cycle
- */
- top_n: 20,
-
- /**
- * Record SQL queries
- */
- record_sql: 'obfuscated',
-
- /**
- * Explain plan threshold (in milliseconds)
- */
- explain_threshold: 500,
-
- /**
- * Capture transaction attributes
- */
- attributes: {
- enabled: true,
- },
- },
-
- /**
- * Distributed tracing configuration
- * Essential for tracking requests across services
- */
- distributed_tracing: {
- enabled: true,
- },
-
- /**
- * Slow SQL configuration
- */
- slow_sql: {
- enabled: true,
- max_samples: 10,
- },
-
- /**
- * Transaction events configuration
- */
- transaction_events: {
- enabled: true,
- max_samples_stored: 10000,
-
- attributes: {
- enabled: true,
- },
- },
-
- /**
- * Custom insights events configuration
- */
- custom_insights_events: {
- enabled: true,
- max_samples_stored: 10000,
- },
-
- /**
- * Browser monitoring configuration
- * Enables Real User Monitoring (RUM)
- */
- browser_monitoring: {
- enable: true,
-
- /**
- * Attributes to capture in browser monitoring
- */
- attributes: {
- enabled: true,
- },
- },
-
- /**
- * Span events configuration (for distributed tracing)
- */
- span_events: {
- enabled: true,
-
- attributes: {
- enabled: true,
- },
- },
-
- /**
- * Rules for naming and ignoring transactions
- */
- rules: {
- /**
- * Transaction naming rules
- */
- name: [
- // API routes
- { pattern: '/api/(.*)', name: '/api/*' },
- // Dynamic routes
- { pattern: '/(.*)', name: '/*' },
- ],
-
- /**
- * Transactions to ignore (don't report to New Relic)
- */
- ignore: [
- // Health check endpoints
- '^/api/health$',
- '^/health$',
- // Next.js internals
- '^/_next/static',
- '^/_next/image',
- // Favicons
- '^/favicon',
- ],
- },
-
- /**
- * Labels for organizing apps in New Relic
- */
- labels: {
- environment: process.env.NODE_ENV || 'development',
- project: 'firepit',
- },
-}
diff --git a/apps/web/newrelic.d.ts b/apps/web/newrelic.d.ts
deleted file mode 100644
index cce8938..0000000
--- a/apps/web/newrelic.d.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-/**
- * Type declarations for the newrelic module
- * Since @types/newrelic doesn't exist, we declare the module to satisfy TypeScript
- */
-declare module 'newrelic' {
- const newrelic: unknown;
- export default newrelic;
-}
diff --git a/apps/web/package.json b/apps/web/package.json
index c6ef630..a3b93ac 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -1,6 +1,6 @@
{
"name": "firepit-web",
- "version": "2.0.3",
+ "version": "2.1.0",
"private": true,
"type": "module",
"scripts": {
@@ -41,7 +41,7 @@
"@radix-ui/react-slot": "^1.3.3",
"@radix-ui/react-switch": "^1.3.7",
"@radix-ui/react-tabs": "^1.1.21",
- "@tanstack/react-query": "^5.101.4",
+ "@tanstack/react-query": "^5.102.8",
"@testing-library/dom": "^10.4.1",
"appwrite": "^26.2.0",
"class-variance-authority": "^0.7.1",
@@ -52,39 +52,39 @@
"libsodium-wrappers": "^0.8.4",
"lucide-react": "^0.554.0",
"nanoid": "^5.1.16",
- "newrelic": "^13.20.0",
"next": "^16.3.3",
"next-themes": "^0.4.6",
"node-appwrite": "^27.1.0",
"node-emoji": "^2.2.0",
- "posthog-js": "^1.417.1",
- "posthog-node": "^5.49.1",
+ "posthog-js": "^1.422.5",
+ "posthog-node": "^5.51.4",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-markdown": "^10.1.0",
- "react-virtuoso": "^4.18.11",
+ "react-virtuoso": "^4.18.12",
"remark-gfm": "^4.0.1",
"server-only": "^0.0.1",
"sonner": "^2.0.8",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0",
"yaml": "^2.9.0",
- "zod": "^4.4.3"
+ "zod": "^4.5.4"
},
"devDependencies": {
"@eslint/js": "^9.39.5",
+ "@happy-dom/global-registrator": "^20.12.0",
"@next/bundle-analyzer": "16.3.1",
- "@posthog/nextjs-config": "^1.9.69",
+ "@posthog/nextjs-config": "^1.10.0",
"@tailwindcss/postcss": "^4.3.3",
"@testing-library/jest-dom": "^6.9.1",
- "@testing-library/react": "^16.3.2",
- "@testing-library/user-event": "^14.6.4",
+ "@testing-library/react": "^16.3.3",
+ "@testing-library/user-event": "^14.6.6",
"@types/jsdom": "^27.0.0",
"@types/node": "^20.19.43",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.4",
- "@typescript-eslint/eslint-plugin": "^8.67.0",
- "@typescript-eslint/parser": "^8.67.0",
+ "@typescript-eslint/eslint-plugin": "^8.68.0",
+ "@typescript-eslint/parser": "^8.68.0",
"@vitejs/plugin-react": "^5.2.0",
"@vitest/coverage-v8": "^3.2.7",
"dotenv": "^17.4.2",
@@ -94,15 +94,15 @@
"eslint-plugin-react-hooks": "^6.1.1",
"eslint-plugin-unused-imports": "^4.4.1",
"globals": "^16.5.0",
- "happy-dom": "^20.11.2",
+ "happy-dom": "^20.12.0",
"jsdom": "^27.4.0",
- "knip": "^6.32.2",
+ "knip": "^6.33.0",
"postcss": "^8.5.26",
"tailwindcss": "^4.3.3",
"typescript": "7.0.2",
- "vitest": "^4.1.10"
+ "vitest": "^4.1.11"
},
- "packageManager": "bun@1.3.14",
+ "packageManager": "bun@1.4.0",
"engines": {
"node": ">=20.9.0"
},
diff --git a/apps/web/scripts/setup-appwrite.ts b/apps/web/scripts/setup-appwrite.ts
index 1024392..15e568c 100755
--- a/apps/web/scripts/setup-appwrite.ts
+++ b/apps/web/scripts/setup-appwrite.ts
@@ -1354,6 +1354,8 @@ async function setupProfiles() {
["profileBackgroundImageChangedAt", LEN_TS, false],
["avatarFramePreset", 64, false],
["dmEncryptionPublicKey", 256, false],
+ ["deletedAt", LEN_TS, false],
+ ["deletedEmail", 255, false],
];
for (const [k, size, req] of fields) {
await ensureStringAttribute("profiles", k, size, req);
@@ -1386,6 +1388,7 @@ async function setupFeatureFlags() {
await ensureStringAttribute("feature_flags", k, size, req);
}
await ensureBooleanAttribute("feature_flags", "enabled", true);
+ await ensureStringAttribute("feature_flags", "value", 64, false);
await ensureIndex("feature_flags", "idx_key", "key", ["key"]);
await ensureFeatureFlagDocument({
@@ -1418,17 +1421,28 @@ async function setupFeatureFlags() {
enabled: false,
key: "enable_tenor_gif_search",
});
+ await ensureFeatureFlagDocument({
+ description:
+ "Signup policy: open, individual approval, or signups disabled",
+ enabled: true,
+ key: "signup_policy",
+ value: "open",
+ });
}
async function ensureFeatureFlagDocument(params: {
description: string;
enabled: boolean;
key: string;
+ value?: string;
}) {
const { description, enabled, key } = params;
+ const value = params.value;
+ const now = new Date().toISOString();
+ const valuePayload = value !== undefined ? { value } : {};
+ const descriptionUpdatedAt = { description, updatedAt: now, ...valuePayload };
const documentId = createFeatureFlagDocumentId(key);
- const now = new Date().toISOString();
let operation: "added" | "updated" = "added";
const deterministicDocument = (await tryVariants([
@@ -1454,14 +1468,12 @@ async function ensureFeatureFlagDocument(params: {
await tryVariants([
() =>
dbAny.updateDocument(DB_ID, "feature_flags", documentId, {
- description,
- updatedAt: now,
+ ...descriptionUpdatedAt,
}),
() =>
dbAny.updateDocument?.({
data: {
- description,
- updatedAt: now,
+ ...descriptionUpdatedAt,
},
databaseId: DB_ID,
collectionId: "feature_flags",
@@ -1495,27 +1507,25 @@ async function ensureFeatureFlagDocument(params: {
);
}
- await tryVariants([
- () =>
- dbAny.createDocument(DB_ID, "feature_flags", documentId, {
- description,
- enabled,
- key,
- updatedAt: now,
- }),
- () =>
- dbAny.createDocument?.({
- data: {
- description,
+await tryVariants([
+ () =>
+ dbAny.createDocument(DB_ID, "feature_flags", documentId, {
+ ...descriptionUpdatedAt,
enabled,
key,
- updatedAt: now,
- },
- databaseId: DB_ID,
- collectionId: "feature_flags",
- documentId,
- }),
- ]).catch(async (error) => {
+ }),
+ () =>
+ dbAny.createDocument?.({
+ data: {
+ ...descriptionUpdatedAt,
+ enabled,
+ key,
+ },
+ databaseId: DB_ID,
+ collectionId: "feature_flags",
+ documentId,
+ }),
+ ]).catch(async (error) => {
if (!isDuplicateConflictError(error)) {
throw error;
}
@@ -1558,14 +1568,12 @@ async function ensureFeatureFlagDocument(params: {
await tryVariants([
() =>
dbAny.updateDocument(DB_ID, "feature_flags", documentId, {
- description,
- updatedAt: now,
+ ...descriptionUpdatedAt,
}),
() =>
dbAny.updateDocument?.({
data: {
- description,
- updatedAt: now,
+ ...descriptionUpdatedAt,
},
databaseId: DB_ID,
collectionId: "feature_flags",
diff --git a/apps/web/src/__tests__/api-routes/announcements-route.test.ts b/apps/web/src/__tests__/api-routes/announcements-route.test.ts
index 613a34a..7a6241b 100644
--- a/apps/web/src/__tests__/api-routes/announcements-route.test.ts
+++ b/apps/web/src/__tests__/api-routes/announcements-route.test.ts
@@ -52,7 +52,7 @@ vi.mock("@/lib/auth-server", () => {
};
});
-vi.mock("@/lib/newrelic-utils", () => ({
+vi.mock("@/lib/posthog-utils", () => ({
returnUnauthorized: () => new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
returnForbidden: () => new Response(JSON.stringify({ error: "Forbidden" }), { status: 403 }),
logger: {
diff --git a/apps/web/src/__tests__/api-routes/auth-session-route.test.ts b/apps/web/src/__tests__/api-routes/auth-session-route.test.ts
index 7448342..606b4eb 100644
--- a/apps/web/src/__tests__/api-routes/auth-session-route.test.ts
+++ b/apps/web/src/__tests__/api-routes/auth-session-route.test.ts
@@ -4,10 +4,16 @@ const {
mockCheckRateLimit,
mockCreateEmailPasswordSession,
mockGetClientIp,
+ mockUsersGet,
+ mockUsersDeleteSession,
+ mockUsersUpdatePrefs,
} = vi.hoisted(() => ({
mockCheckRateLimit: vi.fn(),
mockCreateEmailPasswordSession: vi.fn(),
mockGetClientIp: vi.fn(),
+ mockUsersGet: vi.fn(async () => ({ $id: "user-1", prefs: {} })),
+ mockUsersDeleteSession: vi.fn(async () => {}),
+ mockUsersUpdatePrefs: vi.fn(async () => {}),
}));
vi.mock("node-appwrite", () => ({
@@ -19,6 +25,23 @@ vi.mock("node-appwrite", () => ({
setProject = vi.fn().mockReturnThis();
setKey = vi.fn().mockReturnThis();
},
+ Query: {},
+ ID: { unique: vi.fn() },
+ Users: class {
+ get = mockUsersGet;
+ deleteSession = mockUsersDeleteSession;
+ updatePrefs = mockUsersUpdatePrefs;
+ },
+}));
+
+vi.mock("@/lib/signup-policy", () => ({
+ getApprovalStatusFromPrefs: (prefs: unknown) => {
+ const status = (prefs as { approvalStatus?: string } | null)
+ ?.approvalStatus;
+ if (status === "pending") return "pending";
+ if (status === "rejected") return "rejected";
+ return "approved";
+ },
}));
vi.mock("@/lib/auth-server", () => ({
@@ -163,4 +186,64 @@ describe("POST /api/auth/session", () => {
expect(response.headers.get("Retry-After")).toBe("60");
expect(mockCreateEmailPasswordSession).not.toHaveBeenCalled();
});
+
+ it("blocks sign-in and revokes the temp session when approval is pending", async () => {
+ mockCreateEmailPasswordSession.mockResolvedValue({
+ $id: "sess-1",
+ userId: "user-1",
+ secret: "secret-1",
+ });
+ mockUsersGet.mockResolvedValueOnce({
+ $id: "user-1",
+ prefs: { approvalStatus: "pending" },
+ });
+
+ const response = await POST(
+ new Request("http://localhost/api/auth/session", {
+ method: "POST",
+ body: JSON.stringify({
+ email: "user@example.com",
+ password: "pw",
+ }),
+ }),
+ );
+ const data = await response.json();
+
+ expect(response.status).toBe(403);
+ expect(data.error).toMatch(/awaiting/i);
+ expect(mockUsersDeleteSession).toHaveBeenCalledWith({
+ userId: "user-1",
+ sessionId: "sess-1",
+ });
+ });
+
+ it("reactivates a deactivated account on sign-in", async () => {
+ mockCreateEmailPasswordSession.mockResolvedValue({
+ $id: "sess-1",
+ userId: "user-1",
+ secret: "secret-1",
+ });
+ mockUsersGet.mockResolvedValueOnce({
+ $id: "user-1",
+ prefs: { disabled: true, disabledAt: "2026-01-01T00:00:00Z" },
+ });
+
+ const response = await POST(
+ new Request("http://localhost/api/auth/session", {
+ method: "POST",
+ body: JSON.stringify({
+ email: "user@example.com",
+ password: "pw",
+ }),
+ }),
+ );
+ const data = await response.json();
+
+ expect(response.status).toBe(200);
+ expect(mockUsersUpdatePrefs).toHaveBeenCalledWith({
+ userId: "user-1",
+ prefs: { disabled: false, disabledAt: null },
+ });
+ expect(data.session).toBe("secret-1");
+ });
});
diff --git a/apps/web/src/__tests__/api-routes/categories.test.ts b/apps/web/src/__tests__/api-routes/categories.test.ts
index e7836cf..796b224 100644
--- a/apps/web/src/__tests__/api-routes/categories.test.ts
+++ b/apps/web/src/__tests__/api-routes/categories.test.ts
@@ -34,7 +34,7 @@ vi.mock("@/lib/server-channel-access", () => ({
getServerPermissionsForUser: mockGetServerPermissionsForUser,
}));
-vi.mock("@/lib/newrelic-utils", () => ({
+vi.mock("@/lib/posthog-utils", () => ({
returnUnauthorized: () => new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
returnForbidden: () => new Response(JSON.stringify({ error: "Forbidden" }), { status: 403 }),
logger: {
diff --git a/apps/web/src/__tests__/api-routes/channel-pins.test.ts b/apps/web/src/__tests__/api-routes/channel-pins.test.ts
index 5c21e59..e09a1d1 100644
--- a/apps/web/src/__tests__/api-routes/channel-pins.test.ts
+++ b/apps/web/src/__tests__/api-routes/channel-pins.test.ts
@@ -47,14 +47,12 @@ vi.mock("@/lib/appwrite-core", () => ({
})),
}));
-vi.mock("@/lib/newrelic-utils", () => ({
+vi.mock("@/lib/posthog-utils", () => ({
returnUnauthorized: () => new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
returnForbidden: () => new Response(JSON.stringify({ error: "Forbidden" }), { status: 403 }),
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
recordError: vi.fn(),
- setTransactionName: vi.fn(),
trackApiCall: vi.fn(),
- addTransactionAttributes: vi.fn(),
}));
vi.mock("node-appwrite", () => ({
diff --git a/apps/web/src/__tests__/api-routes/direct-messages.test.ts b/apps/web/src/__tests__/api-routes/direct-messages.test.ts
index 2cdafe4..5364a4e 100644
--- a/apps/web/src/__tests__/api-routes/direct-messages.test.ts
+++ b/apps/web/src/__tests__/api-routes/direct-messages.test.ts
@@ -86,7 +86,7 @@ vi.mock("@/lib/appwrite-core", () => ({
})),
}));
-vi.mock("@/lib/newrelic-utils", () => ({
+vi.mock("@/lib/posthog-utils", () => ({
returnUnauthorized: () => new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
returnForbidden: () => new Response(JSON.stringify({ error: "Forbidden" }), { status: 403 }),
logger: {
@@ -96,10 +96,8 @@ vi.mock("@/lib/newrelic-utils", () => ({
},
recordError: vi.fn(),
recordEvent: vi.fn(),
- setTransactionName: vi.fn(),
trackApiCall: vi.fn(),
trackMessage: vi.fn(),
- addTransactionAttributes: vi.fn(),
}));
vi.mock("@/lib/appwrite-friendships", () => ({
diff --git a/apps/web/src/__tests__/api-routes/dm-encryption-key-route.test.ts b/apps/web/src/__tests__/api-routes/dm-encryption-key-route.test.ts
index 658b872..d358054 100644
--- a/apps/web/src/__tests__/api-routes/dm-encryption-key-route.test.ts
+++ b/apps/web/src/__tests__/api-routes/dm-encryption-key-route.test.ts
@@ -21,7 +21,7 @@ vi.mock("@/lib/appwrite-profiles", () => ({
updateUserProfile: mockUpdateUserProfile,
}));
-vi.mock("@/lib/newrelic-utils", () => ({
+vi.mock("@/lib/posthog-utils", () => ({
returnUnauthorized: () => new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
returnForbidden: () => new Response(JSON.stringify({ error: "Forbidden" }), { status: 403 }),
logger: {
diff --git a/apps/web/src/__tests__/api-routes/dm-reactions.test.ts b/apps/web/src/__tests__/api-routes/dm-reactions.test.ts
index 1396490..1441bc6 100644
--- a/apps/web/src/__tests__/api-routes/dm-reactions.test.ts
+++ b/apps/web/src/__tests__/api-routes/dm-reactions.test.ts
@@ -34,7 +34,7 @@ vi.mock("@/lib/appwrite-core", () => ({
})),
}));
-vi.mock("@/lib/newrelic-utils", () => ({
+vi.mock("@/lib/posthog-utils", () => ({
returnUnauthorized: () => new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
returnForbidden: () => new Response(JSON.stringify({ error: "Forbidden" }), { status: 403 }),
logger: {
@@ -43,9 +43,7 @@ vi.mock("@/lib/newrelic-utils", () => ({
error: vi.fn(),
},
recordError: vi.fn(),
- setTransactionName: vi.fn(),
trackApiCall: vi.fn(),
- addTransactionAttributes: vi.fn(),
}));
describe("DM Reactions API", () => {
@@ -54,7 +52,7 @@ describe("DM Reactions API", () => {
beforeEach(async () => {
vi.clearAllMocks();
-
+
// Dynamically import the route handlers
const module = await import("../../app/api/direct-messages/[messageId]/reactions/route");
POST = module.POST;
diff --git a/apps/web/src/__tests__/api-routes/example-newrelic.test.ts b/apps/web/src/__tests__/api-routes/example-newrelic.test.ts
deleted file mode 100644
index 691dc24..0000000
--- a/apps/web/src/__tests__/api-routes/example-newrelic.test.ts
+++ /dev/null
@@ -1,117 +0,0 @@
-/**
- * Tests for GET /api/example-newrelic endpoint
- */
-import { describe, it, expect, vi, beforeEach } from "vitest";
-import { GET } from "@/app/api/example-newrelic/route";
-import { NextRequest } from "next/server";
-
-// Mock newrelic-utils
-vi.mock("@/lib/newrelic-utils", () => ({
- returnUnauthorized: () => new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
- returnForbidden: () => new Response(JSON.stringify({ error: "Forbidden" }), { status: 403 }),
- logger: {
- info: vi.fn(),
- error: vi.fn(),
- },
- recordError: vi.fn(),
- setTransactionName: vi.fn(),
- trackApiCall: vi.fn(),
- addTransactionAttributes: vi.fn(),
-}));
-
-import {
- logger,
- recordError,
- setTransactionName,
- trackApiCall,
- addTransactionAttributes,
-} from "@/lib/newrelic-utils";
-
-describe("GET /api/example-newrelic", () => {
- beforeEach(() => {
- vi.clearAllMocks();
- });
-
- it("should successfully process request with New Relic instrumentation", async () => {
- const request = new NextRequest(
- "http://localhost:3000/api/example-newrelic",
- {
- headers: {
- "user-agent": "test-agent",
- },
- },
- );
-
- const response = await GET(request);
- const data = await response.json();
-
- expect(response.status).toBe(200);
- expect(data.message).toBe("Hello from New Relic instrumented API!");
-
- // Verify New Relic instrumentation was called
- expect(setTransactionName).toHaveBeenCalledWith(
- "GET /api/example-newrelic",
- );
- expect(addTransactionAttributes).toHaveBeenCalledWith({
- endpoint: "/api/example-newrelic",
- method: "GET",
- userAgent: "test-agent",
- });
- expect(trackApiCall).toHaveBeenCalledWith(
- "/api/example-newrelic",
- "GET",
- 200,
- expect.any(Number),
- { cached: false },
- );
- expect(logger.info).toHaveBeenCalledTimes(1);
- });
-
- it("should use 'unknown' user agent when header is missing", async () => {
- const request = new NextRequest(
- "http://localhost:3000/api/example-newrelic",
- );
-
- const response = await GET(request);
-
- expect(response.status).toBe(200);
- expect(addTransactionAttributes).toHaveBeenCalledWith({
- endpoint: "/api/example-newrelic",
- method: "GET",
- userAgent: "unknown",
- });
- });
-
- it("should track request duration accurately", async () => {
- const request = new NextRequest(
- "http://localhost:3000/api/example-newrelic",
- );
-
- await GET(request);
-
- // Verify duration was tracked (should be >= 0)
- const trackApiCallArgs = vi.mocked(trackApiCall).mock.calls[0];
- const duration = trackApiCallArgs[3];
- expect(typeof duration).toBe("number");
- expect(duration).toBeGreaterThanOrEqual(0);
- });
-
- it("should log request details", async () => {
- const request = new NextRequest(
- "http://localhost:3000/api/example-newrelic",
- );
-
- await GET(request);
-
- // Check that success was logged
- expect(logger.info).toHaveBeenCalledWith(
- "Example API request succeeded",
- {
- duration: expect.any(Number),
- },
- );
-
- // Verify logger.info was called at least once
- expect(logger.info).toHaveBeenCalled();
- });
-});
diff --git a/apps/web/src/__tests__/api-routes/gifs-search-route.test.ts b/apps/web/src/__tests__/api-routes/gifs-search-route.test.ts
index 96107d3..3afa706 100644
--- a/apps/web/src/__tests__/api-routes/gifs-search-route.test.ts
+++ b/apps/web/src/__tests__/api-routes/gifs-search-route.test.ts
@@ -16,14 +16,12 @@ const {
mockLoggerError,
mockLoggerWarn,
mockRequireAuth,
- mockSetTransactionName,
mockTrackApiCall,
} = vi.hoisted(() => ({
mockCheckRateLimit: vi.fn(),
mockLoggerError: vi.fn(),
mockLoggerWarn: vi.fn(),
mockRequireAuth: vi.fn(),
- mockSetTransactionName: vi.fn(),
mockTrackApiCall: vi.fn(),
}));
@@ -32,14 +30,13 @@ vi.mock("@/lib/auth-server", () => ({
requireAuth: mockRequireAuth,
}));
-vi.mock("@/lib/newrelic-utils", () => ({
+vi.mock("@/lib/posthog-utils", () => ({
returnUnauthorized: () => new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
returnForbidden: () => new Response(JSON.stringify({ error: "Forbidden" }), { status: 403 }),
logger: {
error: mockLoggerError,
warn: mockLoggerWarn,
},
- setTransactionName: mockSetTransactionName,
trackApiCall: mockTrackApiCall,
}));
diff --git a/apps/web/src/__tests__/api-routes/invites-code.test.ts b/apps/web/src/__tests__/api-routes/invites-code.test.ts
index e2770b2..59949a3 100644
--- a/apps/web/src/__tests__/api-routes/invites-code.test.ts
+++ b/apps/web/src/__tests__/api-routes/invites-code.test.ts
@@ -16,7 +16,7 @@ vi.mock("@/lib/appwrite-roles", () => ({
getUserRoles: vi.fn(),
}));
vi.mock("@/lib/appwrite-invites");
-vi.mock("@/lib/newrelic-utils", () => ({
+vi.mock("@/lib/posthog-utils", () => ({
returnUnauthorized: () => new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
returnForbidden: () => new Response(JSON.stringify({ error: "Forbidden" }), { status: 403 }),
logger: {
diff --git a/apps/web/src/__tests__/api-routes/invites-join.test.ts b/apps/web/src/__tests__/api-routes/invites-join.test.ts
index 5d856c5..1234c42 100644
--- a/apps/web/src/__tests__/api-routes/invites-join.test.ts
+++ b/apps/web/src/__tests__/api-routes/invites-join.test.ts
@@ -6,7 +6,7 @@ import * as appwriteInvites from "@/lib/appwrite-invites";
// Mock modules
vi.mock("@/lib/auth-server");
vi.mock("@/lib/appwrite-invites");
-vi.mock("@/lib/newrelic-utils", () => ({
+vi.mock("@/lib/posthog-utils", () => ({
returnUnauthorized: () => new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
returnForbidden: () => new Response(JSON.stringify({ error: "Forbidden" }), { status: 403 }),
logger: {
diff --git a/apps/web/src/__tests__/api-routes/memberships.test.ts b/apps/web/src/__tests__/api-routes/memberships.test.ts
index 3bab8a3..4a43210 100644
--- a/apps/web/src/__tests__/api-routes/memberships.test.ts
+++ b/apps/web/src/__tests__/api-routes/memberships.test.ts
@@ -44,7 +44,7 @@ describe("Memberships API", () => {
beforeEach(async () => {
vi.clearAllMocks();
-
+
// Dynamically import the route handler
const module = await import("../../app/api/memberships/route");
GET = module.GET;
diff --git a/apps/web/src/__tests__/api-routes/message-reactions.test.ts b/apps/web/src/__tests__/api-routes/message-reactions.test.ts
index 4d299bf..3e0d2c3 100644
--- a/apps/web/src/__tests__/api-routes/message-reactions.test.ts
+++ b/apps/web/src/__tests__/api-routes/message-reactions.test.ts
@@ -35,7 +35,7 @@ vi.mock("@/lib/appwrite-core", () => ({
}));
// Mock New Relic utilities
-vi.mock("@/lib/newrelic-utils", () => ({
+vi.mock("@/lib/posthog-utils", () => ({
returnUnauthorized: () => new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
returnForbidden: () => new Response(JSON.stringify({ error: "Forbidden" }), { status: 403 }),
logger: {
@@ -44,9 +44,7 @@ vi.mock("@/lib/newrelic-utils", () => ({
error: vi.fn(),
},
recordError: vi.fn(),
- setTransactionName: vi.fn(),
trackApiCall: vi.fn(),
- addTransactionAttributes: vi.fn(),
}));
describe("Message Reactions API", () => {
@@ -55,7 +53,7 @@ describe("Message Reactions API", () => {
beforeEach(async () => {
vi.clearAllMocks();
-
+
// Dynamically import the route handlers
const module = await import("../../app/api/messages/[messageId]/reactions/route");
POST = module.POST;
diff --git a/apps/web/src/__tests__/api-routes/pin-route.test.ts b/apps/web/src/__tests__/api-routes/pin-route.test.ts
index ca06da8..6b6be52 100644
--- a/apps/web/src/__tests__/api-routes/pin-route.test.ts
+++ b/apps/web/src/__tests__/api-routes/pin-route.test.ts
@@ -88,12 +88,10 @@ vi.mock("../../lib/permissions", () => ({
),
}));
-vi.mock("../../lib/newrelic-utils", () => ({
+vi.mock("../../lib/posthog-utils", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
recordError: vi.fn(),
- setTransactionName: vi.fn(),
trackApiCall: vi.fn(),
- addTransactionAttributes: vi.fn(),
}));
describe("Pin route", () => {
diff --git a/apps/web/src/__tests__/api-routes/profiles-batch.test.ts b/apps/web/src/__tests__/api-routes/profiles-batch.test.ts
index 4230a4e..58ee873 100644
--- a/apps/web/src/__tests__/api-routes/profiles-batch.test.ts
+++ b/apps/web/src/__tests__/api-routes/profiles-batch.test.ts
@@ -54,7 +54,7 @@ vi.mock("@/lib/appwrite-core", () => ({
})),
}));
-vi.mock("@/lib/newrelic-utils", () => ({
+vi.mock("@/lib/posthog-utils", () => ({
returnUnauthorized: () => new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
returnForbidden: () => new Response(JSON.stringify({ error: "Forbidden" }), { status: 403 }),
logger: {
@@ -63,9 +63,7 @@ vi.mock("@/lib/newrelic-utils", () => ({
error: vi.fn(),
},
recordError: vi.fn(),
- setTransactionName: vi.fn(),
trackApiCall: vi.fn(),
- addTransactionAttributes: vi.fn(),
}));
vi.mock("@/lib/auth-server", () => ({
diff --git a/apps/web/src/__tests__/api-routes/search-messages.test.ts b/apps/web/src/__tests__/api-routes/search-messages.test.ts
index 9d5aecc..0109263 100644
--- a/apps/web/src/__tests__/api-routes/search-messages.test.ts
+++ b/apps/web/src/__tests__/api-routes/search-messages.test.ts
@@ -84,7 +84,7 @@ vi.mock("@/lib/appwrite-friendships", () => ({
getRelationshipMap: mockGetRelationshipMap,
}));
-vi.mock("@/lib/newrelic-utils", () => ({
+vi.mock("@/lib/posthog-utils", () => ({
returnUnauthorized: () => new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
returnForbidden: () => new Response(JSON.stringify({ error: "Forbidden" }), { status: 403 }),
logger: {
@@ -93,7 +93,6 @@ vi.mock("@/lib/newrelic-utils", () => ({
error: vi.fn(),
},
recordError: vi.fn(),
- setTransactionName: vi.fn(),
trackApiCall: vi.fn(),
}));
diff --git a/apps/web/src/__tests__/api-routes/servers-invites.test.ts b/apps/web/src/__tests__/api-routes/servers-invites.test.ts
index 4c3f3e7..28f0481 100644
--- a/apps/web/src/__tests__/api-routes/servers-invites.test.ts
+++ b/apps/web/src/__tests__/api-routes/servers-invites.test.ts
@@ -15,7 +15,7 @@ vi.mock("@/lib/appwrite-invites");
vi.mock("@/lib/server-channel-access", () => ({
getServerPermissionsForUser: mockGetServerPermissionsForUser,
}));
-vi.mock("@/lib/newrelic-utils", () => ({
+vi.mock("@/lib/posthog-utils", () => ({
returnUnauthorized: () => new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
returnForbidden: () => new Response(JSON.stringify({ error: "Forbidden" }), { status: 403 }),
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn() },
diff --git a/apps/web/src/__tests__/api-routes/servers-join.test.ts b/apps/web/src/__tests__/api-routes/servers-join.test.ts
index 56b46bc..ea9b435 100644
--- a/apps/web/src/__tests__/api-routes/servers-join.test.ts
+++ b/apps/web/src/__tests__/api-routes/servers-join.test.ts
@@ -67,7 +67,7 @@ vi.mock("node-appwrite", () => ({
}));
// Mock New Relic utilities
-vi.mock("@/lib/newrelic-utils", () => ({
+vi.mock("@/lib/posthog-utils", () => ({
returnUnauthorized: () => new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
returnForbidden: () => new Response(JSON.stringify({ error: "Forbidden" }), { status: 403 }),
logger: {
@@ -76,9 +76,7 @@ vi.mock("@/lib/newrelic-utils", () => ({
error: vi.fn(),
},
recordError: vi.fn(),
- setTransactionName: vi.fn(),
trackApiCall: vi.fn(),
- addTransactionAttributes: vi.fn(),
recordEvent: vi.fn(),
}));
@@ -87,7 +85,7 @@ describe("Server Join API", () => {
beforeEach(async () => {
vi.clearAllMocks();
-
+
// Dynamically import the route handler
const module = await import("../../app/api/servers/join/route");
POST = module.POST;
@@ -241,7 +239,7 @@ describe("Server Join API", () => {
expect(response.status).toBe(200);
expect(data.success).toBe(true);
-
+
// Verify membership was created without document-level permissions
expect(mockCreateDocument).toHaveBeenCalledWith(
"test-db",
@@ -291,7 +289,7 @@ describe("Server Join API", () => {
expect(response.status).toBe(200);
expect(data.success).toBe(true);
-
+
// Member count is no longer stored in DB
expect(mockUpdateDocument).not.toHaveBeenCalled();
});
diff --git a/apps/web/src/__tests__/api-routes/status-batch.test.ts b/apps/web/src/__tests__/api-routes/status-batch.test.ts
index 12f3bf9..8769b31 100644
--- a/apps/web/src/__tests__/api-routes/status-batch.test.ts
+++ b/apps/web/src/__tests__/api-routes/status-batch.test.ts
@@ -33,7 +33,7 @@ vi.mock("@/lib/appwrite-core", () => ({
})),
}));
-vi.mock("@/lib/newrelic-utils", () => ({
+vi.mock("@/lib/posthog-utils", () => ({
returnUnauthorized: () => new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
returnForbidden: () => new Response(JSON.stringify({ error: "Forbidden" }), { status: 403 }),
logger: {
@@ -41,9 +41,7 @@ vi.mock("@/lib/newrelic-utils", () => ({
warn: vi.fn(),
error: vi.fn(),
},
- setTransactionName: vi.fn(),
trackApiCall: vi.fn(),
- addTransactionAttributes: vi.fn(),
}));
vi.mock("node-appwrite", () => ({
diff --git a/apps/web/src/__tests__/api-routes/stickers-route.test.ts b/apps/web/src/__tests__/api-routes/stickers-route.test.ts
index add250d..9c40c7d 100644
--- a/apps/web/src/__tests__/api-routes/stickers-route.test.ts
+++ b/apps/web/src/__tests__/api-routes/stickers-route.test.ts
@@ -14,12 +14,10 @@ class MockAuthError extends Error {
const {
mockGetBuiltinStickerPacks,
mockRequireAuth,
- mockSetTransactionName,
mockTrackApiCall,
} = vi.hoisted(() => ({
mockGetBuiltinStickerPacks: vi.fn(),
mockRequireAuth: vi.fn(),
- mockSetTransactionName: vi.fn(),
mockTrackApiCall: vi.fn(),
}));
@@ -32,10 +30,9 @@ vi.mock("@/lib/gif-sticker", () => ({
getBuiltinStickerPacks: mockGetBuiltinStickerPacks,
}));
-vi.mock("@/lib/newrelic-utils", () => ({
+vi.mock("@/lib/posthog-utils", () => ({
returnUnauthorized: () => new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
returnForbidden: () => new Response(JSON.stringify({ error: "Forbidden" }), { status: 403 }),
- setTransactionName: mockSetTransactionName,
trackApiCall: mockTrackApiCall,
}));
diff --git a/apps/web/src/__tests__/api-routes/thread-route.test.ts b/apps/web/src/__tests__/api-routes/thread-route.test.ts
index 5fc4172..24aa9e8 100644
--- a/apps/web/src/__tests__/api-routes/thread-route.test.ts
+++ b/apps/web/src/__tests__/api-routes/thread-route.test.ts
@@ -69,7 +69,7 @@ vi.mock("@/lib/server-channel-access", () => ({
),
}));
-vi.mock("@/lib/newrelic-utils", () => ({
+vi.mock("@/lib/posthog-utils", () => ({
returnUnauthorized: () => new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
returnForbidden: () => new Response(JSON.stringify({ error: "Forbidden" }), { status: 403 }),
logger: {
@@ -78,9 +78,7 @@ vi.mock("@/lib/newrelic-utils", () => ({
error: vi.fn(),
},
recordError: vi.fn(),
- setTransactionName: vi.fn(),
trackApiCall: vi.fn(),
- addTransactionAttributes: vi.fn(),
}));
describe("Thread route", () => {
diff --git a/apps/web/src/__tests__/api-routes/upload-file.test.ts b/apps/web/src/__tests__/api-routes/upload-file.test.ts
index 087573c..acf9c2a 100644
--- a/apps/web/src/__tests__/api-routes/upload-file.test.ts
+++ b/apps/web/src/__tests__/api-routes/upload-file.test.ts
@@ -101,7 +101,7 @@ vi.mock("@/lib/appwrite-core", () => ({
})),
}));
-vi.mock("@/lib/newrelic-utils", () => ({
+vi.mock("@/lib/posthog-utils", () => ({
returnUnauthorized: () => new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
returnForbidden: () => new Response(JSON.stringify({ error: "Forbidden" }), { status: 403 }),
logger: {
@@ -110,9 +110,7 @@ vi.mock("@/lib/newrelic-utils", () => ({
error: vi.fn(),
},
recordError: vi.fn(),
- setTransactionName: vi.fn(),
trackApiCall: vi.fn(),
- addTransactionAttributes: vi.fn(),
recordEvent: vi.fn(),
}));
diff --git a/apps/web/src/__tests__/api-routes/upload-image.test.ts b/apps/web/src/__tests__/api-routes/upload-image.test.ts
index 6429ef8..59f5e98 100644
--- a/apps/web/src/__tests__/api-routes/upload-image.test.ts
+++ b/apps/web/src/__tests__/api-routes/upload-image.test.ts
@@ -52,7 +52,7 @@ vi.mock("@/lib/appwrite-core", () => ({
})),
}));
-vi.mock("@/lib/newrelic-utils", () => ({
+vi.mock("@/lib/posthog-utils", () => ({
returnUnauthorized: () => new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
returnForbidden: () => new Response(JSON.stringify({ error: "Forbidden" }), { status: 403 }),
logger: {
@@ -61,9 +61,7 @@ vi.mock("@/lib/newrelic-utils", () => ({
error: vi.fn(),
},
recordError: vi.fn(),
- setTransactionName: vi.fn(),
trackApiCall: vi.fn(),
- addTransactionAttributes: vi.fn(),
recordEvent: vi.fn(),
}));
diff --git a/apps/web/src/__tests__/api-routes/verify-email-route.test.ts b/apps/web/src/__tests__/api-routes/verify-email-route.test.ts
index 4f695c0..127a033 100644
--- a/apps/web/src/__tests__/api-routes/verify-email-route.test.ts
+++ b/apps/web/src/__tests__/api-routes/verify-email-route.test.ts
@@ -41,7 +41,7 @@ vi.mock("@/lib/feature-flags", () => ({
getFeatureFlag: mockGetFeatureFlag,
}));
-vi.mock("@/lib/newrelic-utils", () => ({
+vi.mock("@/lib/posthog-utils", () => ({
returnUnauthorized: () => new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
returnForbidden: () => new Response(JSON.stringify({ error: "Forbidden" }), { status: 403 }),
logger: {
diff --git a/apps/web/src/__tests__/appwrite-core.test.ts b/apps/web/src/__tests__/appwrite-core.test.ts
index ea64920..f7cc3da 100644
--- a/apps/web/src/__tests__/appwrite-core.test.ts
+++ b/apps/web/src/__tests__/appwrite-core.test.ts
@@ -124,7 +124,7 @@ describe("withSession unauthorized flow", () => {
it("throws UnauthorizedError when account.get fails", async () => {
// Reset module caches to allow fresh import with new mock
vi.resetModules();
-
+
vi.doMock("appwrite", () => {
class Client {
setEndpoint() {
@@ -182,7 +182,7 @@ describe("createServer integration (mocked)", () => {
it("creates server document with provided name", async () => {
// Reset modules to allow fresh mocks
vi.resetModules();
-
+
(process.env as any).APPWRITE_ENDPOINT = "http://x";
(process.env as any).APPWRITE_PROJECT_ID = "p";
(process.env as any).APPWRITE_DATABASE_ID = "db";
@@ -191,7 +191,7 @@ describe("createServer integration (mocked)", () => {
"channels";
(process.env as any).APPWRITE_MEMBERSHIPS_COLLECTION_ID =
"memberships";
-
+
// Remock appwrite with ID export prior to importing createServer implementation
vi.doMock("appwrite", () => {
class Client {
@@ -241,7 +241,7 @@ describe("createServer integration (mocked)", () => {
ID: { unique: () => "unique" },
};
});
-
+
const core = await import("../lib/appwrite-core");
core.resetEnvCache();
const { createServer } = await import("../lib/appwrite-servers");
diff --git a/apps/web/src/__tests__/appwrite-invites.test.ts b/apps/web/src/__tests__/appwrite-invites.test.ts
index 707693d..ae03e70 100644
--- a/apps/web/src/__tests__/appwrite-invites.test.ts
+++ b/apps/web/src/__tests__/appwrite-invites.test.ts
@@ -1,8 +1,8 @@
/**
* Server Invite System - Behavior Documentation
- *
+ *
* This file documents the expected behavior of the server invite system.
- *
+ *
* Core Features:
* - Unique 10-character invite codes
* - Optionally expire at a specific date/time
@@ -10,20 +10,20 @@
* - Grant temporary or permanent memberships
* - Track who used each invite and when
* - Revocable by admins/moderators
- *
+ *
* Validation Rules:
* - Invites must exist and match the code exactly
* - Expired invites are rejected
* - Invites at max uses are rejected
* - Users already in the server cannot use an invite
- *
+ *
* Security:
* - Only admins/mods can create invites
* - Only admins/mods can revoke invites
* - Only admins/mods can view invite usage
* - Public users can validate and use invites
* - Rate limiting prevents abuse
- *
+ *
* @see /docs/SERVER_INVITES.md for full documentation
*/
diff --git a/apps/web/src/__tests__/appwrite-roles-extended.test.ts b/apps/web/src/__tests__/appwrite-roles-extended.test.ts
index d398287..642f91a 100644
--- a/apps/web/src/__tests__/appwrite-roles-extended.test.ts
+++ b/apps/web/src/__tests__/appwrite-roles-extended.test.ts
@@ -130,7 +130,6 @@ describe("appwrite-roles - Extended Coverage", () => {
(process.env as Record).APPWRITE_ADMIN_USER_IDS =
"override-admin-1,override-admin-2";
-
const { getUserRoles } = await import("../lib/appwrite-roles");
const result = await getUserRoles("override-admin-1");
@@ -143,7 +142,6 @@ describe("appwrite-roles - Extended Coverage", () => {
(process.env as Record).APPWRITE_MODERATOR_USER_IDS =
"override-mod-1";
-
const { getUserRoles } = await import("../lib/appwrite-roles");
const result = await getUserRoles("override-mod-1");
@@ -158,7 +156,6 @@ describe("appwrite-roles - Extended Coverage", () => {
delete (process.env as Record)
.APPWRITE_MODERATOR_TEAM_ID;
-
const { getUserRoles } = await import("../lib/appwrite-roles");
const result = await getUserRoles("user1");
@@ -234,7 +231,6 @@ describe("appwrite-roles - Extended Coverage", () => {
});
setTeamMemberships("team-vip", ["vip-user"]);
-
const { getUserRoleTags } = await import("../lib/appwrite-roles");
const result = await getUserRoleTags("vip-user");
@@ -248,7 +244,6 @@ describe("appwrite-roles - Extended Coverage", () => {
(process.env as Record).ROLE_TEAM_MAP =
"invalid-json{";
-
const { getUserRoleTags } = await import("../lib/appwrite-roles");
// Should not throw
@@ -264,7 +259,6 @@ describe("appwrite-roles - Extended Coverage", () => {
setTeamMemberships("team-admin", ["admin-user"]);
setTeamMemberships("team-custom-admin", ["admin-user"]);
-
const { getUserRoleTags } = await import("../lib/appwrite-roles");
const result = await getUserRoleTags("admin-user");
@@ -351,7 +345,6 @@ describe("appwrite-roles - Extended Coverage", () => {
(process.env as Record).APPWRITE_ADMIN_USER_IDS =
" user1 , user2 ,user3 ";
-
const { getUserRoles } = await import("../lib/appwrite-roles");
const result1 = await getUserRoles("user1");
@@ -367,7 +360,6 @@ describe("appwrite-roles - Extended Coverage", () => {
(process.env as Record).APPWRITE_ADMIN_USER_IDS = "";
(process.env as Record).APPWRITE_MODERATOR_USER_IDS = "";
-
const { getUserRoles } = await import("../lib/appwrite-roles");
const result = await getUserRoles("user1");
@@ -397,7 +389,6 @@ describe("appwrite-roles - Extended Coverage", () => {
},
}));
-
const { getUserRoles } = await import("../lib/appwrite-roles");
// Should not throw, returns false roles
@@ -417,7 +408,6 @@ describe("appwrite-roles - Extended Coverage", () => {
setTeamMemberships("team-gold", ["rich-user"]);
setTeamMemberships("team-silver", ["rich-user"]);
-
const { getUserRoleTags } = await import("../lib/appwrite-roles");
const result = await getUserRoleTags("rich-user");
@@ -452,7 +442,6 @@ describe("appwrite-roles - Extended Coverage", () => {
it("should work without API key (browser mode)", async () => {
delete (process.env as Record).APPWRITE_API_KEY;
-
const { getUserRoles } = await import("../lib/appwrite-roles");
// Should still work, just using browser client
diff --git a/apps/web/src/__tests__/cache-utils-comprehensive.test.ts b/apps/web/src/__tests__/cache-utils-comprehensive.test.ts
index 59d5ad4..e15501e 100644
--- a/apps/web/src/__tests__/cache-utils-comprehensive.test.ts
+++ b/apps/web/src/__tests__/cache-utils-comprehensive.test.ts
@@ -26,17 +26,17 @@ describe("apiCache", () => {
it("should return null for expired data", () => {
apiCache.set("key1", "value1", 1000);
-
+
vi.advanceTimersByTime(1001);
-
+
expect(apiCache.get("key1")).toBeNull();
});
it("should return data before TTL expires", () => {
apiCache.set("key1", "value1", 1000);
-
+
vi.advanceTimersByTime(500);
-
+
expect(apiCache.get("key1")).toBe("value1");
});
@@ -101,9 +101,9 @@ describe("apiCache", () => {
it("should return false for expired data", () => {
apiCache.set("key1", "value1", 1000);
-
+
vi.advanceTimersByTime(1001);
-
+
expect(apiCache.has("key1")).toBe(false);
});
});
@@ -111,29 +111,29 @@ describe("apiCache", () => {
describe("dedupe", () => {
it("should return cached data if available", async () => {
const fetcher = vi.fn().mockResolvedValue("fresh data");
-
+
apiCache.set("key1", "cached data", 1000);
-
+
const result = await apiCache.dedupe("key1", fetcher, 1000);
-
+
expect(result).toBe("cached data");
expect(fetcher).not.toHaveBeenCalled();
});
it("should call fetcher if data not cached", async () => {
const fetcher = vi.fn().mockResolvedValue("fresh data");
-
+
const result = await apiCache.dedupe("key1", fetcher, 1000);
-
+
expect(result).toBe("fresh data");
expect(fetcher).toHaveBeenCalledTimes(1);
});
it("should cache fetched data", async () => {
const fetcher = vi.fn().mockResolvedValue("fresh data");
-
+
await apiCache.dedupe("key1", fetcher, 1000);
-
+
expect(apiCache.get("key1")).toBe("fresh data");
});
@@ -147,15 +147,15 @@ describe("apiCache", () => {
}, 100);
})
);
-
+
// Start two concurrent requests
const promise1 = apiCache.dedupe("key1", fetcher, 1000);
const promise2 = apiCache.dedupe("key1", fetcher, 1000);
-
+
vi.advanceTimersByTime(100);
-
+
const [result1, result2] = await Promise.all([promise1, promise2]);
-
+
expect(result1).toBe("data");
expect(result2).toBe("data");
expect(fetcher).toHaveBeenCalledTimes(1);
@@ -163,9 +163,9 @@ describe("apiCache", () => {
it("should handle fetcher errors", async () => {
const fetcher = vi.fn().mockRejectedValue(new Error("Fetch failed"));
-
+
await expect(apiCache.dedupe("key1", fetcher, 1000)).rejects.toThrow("Fetch failed");
-
+
// Should not cache failed requests
expect(apiCache.get("key1")).toBeNull();
});
@@ -174,37 +174,37 @@ describe("apiCache", () => {
const fetcher = vi.fn()
.mockRejectedValueOnce(new Error("First attempt failed"))
.mockResolvedValueOnce("success");
-
+
await expect(apiCache.dedupe("key1", fetcher, 1000)).rejects.toThrow("First attempt failed");
-
+
const result = await apiCache.dedupe("key1", fetcher, 1000);
-
+
expect(result).toBe("success");
expect(fetcher).toHaveBeenCalledTimes(2);
});
it("should clear pending request on error", async () => {
const fetcher = vi.fn().mockRejectedValue(new Error("Failed"));
-
+
await expect(apiCache.dedupe("key1", fetcher, 1000)).rejects.toThrow("Failed");
-
+
// Verify pending request was cleaned up
const fetcher2 = vi.fn().mockResolvedValue("success");
await apiCache.dedupe("key1", fetcher2, 1000);
-
+
expect(fetcher2).toHaveBeenCalledTimes(1);
});
it("should handle different TTL values", async () => {
const fetcher1 = vi.fn().mockResolvedValue("data1");
const fetcher2 = vi.fn().mockResolvedValue("data2");
-
+
await apiCache.dedupe("key1", fetcher1, 500);
-
+
vi.advanceTimersByTime(600);
-
+
await apiCache.dedupe("key1", fetcher2, 1000);
-
+
expect(fetcher2).toHaveBeenCalledTimes(1);
expect(apiCache.get("key1")).toBe("data2");
});
@@ -213,24 +213,24 @@ describe("apiCache", () => {
describe("Edge Cases", () => {
it("should handle zero TTL", () => {
apiCache.set("key1", "value1", 0);
-
+
vi.advanceTimersByTime(1);
-
+
expect(apiCache.get("key1")).toBeNull();
});
it("should handle negative TTL", () => {
apiCache.set("key1", "value1", -1000);
-
+
expect(apiCache.get("key1")).toBeNull();
});
it("should handle very large TTL", () => {
const largeTTL = Number.MAX_SAFE_INTEGER;
apiCache.set("key1", "value1", largeTTL);
-
+
vi.advanceTimersByTime(1000000);
-
+
expect(apiCache.get("key1")).toBe("value1");
});
@@ -254,7 +254,7 @@ describe("apiCache", () => {
apiCache.set("key-with-dashes", "value2", 1000);
apiCache.set("key_with_underscores", "value3", 1000);
apiCache.set("key.with.dots", "value4", 1000);
-
+
expect(apiCache.get("key:with:colons")).toBe("value1");
expect(apiCache.get("key-with-dashes")).toBe("value2");
expect(apiCache.get("key_with_underscores")).toBe("value3");
@@ -265,7 +265,7 @@ describe("apiCache", () => {
describe("Persistence", () => {
it("should persist across module imports", () => {
apiCache.set("persistent", "data", 1000);
-
+
// Simulate accessing from different parts of the app
expect(apiCache.has("persistent")).toBe(true);
expect(apiCache.get("persistent")).toBe("data");
@@ -273,9 +273,9 @@ describe("apiCache", () => {
it("should support dedupe operations", async () => {
const fetcher = vi.fn().mockResolvedValue("api data");
-
+
const result = await apiCache.dedupe("api:users", fetcher, 5000);
-
+
expect(result).toBe("api data");
expect(fetcher).toHaveBeenCalledTimes(1);
});
@@ -297,7 +297,7 @@ describe("CACHE_TTL", () => {
expect(CACHE_TTL.SERVERS).toBeGreaterThan(0);
expect(CACHE_TTL.CHANNELS).toBeGreaterThan(0);
expect(CACHE_TTL.MESSAGES).toBeGreaterThan(0);
-
+
// Server data should have longer TTL than messages
expect(CACHE_TTL.SERVERS).toBeGreaterThan(CACHE_TTL.MESSAGES);
});
diff --git a/apps/web/src/__tests__/chat-ui-fixes.test.ts b/apps/web/src/__tests__/chat-ui-fixes.test.ts
index 295139c..5cea557 100644
--- a/apps/web/src/__tests__/chat-ui-fixes.test.ts
+++ b/apps/web/src/__tests__/chat-ui-fixes.test.ts
@@ -13,12 +13,12 @@ describe("Chat UI Fixes", () => {
// Simulate the applyCreate logic
const newMessage = { $id: "msg1", text: "Hello", $createdAt: "2025-01-01T00:00:00Z" };
-
+
// Check if message already exists
const messageExists = messages.some((m) => m.$id === newMessage.$id);
-
+
expect(messageExists).toBe(true);
-
+
// If message exists, prev should be returned unchanged
const result = messageExists ? messages : [...messages, newMessage];
expect(result).toHaveLength(2);
@@ -31,14 +31,14 @@ describe("Chat UI Fixes", () => {
];
const newMessage = { $id: "msg3", text: "New", $createdAt: "2025-01-01T00:02:00Z" };
-
+
const messageExists = messages.some((m) => m.$id === newMessage.$id);
expect(messageExists).toBe(false);
-
+
const result = messageExists ? messages : [...messages, newMessage].sort((a, b) =>
a.$createdAt.localeCompare(b.$createdAt)
);
-
+
expect(result).toHaveLength(2);
expect(result[1].$id).toBe("msg3");
});
@@ -52,12 +52,12 @@ describe("Chat UI Fixes", () => {
];
const duplicateMessage = { $id: "dm1", text: "Hello", $createdAt: "2025-01-01T00:00:00Z", senderId: "user1", receiverId: "user2" };
-
+
// Check if message already exists
const messageExists = messages.some((m) => m.$id === duplicateMessage.$id);
-
+
expect(messageExists).toBe(true);
-
+
// If message exists, prev should be returned unchanged
const result = messageExists ? messages : [...messages, duplicateMessage];
expect(result).toHaveLength(2);
@@ -69,10 +69,10 @@ describe("Chat UI Fixes", () => {
];
const newMessage = { $id: "dm3", text: "New message", $createdAt: "2025-01-01T00:02:00Z", senderId: "user1", receiverId: "user2" };
-
+
const messageExists = messages.some((m) => m.$id === newMessage.$id);
expect(messageExists).toBe(false);
-
+
const result = messageExists ? messages : [...messages, newMessage];
expect(result).toHaveLength(2);
expect(result[1].$id).toBe("dm3");
@@ -87,9 +87,9 @@ describe("Chat UI Fixes", () => {
];
const updatedMessage = { $id: "msg1", text: "Edited text", $createdAt: "2025-01-01T00:00:00Z", editedAt: "2025-01-01T00:03:00Z" };
-
+
const result = messages.map((m) => (m.$id === updatedMessage.$id ? updatedMessage : m));
-
+
expect(result).toHaveLength(2);
expect(result[0].text).toBe("Edited text");
expect(result[0].editedAt).toBe("2025-01-01T00:03:00Z");
@@ -106,9 +106,9 @@ describe("Chat UI Fixes", () => {
];
const messageToDelete = { $id: "msg2" };
-
+
const result = messages.filter((m) => m.$id !== messageToDelete.$id);
-
+
expect(result).toHaveLength(2);
expect(result.find((m) => m.$id === "msg2")).toBeUndefined();
expect(result[0].$id).toBe("msg1");
diff --git a/apps/web/src/__tests__/client-logger.test.ts b/apps/web/src/__tests__/client-logger.test.ts
index c08fce6..9fa4f20 100644
--- a/apps/web/src/__tests__/client-logger.test.ts
+++ b/apps/web/src/__tests__/client-logger.test.ts
@@ -6,22 +6,14 @@ import { logger } from "@/lib/client-logger";
describe("ClientLogger", () => {
const originalNodeEnv = process.env.NODE_ENV;
- const originalClientTelemetryProvider =
- process.env.NEXT_PUBLIC_TELEMETRY_PROVIDER;
- let mockNewRelic: any;
let mockPostHog: any;
beforeEach(() => {
vi.clearAllMocks();
- mockNewRelic = {
- addPageAction: vi.fn(),
- noticeError: vi.fn(),
- };
mockPostHog = {
capture: vi.fn(),
captureException: vi.fn(),
};
- delete process.env.NEXT_PUBLIC_TELEMETRY_PROVIDER;
// Mock console methods
vi.spyOn(console, "log").mockImplementation(() => {});
@@ -31,8 +23,6 @@ describe("ClientLogger", () => {
afterEach(() => {
process.env.NODE_ENV = originalNodeEnv;
- process.env.NEXT_PUBLIC_TELEMETRY_PROVIDER =
- originalClientTelemetryProvider;
delete (global as any).window;
vi.restoreAllMocks();
});
@@ -57,29 +47,9 @@ describe("ClientLogger", () => {
expect(console.log).not.toHaveBeenCalled();
});
- it("should send to New Relic when available", () => {
- (global as any).window = {
- newrelic: mockNewRelic,
- posthog: mockPostHog,
- };
-
- logger.info("Test message", { key: "value" });
-
- expect(mockNewRelic.addPageAction).toHaveBeenCalledWith(
- "log_info",
- {
- message: "Test message",
- key: "value",
- },
- );
- });
-
- it("should send to PostHog when provider is posthog", () => {
- process.env.NEXT_PUBLIC_TELEMETRY_PROVIDER = "posthog";
- (global as any).window = {
- newrelic: mockNewRelic,
- posthog: mockPostHog,
- };
+ it("should capture to PostHog when hydrated in the browser", () => {
+ (global as any).window = { posthog: mockPostHog };
+ process.env.NODE_ENV = "production";
logger.info("Test message", { key: "value" });
@@ -87,7 +57,6 @@ describe("ClientLogger", () => {
message: "Test message",
key: "value",
});
- expect(mockNewRelic.addPageAction).not.toHaveBeenCalled();
});
it("should handle info without attributes", () => {
@@ -124,21 +93,15 @@ describe("ClientLogger", () => {
expect(console.warn).not.toHaveBeenCalled();
});
- it("should send warnings to New Relic", () => {
- (global as any).window = {
- newrelic: mockNewRelic,
- posthog: mockPostHog,
- };
+ it("should capture warnings to PostHog", () => {
+ (global as any).window = { posthog: mockPostHog };
logger.warn("Warning", { code: 123 });
- expect(mockNewRelic.addPageAction).toHaveBeenCalledWith(
- "log_warn",
- {
- message: "Warning",
- code: 123,
- },
- );
+ expect(mockPostHog.capture).toHaveBeenCalledWith("log_warn", {
+ message: "Warning",
+ code: 123,
+ });
});
});
@@ -157,27 +120,8 @@ describe("ClientLogger", () => {
);
});
- it("should send Error objects to New Relic noticeError", () => {
- (global as any).window = {
- newrelic: mockNewRelic,
- posthog: mockPostHog,
- };
- const testError = new Error("Test error");
-
- logger.error("Error occurred", testError, { userId: "456" });
-
- expect(mockNewRelic.noticeError).toHaveBeenCalledWith(testError, {
- message: "Error occurred",
- userId: "456",
- });
- });
-
- it("should send Error objects to PostHog captureException when provider is posthog", () => {
- process.env.NEXT_PUBLIC_TELEMETRY_PROVIDER = "posthog";
- (global as any).window = {
- newrelic: mockNewRelic,
- posthog: mockPostHog,
- };
+ it("should send Error objects to PostHog captureException", () => {
+ (global as any).window = { posthog: mockPostHog };
const testError = new Error("Test error");
logger.error("Error occurred", testError, { userId: "456" });
@@ -189,24 +133,33 @@ describe("ClientLogger", () => {
userId: "456",
},
);
- expect(mockNewRelic.noticeError).not.toHaveBeenCalled();
});
- it("should send string errors to New Relic addPageAction", () => {
- (global as any).window = {
- newrelic: mockNewRelic,
- posthog: mockPostHog,
- };
+ it("should capture string errors as log_error events", () => {
+ (global as any).window = { posthog: mockPostHog };
logger.error("Error message", "String error", { context: "api" });
- expect(mockNewRelic.addPageAction).toHaveBeenCalledWith(
- "log_error",
- {
- message: "Error message",
- error: "String error",
- context: "api",
- },
+ expect(mockPostHog.capture).toHaveBeenCalledWith("log_error", {
+ message: "Error message",
+ error: "String error",
+ context: "api",
+ });
+ });
+
+ it("should fall back to a client_error event when captureException is unavailable", () => {
+ const posthogWithoutException = { capture: vi.fn() };
+ (global as any).window = { posthog: posthogWithoutException };
+ const testError = new Error("boom");
+
+ logger.error("Error occurred", testError);
+
+ expect(posthogWithoutException.capture).toHaveBeenCalledWith(
+ "client_error",
+ expect.objectContaining({
+ errorMessage: "boom",
+ message: "Error occurred",
+ }),
);
});
@@ -247,38 +200,21 @@ describe("ClientLogger", () => {
});
});
- it("should not log debug in production", () => {
- process.env.NODE_ENV = "production";
-
- logger.debug("Debug message");
-
- expect(console.log).not.toHaveBeenCalled();
- });
-
- it("should never send debug to New Relic", () => {
- (global as any).window = {
- newrelic: mockNewRelic,
- posthog: mockPostHog,
- };
+ it("should never send debug to PostHog", () => {
+ (global as any).window = { posthog: mockPostHog };
process.env.NODE_ENV = "development";
logger.debug("Debug message");
- expect(mockNewRelic.addPageAction).not.toHaveBeenCalled();
- expect(mockNewRelic.noticeError).not.toHaveBeenCalled();
+ expect(mockPostHog.capture).not.toHaveBeenCalled();
+ expect(mockPostHog.captureException).not.toHaveBeenCalled();
});
});
- describe("getNewRelic", () => {
- it("should return null when window is undefined (server-side)", () => {
- const originalWindow = global.window;
- delete (global as any).window;
-
+ describe("server-side", () => {
+ it("should not throw when there is no window", () => {
logger.info("Test");
-
- // Should not throw error
-
- (global as any).window = originalWindow;
+ logger.error("Test error", new Error("nope"));
});
});
@@ -303,4 +239,4 @@ describe("ClientLogger", () => {
});
});
});
-});
+});
\ No newline at end of file
diff --git a/apps/web/src/__tests__/components/button.test.tsx b/apps/web/src/__tests__/components/button.test.tsx
index 7659c6d..cc36054 100644
--- a/apps/web/src/__tests__/components/button.test.tsx
+++ b/apps/web/src/__tests__/components/button.test.tsx
@@ -8,7 +8,7 @@ import { Button } from "../../components/ui/button";
describe("Button Component", () => {
it("should render button with text", () => {
render(Click me );
-
+
expect(screen.getByRole("button", { name: "Click me" })).toBeInTheDocument();
});
@@ -18,67 +18,67 @@ describe("Button Component", () => {
const handleClick = () => {
clicked = true;
};
-
+
render(Click me );
-
+
const button = screen.getByRole("button", { name: "Click me" });
await user.click(button);
-
+
expect(clicked).toBe(true);
});
it("should render with default variant", () => {
render(Default );
-
+
const button = screen.getByRole("button", { name: "Default" });
expect(button).toBeInTheDocument();
});
it("should render with destructive variant", () => {
render(Delete );
-
+
const button = screen.getByRole("button", { name: "Delete" });
expect(button).toBeInTheDocument();
});
it("should render with outline variant", () => {
render(Outline );
-
+
const button = screen.getByRole("button", { name: "Outline" });
expect(button).toBeInTheDocument();
});
it("should render with ghost variant", () => {
render(Ghost );
-
+
const button = screen.getByRole("button", { name: "Ghost" });
expect(button).toBeInTheDocument();
});
it("should render with small size", () => {
render(Small );
-
+
const button = screen.getByRole("button", { name: "Small" });
expect(button).toBeInTheDocument();
});
it("should render with large size", () => {
render(Large );
-
+
const button = screen.getByRole("button", { name: "Large" });
expect(button).toBeInTheDocument();
});
it("should render with icon size", () => {
render(🔔 );
-
+
const button = screen.getByRole("button", { name: "Icon button" });
expect(button).toBeInTheDocument();
});
it("should be disabled when disabled prop is true", () => {
render(Disabled );
-
+
const button = screen.getByRole("button", { name: "Disabled" });
expect(button).toBeDisabled();
});
@@ -89,7 +89,7 @@ describe("Button Component", () => {
Link Button
);
-
+
const link = screen.getByRole("link", { name: "Link Button" });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute("href", "/test");
@@ -97,7 +97,7 @@ describe("Button Component", () => {
it("should apply custom className", () => {
render(Custom );
-
+
const button = screen.getByRole("button", { name: "Custom" });
expect(button).toHaveClass("custom-class");
});
diff --git a/apps/web/src/__tests__/components/checkbox.test.tsx b/apps/web/src/__tests__/components/checkbox.test.tsx
index e01b4ea..bfb0538 100644
--- a/apps/web/src/__tests__/components/checkbox.test.tsx
+++ b/apps/web/src/__tests__/components/checkbox.test.tsx
@@ -9,14 +9,14 @@ import { Checkbox } from "../../components/ui/checkbox";
describe("Checkbox Component", () => {
it("should render checkbox", () => {
render( );
-
+
const checkbox = screen.getByRole("checkbox", { name: "Accept terms" });
expect(checkbox).toBeInTheDocument();
});
it("should be unchecked by default", () => {
render( );
-
+
const checkbox = screen.getByRole("checkbox", { name: "Test checkbox" });
expect(checkbox).not.toBeChecked();
});
@@ -24,29 +24,29 @@ describe("Checkbox Component", () => {
it("should be checked when clicked", async () => {
const user = userEvent.setup();
render( );
-
+
const checkbox = screen.getByRole("checkbox", { name: "Test checkbox" });
await user.click(checkbox);
-
+
expect(checkbox).toBeChecked();
});
it("should toggle checked state", async () => {
const user = userEvent.setup();
render( );
-
+
const checkbox = screen.getByRole("checkbox", { name: "Toggle checkbox" });
-
+
await user.click(checkbox);
expect(checkbox).toBeChecked();
-
+
await user.click(checkbox);
expect(checkbox).not.toBeChecked();
});
it("should be disabled when disabled prop is true", () => {
render( );
-
+
const checkbox = screen.getByRole("checkbox", { name: "Disabled checkbox" });
expect(checkbox).toBeDisabled();
});
@@ -54,16 +54,16 @@ describe("Checkbox Component", () => {
it("should not be clickable when disabled", async () => {
const user = userEvent.setup();
render( );
-
+
const checkbox = screen.getByRole("checkbox", { name: "Disabled checkbox" });
await user.click(checkbox);
-
+
expect(checkbox).not.toBeChecked();
});
it("should have default checked state", () => {
render( );
-
+
const checkbox = screen.getByRole("checkbox", { name: "Default checked" });
expect(checkbox).toBeChecked();
});
@@ -74,23 +74,23 @@ describe("Checkbox Component", () => {
const handleChange = (value: boolean) => {
checked = value;
};
-
+
render(
-
);
-
+
const checkbox = screen.getByRole("checkbox", { name: "Controlled checkbox" });
await user.click(checkbox);
-
+
expect(checked).toBe(true);
});
it("should apply custom className", () => {
render( );
-
+
const checkbox = screen.getByRole("checkbox", { name: "Custom checkbox" });
expect(checkbox).toHaveClass("custom-class");
});
@@ -104,7 +104,7 @@ describe("Checkbox Component", () => {
);
-
+
const checkbox = screen.getByRole("checkbox");
expect(checkbox).toBeInTheDocument();
expect(checkbox).toHaveAttribute("id", "terms");
diff --git a/apps/web/src/__tests__/components/input.test.tsx b/apps/web/src/__tests__/components/input.test.tsx
index 1c70663..6cda1b9 100644
--- a/apps/web/src/__tests__/components/input.test.tsx
+++ b/apps/web/src/__tests__/components/input.test.tsx
@@ -7,17 +7,17 @@ import { Input } from "../../components/ui/input";
describe("Input Component", () => {
it("should render input field", () => {
render( );
-
+
expect(screen.getByPlaceholderText("Enter text")).toBeInTheDocument();
});
it("should accept text input", async () => {
const user = userEvent.setup();
render( );
-
+
const input = screen.getByPlaceholderText("Enter text");
await user.type(input, "Hello World");
-
+
expect(input).toHaveValue("Hello World");
});
@@ -37,21 +37,21 @@ describe("Input Component", () => {
it("should be disabled when disabled prop is true", () => {
render( );
-
+
const input = screen.getByPlaceholderText("Disabled input");
expect(input).toBeDisabled();
});
it("should have default value", () => {
render( );
-
+
const input = screen.getByDisplayValue("Default text");
expect(input).toHaveValue("Default text");
});
it("should apply custom className", () => {
render( );
-
+
const input = screen.getByPlaceholderText("Custom");
expect(input).toHaveClass("custom-class");
});
@@ -62,25 +62,25 @@ describe("Input Component", () => {
const handleChange = (e: ChangeEvent) => {
value = e.target.value;
};
-
+
render( );
-
+
const input = screen.getByPlaceholderText("Type here");
await user.type(input, "Test");
-
+
expect(value).toBe("Test");
});
it("should be required when required prop is true", () => {
render( );
-
+
const input = screen.getByPlaceholderText("Required input");
expect(input).toBeRequired();
});
it("should have aria-label for accessibility", () => {
render( );
-
+
const input = screen.getByLabelText("Username input");
expect(input).toBeInTheDocument();
});
diff --git a/apps/web/src/__tests__/components/loader.test.tsx b/apps/web/src/__tests__/components/loader.test.tsx
index b9fbcc0..0c383f5 100644
--- a/apps/web/src/__tests__/components/loader.test.tsx
+++ b/apps/web/src/__tests__/components/loader.test.tsx
@@ -8,7 +8,7 @@ import Loader from "../../components/loader";
describe("Loader Component", () => {
it("should render loader spinner", () => {
const { container } = render( );
-
+
// Check for the spinner element (SVG with aria-hidden)
const spinner = container.querySelector("svg");
expect(spinner).toBeInTheDocument();
@@ -18,14 +18,14 @@ describe("Loader Component", () => {
it("should have animate-spin class", () => {
const { container } = render( );
const svg = container.querySelector("svg");
-
+
expect(svg).toHaveClass("animate-spin");
});
it("should be centered", () => {
const { container } = render( );
const wrapper = container.firstChild;
-
+
expect(wrapper).toHaveClass("flex", "h-full", "items-center", "justify-center");
});
});
diff --git a/apps/web/src/__tests__/components/message-with-mentions.test.tsx b/apps/web/src/__tests__/components/message-with-mentions.test.tsx
index 52a0505..45fec3e 100644
--- a/apps/web/src/__tests__/components/message-with-mentions.test.tsx
+++ b/apps/web/src/__tests__/components/message-with-mentions.test.tsx
@@ -78,7 +78,7 @@ describe("MessageWithMentions", () => {
// Check for mention
expect(container.textContent).toContain("@TestUser");
-
+
// Check for custom emoji image
const images = screen.getAllByRole("img");
expect(images.length).toBeGreaterThan(0);
diff --git a/apps/web/src/__tests__/components/mode-toggle.test.tsx b/apps/web/src/__tests__/components/mode-toggle.test.tsx
index 5d21245..2f12c5f 100644
--- a/apps/web/src/__tests__/components/mode-toggle.test.tsx
+++ b/apps/web/src/__tests__/components/mode-toggle.test.tsx
@@ -19,7 +19,7 @@ vi.mock("next-themes", () => ({
describe("ModeToggle Component", () => {
it("should render the toggle button", () => {
render( );
-
+
const button = screen.getByRole("button", { name: /toggle theme/i });
expect(button).toBeInTheDocument();
});
@@ -27,10 +27,10 @@ describe("ModeToggle Component", () => {
it("should show theme options when clicked", async () => {
const user = userEvent.setup();
render( );
-
+
const button = screen.getByRole("button", { name: /toggle theme/i });
await user.click(button);
-
+
expect(screen.getByText("Light")).toBeInTheDocument();
expect(screen.getByText("Dark")).toBeInTheDocument();
expect(screen.getByText("System")).toBeInTheDocument();
@@ -39,45 +39,45 @@ describe("ModeToggle Component", () => {
it("should call setTheme with 'light' when Light is clicked", async () => {
const user = userEvent.setup();
render( );
-
+
const button = screen.getByRole("button", { name: /toggle theme/i });
await user.click(button);
-
+
const lightOption = screen.getByText("Light");
await user.click(lightOption);
-
+
expect(mockSetTheme).toHaveBeenCalledWith("light");
});
it("should call setTheme with 'dark' when Dark is clicked", async () => {
const user = userEvent.setup();
render( );
-
+
const button = screen.getByRole("button", { name: /toggle theme/i });
await user.click(button);
-
+
const darkOption = screen.getByText("Dark");
await user.click(darkOption);
-
+
expect(mockSetTheme).toHaveBeenCalledWith("dark");
});
it("should call setTheme with 'system' when System is clicked", async () => {
const user = userEvent.setup();
render( );
-
+
const button = screen.getByRole("button", { name: /toggle theme/i });
await user.click(button);
-
+
const systemOption = screen.getByText("System");
await user.click(systemOption);
-
+
expect(mockSetTheme).toHaveBeenCalledWith("system");
});
it("should have sun and moon icons", () => {
const { container } = render( );
-
+
// Check for SVG elements (icons)
const svgs = container.querySelectorAll("svg");
expect(svgs.length).toBeGreaterThanOrEqual(2);
@@ -85,7 +85,7 @@ describe("ModeToggle Component", () => {
it("should have accessible label", () => {
render( );
-
+
const srOnly = screen.getByText("Toggle theme");
expect(srOnly).toBeInTheDocument();
expect(srOnly).toHaveClass("sr-only");
diff --git a/apps/web/src/__tests__/components/reaction-components.test.tsx b/apps/web/src/__tests__/components/reaction-components.test.tsx
index a353e5c..37e2553 100644
--- a/apps/web/src/__tests__/components/reaction-components.test.tsx
+++ b/apps/web/src/__tests__/components/reaction-components.test.tsx
@@ -133,7 +133,7 @@ describe("ReactionButton Component", () => {
});
it("should disable button while loading", async () => {
- const onToggle = vi.fn<(emoji: string, isAdding: boolean) => Promise>(() =>
+ const onToggle = vi.fn<(emoji: string, isAdding: boolean) => Promise>(() =>
new Promise((resolve) => setTimeout(resolve, 100))
);
const reaction = {
@@ -151,9 +151,9 @@ describe("ReactionButton Component", () => {
);
const button = screen.getByRole("button");
-
+
fireEvent.click(button);
-
+
// Button should be disabled while loading
expect(button).toBeDisabled();
});
@@ -269,11 +269,11 @@ describe("ReactionPicker Component", () => {
it("should call onSelectEmoji prop function", async () => {
const onSelectEmoji = vi.fn().mockResolvedValue(undefined);
-
+
// This test validates the prop is passed correctly
// Full integration testing of emoji picker would require mocking the EmojiPicker component
render( );
-
+
expect(onSelectEmoji).toBeDefined();
});
});
diff --git a/apps/web/src/__tests__/components/status-indicator.test.tsx b/apps/web/src/__tests__/components/status-indicator.test.tsx
index c07e606..7cc67a7 100644
--- a/apps/web/src/__tests__/components/status-indicator.test.tsx
+++ b/apps/web/src/__tests__/components/status-indicator.test.tsx
@@ -10,7 +10,7 @@ describe("StatusIndicator Component", () => {
it("should render online status with green color", () => {
const { container } = render( );
const indicator = container.querySelector("span[title='Online']");
-
+
expect(indicator).toHaveClass("bg-green-500");
expect(indicator).toHaveClass("animate-pulse");
});
@@ -18,7 +18,7 @@ describe("StatusIndicator Component", () => {
it("should render away status with yellow color", () => {
const { container } = render( );
const indicator = container.querySelector("span[title='Away']");
-
+
expect(indicator).toHaveClass("bg-yellow-500");
expect(indicator).not.toHaveClass("animate-pulse");
});
@@ -26,14 +26,14 @@ describe("StatusIndicator Component", () => {
it("should render busy status with red color", () => {
const { container } = render( );
const indicator = container.querySelector("span[title='Busy']");
-
+
expect(indicator).toHaveClass("bg-red-500");
});
it("should render offline status with gray color", () => {
const { container } = render( );
const indicator = container.querySelector("span[title='Offline']");
-
+
expect(indicator).toHaveClass("bg-gray-400");
});
});
@@ -42,28 +42,28 @@ describe("StatusIndicator Component", () => {
it("should render small size by default as md", () => {
const { container } = render( );
const indicator = container.querySelector("span[title='Online']");
-
+
expect(indicator).toHaveClass("size-3");
});
it("should render small size", () => {
const { container } = render( );
const indicator = container.querySelector("span[title='Online']");
-
+
expect(indicator).toHaveClass("size-2");
});
it("should render medium size", () => {
const { container } = render( );
const indicator = container.querySelector("span[title='Online']");
-
+
expect(indicator).toHaveClass("size-3");
});
it("should render large size", () => {
const { container } = render( );
const indicator = container.querySelector("span[title='Online']");
-
+
expect(indicator).toHaveClass("size-4");
});
});
@@ -71,31 +71,31 @@ describe("StatusIndicator Component", () => {
describe("Labels", () => {
it("should not show label by default", () => {
render( );
-
+
expect(screen.queryByText("Online")).not.toBeInTheDocument();
});
it("should show label when showLabel is true", () => {
render( );
-
+
expect(screen.getByText("Online")).toBeInTheDocument();
});
it("should show correct label for away status", () => {
render( );
-
+
expect(screen.getByText("Away")).toBeInTheDocument();
});
it("should show correct label for busy status", () => {
render( );
-
+
expect(screen.getByText("Busy")).toBeInTheDocument();
});
it("should show correct label for offline status", () => {
render( );
-
+
expect(screen.getByText("Offline")).toBeInTheDocument();
});
});
@@ -106,7 +106,7 @@ describe("StatusIndicator Component", () => {
);
const wrapper = container.firstChild;
-
+
expect(wrapper).toHaveClass("custom-class");
});
@@ -115,7 +115,7 @@ describe("StatusIndicator Component", () => {
);
const wrapper = container.firstChild;
-
+
expect(wrapper).toHaveClass("flex", "items-center", "gap-1.5", "custom-class");
});
});
@@ -124,7 +124,7 @@ describe("StatusIndicator Component", () => {
it("should have title attribute for screen readers", () => {
const { container } = render( );
const indicator = container.querySelector("span[title='Online']");
-
+
expect(indicator).toHaveAttribute("title", "Online");
});
@@ -135,7 +135,7 @@ describe("StatusIndicator Component", () => {
statuses.forEach((status, index) => {
const { container } = render( );
const indicator = container.querySelector(`span[title='${labels[index]}']`);
-
+
expect(indicator).toHaveAttribute("title", labels[index]);
});
});
diff --git a/apps/web/src/__tests__/custom-emojis.test.ts b/apps/web/src/__tests__/custom-emojis.test.ts
index 18f4b09..4eceb00 100644
--- a/apps/web/src/__tests__/custom-emojis.test.ts
+++ b/apps/web/src/__tests__/custom-emojis.test.ts
@@ -53,7 +53,7 @@ describe("Custom Emojis - Optimistic Updates", () => {
it("should create object URL for optimistic emoji preview", () => {
const file = new File(["test"], "party.png", { type: "image/png" });
const url = URL.createObjectURL(file);
-
+
expect(url).toBe("blob:mock-url");
expect(URL.createObjectURL).toHaveBeenCalledWith(file);
});
@@ -64,10 +64,10 @@ describe("Custom Emojis - Optimistic Updates", () => {
];
localStorageMock.setItem("firepit_custom_emojis", JSON.stringify(mockEmojis));
-
+
const stored = localStorageMock.getItem("firepit_custom_emojis");
expect(stored).toBeDefined();
-
+
const parsed = JSON.parse(stored!);
expect(parsed).toEqual(mockEmojis);
expect(parsed[0].name).toBe("cached");
@@ -76,7 +76,7 @@ describe("Custom Emojis - Optimistic Updates", () => {
it("should cleanup object URL to prevent memory leaks", () => {
const mockUrl = "blob:mock-url";
URL.revokeObjectURL(mockUrl);
-
+
expect(URL.revokeObjectURL).toHaveBeenCalledWith(mockUrl);
});
@@ -161,13 +161,13 @@ describe("Custom Emojis - Optimistic Updates", () => {
const cachedEmojis = [
{ fileId: "cached1", url: "/api/emoji/cached1", name: "offline" },
];
-
+
localStorageMock.setItem("firepit_custom_emojis", JSON.stringify(cachedEmojis));
// Should be able to get from cache
const stored = localStorageMock.getItem("firepit_custom_emojis");
expect(stored).toBeDefined();
-
+
const parsed = JSON.parse(stored!);
expect(parsed).toEqual(cachedEmojis);
expect(parsed[0].name).toBe("offline");
@@ -186,7 +186,7 @@ describe("Custom Emojis - Optimistic Updates", () => {
const response = await fetch("/api/custom-emojis");
expect(response.ok).toBe(true);
-
+
const data = await response.json();
expect(Array.isArray(data)).toBe(true);
expect(data.length).toBe(2);
@@ -198,7 +198,7 @@ describe("Custom Emojis - Optimistic Updates", () => {
describe("Custom Emojis - Realtime Synchronization", () => {
it("should have realtime pool utilities", async () => {
const realtimePool = await import("@/lib/realtime-pool");
-
+
expect(typeof realtimePool.getSharedRealtime).toBe("function");
expect(typeof realtimePool.trackSubscription).toBe("function");
});
@@ -213,7 +213,7 @@ describe("Custom Emojis - Realtime Synchronization", () => {
};
const subscription = mockClient.subscribe("test-channel", () => {});
-
+
expect(mockClient.subscribe).toHaveBeenCalledWith("test-channel", expect.any(Function));
await expect(subscription).resolves.toEqual(
expect.objectContaining({
@@ -233,7 +233,7 @@ describe("Custom Emojis - Realtime Synchronization", () => {
// Simulate event handler
const handleEvent = vi.fn();
handleEvent(mockEvent);
-
+
expect(handleEvent).toHaveBeenCalledWith(mockEvent);
expect(mockEvent.events[0]).toContain("create");
});
@@ -277,7 +277,7 @@ describe("Custom Emojis - Realtime Synchronization", () => {
it("should construct correct channel name", () => {
const bucketId = "emojis";
const channel = `buckets.${bucketId}.files`;
-
+
expect(channel).toBe("buckets.emojis.files");
});
diff --git a/apps/web/src/__tests__/instrumentation.test.ts b/apps/web/src/__tests__/instrumentation.test.ts
index f5ed68b..9cee962 100644
--- a/apps/web/src/__tests__/instrumentation.test.ts
+++ b/apps/web/src/__tests__/instrumentation.test.ts
@@ -1,139 +1,46 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-// Mock the newrelic module
-vi.mock("newrelic", () => ({
- default: {
- initialized: true,
- },
+const { mockRegisterLoggerProvider, mockRegisterProcessHandlers } =
+ vi.hoisted(() => ({
+ mockRegisterLoggerProvider: vi.fn(),
+ mockRegisterProcessHandlers: vi.fn(),
+ }));
+
+vi.mock("@/lib/posthog-utils", () => ({
+ registerPostHogLoggerProvider: mockRegisterLoggerProvider,
+ registerPostHogProcessHandlers: mockRegisterProcessHandlers,
}));
describe("instrumentation", () => {
const originalEnv = process.env;
beforeEach(() => {
- // Create a fresh copy of process.env
process.env = { ...originalEnv };
+ mockRegisterLoggerProvider.mockClear();
+ mockRegisterProcessHandlers.mockClear();
});
afterEach(() => {
- // Restore original environment
process.env = originalEnv;
- vi.clearAllMocks();
});
- it("should initialize New Relic when license key and app name are provided", async () => {
- // Set up environment for Node.js runtime
+ it("should register PostHog hooks on the Node.js runtime", async () => {
process.env.NEXT_RUNTIME = "nodejs";
- process.env.NEW_RELIC_LICENSE_KEY = "test-license-key";
- process.env.NEW_RELIC_APP_NAME = "test-app-name";
-
- const consoleLogSpy = vi
- .spyOn(console, "log")
- .mockImplementation(() => {});
-
- // Import the register function
- const { register } = await import("../../instrumentation");
-
- // Call register
- const result = await register();
-
- expect(result).toMatchObject({
- default: {
- initialized: true,
- },
- });
- expect(consoleLogSpy).not.toHaveBeenCalled();
-
- consoleLogSpy.mockRestore();
- });
-
- it("should warn when license key is missing", async () => {
- process.env.NEXT_RUNTIME = "nodejs";
- process.env.NEW_RELIC_APP_NAME = "test-app-name";
- // NEW_RELIC_LICENSE_KEY is intentionally not set
- delete process.env.NEW_RELIC_LICENSE_KEY;
-
- const consoleWarnSpy = vi
- .spyOn(console, "warn")
- .mockImplementation(() => {});
const { register } = await import("../../instrumentation");
await register();
- expect(consoleWarnSpy).toHaveBeenCalledWith(
- "[New Relic] NEW_RELIC_LICENSE_KEY not found - APM monitoring disabled",
- );
-
- consoleWarnSpy.mockRestore();
+ expect(mockRegisterLoggerProvider).toHaveBeenCalled();
+ expect(mockRegisterProcessHandlers).toHaveBeenCalled();
});
- it("should warn when app name is missing", async () => {
- process.env.NEXT_RUNTIME = "nodejs";
- process.env.NEW_RELIC_LICENSE_KEY = "test-license-key";
- // NEW_RELIC_APP_NAME is intentionally not set
- delete process.env.NEW_RELIC_APP_NAME;
-
- const consoleWarnSpy = vi
- .spyOn(console, "warn")
- .mockImplementation(() => {});
- const { register } = await import("../../instrumentation");
- await register();
-
- expect(consoleWarnSpy).toHaveBeenCalledWith(
- "[New Relic] NEW_RELIC_APP_NAME not found - APM monitoring disabled",
- );
-
- consoleWarnSpy.mockRestore();
- });
-
- it("should not initialize on Edge runtime", async () => {
+ it("should not register hooks on the Edge runtime", async () => {
process.env.NEXT_RUNTIME = "edge";
- process.env.NEW_RELIC_LICENSE_KEY = "test-license-key";
- process.env.NEW_RELIC_APP_NAME = "test-app-name";
-
- const consoleLogSpy = vi
- .spyOn(console, "log")
- .mockImplementation(() => {});
const { register } = await import("../../instrumentation");
await register();
- // Should not log initialization message on Edge runtime
- expect(consoleLogSpy).not.toHaveBeenCalled();
-
- consoleLogSpy.mockRestore();
- });
-
- it("should not initialize when both credentials are missing", async () => {
- process.env.NEXT_RUNTIME = "nodejs";
- // Both NEW_RELIC_LICENSE_KEY and NEW_RELIC_APP_NAME are intentionally not set
- delete process.env.NEW_RELIC_LICENSE_KEY;
- delete process.env.NEW_RELIC_APP_NAME;
-
- const consoleWarnSpy = vi
- .spyOn(console, "warn")
- .mockImplementation(() => {});
- const consoleLogSpy = vi
- .spyOn(console, "log")
- .mockImplementation(() => {});
-
- const { register } = await import("../../instrumentation");
- await register();
-
- // Should warn about both missing credentials
- expect(consoleWarnSpy).toHaveBeenCalledWith(
- "[New Relic] NEW_RELIC_LICENSE_KEY not found - APM monitoring disabled",
- );
- expect(consoleWarnSpy).toHaveBeenCalledWith(
- "[New Relic] NEW_RELIC_APP_NAME not found - APM monitoring disabled",
- );
-
- // Should not log initialization message
- expect(consoleLogSpy).not.toHaveBeenCalledWith(
- expect.stringContaining("[New Relic] Initialized"),
- );
-
- consoleWarnSpy.mockRestore();
- consoleLogSpy.mockRestore();
+ expect(mockRegisterLoggerProvider).not.toHaveBeenCalled();
+ expect(mockRegisterProcessHandlers).not.toHaveBeenCalled();
});
-});
+});
\ No newline at end of file
diff --git a/apps/web/src/__tests__/invite-api-routes.test.ts b/apps/web/src/__tests__/invite-api-routes.test.ts
index e64ebaf..550d721 100644
--- a/apps/web/src/__tests__/invite-api-routes.test.ts
+++ b/apps/web/src/__tests__/invite-api-routes.test.ts
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
/**
* API Route Behavior Documentation Tests
- *
+ *
* These tests document the expected behavior of invite-related API routes.
* They serve as executable documentation and behavior specifications.
*/
@@ -16,14 +16,14 @@ describe("Invite API Routes - Behavior Documentation", () => {
it("should generate unique 6-character alphanumeric code", () => {
const codePattern = /^[A-Z0-9]{6}$/;
const codes = new Set();
-
+
// Generate multiple codes to test uniqueness
for (let i = 0; i < 100; i++) {
const code = Math.random().toString(36).substring(2, 8).toUpperCase();
expect(code).toMatch(codePattern);
codes.add(code);
}
-
+
// Expect high uniqueness (at least 95% unique)
expect(codes.size).toBeGreaterThan(95);
});
@@ -33,7 +33,7 @@ describe("Invite API Routes - Behavior Documentation", () => {
maxUses: null,
currentUses: 0,
};
-
+
expect(defaultInvite.maxUses).toBeNull();
expect(defaultInvite.currentUses).toBe(0);
});
@@ -42,13 +42,13 @@ describe("Invite API Routes - Behavior Documentation", () => {
const defaultInvite = {
temporary: false,
};
-
+
expect(defaultInvite.temporary).toBe(false);
});
it("should accept valid maxUses values (1-100)", () => {
const validMaxUses = [1, 5, 10, 25, 50, 100];
-
+
validMaxUses.forEach(maxUses => {
expect(maxUses).toBeGreaterThan(0);
expect(maxUses).toBeLessThanOrEqual(100);
@@ -58,7 +58,7 @@ describe("Invite API Routes - Behavior Documentation", () => {
it("should reject invalid maxUses values", () => {
const invalidMaxUses = [0, -1, 101, 1.5, 2.7];
-
+
invalidMaxUses.forEach(val => {
const isInvalid = val <= 0 || val > 100 || val % 1 !== 0;
expect(isInvalid).toBe(true);
@@ -69,7 +69,7 @@ describe("Invite API Routes - Behavior Documentation", () => {
const now = Date.now();
const duration = 3600000; // 1 hour in ms
const expiresAt = new Date(now + duration);
-
+
expect(expiresAt.getTime()).toBeGreaterThan(now);
expect(expiresAt.getTime()).toBeLessThanOrEqual(now + duration + 1000);
});
@@ -77,7 +77,7 @@ describe("Invite API Routes - Behavior Documentation", () => {
it("should require valid server ID", () => {
const validServerId = "server-123";
const invalidServerId = "";
-
+
expect(validServerId.length).toBeGreaterThan(0);
expect(invalidServerId.length).toBe(0);
});
@@ -85,7 +85,7 @@ describe("Invite API Routes - Behavior Documentation", () => {
it("should require authenticated user", () => {
const authenticatedUser = { $id: "user-123" };
const unauthenticatedUser = null;
-
+
expect(authenticatedUser).not.toBeNull();
expect(unauthenticatedUser).toBeNull();
});
@@ -99,12 +99,12 @@ describe("Invite API Routes - Behavior Documentation", () => {
currentUses: 0,
expiresAt: null,
};
-
+
const isValid = (
invite.currentUses < (invite.maxUses ?? Number.POSITIVE_INFINITY) &&
(invite.expiresAt === null || new Date(invite.expiresAt) > new Date())
);
-
+
expect(isValid).toBe(true);
});
@@ -114,7 +114,7 @@ describe("Invite API Routes - Behavior Documentation", () => {
maxUses: 5,
currentUses: 5,
};
-
+
const isExhausted = invite.currentUses >= invite.maxUses;
expect(isExhausted).toBe(true);
});
@@ -125,7 +125,7 @@ describe("Invite API Routes - Behavior Documentation", () => {
code: "ABC123",
expiresAt: pastDate.toISOString(),
};
-
+
const isExpired = new Date(invite.expiresAt) < new Date();
expect(isExpired).toBe(true);
});
@@ -142,7 +142,7 @@ describe("Invite API Routes - Behavior Documentation", () => {
name: "Test Server",
memberCount: 42,
};
-
+
expect(preview.name).toBeDefined();
expect(typeof preview.name).toBe("string");
expect(preview.memberCount).toBeDefined();
@@ -155,7 +155,7 @@ describe("Invite API Routes - Behavior Documentation", () => {
name: "Test Server",
memberCount: 42,
};
-
+
// Should not include these fields
expect(preview).not.toHaveProperty("ownerId");
expect(preview).not.toHaveProperty("$permissions");
@@ -171,7 +171,7 @@ describe("Invite API Routes - Behavior Documentation", () => {
userId: "user-1",
temporary: false,
};
-
+
expect(membership.$id).toBeDefined();
expect(membership.serverId).toBeDefined();
expect(membership.userId).toBeDefined();
@@ -180,14 +180,14 @@ describe("Invite API Routes - Behavior Documentation", () => {
it("should increment currentUses after use", () => {
const beforeUse = { currentUses: 0 };
const afterUse = { currentUses: 1 };
-
+
expect(afterUse.currentUses).toBe(beforeUse.currentUses + 1);
});
it("should create temporary membership when invite.temporary=true", () => {
const invite = { temporary: true };
const membership = { temporary: true };
-
+
expect(membership.temporary).toBe(invite.temporary);
});
@@ -196,17 +196,17 @@ describe("Invite API Routes - Behavior Documentation", () => {
serverId: "server-1",
userId: "user-1",
};
-
+
const attemptedJoin = {
serverId: "server-1",
userId: "user-1",
};
-
+
const isDuplicate = (
existingMembership.serverId === attemptedJoin.serverId &&
existingMembership.userId === attemptedJoin.userId
);
-
+
expect(isDuplicate).toBe(true);
});
@@ -217,7 +217,7 @@ describe("Invite API Routes - Behavior Documentation", () => {
serverId: "server-1",
joinedAt: new Date().toISOString(),
};
-
+
expect(usage.inviteCode).toBeDefined();
expect(usage.userId).toBeDefined();
expect(usage.serverId).toBeDefined();
@@ -234,7 +234,7 @@ describe("Invite API Routes - Behavior Documentation", () => {
it("should require invite creator or server admin", () => {
const isCreator = true;
const isAdmin = false;
-
+
const canRevoke = isCreator || isAdmin;
expect(canRevoke).toBe(true);
});
@@ -246,7 +246,7 @@ describe("Invite API Routes - Behavior Documentation", () => {
{ code: "ABC123", currentUses: 0 },
{ code: "XYZ789", currentUses: 2 },
];
-
+
expect(Array.isArray(invites)).toBe(true);
expect(invites.length).toBeGreaterThanOrEqual(0);
});
@@ -254,7 +254,7 @@ describe("Invite API Routes - Behavior Documentation", () => {
it("should require server admin role", () => {
const userRoles = ["member"];
const hasAdminRole = userRoles.includes("admin");
-
+
expect(hasAdminRole).toBe(false);
});
});
@@ -263,7 +263,7 @@ describe("Invite API Routes - Behavior Documentation", () => {
it("should generate URL-safe characters only", () => {
const urlSafePattern = /^[A-Za-z0-9_-]+$/;
const code = "ABC123";
-
+
expect(code).toMatch(urlSafePattern);
});
@@ -275,7 +275,7 @@ describe("Invite API Routes - Behavior Documentation", () => {
it("should avoid ambiguous characters (0/O, 1/I/l)", () => {
const code = "ABC234"; // Example without ambiguous chars
const hasAmbiguous = /[0O1Il]/.test(code);
-
+
// This is aspirational - current impl may include these
expect(hasAmbiguous || !hasAmbiguous).toBeDefined();
});
@@ -285,7 +285,7 @@ describe("Invite API Routes - Behavior Documentation", () => {
it("should support null (never expires)", () => {
const invite = { expiresAt: null };
const isExpired = false;
-
+
expect(invite.expiresAt).toBeNull();
expect(isExpired).toBe(false);
});
@@ -293,7 +293,7 @@ describe("Invite API Routes - Behavior Documentation", () => {
it("should support ISO 8601 date strings", () => {
const date = new Date();
const isoString = date.toISOString();
-
+
expect(isoString).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
});
@@ -301,7 +301,7 @@ describe("Invite API Routes - Behavior Documentation", () => {
const futureDate = new Date(Date.now() + 3600000);
const pastDate = new Date(Date.now() - 3600000);
const now = new Date();
-
+
expect(futureDate > now).toBe(true);
expect(pastDate < now).toBe(true);
});
@@ -311,14 +311,14 @@ describe("Invite API Routes - Behavior Documentation", () => {
it("should support unlimited invites (maxUses=null)", () => {
const invite = { maxUses: null, currentUses: 1000 };
const hasReachedLimit = invite.currentUses >= (invite.maxUses ?? Number.POSITIVE_INFINITY);
-
+
expect(hasReachedLimit).toBe(false);
});
it("should enforce maxUses when set", () => {
const invite = { maxUses: 5, currentUses: 5 };
const hasReachedLimit = invite.currentUses >= invite.maxUses;
-
+
expect(hasReachedLimit).toBe(true);
});
});
diff --git a/apps/web/src/__tests__/invite-integration.test.ts b/apps/web/src/__tests__/invite-integration.test.ts
index 9fa6c3d..4cc5b50 100644
--- a/apps/web/src/__tests__/invite-integration.test.ts
+++ b/apps/web/src/__tests__/invite-integration.test.ts
@@ -26,7 +26,7 @@ describe("Invite Integration Tests", () => {
describe("Full Invite Flow", () => {
it("should complete create → validate → use → exhaust lifecycle", async () => {
const mockFetch = global.fetch as ReturnType;
-
+
// Step 1: Create invite with max uses = 1
mockFetch.mockResolvedValueOnce({
ok: true,
@@ -410,7 +410,7 @@ describe("Invite Integration Tests", () => {
const res = await fetch("/api/invites/track123/usage");
const usage = await res.json();
-
+
expect(Array.isArray(usage)).toBe(true);
expect(usage.length).toBe(2);
expect(usage[0].inviteCode).toBe("track123");
diff --git a/apps/web/src/__tests__/login-security.test.ts b/apps/web/src/__tests__/login-security.test.ts
index 876c784..0e101ba 100644
--- a/apps/web/src/__tests__/login-security.test.ts
+++ b/apps/web/src/__tests__/login-security.test.ts
@@ -488,10 +488,12 @@ describe("Login Security", () => {
const { registerAction } = await import("@/app/(auth)/login/actions");
mockDatabases.listDocuments
+ .mockResolvedValueOnce({ documents: [] }) // signup policy lookup
+ .mockResolvedValueOnce({ documents: [] }) // userId collision check
.mockResolvedValueOnce({
documents: [{ $id: "server-default", defaultOnSignup: true }],
})
- .mockResolvedValueOnce({ documents: [] });
+ .mockResolvedValueOnce({ documents: [] }); // existing membership
const formData = new FormData();
formData.set("email", "newuser@example.com");
@@ -517,10 +519,12 @@ describe("Login Security", () => {
const { registerAction } = await import("@/app/(auth)/login/actions");
mockDatabases.listDocuments
- .mockResolvedValueOnce({ documents: [] })
+ .mockResolvedValueOnce({ documents: [] }) // signup policy lookup
+ .mockResolvedValueOnce({ documents: [] }) // userId collision check
+ .mockResolvedValueOnce({ documents: [] }) // default signup server
.mockResolvedValueOnce({
documents: [{ $id: "server-a" }, { $id: "server-b" }],
- });
+ }); // single-server fallback
const formData = new FormData();
formData.set("email", "newuser@example.com");
diff --git a/apps/web/src/__tests__/membership-counter.test.ts b/apps/web/src/__tests__/membership-counter.test.ts
index 1ce3a5c..0389362 100644
--- a/apps/web/src/__tests__/membership-counter.test.ts
+++ b/apps/web/src/__tests__/membership-counter.test.ts
@@ -105,7 +105,7 @@ describe("Membership Counter", () => {
callCount++;
return Promise.resolve({
ok: true,
- json: () => Promise.resolve({
+ json: () => Promise.resolve({
memberships: initialMemberships
}),
});
diff --git a/apps/web/src/__tests__/message-reactions.test.ts b/apps/web/src/__tests__/message-reactions.test.ts
index da13822..b513aef 100644
--- a/apps/web/src/__tests__/message-reactions.test.ts
+++ b/apps/web/src/__tests__/message-reactions.test.ts
@@ -7,7 +7,7 @@ function parseReactions(reactionsData: string | any[] | undefined): Array<{
count: number;
}> {
if (!reactionsData) return [];
-
+
if (typeof reactionsData === "string") {
try {
return JSON.parse(reactionsData);
@@ -15,11 +15,11 @@ function parseReactions(reactionsData: string | any[] | undefined): Array<{
return [];
}
}
-
+
if (Array.isArray(reactionsData)) {
return reactionsData;
}
-
+
return [];
}
@@ -29,7 +29,7 @@ function addReactionToMessage(
userId: string
): Array<{ emoji: string; userIds: string[]; count: number }> {
const existingReaction = reactions.find((r) => r.emoji === emoji);
-
+
if (existingReaction) {
if (!existingReaction.userIds.includes(userId)) {
existingReaction.userIds.push(userId);
@@ -42,7 +42,7 @@ function addReactionToMessage(
count: 1,
});
}
-
+
return reactions;
}
@@ -52,17 +52,17 @@ function removeReactionFromMessage(
userId: string
): Array<{ emoji: string; userIds: string[]; count: number }> {
const existingReaction = reactions.find((r) => r.emoji === emoji);
-
+
if (existingReaction) {
existingReaction.userIds = existingReaction.userIds.filter((id) => id !== userId);
-
+
if (existingReaction.userIds.length === 0) {
return reactions.filter((r) => r.emoji !== emoji);
}
-
+
existingReaction.count = existingReaction.userIds.length;
}
-
+
return reactions;
}
@@ -72,9 +72,9 @@ describe("Message Reactions", () => {
const reactionsString = JSON.stringify([
{ emoji: "👍", userIds: ["user1"], count: 1 },
]);
-
+
const reactions = parseReactions(reactionsString);
-
+
expect(reactions).toHaveLength(1);
expect(reactions[0].emoji).toBe("👍");
expect(reactions[0].userIds).toContain("user1");
@@ -84,9 +84,9 @@ describe("Message Reactions", () => {
const reactionsArray = [
{ emoji: "👍", userIds: ["user1"], count: 1 },
];
-
+
const reactions = parseReactions(reactionsArray);
-
+
expect(reactions).toHaveLength(1);
expect(reactions[0].emoji).toBe("👍");
});
@@ -110,9 +110,9 @@ describe("Message Reactions", () => {
describe("Adding Reactions", () => {
it("should add a new reaction to empty array", () => {
let reactions: Array<{ emoji: string; userIds: string[]; count: number }> = [];
-
+
reactions = addReactionToMessage(reactions, "👍", "user1");
-
+
expect(reactions).toHaveLength(1);
expect(reactions[0].emoji).toBe("👍");
expect(reactions[0].userIds).toContain("user1");
@@ -123,9 +123,9 @@ describe("Message Reactions", () => {
let reactions = [
{ emoji: "👍", userIds: ["user1"], count: 1 },
];
-
+
reactions = addReactionToMessage(reactions, "👍", "user2");
-
+
expect(reactions).toHaveLength(1);
expect(reactions[0].userIds).toHaveLength(2);
expect(reactions[0].userIds).toContain("user2");
@@ -136,9 +136,9 @@ describe("Message Reactions", () => {
let reactions = [
{ emoji: "👍", userIds: ["user1"], count: 1 },
];
-
+
reactions = addReactionToMessage(reactions, "👍", "user1");
-
+
expect(reactions).toHaveLength(1);
expect(reactions[0].userIds).toHaveLength(1);
expect(reactions[0].count).toBe(1);
@@ -148,9 +148,9 @@ describe("Message Reactions", () => {
let reactions = [
{ emoji: "👍", userIds: ["user1"], count: 1 },
];
-
+
reactions = addReactionToMessage(reactions, "❤️", "user2");
-
+
expect(reactions).toHaveLength(2);
expect(reactions[0].emoji).toBe("👍");
expect(reactions[1].emoji).toBe("❤️");
@@ -158,11 +158,11 @@ describe("Message Reactions", () => {
it("should handle multiple users on different reactions", () => {
let reactions: Array<{ emoji: string; userIds: string[]; count: number }> = [];
-
+
reactions = addReactionToMessage(reactions, "👍", "user1");
reactions = addReactionToMessage(reactions, "👍", "user2");
reactions = addReactionToMessage(reactions, "❤️", "user3");
-
+
expect(reactions).toHaveLength(2);
expect(reactions[0].count).toBe(2);
expect(reactions[1].count).toBe(1);
@@ -170,12 +170,12 @@ describe("Message Reactions", () => {
it("should handle special emoji characters", () => {
let reactions: Array<{ emoji: string; userIds: string[]; count: number }> = [];
-
+
const specialEmojis = ["🎉", "🔥", "👀", "😂", "🚀"];
specialEmojis.forEach((emoji, index) => {
reactions = addReactionToMessage(reactions, emoji, `user${index + 1}`);
});
-
+
expect(reactions).toHaveLength(5);
reactions.forEach((reaction, index) => {
expect(reaction.emoji).toBe(specialEmojis[index]);
@@ -188,9 +188,9 @@ describe("Message Reactions", () => {
let reactions = [
{ emoji: "👍", userIds: ["user1", "user2"], count: 2 },
];
-
+
reactions = removeReactionFromMessage(reactions, "👍", "user1");
-
+
expect(reactions).toHaveLength(1);
expect(reactions[0].userIds).toHaveLength(1);
expect(reactions[0].userIds).not.toContain("user1");
@@ -202,9 +202,9 @@ describe("Message Reactions", () => {
{ emoji: "👍", userIds: ["user1"], count: 1 },
{ emoji: "❤️", userIds: ["user2"], count: 1 },
];
-
+
reactions = removeReactionFromMessage(reactions, "👍", "user1");
-
+
expect(reactions).toHaveLength(1);
expect(reactions[0].emoji).toBe("❤️");
});
@@ -213,9 +213,9 @@ describe("Message Reactions", () => {
let reactions = [
{ emoji: "👍", userIds: ["user1"], count: 1 },
];
-
+
reactions = removeReactionFromMessage(reactions, "👍", "user2");
-
+
expect(reactions).toHaveLength(1);
expect(reactions[0].userIds).toHaveLength(1);
expect(reactions[0].count).toBe(1);
@@ -225,18 +225,18 @@ describe("Message Reactions", () => {
let reactions = [
{ emoji: "👍", userIds: ["user1"], count: 1 },
];
-
+
reactions = removeReactionFromMessage(reactions, "❤️", "user1");
-
+
expect(reactions).toHaveLength(1);
expect(reactions[0].emoji).toBe("👍");
});
it("should handle empty reactions array", () => {
let reactions: Array<{ emoji: string; userIds: string[]; count: number }> = [];
-
+
reactions = removeReactionFromMessage(reactions, "👍", "user1");
-
+
expect(reactions).toHaveLength(0);
});
});
diff --git a/apps/web/src/__tests__/newrelic-utils.test.ts b/apps/web/src/__tests__/posthog-utils.test.ts
similarity index 63%
rename from apps/web/src/__tests__/newrelic-utils.test.ts
rename to apps/web/src/__tests__/posthog-utils.test.ts
index 4bcad50..2cc9ded 100644
--- a/apps/web/src/__tests__/newrelic-utils.test.ts
+++ b/apps/web/src/__tests__/posthog-utils.test.ts
@@ -2,46 +2,24 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
logger,
recordError,
- setTransactionName,
trackApiCall,
- addTransactionAttributes,
recordEvent,
recordMetric,
__resetPostHogClient,
-} from "@/lib/newrelic-utils";
+} from "@/lib/posthog-utils";
const {
mockEmit,
mockSetGlobalLoggerProvider,
- mockNewRelic,
mockPostHogCapture,
mockPostHogCaptureException,
} = vi.hoisted(() => ({
mockEmit: vi.fn(),
mockSetGlobalLoggerProvider: vi.fn(),
- mockNewRelic: {
- recordCustomEvent: vi.fn(),
- recordMetric: vi.fn(),
- incrementMetric: vi.fn(),
- noticeError: vi.fn(),
- addCustomAttribute: vi.fn(),
- addCustomAttributes: vi.fn(),
- setTransactionName: vi.fn(),
- getTransaction: vi.fn(),
- startBackgroundTransaction: vi.fn(),
- startWebTransaction: vi.fn(),
- endTransaction: vi.fn(),
- getBrowserTimingHeader: vi.fn(),
- setLlmTokenCountCallback: vi.fn(),
- },
mockPostHogCapture: vi.fn(),
mockPostHogCaptureException: vi.fn(),
}));
-vi.mock("newrelic", () => ({
- default: mockNewRelic,
-}));
-
vi.mock("@opentelemetry/sdk-logs", () => ({
LoggerProvider: vi.fn().mockImplementation(function () {
return {
@@ -93,15 +71,13 @@ vi.mock("next/server", () => ({
after: vi.fn(),
}));
-describe("newrelic-utils", () => {
+describe("posthog-utils", () => {
beforeEach(() => {
__resetPostHogClient();
- Object.values(mockNewRelic).forEach((fn) => fn.mockClear());
mockEmit.mockClear();
mockSetGlobalLoggerProvider.mockClear();
mockPostHogCapture.mockClear();
mockPostHogCaptureException.mockClear();
- delete process.env.TELEMETRY_PROVIDER;
delete process.env.POSTHOG_PROJECT_API_KEY;
delete process.env.POSTHOG_HOST;
delete process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN;
@@ -119,7 +95,7 @@ describe("newrelic-utils", () => {
});
describe("logger", () => {
- it("should log info messages", () => {
+ it("should log info messages to the OTLP pipeline", () => {
logger.info("Test info message");
expect(console.log).toHaveBeenCalled();
expect(mockEmit).toHaveBeenCalledWith(
@@ -132,35 +108,33 @@ describe("newrelic-utils", () => {
it("should log info messages with attributes", () => {
logger.info("Test info", { userId: "123" });
- expect(console.log).toHaveBeenCalled();
expect(mockEmit).toHaveBeenCalledWith(
expect.objectContaining({
body: "Test info",
- severityNumber: expect.any(Number),
attributes: expect.objectContaining({ userId: "123" }),
}),
);
});
- it("should log error messages", () => {
- logger.error("Test error message");
- expect(console.error).toHaveBeenCalled();
+ it("should redact sensitive keys from log attributes", () => {
+ logger.info("Test info", { email: "a@b.c", userId: "123" });
expect(mockEmit).toHaveBeenCalledWith(
expect.objectContaining({
- body: "Test error message",
- severityNumber: expect.any(Number),
+ attributes: expect.objectContaining({
+ email: "[REDACTED]",
+ userId: "123",
+ }),
}),
);
});
- it("should log error messages with attributes", () => {
- logger.error("Test error", { code: 500 });
+ it("should log error messages", () => {
+ logger.error("Test error message");
expect(console.error).toHaveBeenCalled();
expect(mockEmit).toHaveBeenCalledWith(
expect.objectContaining({
- body: "Test error",
+ body: "Test error message",
severityNumber: expect.any(Number),
- attributes: expect.objectContaining({ code: 500 }),
}),
);
});
@@ -176,18 +150,6 @@ describe("newrelic-utils", () => {
);
});
- it("should log warn messages with attributes", () => {
- logger.warn("Test warning", { threshold: 100 });
- expect(console.warn).toHaveBeenCalled();
- expect(mockEmit).toHaveBeenCalledWith(
- expect.objectContaining({
- body: "Test warning",
- severityNumber: expect.any(Number),
- attributes: expect.objectContaining({ threshold: 100 }),
- }),
- );
- });
-
it("should log debug messages", () => {
logger.debug("Test debug message");
expect(console.log).toHaveBeenCalled();
@@ -199,14 +161,19 @@ describe("newrelic-utils", () => {
);
});
- it("should log debug messages with attributes", () => {
- logger.debug("Test debug", { step: 1 });
- expect(console.log).toHaveBeenCalled();
- expect(mockEmit).toHaveBeenCalledWith(
+ it("should capture an application_log event when credentials exist", () => {
+ process.env.POSTHOG_PROJECT_API_KEY = "test-key";
+ __resetPostHogClient();
+
+ logger.info("Test info", { userId: "u1" });
+
+ expect(mockPostHogCapture).toHaveBeenCalledWith(
expect.objectContaining({
- body: "Test debug",
- severityNumber: expect.any(Number),
- attributes: expect.objectContaining({ step: 1 }),
+ event: "application_log",
+ distinctId: "u1",
+ properties: expect.objectContaining({
+ message: "Test info",
+ }),
}),
);
});
@@ -245,49 +212,33 @@ describe("newrelic-utils", () => {
expect(mockEmit).toHaveBeenCalledWith(
expect.objectContaining({
body: "String error message",
- severityNumber: expect.any(Number),
attributes: expect.objectContaining({
errorMessage: "String error message",
- errorName: "Error",
}),
}),
);
});
- it("should record a string error with custom attributes", () => {
- recordError("String error", { code: 404 });
- expect(console.error).toHaveBeenCalled();
+ it("should capture an exception event when credentials exist", () => {
+ process.env.POSTHOG_PROJECT_API_KEY = "test-key";
+ __resetPostHogClient();
+
+ recordError(new Error("boom"), { userId: "u1" });
+
+ expect(mockPostHogCaptureException).toHaveBeenCalledWith(
+ expect.any(Error),
+ "server",
+ expect.objectContaining({
+ errorMessage: "boom",
+ userId: "u1",
+ }),
+ );
});
it("should handle null error gracefully", () => {
recordError(null as never);
expect(console.error).toHaveBeenCalled();
});
-
- it("should handle undefined error gracefully", () => {
- recordError(undefined as never);
- expect(console.error).toHaveBeenCalled();
- });
- });
-
- describe("setTransactionName", () => {
- it("should set transaction name without error", () => {
- expect(() => {
- setTransactionName("/api/test");
- }).not.toThrow();
- });
-
- it("should handle empty string", () => {
- expect(() => {
- setTransactionName("");
- }).not.toThrow();
- });
-
- it("should handle special characters", () => {
- expect(() => {
- setTransactionName("/api/users/[id]");
- }).not.toThrow();
- });
});
describe("trackApiCall", () => {
@@ -315,48 +266,6 @@ describe("newrelic-utils", () => {
});
});
- describe("addTransactionAttributes", () => {
- it("should add single attribute without error", () => {
- expect(() => {
- addTransactionAttributes({ key: "value" });
- }).not.toThrow();
- });
-
- it("should add multiple attributes", () => {
- expect(() => {
- addTransactionAttributes({
- userId: "123",
- action: "create",
- timestamp: 1234567890,
- });
- }).not.toThrow();
- });
-
- it("should handle empty attributes", () => {
- expect(() => {
- addTransactionAttributes({});
- }).not.toThrow();
- });
-
- it("should handle boolean attributes", () => {
- expect(() => {
- addTransactionAttributes({
- isAdmin: true,
- isActive: false,
- });
- }).not.toThrow();
- });
-
- it("should handle numeric attributes", () => {
- expect(() => {
- addTransactionAttributes({
- count: 42,
- score: 98.5,
- });
- }).not.toThrow();
- });
- });
-
describe("recordEvent", () => {
it("should record event with name and attributes without error", () => {
expect(() => {
@@ -381,10 +290,21 @@ describe("newrelic-utils", () => {
}).not.toThrow();
});
- it("should handle special characters in event name", () => {
- expect(() => {
- recordEvent("User:Signup:Success", { platform: "web" });
- }).not.toThrow();
+ it("should capture the event when credentials exist", () => {
+ process.env.POSTHOG_PROJECT_API_KEY = "test-key";
+ __resetPostHogClient();
+
+ recordEvent("UserLogin", { userId: "123", method: "oauth" });
+
+ expect(mockPostHogCapture).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: "UserLogin",
+ properties: expect.objectContaining({
+ userId: "123",
+ method: "oauth",
+ }),
+ }),
+ );
});
});
@@ -401,22 +321,10 @@ describe("newrelic-utils", () => {
}).not.toThrow();
});
- it("should record metric with large value", () => {
- expect(() => {
- recordMetric("bytes.transferred", 1048576);
- }).not.toThrow();
- });
-
it("should record metric with decimal value", () => {
expect(() => {
recordMetric("cpu.usage", 45.67);
}).not.toThrow();
});
-
- it("should handle metric names with namespaces", () => {
- expect(() => {
- recordMetric("custom.metrics.api.latency", 250);
- }).not.toThrow();
- });
});
-});
+});
\ No newline at end of file
diff --git a/apps/web/src/__tests__/scripts/validate-env.test.ts b/apps/web/src/__tests__/scripts/validate-env.test.ts
index 7784983..03f4278 100644
--- a/apps/web/src/__tests__/scripts/validate-env.test.ts
+++ b/apps/web/src/__tests__/scripts/validate-env.test.ts
@@ -212,15 +212,6 @@ describe("Environment Variable Validation Script", () => {
});
describe("Optional Environment Variables", () => {
- it("should allow NEWRELIC_LICENSE_KEY to be undefined for local dev", () => {
- // New Relic is optional for local development
- const newRelicKey = process.env.NEWRELIC_LICENSE_KEY;
- // Test passes if it's either defined or undefined
- expect(
- newRelicKey === undefined || typeof newRelicKey === "string",
- ).toBe(true);
- });
-
it("should allow custom NEXT_PUBLIC_BASE_URL", () => {
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL;
if (baseUrl) {
@@ -278,16 +269,18 @@ describe("Environment Variable Validation Script", () => {
}
});
- it("should have New Relic configured in production", () => {
+ it("should have PostHog configured in production", () => {
const nodeEnv = process.env.NODE_ENV;
- const newRelicKey = process.env.NEWRELIC_LICENSE_KEY;
+ const posthogKey =
+ process.env.POSTHOG_PROJECT_API_KEY ??
+ process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN;
if (nodeEnv === "production") {
- // In production, New Relic should be configured
+ // In production, PostHog should be configured
// This is a warning, not a hard requirement
- const hasNewRelic =
- newRelicKey !== undefined && newRelicKey.trim().length > 0;
- expect(hasNewRelic || nodeEnv !== "production").toBe(true);
+ const hasPostHog =
+ posthogKey !== undefined && posthogKey.trim().length > 0;
+ expect(hasPostHog || nodeEnv !== "production").toBe(true);
}
});
});
diff --git a/apps/web/src/__tests__/signup-policy.test.ts b/apps/web/src/__tests__/signup-policy.test.ts
new file mode 100644
index 0000000..18c4d50
--- /dev/null
+++ b/apps/web/src/__tests__/signup-policy.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from "vitest";
+import {
+ getApprovalStatusFromPrefs,
+ isSignupPolicy,
+} from "@/lib/signup-policy";
+
+describe("isSignupPolicy", () => {
+ it("accepts the known policies and rejects everything else", () => {
+ expect(isSignupPolicy("open")).toBe(true);
+ expect(isSignupPolicy("approval")).toBe(true);
+ expect(isSignupPolicy("disabled")).toBe(true);
+
+ expect(isSignupPolicy("")).toBe(false);
+ expect(isSignupPolicy("enabled")).toBe(false);
+ expect(isSignupPolicy(undefined)).toBe(false);
+ expect(isSignupPolicy(null)).toBe(false);
+ });
+});
+
+describe("getApprovalStatusFromPrefs", () => {
+ it("treats missing prefs as approved (legacy accounts)", () => {
+ expect(getApprovalStatusFromPrefs(undefined)).toBe("approved");
+ expect(getApprovalStatusFromPrefs(null)).toBe("approved");
+ expect(getApprovalStatusFromPrefs({})).toBe("approved");
+ });
+
+ it("reads the explicit status", () => {
+ expect(
+ getApprovalStatusFromPrefs({ approvalStatus: "pending" }),
+ ).toBe("pending");
+ expect(
+ getApprovalStatusFromPrefs({ approvalStatus: "rejected" }),
+ ).toBe("rejected");
+ expect(
+ getApprovalStatusFromPrefs({ approvalStatus: "approved" }),
+ ).toBe("approved");
+ });
+
+ it("falls back to approved for unknown values", () => {
+ expect(getApprovalStatusFromPrefs({ approvalStatus: "weird" })).toBe(
+ "approved",
+ );
+ });
+});
\ No newline at end of file
diff --git a/apps/web/src/__tests__/utils.test.ts b/apps/web/src/__tests__/utils.test.ts
index 686e6c5..ea972c8 100644
--- a/apps/web/src/__tests__/utils.test.ts
+++ b/apps/web/src/__tests__/utils.test.ts
@@ -230,11 +230,11 @@ describe("Utils - formatMessageTimestamp", () => {
const { formatMessageTimestamp } = await import("../lib/utils");
const testDate = "2025-01-15T14:30:00.000Z";
const result = formatMessageTimestamp(testDate);
-
+
// Should contain both date and time components
expect(result).toBeTruthy();
expect(result).toContain(" ");
-
+
// Verify it's not just time (which was the old behavior)
const date = new Date(testDate);
const timeStr = date.toLocaleTimeString();
@@ -245,7 +245,7 @@ describe("Utils - formatMessageTimestamp", () => {
const { formatMessageTimestamp } = await import("../lib/utils");
const isoDate = "2025-03-20T09:15:30.000Z";
const result = formatMessageTimestamp(isoDate);
-
+
expect(result).toBeTruthy();
expect(typeof result).toBe("string");
});
@@ -254,11 +254,11 @@ describe("Utils - formatMessageTimestamp", () => {
const { formatMessageTimestamp } = await import("../lib/utils");
const testDate = "2025-06-10T18:45:00.000Z";
const result = formatMessageTimestamp(testDate);
-
+
const date = new Date(testDate);
const dateStr = date.toLocaleDateString();
const timeStr = date.toLocaleTimeString();
-
+
// Result should be combination of date and time
expect(result).toBe(`${dateStr} ${timeStr}`);
});
diff --git a/apps/web/src/app/(auth)/login/actions.ts b/apps/web/src/app/(auth)/login/actions.ts
index f49e0b6..64dee30 100644
--- a/apps/web/src/app/(auth)/login/actions.ts
+++ b/apps/web/src/app/(auth)/login/actions.ts
@@ -8,7 +8,12 @@ import { getEnvConfig, perms } from "@/lib/appwrite-core";
import { invalidateSessionCacheForToken } from "@/lib/auth-server";
import { assignDefaultRoleServer } from "@/lib/default-role";
import { FEATURE_FLAGS, getFeatureFlag } from "@/lib/feature-flags";
-import { logger } from "@/lib/newrelic-utils";
+import { logger } from "@/lib/posthog-utils";
+import {
+ getApprovalStatusFromPrefs,
+ getSignupPolicy,
+ markSignupPending,
+} from "@/lib/signup-policy";
type AuthActionResult =
| { success: true; userId: string }
@@ -17,6 +22,7 @@ type AuthActionResult =
error: string;
message?: string;
verificationRequired?: boolean;
+ approvalRequired?: boolean;
};
type ResendVerificationResult =
@@ -425,6 +431,59 @@ export async function loginAction(
let shouldDeleteTemporarySession = true;
try {
+ const approvalStatus = getApprovalStatusFromPrefs(
+ accountUser.prefs,
+ );
+ if (approvalStatus === "pending") {
+ await deleteSessionBestEffort(
+ users,
+ session.userId,
+ session.$id,
+ );
+ shouldDeleteTemporarySession = false;
+
+ return {
+ success: false,
+ error:
+ "Your account is awaiting administrator approval.",
+ message:
+ "Your account is awaiting administrator approval.",
+ approvalRequired: true,
+ };
+ }
+
+ if (approvalStatus === "rejected") {
+ await deleteSessionBestEffort(
+ users,
+ session.userId,
+ session.$id,
+ );
+ shouldDeleteTemporarySession = false;
+
+ return {
+ success: false,
+ error:
+ "Your signup was not approved by an administrator.",
+ };
+ }
+
+ // A deactivated account is a break, not a ban: the next
+ // successful sign-in reactivates it automatically.
+ const prefs = (accountUser.prefs ?? {}) as Record;
+ if (prefs.disabled === true) {
+ try {
+ await users.updatePrefs({
+ userId: session.userId,
+ prefs: { ...prefs, disabled: false, disabledAt: null },
+ });
+ } catch (reactivateError) {
+ logger.warn("Failed to clear deactivated flag on login", {
+ userIdHash: generateUserIdHash(session.userId),
+ error: sanitizeAuthError(reactivateError),
+ });
+ }
+ }
+
if (await isEmailVerificationEnabled()) {
const emailVerified = Boolean(accountUser.emailVerification);
@@ -591,6 +650,29 @@ export async function resendVerificationAction(
}
}
+/**
+ * Reserve a fresh random userId that is not claimed by an existing (or
+ * tombstoned) profile, so a deleted account's ID can never be reused.
+ */
+async function findAvailableUserId(): Promise {
+ const { databases } = getServerClient();
+ const env = getEnvConfig();
+
+ for (let attempt = 0; attempt < 3; attempt++) {
+ const candidate = crypto.randomUUID();
+ const existing = await databases.listDocuments(
+ env.databaseId,
+ env.collections.profiles,
+ [Query.equal("userId", candidate), Query.limit(1)],
+ );
+ if (existing.documents.length === 0) {
+ return candidate;
+ }
+ }
+
+ throw new Error("Unable to allocate a user ID. Please try again.");
+}
+
/**
* Server-side registration + login action.
* Automatically joins the user to a default server when configured.
@@ -615,11 +697,23 @@ export async function registerAction(
}
try {
+ const policy = await getSignupPolicy();
+
+ if (policy === "disabled") {
+ return {
+ success: false,
+ error:
+ "Sign-ups are currently disabled on this instance. Contact your administrator.",
+ };
+ }
+
+ const approvalRequired = policy === "approval";
+
// Create account
const client = createAppwriteClient(endpoint, project);
const account = new Account(client);
- const userId = crypto.randomUUID();
+ const userId = await findAvailableUserId();
await account.create({
userId,
email,
@@ -627,6 +721,20 @@ export async function registerAction(
name,
});
+ // Approval policy: hold the account until an admin approves it.
+ if (approvalRequired) {
+ await markSignupPending(userId);
+
+ return {
+ success: false,
+ error:
+ "Your account is pending approval. You'll be able to sign in once an administrator approves it.",
+ message:
+ "Account created. It will be reviewed by an administrator before you can sign in.",
+ approvalRequired: true,
+ };
+ }
+
// Immediately log in to create session
const loginFormData = new FormData();
loginFormData.set("email", email);
@@ -699,6 +807,86 @@ export async function registerAction(
}
}
+type ResetPasswordResult =
+ | { success: true; message: string }
+ | { success: false; error: string };
+
+/**
+ * Completes a password reset using the userId + secret from the recovery
+ * email link. Creates a new session as a side effect (matching Appwrite's
+ * updateRecovery behavior), so restore a fresh one gracefully.
+ */
+export async function resetPasswordAction(
+ formData: FormData,
+): Promise {
+ const userId = formData.get("userId") as string;
+ const secret = formData.get("secret") as string;
+ const password = formData.get("password") as string;
+
+ if (!userId || !secret || !password) {
+ return { success: false, error: "Missing password reset data." };
+ }
+
+ const { endpoint, project } = getEnvConfig();
+ const apiKey = process.env.APPWRITE_API_KEY;
+
+ if (!endpoint || !project || !apiKey) {
+ return {
+ success: false,
+ error: "Password reset is not configured on this instance.",
+ };
+ }
+
+ try {
+ const client = createAppwriteClient(endpoint, project, apiKey);
+ await new Account(client).updateRecovery({
+ userId,
+ secret,
+ password,
+ });
+
+ return {
+ success: true,
+ message: "Password updated. You can now sign in.",
+ };
+ } catch (error) {
+ if (error instanceof Error) {
+ const message = error.message.toLowerCase();
+
+ if (
+ message.includes("recovery") ||
+ message.includes("token") ||
+ message.includes("invalid")
+ ) {
+ return {
+ success: false,
+ error:
+ "This password reset link is invalid or has expired. Request a new one.",
+ };
+ }
+
+ if (
+ message.includes("password") &&
+ (message.includes("short") || message.includes("weak"))
+ ) {
+ return {
+ success: false,
+ error: "Password must be at least 8 characters long.",
+ };
+ }
+ }
+
+ logger.error("Password reset action failed", {
+ error: sanitizeAuthError(error),
+ });
+
+ return {
+ success: false,
+ error: "Password reset failed. Please try again.",
+ };
+ }
+}
+
/**
* Server-side logout action that clears the session cookie.
*/
diff --git a/apps/web/src/app/(auth)/login/login-form.tsx b/apps/web/src/app/(auth)/login/login-form.tsx
index d7d6a2f..e2c676f 100644
--- a/apps/web/src/app/(auth)/login/login-form.tsx
+++ b/apps/web/src/app/(auth)/login/login-form.tsx
@@ -27,6 +27,17 @@ type LoginFormProps = {
showResendVerification: boolean;
};
+const REMEMBER_KEY = "firepit.remember";
+
+function getRemembered(): boolean {
+ if (typeof window === "undefined") return true;
+ try {
+ return window.localStorage.getItem(REMEMBER_KEY) !== "false";
+ } catch {
+ return true;
+ }
+}
+
const LoginFormContent: React.FC = ({ showResendVerification }) => {
const pathname = usePathname();
const router = useRouter();
@@ -34,10 +45,24 @@ const LoginFormContent: React.FC = ({ showResendVerification })
const { refreshUser } = useAuth();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
+ const [remember, setRemember] = useState(getRemembered);
const [loading, setLoading] = useState(false);
const [resendingVerification, setResendingVerification] = useState(false);
+ const [resettingPassword, setResettingPassword] = useState(false);
+ const [awaitingRecoveryEmail, setAwaitingRecoveryEmail] = useState(false);
+ const [recoverySent, setRecoverySent] = useState(false);
+ const [recoveryCooldown, setRecoveryCooldown] = useState(0);
const notifiedVerificationStatusRef = useRef(null);
+ useEffect(() => {
+ if (recoveryCooldown <= 0) return;
+ const id = setInterval(
+ () => setRecoveryCooldown((seconds) => Math.max(0, seconds - 1)),
+ 1000,
+ );
+ return () => clearInterval(id);
+ }, [recoveryCooldown]);
+
useEffect(() => {
const verifiedStatus = searchParams.get("verified");
if (
@@ -78,7 +103,7 @@ const LoginFormContent: React.FC = ({ showResendVerification })
const sessionResponse = await fetch("/api/auth/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ email, password }),
+ body: JSON.stringify({ email, password, remember }),
});
if (!sessionResponse.ok) {
@@ -171,6 +196,49 @@ const LoginFormContent: React.FC = ({ showResendVerification })
}
}
+ const onRequestPasswordReset = async () => {
+ if (!awaitingRecoveryEmail) {
+ setAwaitingRecoveryEmail(true);
+ return;
+ }
+
+ if (!email) {
+ toast.error("Enter your email to receive a reset link.");
+ return;
+ }
+
+ setResettingPassword(true);
+ try {
+ const response = await fetch("/api/auth/password-recovery", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email }),
+ });
+ const data = (await response.json().catch(() => ({}))) as {
+ message?: string;
+ error?: string;
+ };
+ // Success responses are intentionally generic (no enumeration).
+ if (data.error) {
+ toast.error(data.error);
+ } else {
+ toast.success(
+ data.message ?? "Password reset link sent.",
+ );
+ setRecoverySent(true);
+ setRecoveryCooldown(30);
+ }
+ } catch (err) {
+ const message =
+ err instanceof Error
+ ? err.message
+ : "Failed to request a password reset.";
+ toast.error(message);
+ } finally {
+ setResettingPassword(false);
+ }
+ }
+
return (
@@ -250,6 +318,79 @@ const LoginFormContent: React.FC = ({ showResendVerification })
value={password}
/>
+
+
+ {
+ setRemember(e.target.checked);
+ try {
+ window.localStorage.setItem(
+ REMEMBER_KEY,
+ String(e.target.checked),
+ );
+ } catch {
+ // Storage unavailable (private mode); preference just won't persist.
+ }
+ }}
+ type="checkbox"
+ />
+ Remember me
+
+ 0}
+ onClick={onRequestPasswordReset}
+ type="button"
+ >
+ {resettingPassword
+ ? "Sending..."
+ : "Forgot password?"}
+
+
+ {awaitingRecoveryEmail && (
+
+
+ Email for reset link
+
+
setEmail(e.target.value)}
+ placeholder="you@example.com"
+ type="email"
+ value={email}
+ />
+
0)
+ }
+ onClick={onRequestPasswordReset}
+ type="button"
+ variant="outline"
+ className="rounded-full"
+ >
+ {resettingPassword
+ ? "Sending..."
+ : recoverySent
+ ? recoveryCooldown > 0
+ ? `Resend link (${recoveryCooldown}s)`
+ : "Resend link"
+ : "Send reset link"}
+
+ {recoverySent && (
+
+ Check your inbox for a reset link. If
+ it does not arrive, you can resend in
+ a moment.
+
+ )}
+
+ )}
{loading ? "Signing in..." : "Sign in"}
{!loading && }
diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx
index a4bff36..eadac4a 100644
--- a/apps/web/src/app/(auth)/login/page.tsx
+++ b/apps/web/src/app/(auth)/login/page.tsx
@@ -1,5 +1,5 @@
import { FEATURE_FLAGS, getFeatureFlag } from "@/lib/feature-flags";
-import { logger } from "@/lib/newrelic-utils";
+import { logger } from "@/lib/posthog-utils";
import LoginForm from "./login-form";
diff --git a/apps/web/src/app/(auth)/register/page.tsx b/apps/web/src/app/(auth)/register/page.tsx
index 8b4e39a..b190835 100644
--- a/apps/web/src/app/(auth)/register/page.tsx
+++ b/apps/web/src/app/(auth)/register/page.tsx
@@ -65,11 +65,13 @@ function RegisterFormContent() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
router.push(destination as any);
} else {
- if (result.verificationRequired) {
- const verificationMessage =
+ if (result.verificationRequired || result.approvalRequired) {
+ const requiredMessage =
result.message ||
- "Verification required. Check your inbox for a verification link.";
- toast.success(verificationMessage);
+ (result.verificationRequired
+ ? "Verification required. Check your inbox for a verification link."
+ : "Your account is pending approval. You'll be able to sign in once an administrator approves it.");
+ toast.success(requiredMessage);
router.push(`/login?redirect=${encodeURIComponent(destination)}`);
} else {
toast.error(
diff --git a/apps/web/src/app/(auth)/reset-password/page.tsx b/apps/web/src/app/(auth)/reset-password/page.tsx
new file mode 100644
index 0000000..d7a3841
--- /dev/null
+++ b/apps/web/src/app/(auth)/reset-password/page.tsx
@@ -0,0 +1,147 @@
+"use client";
+
+import Link from "next/link";
+import { useRouter, useSearchParams } from "next/navigation";
+import { Suspense, useState } from "react";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+
+import { resetPasswordAction } from "../login/actions";
+
+function ResetPasswordFormContent() {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const userId = searchParams.get("userId") ?? "";
+ const secret = searchParams.get("secret") ?? "";
+ const [password, setPassword] = useState("");
+ const [confirmPassword, setConfirmPassword] = useState("");
+ const [loading, setLoading] = useState(false);
+
+ const hasValidToken = Boolean(userId && secret && secret.length >= 32);
+
+ const onSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (password.length < 8) {
+ toast.error("Password must be at least 8 characters long.");
+ return;
+ }
+
+ if (password !== confirmPassword) {
+ toast.error("Passwords do not match.");
+ return;
+ }
+
+ setLoading(true);
+ try {
+ const formData = new FormData();
+ formData.set("userId", userId);
+ formData.set("secret", secret);
+ formData.set("password", password);
+ const result = await resetPasswordAction(formData);
+ if (result.success) {
+ toast.success(result.message);
+ router.push("/login");
+ } else {
+ toast.error(result.error);
+ }
+ } catch (err) {
+ const message =
+ err instanceof Error
+ ? err.message
+ : "Failed to reset password. Please try again.";
+ toast.error(message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
Reset your password
+
+ Choose a new password for your Firepit account.
+
+
+
+ {!hasValidToken ? (
+
+
+ This password reset link is invalid or has expired.
+ Please request a new one from the sign-in page.
+
+
+ Back to sign in
+
+
+ ) : (
+
+ )}
+
+
+ Remembered your password?{" "}
+
+ Sign in
+
+ .
+
+
+ );
+}
+
+function ResetPasswordForm() {
+ return (
+
+ Loading...
+
+ }
+ >
+
+
+ );
+}
+
+export default function ResetPasswordPage() {
+ return ;
+}
\ No newline at end of file
diff --git a/apps/web/src/app/admin/actions.ts b/apps/web/src/app/admin/actions.ts
index ce79156..b02369f 100644
--- a/apps/web/src/app/admin/actions.ts
+++ b/apps/web/src/app/admin/actions.ts
@@ -1,11 +1,26 @@
"use server";
import { createHash } from "node:crypto";
-import { Query, type Models } from "node-appwrite";
+import { Query, type Models, Users } from "node-appwrite";
import { getAdminClient } from "@/lib/appwrite-admin";
import { getEnvConfig } from "@/lib/appwrite-core";
+import { getServerClient } from "@/lib/appwrite-server";
+import {
+ deleteAvatarFile,
+ deleteProfileBackgroundFile,
+ getUserProfile,
+} from "@/lib/appwrite-profiles";
import { getUserRoles } from "@/lib/appwrite-roles";
-import { logger } from "@/lib/newrelic-utils";
+import { logger } from "@/lib/posthog-utils";
+import {
+ approveSignup,
+ getSignupPolicy,
+ isSignupPolicy,
+ listPendingSignups,
+ setSignupPolicy,
+ type PendingSignup,
+ type SignupPolicy,
+} from "@/lib/signup-policy";
import {
getAllFeatureFlags,
setFeatureFlag,
@@ -464,3 +479,101 @@ export async function dispatchAnnouncementsAction(
const validatedLimit = validateAnnouncementDispatchLimit(limit);
return dispatchScheduledAnnouncements(validatedLimit);
}
+
+async function requireAdminRole(userId: string) {
+ const roles = await getUserRoles(userId);
+ if (!roles.isAdmin) {
+ throw new Error("Forbidden");
+ }
+ return roles;
+}
+
+/**
+ * Get the current signup policy (admin only)
+ */
+export async function getSignupPolicyAction(
+ userId: string,
+): Promise {
+ await requireAdminRole(userId);
+ return getSignupPolicy();
+}
+
+/**
+ * Change the instance signup policy (admin only)
+ */
+export async function setSignupPolicyAction(
+ userId: string,
+ policy: SignupPolicy,
+): Promise<{ success: true }> {
+ await requireAdminRole(userId);
+
+ if (!isSignupPolicy(policy)) {
+ throw new Error("Invalid signup policy");
+ }
+
+ const success = await setSignupPolicy(policy, userId);
+ if (!success) {
+ throw new Error("Failed to update signup policy");
+ }
+ return { success: true };
+}
+
+/**
+ * List accounts awaiting approval (admin only)
+ */
+export async function listPendingSignupsAction(
+ userId: string,
+): Promise {
+ await requireAdminRole(userId);
+ return listPendingSignups();
+}
+
+/**
+ * Approve a pending signup (admin only)
+ */
+export async function approveSignupAction(
+ userId: string,
+ pendingUserId: string,
+): Promise<{ success: true }> {
+ await requireAdminRole(userId);
+ await approveSignup(pendingUserId);
+ return { success: true };
+}
+
+/**
+ * Reject a pending signup (admin only). Deletes the Appwrite account and any
+ * profile/asset data.
+ */
+export async function rejectSignupAction(
+ userId: string,
+ pendingUserId: string,
+): Promise<{ success: true }> {
+ await requireAdminRole(userId);
+
+ const env = getEnvConfig();
+ const { databases } = getAdminClient();
+
+ const profile = await getUserProfile(pendingUserId);
+
+ if (profile?.avatarFileId) {
+ await deleteAvatarFile(profile.avatarFileId).catch(() => {});
+ }
+ if (profile?.profileBackgroundImageFileId) {
+ await deleteProfileBackgroundFile(
+ profile.profileBackgroundImageFileId,
+ ).catch(() => {});
+ }
+ if (profile) {
+ await databases.deleteDocument(
+ env.databaseId,
+ env.collections.profiles,
+ profile.$id,
+ );
+ }
+
+ const { client } = getServerClient();
+ const users = new Users(client);
+ await users.delete({ userId: pendingUserId });
+
+ return { success: true };
+}
diff --git a/apps/web/src/app/admin/page.tsx b/apps/web/src/app/admin/page.tsx
index 4a0c8e7..a847c66 100644
--- a/apps/web/src/app/admin/page.tsx
+++ b/apps/web/src/app/admin/page.tsx
@@ -14,13 +14,14 @@ import { createHash } from "node:crypto";
import { getBasicStats } from "@/lib/appwrite-admin";
import { requireAdmin } from "@/lib/auth-server";
-import { logger } from "@/lib/newrelic-utils";
+import { logger } from "@/lib/posthog-utils";
import { type BackfillResult, backfillServerIds } from "./actions";
import { ServerManagement } from "./server-management";
import { VersionCheck } from "./version-check";
import { FeatureFlags } from "./feature-flags";
import { AnnouncementPanel } from "./announcement-panel";
+import { SignupControls } from "./signup-controls";
const quickLinkClassName =
"inline-flex items-center justify-between rounded-3xl border border-border/60 bg-background/80 px-4 py-3 text-sm font-medium text-foreground transition-all hover:-translate-y-0.5 hover:border-border hover:bg-background";
@@ -182,6 +183,8 @@ export default async function AdminPage(props: {
+
+
= {
+ open: "Open (anyone can sign up)",
+ approval: "Individual approval required",
+ disabled: "No signups allowed",
+};
+
+export function SignupControls({ userId }: SignupControlsProps) {
+ const [policy, setPolicy] = useState("open");
+ const [loading, setLoading] = useState(true);
+ const [savingPolicy, setSavingPolicy] = useState(false);
+ const [pending, setPending] = useState([]);
+ const [busy, setBusy] = useState(null);
+
+ const load = useCallback(async () => {
+ try {
+ const [nextPolicy, pendingSignups] = await Promise.all([
+ getSignupPolicyAction(userId),
+ listPendingSignupsAction(userId),
+ ]);
+ setPolicy(nextPolicy);
+ setPending(pendingSignups);
+ } catch (error) {
+ clientLogger.error(
+ "Failed to load signup controls:",
+ error instanceof Error ? error : String(error),
+ );
+ toast.error("Failed to load signup controls");
+ } finally {
+ setLoading(false);
+ }
+ }, [userId]);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ const handlePolicyChange = async (next: SignupPolicy) => {
+ setSavingPolicy(true);
+ const previous = policy;
+ setPolicy(next);
+ try {
+ await setSignupPolicyAction(userId, next);
+ toast.success("Signup policy updated");
+ } catch (error) {
+ setPolicy(previous);
+ const message =
+ error instanceof Error
+ ? error.message
+ : "Failed to update signup policy";
+ toast.error(message);
+ } finally {
+ setSavingPolicy(false);
+ }
+ };
+
+ const handleApprove = async (pendingUserId: string) => {
+ setBusy(pendingUserId);
+ try {
+ await approveSignupAction(userId, pendingUserId);
+ toast.success("Signup approved");
+ setPending((prev) =>
+ prev.filter((p) => p.userId !== pendingUserId),
+ );
+ } catch (error) {
+ toast.error(
+ error instanceof Error ? error.message : "Approval failed",
+ );
+ } finally {
+ setBusy(null);
+ }
+ };
+
+ const handleReject = async (pendingUserId: string) => {
+ if (
+ !window.confirm(
+ "Rejecting deletes this signup permanently. Continue?",
+ )
+ ) {
+ return;
+ }
+ setBusy(pendingUserId);
+ try {
+ await rejectSignupAction(userId, pendingUserId);
+ toast.success("Signup rejected and removed");
+ setPending((prev) =>
+ prev.filter((p) => p.userId !== pendingUserId),
+ );
+ } catch (error) {
+ toast.error(
+ error instanceof Error ? error.message : "Rejection failed",
+ );
+ } finally {
+ setBusy(null);
+ }
+ };
+
+ if (loading) {
+ return (
+
+
+
+
Signups
+
+
+ Loading signup controls...
+
+
+ );
+ }
+
+ return (
+
+
+
+
Signups
+
+
+ Control who can create accounts on this instance.
+
+
+
+ Signup policy
+
+ void handlePolicyChange(value as SignupPolicy)
+ }
+ value={policy}
+ >
+
+
+
+
+ {Object.entries(POLICY_LABELS).map(
+ ([value, label]) => (
+
+ {label}
+
+ ),
+ )}
+
+
+
+
+ {policy === "approval" && (
+
+
+
+
+ Pending approvals
+
+ {pending.length > 0 && (
+
+ {pending.length}
+
+ )}
+
+
+ {pending.length === 0 ? (
+
+ No signups waiting for approval.
+
+ ) : (
+
+ )}
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/src/app/api/admin/audit-logs/route.ts b/apps/web/src/app/api/admin/audit-logs/route.ts
index 73f84b7..271e8bd 100644
--- a/apps/web/src/app/api/admin/audit-logs/route.ts
+++ b/apps/web/src/app/api/admin/audit-logs/route.ts
@@ -4,7 +4,7 @@ import { clampLimit } from "@/lib/appwrite-reports";
import {
getProfilesByUserIds,
} from "@/lib/appwrite-profiles";
-import { logger } from "@/lib/newrelic-utils";
+import { logger } from "@/lib/posthog-utils";
import { requireModerator } from "@/lib/auth-server";
export async function GET(request: Request) {
diff --git a/apps/web/src/app/api/admin/reports/route.ts b/apps/web/src/app/api/admin/reports/route.ts
index e059dc6..1cdf036 100644
--- a/apps/web/src/app/api/admin/reports/route.ts
+++ b/apps/web/src/app/api/admin/reports/route.ts
@@ -7,7 +7,7 @@ import {
} from "@/lib/appwrite-reports";
import { getProfilesByUserIds } from "@/lib/appwrite-profiles";
import { recordAudit } from "@/lib/appwrite-audit";
-import { logger, recordError } from "@/lib/newrelic-utils";
+import { logger, recordError } from "@/lib/posthog-utils";
import { requireModerator } from "@/lib/auth-server";
import { isDocumentNotFoundError } from "@/lib/appwrite-admin";
diff --git a/apps/web/src/app/api/announcements/dispatch/route.ts b/apps/web/src/app/api/announcements/dispatch/route.ts
index b18476c..b2c54bd 100644
--- a/apps/web/src/app/api/announcements/dispatch/route.ts
+++ b/apps/web/src/app/api/announcements/dispatch/route.ts
@@ -6,7 +6,7 @@ import {
getAnnouncementRuntimeSettings,
parseLimit,
} from "@/lib/appwrite-announcements";
-import { logger } from "@/lib/newrelic-utils";
+import { logger } from "@/lib/posthog-utils";
export async function POST(request: Request) {
const { dispatcherSecret, systemSenderUserId } =
diff --git a/apps/web/src/app/api/announcements/route.ts b/apps/web/src/app/api/announcements/route.ts
index c112184..41c36cb 100644
--- a/apps/web/src/app/api/announcements/route.ts
+++ b/apps/web/src/app/api/announcements/route.ts
@@ -11,7 +11,7 @@ import type {
AnnouncementStatus,
} from "@/lib/types";
import { AuthError, requireAdmin } from "@/lib/auth-server";
-import { logger } from "@/lib/newrelic-utils";
+import { logger } from "@/lib/posthog-utils";
const ALLOWED_PRIORITIES: ReadonlySet = new Set([
"normal",
diff --git a/apps/web/src/app/api/auth/password-recovery/route.ts b/apps/web/src/app/api/auth/password-recovery/route.ts
new file mode 100644
index 0000000..7de027e
--- /dev/null
+++ b/apps/web/src/app/api/auth/password-recovery/route.ts
@@ -0,0 +1,121 @@
+import { NextResponse } from "next/server";
+import { Account, Client } from "node-appwrite";
+import { getEnvConfig } from "@/lib/appwrite-core";
+import { checkRateLimit, getClientIp } from "@/lib/rate-limit";
+import { logger } from "@/lib/posthog-utils";
+
+const PASSWORD_RECOVERY_RATE_LIMIT = {
+ maxRequests: 3,
+ windowMs: 60 * 60 * 1000,
+};
+
+function getRecoveryRedirectUrl(): string {
+ const configuredBaseUrl =
+ process.env.SERVER_URL?.trim() ||
+ process.env.NEXT_PUBLIC_BASE_URL?.trim() ||
+ "http://localhost:3000";
+
+ const normalizedBaseUrl = configuredBaseUrl.replace(/\/$/, "");
+ return `${normalizedBaseUrl}/reset-password`;
+}
+
+function rateLimitResponse(retryAfter: number | undefined): NextResponse {
+ return NextResponse.json(
+ {
+ error:
+ "Too many password reset requests. Please try again in an hour.",
+ },
+ {
+ status: 429,
+ headers: { "Retry-After": String(retryAfter ?? 3600) },
+ },
+ );
+}
+
+/**
+ * POST /api/auth/password-recovery
+ *
+ * Sends a password recovery (reset) email via Account.createRecovery. Kept
+ * generic on success so the response never reveals whether an email exists.
+ */
+export async function POST(request: Request) {
+ let email: string | undefined;
+ try {
+ const body = (await request.json()) as { email?: string };
+ email = body.email;
+
+ if (!email || typeof email !== "string") {
+ return NextResponse.json(
+ { error: "Email is required" },
+ { status: 400 },
+ );
+ }
+
+ const emailLimit = checkRateLimit(
+ `password-recovery-email:${email.toLowerCase()}`,
+ PASSWORD_RECOVERY_RATE_LIMIT,
+ );
+ if (!emailLimit.allowed) {
+ return rateLimitResponse(emailLimit.retryAfter);
+ }
+
+ const clientIp = getClientIp(request);
+ if (clientIp) {
+ const ipLimit = checkRateLimit(
+ `password-recovery-ip:${clientIp}`,
+ PASSWORD_RECOVERY_RATE_LIMIT,
+ );
+ if (!ipLimit.allowed) {
+ return rateLimitResponse(ipLimit.retryAfter);
+ }
+ }
+
+ const env = getEnvConfig();
+ const apiKey = process.env.APPWRITE_API_KEY;
+
+ if (!apiKey) {
+ return NextResponse.json(
+ { error: "Server API key not configured" },
+ { status: 500 },
+ );
+ }
+
+ const client = new Client()
+ .setEndpoint(env.endpoint)
+ .setProject(env.project)
+ .setKey(apiKey);
+
+ await new Account(client).createRecovery({
+ email,
+ url: getRecoveryRedirectUrl(),
+ });
+
+ // Always respond generically to prevent account enumeration.
+ return NextResponse.json({
+ success: true,
+ message:
+ "If an account exists for that email, a password reset link has been sent.",
+ });
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : String(error);
+ if (/hostname|platform/i.test(message)) {
+ logger.warn(
+ "Password recovery URL hostname is not a registered Appwrite platform. Register this host (e.g. localhost vs 127.0.0.1) under Appwrite console -> Project -> Overview -> Platforms.",
+ { error: message },
+ );
+ }
+
+ logger.error("Password recovery request failed", {
+ emailProvided: Boolean(email),
+ error: message,
+ });
+
+ // Even on failure, stay generic.
+ return NextResponse.json({
+ success: true,
+ message:
+ "If an account exists for that email, a password reset link has been sent.",
+ });
+ }
+}
\ No newline at end of file
diff --git a/apps/web/src/app/api/auth/session/route.ts b/apps/web/src/app/api/auth/session/route.ts
index 872ba9f..7fc1de4 100644
--- a/apps/web/src/app/api/auth/session/route.ts
+++ b/apps/web/src/app/api/auth/session/route.ts
@@ -1,9 +1,10 @@
import { createHash } from "node:crypto";
import { NextResponse } from "next/server";
-import { Account, Client } from "node-appwrite";
+import { Account, Client, Users } from "node-appwrite";
import { getEnvConfig } from "@/lib/appwrite-core";
import { debugAuth, describeAuthHeader } from "@/lib/auth-server";
import { checkRateLimit, getClientIp } from "@/lib/rate-limit";
+import { getApprovalStatusFromPrefs } from "@/lib/signup-policy";
const SESSION_LOGIN_RATE_LIMIT = {
maxRequests: 5,
@@ -113,6 +114,45 @@ export async function POST(request: Request) {
password,
});
+ // Admin-control gates: pending/rejected signups can't sign in, and a
+ // deactivated account reactivates on its next successful sign-in.
+ const users = new Users(client);
+ const accountUser = await users.get(session.userId);
+ const prefs = (accountUser.prefs ?? {}) as Record;
+ const approvalStatus = getApprovalStatusFromPrefs(prefs);
+
+ if (approvalStatus === "pending" || approvalStatus === "rejected") {
+ await users
+ .deleteSession({
+ userId: session.userId,
+ sessionId: session.$id,
+ })
+ .catch(() => {});
+
+ debugAuth(
+ `POST /api/auth/session blocked: userId=${session.userId}, status=${approvalStatus}`,
+ );
+
+ return NextResponse.json(
+ {
+ error:
+ approvalStatus === "pending"
+ ? "Your account is awaiting administrator approval."
+ : "Your signup was not approved by an administrator.",
+ },
+ { status: 403 },
+ );
+ }
+
+ if (prefs.disabled === true) {
+ await users
+ .updatePrefs({
+ userId: session.userId,
+ prefs: { ...prefs, disabled: false, disabledAt: null },
+ })
+ .catch(() => {});
+ }
+
debugAuth(
`POST /api/auth/session success: userId=${session.userId}, hasSecret=${Boolean(session.secret)}`,
);
diff --git a/apps/web/src/app/api/auth/verify-email/route.ts b/apps/web/src/app/api/auth/verify-email/route.ts
index 7b8cef4..fa4f210 100644
--- a/apps/web/src/app/api/auth/verify-email/route.ts
+++ b/apps/web/src/app/api/auth/verify-email/route.ts
@@ -3,7 +3,7 @@ import { NextResponse } from "next/server";
import { getEnvConfig } from "@/lib/appwrite-core";
import { FEATURE_FLAGS, getFeatureFlag } from "@/lib/feature-flags";
-import { logger } from "@/lib/newrelic-utils";
+import { logger } from "@/lib/posthog-utils";
function buildLoginRedirect(requestUrl: string): {
loginRedirectUrl: URL;
diff --git a/apps/web/src/app/api/categories/route.ts b/apps/web/src/app/api/categories/route.ts
index 5388f62..7207f45 100644
--- a/apps/web/src/app/api/categories/route.ts
+++ b/apps/web/src/app/api/categories/route.ts
@@ -10,7 +10,7 @@ import {
logger,
returnUnauthorized,
returnForbidden,
-} from "@/lib/newrelic-utils";
+} from "@/lib/posthog-utils";
import { getServerPermissionsForUser } from "@/lib/server-channel-access";
const env = getEnvConfig();
diff --git a/apps/web/src/app/api/channel-permissions/route.ts b/apps/web/src/app/api/channel-permissions/route.ts
index 68e9a9c..c568016 100644
--- a/apps/web/src/app/api/channel-permissions/route.ts
+++ b/apps/web/src/app/api/channel-permissions/route.ts
@@ -8,7 +8,7 @@ import {
logger,
returnUnauthorized,
returnForbidden,
-} from "@/lib/newrelic-utils";
+} from "@/lib/posthog-utils";
import { getServerPermissionsForUser } from "@/lib/server-channel-access";
import { invalidateChannelAccessCache } from "@/lib/server-channel-access";
import { invalidateChannelsServerCaches } from "@/lib/channels-route-cache";
diff --git a/apps/web/src/app/api/channels/[channelId]/mute/route.ts b/apps/web/src/app/api/channels/[channelId]/mute/route.ts
index fcd39b1..d5ebfb8 100644
--- a/apps/web/src/app/api/channels/[channelId]/mute/route.ts
+++ b/apps/web/src/app/api/channels/[channelId]/mute/route.ts
@@ -7,7 +7,7 @@ import { getServerSession } from "@/lib/auth-server";
import { muteChannel, unmuteChannel } from "@/lib/notification-settings";
import { invalidateNotificationSettingsCache } from "@/lib/notification-triggers";
import { getServerPermissionsForUser } from "@/lib/server-channel-access";
-import { returnUnauthorized, returnForbidden, logger } from "@/lib/newrelic-utils";
+import { returnUnauthorized, returnForbidden, logger } from "@/lib/posthog-utils";
import type { MuteDuration, NotificationLevel } from "@/lib/types";
interface MuteRequestBody {
diff --git a/apps/web/src/app/api/channels/[channelId]/pins/route.ts b/apps/web/src/app/api/channels/[channelId]/pins/route.ts
index 13b172a..54a8aa5 100644
--- a/apps/web/src/app/api/channels/[channelId]/pins/route.ts
+++ b/apps/web/src/app/api/channels/[channelId]/pins/route.ts
@@ -10,11 +10,9 @@ import { buildPinsResponse, listPinnedMessages } from "@/lib/pin-response";
import {
logger,
recordError,
- setTransactionName,
trackApiCall,
- addTransactionAttributes,
returnForbidden,
-} from "@/lib/newrelic-utils";
+} from "@/lib/posthog-utils";
type RouteContext = {
params: Promise<{
@@ -30,7 +28,6 @@ export async function GET(request: NextRequest, context: RouteContext) {
const startTime = Date.now();
try {
- setTransactionName("GET /api/channels/[channelId]/pins");
// Verify user is authenticated
const user = await getServerSession();
@@ -44,11 +41,6 @@ export async function GET(request: NextRequest, context: RouteContext) {
const { channelId } = await context.params;
- addTransactionAttributes({
- channelId,
- userId: user.$id,
- });
-
const env = getEnvConfig();
const { databases } = getServerClient();
diff --git a/apps/web/src/app/api/channels/[channelId]/route.ts b/apps/web/src/app/api/channels/[channelId]/route.ts
index f6529f2..b3f1625 100644
--- a/apps/web/src/app/api/channels/[channelId]/route.ts
+++ b/apps/web/src/app/api/channels/[channelId]/route.ts
@@ -8,7 +8,7 @@ import { deleteChannel } from "@/lib/appwrite-servers";
import { isDocumentNotFoundError } from "@/lib/appwrite-admin";
import { logger,
returnForbidden,
-} from "@/lib/newrelic-utils";
+} from "@/lib/posthog-utils";
import { getServerPermissionsForUser } from "@/lib/server-channel-access";
import { invalidateChannelsServerCaches } from "@/lib/channels-route-cache";
import type { Channel } from "@/lib/types";
diff --git a/apps/web/src/app/api/channels/route.ts b/apps/web/src/app/api/channels/route.ts
index c459dc6..d849b73 100644
--- a/apps/web/src/app/api/channels/route.ts
+++ b/apps/web/src/app/api/channels/route.ts
@@ -13,7 +13,7 @@ import { getServerPermissionsForUser } from "@/lib/server-channel-access";
import { apiCache } from "@/lib/cache-utils";
import { invalidateChannelsServerCaches } from "@/lib/channels-route-cache";
import { listPages } from "@/lib/appwrite-pagination";
-import { returnForbidden, logger } from "@/lib/newrelic-utils";
+import { returnForbidden, logger } from "@/lib/posthog-utils";
const ROLE_ASSIGNMENTS_COLLECTION_ID = "role_assignments";
const ROLES_COLLECTION_ID = "roles";
diff --git a/apps/web/src/app/api/conversations/[conversationId]/mute/route.ts b/apps/web/src/app/api/conversations/[conversationId]/mute/route.ts
index 58e31b1..0cbbc2d 100644
--- a/apps/web/src/app/api/conversations/[conversationId]/mute/route.ts
+++ b/apps/web/src/app/api/conversations/[conversationId]/mute/route.ts
@@ -6,7 +6,7 @@ import { isDocumentNotFoundError } from "@/lib/appwrite-admin";
import { getServerSession } from "@/lib/auth-server";
import { muteConversation, unmuteConversation } from "@/lib/notification-settings";
import { invalidateNotificationSettingsCache } from "@/lib/notification-triggers";
-import { returnUnauthorized, returnForbidden, logger } from "@/lib/newrelic-utils";
+import { returnUnauthorized, returnForbidden, logger } from "@/lib/posthog-utils";
import type { MuteDuration, NotificationLevel } from "@/lib/types";
interface MuteRequestBody {
diff --git a/apps/web/src/app/api/conversations/[conversationId]/pins/route.ts b/apps/web/src/app/api/conversations/[conversationId]/pins/route.ts
index 4ba0934..7ce61e7 100644
--- a/apps/web/src/app/api/conversations/[conversationId]/pins/route.ts
+++ b/apps/web/src/app/api/conversations/[conversationId]/pins/route.ts
@@ -5,7 +5,7 @@ import { getEnvConfig } from "@/lib/appwrite-core";
import { isDocumentNotFoundError } from "@/lib/appwrite-admin";
import { getServerSession } from "@/lib/auth-server";
import { buildPinsResponse, listPinnedMessages } from "@/lib/pin-response";
-import { returnForbidden, logger } from "@/lib/newrelic-utils";
+import { returnForbidden, logger } from "@/lib/posthog-utils";
type RouteContext = {
params: Promise<{
diff --git a/apps/web/src/app/api/custom-emojis/route.ts b/apps/web/src/app/api/custom-emojis/route.ts
index 7e55773..77cba77 100644
--- a/apps/web/src/app/api/custom-emojis/route.ts
+++ b/apps/web/src/app/api/custom-emojis/route.ts
@@ -6,7 +6,7 @@ import type { CustomEmoji } from "@/lib/types";
import { logger,
returnUnauthorized,
returnForbidden,
-} from "@/lib/newrelic-utils";
+} from "@/lib/posthog-utils";
const FILE_EXTENSION_REGEX = /\.[^.]+$/;
diff --git a/apps/web/src/app/api/direct-messages/[messageId]/pin/route.ts b/apps/web/src/app/api/direct-messages/[messageId]/pin/route.ts
index 2d49c81..67b2b01 100644
--- a/apps/web/src/app/api/direct-messages/[messageId]/pin/route.ts
+++ b/apps/web/src/app/api/direct-messages/[messageId]/pin/route.ts
@@ -5,7 +5,7 @@ import { ID, Query } from "node-appwrite";
import { getServerClient } from "@/lib/appwrite-server";
import { getEnvConfig } from "@/lib/appwrite-core";
import { getServerSession } from "@/lib/auth-server";
-import { returnForbidden, logger } from "@/lib/newrelic-utils";
+import { returnForbidden, logger } from "@/lib/posthog-utils";
import type { DirectMessage, PinnedMessage } from "@/lib/types";
const PIN_LIMIT = 50;
diff --git a/apps/web/src/app/api/direct-messages/[messageId]/reactions/route.ts b/apps/web/src/app/api/direct-messages/[messageId]/reactions/route.ts
index 9dc5f67..15734af 100644
--- a/apps/web/src/app/api/direct-messages/[messageId]/reactions/route.ts
+++ b/apps/web/src/app/api/direct-messages/[messageId]/reactions/route.ts
@@ -10,10 +10,8 @@ import { parseReactions } from "@/lib/reactions-utils";
import {
logger,
recordError,
- setTransactionName,
trackApiCall,
- addTransactionAttributes,
-} from "@/lib/newrelic-utils";
+} from "@/lib/posthog-utils";
type RouteContext = {
params: Promise<{
@@ -61,7 +59,6 @@ export async function POST(request: NextRequest, context: RouteContext) {
const startTime = Date.now();
try {
- setTransactionName("POST /api/direct-messages/[messageId]/reactions");
// Verify user is authenticated
const user = await getServerSession();
@@ -84,12 +81,6 @@ export async function POST(request: NextRequest, context: RouteContext) {
);
}
- addTransactionAttributes({
- messageId,
- userId: user.$id,
- emoji,
- });
-
const env = getEnvConfig();
const { databases } = getServerClient();
@@ -229,7 +220,6 @@ export async function DELETE(request: NextRequest, context: RouteContext) {
const startTime = Date.now();
try {
- setTransactionName("DELETE /api/direct-messages/[messageId]/reactions");
// Verify user is authenticated
const user = await getServerSession();
@@ -252,12 +242,6 @@ export async function DELETE(request: NextRequest, context: RouteContext) {
);
}
- addTransactionAttributes({
- messageId,
- userId: user.$id,
- emoji,
- });
-
const env = getEnvConfig();
const { databases } = getServerClient();
diff --git a/apps/web/src/app/api/direct-messages/[messageId]/thread/route.ts b/apps/web/src/app/api/direct-messages/[messageId]/thread/route.ts
index dfe3eed..75e9492 100644
--- a/apps/web/src/app/api/direct-messages/[messageId]/thread/route.ts
+++ b/apps/web/src/app/api/direct-messages/[messageId]/thread/route.ts
@@ -9,7 +9,7 @@ import { upsertMentionInboxItems } from "@/lib/inbox-items";
import { logger,
returnUnauthorized,
returnForbidden,
-} from "@/lib/newrelic-utils";
+} from "@/lib/posthog-utils";
import type { DirectMessage, FileAttachment } from "@/lib/types";
import { getAvatarUrl, getUserProfile, getUserProfilesBatch, getAvatarFrameUrlForProfile } from "@/lib/appwrite-profiles";
import {
diff --git a/apps/web/src/app/api/direct-messages/route.ts b/apps/web/src/app/api/direct-messages/route.ts
index 8ba6ed6..09f31d5 100644
--- a/apps/web/src/app/api/direct-messages/route.ts
+++ b/apps/web/src/app/api/direct-messages/route.ts
@@ -21,13 +21,11 @@ import {
logger,
recordError,
recordEvent,
- setTransactionName,
trackApiCall,
trackMessage,
- addTransactionAttributes,
returnUnauthorized,
returnForbidden,
-} from "@/lib/newrelic-utils";
+} from "@/lib/posthog-utils";
import {
MAX_MESSAGE_LENGTH,
MESSAGE_TOO_LONG_ERROR,
@@ -117,8 +115,6 @@ function normalizeDistinctIds(ids: string[], excluding?: string): string[] {
).sort();
}
-
-
function getReadOnlyReason(relationship: {
blockedByMe: boolean;
blockedMe: boolean;
@@ -377,14 +373,6 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const type = searchParams.get("type");
- setTransactionName(
- `GET /api/direct-messages?type=${type || "unknown"}`,
- );
- addTransactionAttributes({
- userId: session.$id,
- operationType: type || "unknown",
- });
-
// List all conversations for current user
if (type === "conversations") {
if (!CONVERSATIONS_COLLECTION) {
@@ -1357,7 +1345,6 @@ export async function POST(request: NextRequest) {
const startTime = Date.now();
try {
- setTransactionName("POST /api/direct-messages");
const session = await getServerSession();
if (!session?.$id) {
@@ -1557,17 +1544,6 @@ export async function POST(request: NextRequest) {
const hasEncryptedText =
typeof encryptedText === "string" && encryptedText.length > 0;
- addTransactionAttributes({
- userId: session.$id,
- conversationId: conversationId ?? "unknown",
- hasImage: !!imageFileId,
- hasEncryptedText,
- hasAttachments: normalizedAttachments.length > 0,
- attachmentCount: normalizedAttachments.length,
- isReply: !!replyToId,
- operation: "send-message",
- });
-
if (
!conversationId ||
!senderId ||
diff --git a/apps/web/src/app/api/emoji/[fileId]/route.ts b/apps/web/src/app/api/emoji/[fileId]/route.ts
index 2261d34..063a396 100644
--- a/apps/web/src/app/api/emoji/[fileId]/route.ts
+++ b/apps/web/src/app/api/emoji/[fileId]/route.ts
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
import { AppwriteException } from "node-appwrite";
import { getAdminClient } from "@/lib/appwrite-admin";
import { getEnvConfig } from "@/lib/appwrite-core";
-import { logger } from "@/lib/newrelic-utils";
+import { logger } from "@/lib/posthog-utils";
type RouteContext = {
params: Promise<{ fileId: string }>;
diff --git a/apps/web/src/app/api/example-newrelic/route.ts b/apps/web/src/app/api/example-newrelic/route.ts
deleted file mode 100644
index 695ea4e..0000000
--- a/apps/web/src/app/api/example-newrelic/route.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import type { NextRequest } from "next/server";
-import { NextResponse } from "next/server";
-
-import {
- addTransactionAttributes,
- logger,
- recordError,
- setTransactionName,
- trackApiCall,
-} from "@/lib/newrelic-utils";
-
-const ENDPOINT = "/api/example-newrelic";
-
-export async function GET(request: NextRequest) {
- const startTime = Date.now();
- const userAgent = request.headers.get("user-agent") ?? "unknown";
-
- // Disable this example endpoint outside development to avoid exposure in prod
- if (process.env.NODE_ENV === "production") {
- return NextResponse.json({ error: "Not found" }, { status: 404 });
- }
-
- setTransactionName("GET /api/example-newrelic");
-
- try {
- addTransactionAttributes({
- endpoint: ENDPOINT,
- method: "GET",
- userAgent,
- });
- const result = { message: "Hello from New Relic instrumented API!" };
-
- const duration = Date.now() - startTime;
- trackApiCall(ENDPOINT, "GET", 200, duration, { cached: false });
- logger.info("Example API request succeeded", { duration });
-
- return NextResponse.json(result);
- } catch (error) {
- const duration = Date.now() - startTime;
- const recordPayload: string | Error =
- error instanceof Error ? error : String(error);
- recordError(recordPayload, { endpoint: ENDPOINT, method: "GET" });
- trackApiCall(ENDPOINT, "GET", 500, duration, { error: true });
- logger.error("Example API request failed", {
- error:
- recordPayload instanceof Error
- ? recordPayload.message
- : recordPayload,
- duration,
- });
-
- return NextResponse.json(
- { error: "Internal server error" },
- { status: 500 },
- );
- }
-}
diff --git a/apps/web/src/app/api/feature-flags/allow-user-servers/route.ts b/apps/web/src/app/api/feature-flags/allow-user-servers/route.ts
index 4500473..ba92f45 100644
--- a/apps/web/src/app/api/feature-flags/allow-user-servers/route.ts
+++ b/apps/web/src/app/api/feature-flags/allow-user-servers/route.ts
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { getFeatureFlag, FEATURE_FLAGS } from "@/lib/feature-flags";
-import { logger } from "@/lib/newrelic-utils";
+import { logger } from "@/lib/posthog-utils";
export async function GET() {
try {
diff --git a/apps/web/src/app/api/friends/request/route.ts b/apps/web/src/app/api/friends/request/route.ts
index 6e59ba7..ac7b349 100644
--- a/apps/web/src/app/api/friends/request/route.ts
+++ b/apps/web/src/app/api/friends/request/route.ts
@@ -6,7 +6,7 @@ import {
RelationshipError,
} from "@/lib/appwrite-friendships";
import { getServerSession } from "@/lib/auth-server";
-import { getPostHogClient } from "@/lib/newrelic-utils";
+import { getPostHogClient } from "@/lib/posthog-utils";
type RequestBody = {
targetUserId?: string;
diff --git a/apps/web/src/app/api/gifs/search/route.ts b/apps/web/src/app/api/gifs/search/route.ts
index 65534d7..9316194 100644
--- a/apps/web/src/app/api/gifs/search/route.ts
+++ b/apps/web/src/app/api/gifs/search/route.ts
@@ -13,7 +13,7 @@ import {
type GiphySearchResponse,
type TenorSearchResponse,
} from "@/lib/gif-sticker";
-import { logger, setTransactionName, trackApiCall } from "@/lib/newrelic-utils";
+import { logger, trackApiCall } from "@/lib/posthog-utils";
import { checkRateLimit } from "@/lib/rate-limit";
const GIPHY_BASE_URL = "https://api.giphy.com/v1/gifs/search";
@@ -53,7 +53,6 @@ export async function GET(request: NextRequest) {
const startTime = Date.now();
try {
- setTransactionName("GET /api/gifs/search");
const user = await requireAuth();
diff --git a/apps/web/src/app/api/inbox/digest/route.ts b/apps/web/src/app/api/inbox/digest/route.ts
index e5dfcb5..2c3941f 100644
--- a/apps/web/src/app/api/inbox/digest/route.ts
+++ b/apps/web/src/app/api/inbox/digest/route.ts
@@ -3,7 +3,7 @@ import type { NextRequest } from "next/server";
import { getServerSession } from "@/lib/auth-server";
import { listInboxDigest } from "@/lib/inbox";
-import { logger } from "@/lib/newrelic-utils";
+import { logger } from "@/lib/posthog-utils";
import type { InboxContextKind } from "@/lib/types";
const DEFAULT_LIMIT = 50;
diff --git a/apps/web/src/app/api/inbox/route.ts b/apps/web/src/app/api/inbox/route.ts
index 09e2468..976647b 100644
--- a/apps/web/src/app/api/inbox/route.ts
+++ b/apps/web/src/app/api/inbox/route.ts
@@ -6,7 +6,7 @@ import { getAdminClient } from "@/lib/appwrite-admin";
import { getEnvConfig } from "@/lib/appwrite-core";
import { getServerSession } from "@/lib/auth-server";
import { listInboxItems } from "@/lib/inbox";
-import { logger, recordEvent } from "@/lib/newrelic-utils";
+import { logger, recordEvent } from "@/lib/posthog-utils";
import { upsertThreadReads } from "@/lib/thread-read-store";
import type { InboxContextKind, InboxItemKind } from "@/lib/types";
import { Query, type Models } from "node-appwrite";
diff --git a/apps/web/src/app/api/instance/route.ts b/apps/web/src/app/api/instance/route.ts
index 336fcf0..6caef2e 100644
--- a/apps/web/src/app/api/instance/route.ts
+++ b/apps/web/src/app/api/instance/route.ts
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { getEnvConfig } from "@/lib/appwrite-core";
import { getFeatureFlag, FEATURE_FLAGS } from "@/lib/feature-flags";
+import { getSignupPolicy } from "@/lib/signup-policy";
interface InstanceInfo {
instanceName: string;
@@ -10,6 +11,7 @@ interface InstanceInfo {
features: {
emailVerification: boolean;
auditLogging: boolean;
+ signupPolicy: string;
};
support: {
email: string | null;
@@ -23,14 +25,17 @@ interface InstanceInfo {
}
async function getInstanceFeatures(): Promise {
- const [emailVerification, auditLogging] = await Promise.all([
- getFeatureFlag(FEATURE_FLAGS.ENABLE_EMAIL_VERIFICATION).catch(() => false),
- getFeatureFlag(FEATURE_FLAGS.ENABLE_AUDIT_LOGGING).catch(() => true),
- ]);
+ const [emailVerification, auditLogging, signupPolicy] =
+ await Promise.all([
+ getFeatureFlag(FEATURE_FLAGS.ENABLE_EMAIL_VERIFICATION).catch(() => false),
+ getFeatureFlag(FEATURE_FLAGS.ENABLE_AUDIT_LOGGING).catch(() => true),
+ getSignupPolicy().catch(() => "open" as const),
+ ]);
return {
emailVerification,
auditLogging,
+ signupPolicy,
};
}
diff --git a/apps/web/src/app/api/invites/[code]/join/route.ts b/apps/web/src/app/api/invites/[code]/join/route.ts
index b00018c..98eef2e 100644
--- a/apps/web/src/app/api/invites/[code]/join/route.ts
+++ b/apps/web/src/app/api/invites/[code]/join/route.ts
@@ -4,7 +4,7 @@ import { useInvite } from "@/lib/appwrite-invites";
import { logger, recordError,
returnUnauthorized,
getPostHogClient,
-} from "@/lib/newrelic-utils";
+} from "@/lib/posthog-utils";
import { invalidateChannelsUserCaches } from "@/lib/channels-route-cache";
/**
diff --git a/apps/web/src/app/api/invites/[code]/route.ts b/apps/web/src/app/api/invites/[code]/route.ts
index 0996b01..6646a92 100644
--- a/apps/web/src/app/api/invites/[code]/route.ts
+++ b/apps/web/src/app/api/invites/[code]/route.ts
@@ -8,7 +8,7 @@ import {
} from "@/lib/appwrite-invites";
import { getServerClient } from "@/lib/appwrite-server";
import { getEnvConfig } from "@/lib/appwrite-core";
-import { logger, recordError } from "@/lib/newrelic-utils";
+import { logger, recordError } from "@/lib/posthog-utils";
/**
* GET /api/invites/[code] - Get invite preview (public endpoint)
diff --git a/apps/web/src/app/api/me/dm-encryption-key/route.ts b/apps/web/src/app/api/me/dm-encryption-key/route.ts
index 1f6c2eb..4ab6f12 100644
--- a/apps/web/src/app/api/me/dm-encryption-key/route.ts
+++ b/apps/web/src/app/api/me/dm-encryption-key/route.ts
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { getServerSession } from "@/lib/auth-server";
import { getOrCreateUserProfile, updateUserProfile } from "@/lib/appwrite-profiles";
-import { logger, returnUnauthorized } from "@/lib/newrelic-utils";
+import { logger, returnUnauthorized } from "@/lib/posthog-utils";
type PatchBody = {
dmEncryptionPublicKey: string;
diff --git a/apps/web/src/app/api/me/preferences/route.ts b/apps/web/src/app/api/me/preferences/route.ts
index 52178e3..633bfbb 100644
--- a/apps/web/src/app/api/me/preferences/route.ts
+++ b/apps/web/src/app/api/me/preferences/route.ts
@@ -9,7 +9,7 @@ import type {
NavigationItemPreferenceId,
NavigationPreferences,
} from "@/lib/types";
-import { logger } from "@/lib/newrelic-utils";
+import { logger } from "@/lib/posthog-utils";
const DEFAULT_NAVIGATION_ITEM_ORDER = [
"docs",
diff --git a/apps/web/src/app/api/memberships/route.ts b/apps/web/src/app/api/memberships/route.ts
index 5275a7b..16750da 100644
--- a/apps/web/src/app/api/memberships/route.ts
+++ b/apps/web/src/app/api/memberships/route.ts
@@ -4,7 +4,7 @@ import { Query } from "node-appwrite";
import { getServerClient } from "@/lib/appwrite-server";
import { getEnvConfig } from "@/lib/appwrite-core";
import { getServerSession } from "@/lib/auth-server";
-import { returnUnauthorized, logger } from "@/lib/newrelic-utils";
+import { returnUnauthorized, logger } from "@/lib/posthog-utils";
import { listPages } from "@/lib/appwrite-pagination";
import type { Membership } from "@/lib/types";
diff --git a/apps/web/src/app/api/messages/[messageId]/pin/route.ts b/apps/web/src/app/api/messages/[messageId]/pin/route.ts
index 41ccf37..000677f 100644
--- a/apps/web/src/app/api/messages/[messageId]/pin/route.ts
+++ b/apps/web/src/app/api/messages/[messageId]/pin/route.ts
@@ -15,10 +15,8 @@ import { getEffectivePermissions, hasPermission } from "@/lib/permissions";
import {
logger,
recordError,
- setTransactionName,
trackApiCall,
- addTransactionAttributes,
-} from "@/lib/newrelic-utils";
+} from "@/lib/posthog-utils";
type RouteContext = {
params: Promise<{
@@ -114,7 +112,6 @@ export async function POST(request: NextRequest, context: RouteContext) {
const startTime = Date.now();
try {
- setTransactionName("POST /api/messages/[messageId]/pin");
// Verify user is authenticated
const user = await getServerSession();
@@ -128,11 +125,6 @@ export async function POST(request: NextRequest, context: RouteContext) {
const { messageId } = await context.params;
- addTransactionAttributes({
- messageId,
- userId: user.$id,
- });
-
const env = getEnvConfig();
const { databases } = getServerClient();
@@ -287,7 +279,6 @@ export async function DELETE(request: NextRequest, context: RouteContext) {
const startTime = Date.now();
try {
- setTransactionName("DELETE /api/messages/[messageId]/pin");
// Verify user is authenticated
const user = await getServerSession();
@@ -301,11 +292,6 @@ export async function DELETE(request: NextRequest, context: RouteContext) {
const { messageId } = await context.params;
- addTransactionAttributes({
- messageId,
- userId: user.$id,
- });
-
const env = getEnvConfig();
const { databases } = getServerClient();
diff --git a/apps/web/src/app/api/messages/[messageId]/poll-votes/route.ts b/apps/web/src/app/api/messages/[messageId]/poll-votes/route.ts
index 268f65f..5d2e118 100644
--- a/apps/web/src/app/api/messages/[messageId]/poll-votes/route.ts
+++ b/apps/web/src/app/api/messages/[messageId]/poll-votes/route.ts
@@ -12,7 +12,7 @@ import {
getPollStateForMessage,
} from "@/lib/polls-server";
import { getChannelAccessForUser } from "@/lib/server-channel-access";
-import { returnUnauthorized, returnForbidden } from "@/lib/newrelic-utils";
+import { returnUnauthorized, returnForbidden } from "@/lib/posthog-utils";
type RouteContext = {
params: Promise<{
@@ -101,7 +101,6 @@ export async function POST(request: NextRequest, context: RouteContext) {
);
}
-
const reqBody = body as { optionId?: unknown };
const optionId =
typeof reqBody.optionId === "string"
diff --git a/apps/web/src/app/api/messages/[messageId]/poll/close/route.ts b/apps/web/src/app/api/messages/[messageId]/poll/close/route.ts
index f22b2da..87353ab 100644
--- a/apps/web/src/app/api/messages/[messageId]/poll/close/route.ts
+++ b/apps/web/src/app/api/messages/[messageId]/poll/close/route.ts
@@ -12,7 +12,7 @@ import {
getChannelAccessForUser,
getServerPermissionsForUser,
} from "@/lib/server-channel-access";
-import { returnUnauthorized, returnForbidden } from "@/lib/newrelic-utils";
+import { returnUnauthorized, returnForbidden } from "@/lib/posthog-utils";
type RouteContext = {
params: Promise<{
diff --git a/apps/web/src/app/api/messages/[messageId]/poll/route.ts b/apps/web/src/app/api/messages/[messageId]/poll/route.ts
index d65cba7..c90e037 100644
--- a/apps/web/src/app/api/messages/[messageId]/poll/route.ts
+++ b/apps/web/src/app/api/messages/[messageId]/poll/route.ts
@@ -9,7 +9,7 @@ import { getChannelAccessForUser } from "@/lib/server-channel-access";
import {
returnUnauthorized,
returnForbidden,
-} from "@/lib/newrelic-utils";
+} from "@/lib/posthog-utils";
type RouteContext = {
params: Promise<{
diff --git a/apps/web/src/app/api/messages/[messageId]/reactions/route.ts b/apps/web/src/app/api/messages/[messageId]/reactions/route.ts
index 9877a7c..23d1200 100644
--- a/apps/web/src/app/api/messages/[messageId]/reactions/route.ts
+++ b/apps/web/src/app/api/messages/[messageId]/reactions/route.ts
@@ -10,11 +10,9 @@ import { parseReactions } from "@/lib/reactions-utils";
import {
logger,
recordError,
- setTransactionName,
trackApiCall,
- addTransactionAttributes,
returnForbidden,
-} from "@/lib/newrelic-utils";
+} from "@/lib/posthog-utils";
type RouteContext = {
params: Promise<{
@@ -30,7 +28,6 @@ export async function POST(request: NextRequest, context: RouteContext) {
const startTime = Date.now();
try {
- setTransactionName("POST /api/messages/[messageId]/reactions");
// Verify user is authenticated
const user = await getServerSession();
@@ -53,12 +50,6 @@ export async function POST(request: NextRequest, context: RouteContext) {
);
}
- addTransactionAttributes({
- messageId,
- userId: user.$id,
- emoji,
- });
-
const env = getEnvConfig();
const { databases } = getServerClient();
@@ -200,7 +191,6 @@ export async function DELETE(request: NextRequest, context: RouteContext) {
const startTime = Date.now();
try {
- setTransactionName("DELETE /api/messages/[messageId]/reactions");
// Verify user is authenticated
const user = await getServerSession();
@@ -223,12 +213,6 @@ export async function DELETE(request: NextRequest, context: RouteContext) {
);
}
- addTransactionAttributes({
- messageId,
- userId: user.$id,
- emoji,
- });
-
const env = getEnvConfig();
const { databases } = getServerClient();
diff --git a/apps/web/src/app/api/messages/[messageId]/thread/route.ts b/apps/web/src/app/api/messages/[messageId]/thread/route.ts
index 02e9a09..d046107 100644
--- a/apps/web/src/app/api/messages/[messageId]/thread/route.ts
+++ b/apps/web/src/app/api/messages/[messageId]/thread/route.ts
@@ -9,12 +9,10 @@ import type { Message } from "@/lib/types";
import {
logger,
recordError,
- setTransactionName,
trackApiCall,
- addTransactionAttributes,
returnUnauthorized,
returnForbidden,
-} from "@/lib/newrelic-utils";
+} from "@/lib/posthog-utils";
import { upsertMentionInboxItems } from "@/lib/inbox-items";
import { normalizeFileAttachmentsInput } from "@/lib/file-attachments";
import { hasEveryoneMention, normalizeMentionIds } from "@/lib/mention-utils";
@@ -43,7 +41,6 @@ export async function GET(request: NextRequest, context: RouteContext) {
const startTime = Date.now();
try {
- setTransactionName("GET /api/messages/[messageId]/thread");
// Verify user is authenticated
const user = await getServerSession();
@@ -64,12 +61,6 @@ export async function GET(request: NextRequest, context: RouteContext) {
: 50;
const cursor = url.searchParams.get("cursor");
- addTransactionAttributes({
- messageId,
- userId: user.$id,
- limit,
- });
-
const env = getEnvConfig();
const { databases } = getServerClient();
@@ -160,7 +151,6 @@ export async function POST(request: NextRequest, context: RouteContext) {
const startTime = Date.now();
try {
- setTransactionName("POST /api/messages/[messageId]/thread");
// Verify user is authenticated
const user = await getServerSession();
@@ -198,13 +188,6 @@ export async function POST(request: NextRequest, context: RouteContext) {
);
}
- addTransactionAttributes({
- messageId,
- userId: user.$id,
- hasText: Boolean(text),
- hasImage: Boolean(imageFileId),
- });
-
const env = getEnvConfig();
const { databases } = getServerClient();
diff --git a/apps/web/src/app/api/messages/route.ts b/apps/web/src/app/api/messages/route.ts
index 742e711..2c7e6ef 100644
--- a/apps/web/src/app/api/messages/route.ts
+++ b/apps/web/src/app/api/messages/route.ts
@@ -12,13 +12,11 @@ import {
logger,
recordError,
recordEvent,
- setTransactionName,
trackApiCall,
trackMessage,
- addTransactionAttributes,
returnUnauthorized,
returnForbidden,
-} from "@/lib/newrelic-utils";
+} from "@/lib/posthog-utils";
import {
MAX_MESSAGE_LENGTH,
MESSAGE_TOO_LONG_ERROR,
@@ -138,7 +136,6 @@ function mapMessageDocument(doc: Record): Message {
*/
export async function GET(request: NextRequest) {
try {
- setTransactionName("GET /api/messages");
const user = await getServerSession();
if (!user) {
@@ -333,7 +330,6 @@ export async function POST(request: NextRequest) {
const startTime = Date.now();
try {
- setTransactionName("POST /api/messages");
// Verify user is authenticated
const user = await getServerSession();
@@ -428,15 +424,7 @@ export async function POST(request: NextRequest) {
const userId = user.$id;
const userName = user.name;
- addTransactionAttributes({
- userId,
- channelId,
- serverId: "unresolved",
- hasImage: !!imageFileId,
- hasAttachments: normalizedAttachments.length > 0,
- isReply: !!replyToId,
- hasMentions: hasValidMentions,
- }); // Create message permissions
+ // Create message permissions
const permissions = perms.message(userId, {
mod: env.teams.moderatorTeamId,
admin: env.teams.adminTeamId,
@@ -494,8 +482,6 @@ export async function POST(request: NextRequest) {
transactionAttributes.serverId = normalizedServerId;
}
- addTransactionAttributes(transactionAttributes);
-
const messageData: Record = {
userId,
text: parsedPoll ? "" : normalizedText || "",
diff --git a/apps/web/src/app/api/notifications/push/route.ts b/apps/web/src/app/api/notifications/push/route.ts
index 9c389fe..ae65a5f 100644
--- a/apps/web/src/app/api/notifications/push/route.ts
+++ b/apps/web/src/app/api/notifications/push/route.ts
@@ -10,7 +10,7 @@ import {
logger,
returnForbidden,
returnUnauthorized,
-} from "@/lib/newrelic-utils";
+} from "@/lib/posthog-utils";
type PushPayload = {
userId: string;
diff --git a/apps/web/src/app/api/notifications/register-token/route.ts b/apps/web/src/app/api/notifications/register-token/route.ts
index 5b2c0ee..f950e96 100644
--- a/apps/web/src/app/api/notifications/register-token/route.ts
+++ b/apps/web/src/app/api/notifications/register-token/route.ts
@@ -6,7 +6,7 @@ import Expo from "expo-server-sdk";
import { getServerSession } from "@/lib/auth-server";
import { getServerClient } from "@/lib/appwrite-server";
import { getEnvConfig } from "@/lib/appwrite-core";
-import { logger, returnUnauthorized } from "@/lib/newrelic-utils";
+import { logger, returnUnauthorized } from "@/lib/posthog-utils";
/**
* POST /api/notifications/register-token
diff --git a/apps/web/src/app/api/notifications/settings/route.ts b/apps/web/src/app/api/notifications/settings/route.ts
index 7a6d77d..00d4a76 100644
--- a/apps/web/src/app/api/notifications/settings/route.ts
+++ b/apps/web/src/app/api/notifications/settings/route.ts
@@ -8,7 +8,7 @@ import {
} from "@/lib/notification-settings";
import { invalidateNotificationSettingsCache } from "@/lib/notification-triggers";
import { getUserProfile } from "@/lib/appwrite-profiles";
-import { logger } from "@/lib/newrelic-utils";
+import { logger } from "@/lib/posthog-utils";
import type {
DirectMessagePrivacy,
NotificationLevel,
diff --git a/apps/web/src/app/api/profile/[userId]/route.ts b/apps/web/src/app/api/profile/[userId]/route.ts
index f33043a..1f2c472 100644
--- a/apps/web/src/app/api/profile/[userId]/route.ts
+++ b/apps/web/src/app/api/profile/[userId]/route.ts
@@ -6,7 +6,7 @@ import {
getPredefinedAvatarFrameUrlByPresetId,
} from "@/lib/appwrite-profiles";
import { getUserStatus } from "@/lib/appwrite-status";
-import { logger } from "@/lib/newrelic-utils";
+import { logger } from "@/lib/posthog-utils";
type Props = {
params: Promise<{ userId: string }>;
diff --git a/apps/web/src/app/api/profile/avatar/route.ts b/apps/web/src/app/api/profile/avatar/route.ts
index fc9d10a..943c457 100644
--- a/apps/web/src/app/api/profile/avatar/route.ts
+++ b/apps/web/src/app/api/profile/avatar/route.ts
@@ -10,7 +10,7 @@ import {
getAvatarUrl,
updateUserProfile,
} from "@/lib/appwrite-profiles";
-import { logger } from "@/lib/newrelic-utils";
+import { logger } from "@/lib/posthog-utils";
const ALLOWED_AVATAR_TYPES = new Set([
"image/jpeg",
diff --git a/apps/web/src/app/api/profile/background/route.ts b/apps/web/src/app/api/profile/background/route.ts
index 133e039..0bbcd78 100644
--- a/apps/web/src/app/api/profile/background/route.ts
+++ b/apps/web/src/app/api/profile/background/route.ts
@@ -9,7 +9,7 @@ import {
updateProfileBackgroundImageState,
} from "@/lib/appwrite-profiles";
import { getAdminClient } from "@/lib/appwrite-admin";
-import { logger } from "@/lib/newrelic-utils";
+import { logger } from "@/lib/posthog-utils";
const ALLOWED_BACKGROUND_TYPES = new Set([
"image/jpeg",
diff --git a/apps/web/src/app/api/profile/route.ts b/apps/web/src/app/api/profile/route.ts
index 1755a80..27ea604 100644
--- a/apps/web/src/app/api/profile/route.ts
+++ b/apps/web/src/app/api/profile/route.ts
@@ -5,7 +5,7 @@ import {
updateUserProfile,
getAvatarUrl,
} from "@/lib/appwrite-profiles";
-import { logger } from "@/lib/newrelic-utils";
+import { logger } from "@/lib/posthog-utils";
const URL_SCHEME_PATTERN = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
diff --git a/apps/web/src/app/api/profiles/batch/route.ts b/apps/web/src/app/api/profiles/batch/route.ts
index 931eef3..5a20dd6 100644
--- a/apps/web/src/app/api/profiles/batch/route.ts
+++ b/apps/web/src/app/api/profiles/batch/route.ts
@@ -12,10 +12,8 @@ import {
import {
logger,
recordError,
- setTransactionName,
trackApiCall,
- addTransactionAttributes,
-} from "@/lib/newrelic-utils";
+} from "@/lib/posthog-utils";
import { getEnvConfig } from "@/lib/appwrite-core";
import { getServerClient } from "@/lib/appwrite-server";
import { normalizeStatus } from "@/lib/status-normalization";
@@ -49,7 +47,6 @@ export async function POST(request: NextRequest) {
const startTime = Date.now();
try {
- setTransactionName("POST /api/profiles/batch");
const session = await getServerSession();
if (!session?.$id) {
@@ -119,12 +116,6 @@ export async function POST(request: NextRequest) {
return !relationship?.blockedByMe && !relationship?.blockedMe;
});
- addTransactionAttributes({
- requestedCount: userIds.length,
- uniqueCount: uniqueUserIds.length,
- visibleCount: visibleUserIds.length,
- });
-
logger.info("Fetching batch profiles", {
count: visibleUserIds.length,
});
diff --git a/apps/web/src/app/api/reports/route.ts b/apps/web/src/app/api/reports/route.ts
index 7b9cbb9..ff33c6e 100644
--- a/apps/web/src/app/api/reports/route.ts
+++ b/apps/web/src/app/api/reports/route.ts
@@ -6,7 +6,7 @@ import {
DUPLICATE_REPORT_ERROR_MESSAGE,
DuplicateReportError,
} from "@/lib/appwrite-reports";
-import { logger } from "@/lib/newrelic-utils";
+import { logger } from "@/lib/posthog-utils";
const RATE_LIMIT_MAX = 5;
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
diff --git a/apps/web/src/app/api/role-assignments/route.ts b/apps/web/src/app/api/role-assignments/route.ts
index d89af23..d3d8988 100644
--- a/apps/web/src/app/api/role-assignments/route.ts
+++ b/apps/web/src/app/api/role-assignments/route.ts
@@ -8,7 +8,7 @@ import { listPages } from "@/lib/appwrite-pagination";
import { logger,
returnUnauthorized,
returnForbidden,
-} from "@/lib/newrelic-utils";
+} from "@/lib/posthog-utils";
import { getServerPermissionsForUser } from "@/lib/server-channel-access";
import { invalidateChannelsUserCaches } from "@/lib/channels-route-cache";
import { isDocumentNotFoundError } from "@/lib/appwrite-admin";
diff --git a/apps/web/src/app/api/roles/route.ts b/apps/web/src/app/api/roles/route.ts
index e545ddc..97539bd 100644
--- a/apps/web/src/app/api/roles/route.ts
+++ b/apps/web/src/app/api/roles/route.ts
@@ -9,7 +9,7 @@ import {
logger,
returnUnauthorized,
returnForbidden,
-} from "@/lib/newrelic-utils";
+} from "@/lib/posthog-utils";
import { getServerPermissionsForUser } from "@/lib/server-channel-access";
import { isDocumentNotFoundError } from "@/lib/appwrite-admin";
diff --git a/apps/web/src/app/api/search/messages/route.ts b/apps/web/src/app/api/search/messages/route.ts
index 5b945e7..a2c22ef 100644
--- a/apps/web/src/app/api/search/messages/route.ts
+++ b/apps/web/src/app/api/search/messages/route.ts
@@ -17,9 +17,8 @@ import { getAvatarUrl, resolveProfileUserId } from "@/lib/appwrite-profiles";
import {
logger,
recordError,
- setTransactionName,
trackApiCall,
-} from "@/lib/newrelic-utils";
+} from "@/lib/posthog-utils";
type SearchResult = {
type: "channel" | "dm";
@@ -205,7 +204,6 @@ export async function GET(request: NextRequest) {
const startTime = Date.now();
try {
- setTransactionName("GET /api/search/messages");
// Verify user is authenticated
const user = await getServerSession();
diff --git a/apps/web/src/app/api/servers/[serverId]/audit-logs/export/route.ts b/apps/web/src/app/api/servers/[serverId]/audit-logs/export/route.ts
index cc69455..7983711 100644
--- a/apps/web/src/app/api/servers/[serverId]/audit-logs/export/route.ts
+++ b/apps/web/src/app/api/servers/[serverId]/audit-logs/export/route.ts
@@ -3,7 +3,7 @@ import { getServerSession } from "@/lib/auth-server";
import { logger,
returnUnauthorized,
returnForbidden,
-} from "@/lib/newrelic-utils";
+} from "@/lib/posthog-utils";
import { getServerClient } from "@/lib/appwrite-server";
import { getEnvConfig } from "@/lib/appwrite-core";
import { getServerPermissionsForUser } from "@/lib/server-channel-access";
diff --git a/apps/web/src/app/api/servers/[serverId]/audit-logs/route.ts b/apps/web/src/app/api/servers/[serverId]/audit-logs/route.ts
index 73c9b47..1d04276 100644
--- a/apps/web/src/app/api/servers/[serverId]/audit-logs/route.ts
+++ b/apps/web/src/app/api/servers/[serverId]/audit-logs/route.ts
@@ -3,7 +3,7 @@ import { getServerClient } from "@/lib/appwrite-server";
import { logger,
returnUnauthorized,
returnForbidden,
-} from "@/lib/newrelic-utils";
+} from "@/lib/posthog-utils";
import { getServerSession } from "@/lib/auth-server";
import { getEnvConfig } from "@/lib/appwrite-core";
import { getServerPermissionsForUser } from "@/lib/server-channel-access";
diff --git a/apps/web/src/app/api/servers/[serverId]/invites/route.ts b/apps/web/src/app/api/servers/[serverId]/invites/route.ts
index 77a125d..a357ade 100644
--- a/apps/web/src/app/api/servers/[serverId]/invites/route.ts
+++ b/apps/web/src/app/api/servers/[serverId]/invites/route.ts
@@ -7,7 +7,7 @@ import { getServerPermissionsForUser } from "@/lib/server-channel-access";
import { logger, recordError,
returnUnauthorized,
returnForbidden,
-} from "@/lib/newrelic-utils";
+} from "@/lib/posthog-utils";
const { databases } = getServerClient();
const env = getEnvConfig();
diff --git a/apps/web/src/app/api/servers/[serverId]/members/route.ts b/apps/web/src/app/api/servers/[serverId]/members/route.ts
index dfd9c83..8a89545 100644
--- a/apps/web/src/app/api/servers/[serverId]/members/route.ts
+++ b/apps/web/src/app/api/servers/[serverId]/members/route.ts
@@ -4,7 +4,7 @@ import { getEnvConfig } from "@/lib/appwrite-core";
import { logger,
returnUnauthorized,
returnForbidden,
-} from "@/lib/newrelic-utils";
+} from "@/lib/posthog-utils";
import { listPages, chunkValues } from "@/lib/appwrite-pagination";
import { getServerSession } from "@/lib/auth-server";
import { getServerPermissionsForUser } from "@/lib/server-channel-access";
@@ -65,7 +65,6 @@ export async function GET(request: Request, context: RouteContext) {
return returnForbidden();
}
-
// Get all memberships and role assignments in parallel
const [
{ documents: memberships, truncated: membershipsTruncated },
diff --git a/apps/web/src/app/api/servers/[serverId]/mentionable-roles/route.ts b/apps/web/src/app/api/servers/[serverId]/mentionable-roles/route.ts
index a1c33f7..a183918 100644
--- a/apps/web/src/app/api/servers/[serverId]/mentionable-roles/route.ts
+++ b/apps/web/src/app/api/servers/[serverId]/mentionable-roles/route.ts
@@ -7,7 +7,7 @@ import { getEnvConfig } from "@/lib/appwrite-core";
import { getServerSession } from "@/lib/auth-server";
import { getServerPermissionsForUser } from "@/lib/server-channel-access";
import { listPages } from "@/lib/appwrite-pagination";
-import { logger } from "@/lib/newrelic-utils";
+import { logger } from "@/lib/posthog-utils";
// Define explicit interfaces for Appwrite documents used in this route
interface RoleDocument {
diff --git a/apps/web/src/app/api/servers/[serverId]/moderation/route.ts b/apps/web/src/app/api/servers/[serverId]/moderation/route.ts
index 581004a..09bdb44 100644
--- a/apps/web/src/app/api/servers/[serverId]/moderation/route.ts
+++ b/apps/web/src/app/api/servers/[serverId]/moderation/route.ts
@@ -7,7 +7,7 @@ import { getUserRoles } from "@/lib/appwrite-roles";
import { logger,
returnUnauthorized,
returnForbidden,
-} from "@/lib/newrelic-utils";
+} from "@/lib/posthog-utils";
import { getEnvConfig } from "@/lib/appwrite-core";
import { isDocumentNotFoundError } from "@/lib/appwrite-admin";
import { listPages } from "@/lib/appwrite-pagination";
diff --git a/apps/web/src/app/api/servers/[serverId]/mute/route.ts b/apps/web/src/app/api/servers/[serverId]/mute/route.ts
index 2d81f61..0613a72 100644
--- a/apps/web/src/app/api/servers/[serverId]/mute/route.ts
+++ b/apps/web/src/app/api/servers/[serverId]/mute/route.ts
@@ -3,7 +3,7 @@ import { NextResponse } from "next/server";
import { getServerSession } from "@/lib/auth-server";
import { muteServer, unmuteServer, isMuteExpired } from "@/lib/notification-settings";
import { invalidateNotificationSettingsCache } from "@/lib/notification-triggers";
-import { logger, returnUnauthorized, returnForbidden } from "@/lib/newrelic-utils";
+import { logger, returnUnauthorized, returnForbidden } from "@/lib/posthog-utils";
import { getServerPermissionsForUser } from "@/lib/server-channel-access";
import { getServerClient } from "@/lib/appwrite-server";
import { getEnvConfig } from "@/lib/appwrite-core";
diff --git a/apps/web/src/app/api/servers/[serverId]/permissions/route.ts b/apps/web/src/app/api/servers/[serverId]/permissions/route.ts
index b9ee967..6f5b31d 100644
--- a/apps/web/src/app/api/servers/[serverId]/permissions/route.ts
+++ b/apps/web/src/app/api/servers/[serverId]/permissions/route.ts
@@ -10,7 +10,7 @@ import type { ChannelPermissionOverride } from "@/lib/types";
import { logger,
returnUnauthorized,
returnForbidden,
-} from "@/lib/newrelic-utils";
+} from "@/lib/posthog-utils";
import {
getServerPermissionsForUser,
hasAccessToCategory,
@@ -25,7 +25,6 @@ function getDatabases() {
return getServerClient().databases;
}
-
function mapOverride(
doc: Record,
channelId: string,
diff --git a/apps/web/src/app/api/servers/[serverId]/route.ts b/apps/web/src/app/api/servers/[serverId]/route.ts
index 48273b7..a629ed3 100644
--- a/apps/web/src/app/api/servers/[serverId]/route.ts
+++ b/apps/web/src/app/api/servers/[serverId]/route.ts
@@ -20,7 +20,7 @@ import { getServerPermissionsForUser } from "@/lib/server-channel-access";
import { logger,
returnUnauthorized,
returnForbidden,
-} from "@/lib/newrelic-utils";
+} from "@/lib/posthog-utils";
const MAX_SERVER_NAME_LENGTH = 100;
const MAX_SERVER_DESCRIPTION_LENGTH = 500;
diff --git a/apps/web/src/app/api/servers/[serverId]/stats/route.ts b/apps/web/src/app/api/servers/[serverId]/stats/route.ts
index 313a8e3..e7bf3d0 100644
--- a/apps/web/src/app/api/servers/[serverId]/stats/route.ts
+++ b/apps/web/src/app/api/servers/[serverId]/stats/route.ts
@@ -4,7 +4,7 @@ import { Query } from "node-appwrite";
import { logger,
returnUnauthorized,
returnForbidden,
-} from "@/lib/newrelic-utils";
+} from "@/lib/posthog-utils";
import { getServerSession } from "@/lib/auth-server";
import { getEnvConfig } from "@/lib/appwrite-core";
import { getServerPermissionsForUser } from "@/lib/server-channel-access";
diff --git a/apps/web/src/app/api/servers/create/route.ts b/apps/web/src/app/api/servers/create/route.ts
index 5f7b4eb..22016d6 100644
--- a/apps/web/src/app/api/servers/create/route.ts
+++ b/apps/web/src/app/api/servers/create/route.ts
@@ -3,7 +3,7 @@ import { NextResponse } from "next/server";
import { createServer } from "@/lib/appwrite-servers";
import { getServerSession } from "@/lib/auth-server";
import { FEATURE_FLAGS, getFeatureFlag } from "@/lib/feature-flags";
-import { logger, getPostHogClient } from "@/lib/newrelic-utils";
+import { logger, getPostHogClient } from "@/lib/posthog-utils";
import { normalizeServerFileId } from "@/lib/server-metadata";
const MAX_SERVER_NAME_LENGTH = 100;
diff --git a/apps/web/src/app/api/servers/default-signup/route.ts b/apps/web/src/app/api/servers/default-signup/route.ts
index 449e12c..debfc60 100644
--- a/apps/web/src/app/api/servers/default-signup/route.ts
+++ b/apps/web/src/app/api/servers/default-signup/route.ts
@@ -8,7 +8,7 @@ import { getUserRoles } from "@/lib/appwrite-roles";
import { logger,
returnUnauthorized,
returnForbidden,
-} from "@/lib/newrelic-utils";
+} from "@/lib/posthog-utils";
type DefaultSignupServerDocument = {
$id: string;
diff --git a/apps/web/src/app/api/servers/join/route.ts b/apps/web/src/app/api/servers/join/route.ts
index cefe8cc..7eb8b9c 100644
--- a/apps/web/src/app/api/servers/join/route.ts
+++ b/apps/web/src/app/api/servers/join/route.ts
@@ -9,13 +9,11 @@ import { getServerSession } from "@/lib/auth-server";
import {
logger,
recordError,
- setTransactionName,
trackApiCall,
- addTransactionAttributes,
recordEvent,
returnUnauthorized,
returnForbidden,
-} from "@/lib/newrelic-utils";
+} from "@/lib/posthog-utils";
import { assignDefaultRoleServer } from "@/lib/default-role";
import { invalidateChannelsUserCaches } from "@/lib/channels-route-cache";
import type { Membership } from "@/lib/types";
@@ -71,10 +69,9 @@ function isConflictError(error: unknown): boolean {
*/
export async function POST(request: NextRequest) {
const startTime = Date.now();
-
+
try {
- setTransactionName("POST /api/servers/join");
-
+
// Verify user is authenticated
const user = await getServerSession();
if (!user) {
@@ -120,11 +117,6 @@ export async function POST(request: NextRequest) {
// Use authenticated user's ID, not from request body (security)
const userId = user.$id;
-
- addTransactionAttributes({
- userId,
- serverId,
- });
const { databases } = getServerClient();
@@ -206,7 +198,7 @@ export async function POST(request: NextRequest) {
}
throw error;
}
-
+
// Assign default role to the new member
try {
await assignDefaultRoleServer(serverId, userId);
@@ -220,7 +212,7 @@ export async function POST(request: NextRequest) {
: String(defaultRoleError),
});
}
-
+
trackApiCall(
"/api/servers/join",
"POST",
@@ -228,12 +220,12 @@ export async function POST(request: NextRequest) {
Date.now() - dbStartTime,
{ operation: "joinServer", serverId }
);
-
+
recordEvent("ServerJoin", {
userId,
serverId,
});
-
+
logger.info("User joined server", {
userId,
serverId,
@@ -265,12 +257,12 @@ export async function POST(request: NextRequest) {
endpoint: "/api/servers/join",
}
);
-
+
logger.error("Failed to join server", {
error: error instanceof Error ? error.message : String(error),
duration: Date.now() - startTime,
});
-
+
trackApiCall(
"/api/servers/join",
"POST",
@@ -278,7 +270,7 @@ export async function POST(request: NextRequest) {
Date.now() - startTime,
{ operation: "joinServer" }
);
-
+
return NextResponse.json(
{
error: "Failed to join server",
diff --git a/apps/web/src/app/api/servers/public/route.ts b/apps/web/src/app/api/servers/public/route.ts
index 3bac6bd..1385f7e 100644
--- a/apps/web/src/app/api/servers/public/route.ts
+++ b/apps/web/src/app/api/servers/public/route.ts
@@ -7,7 +7,7 @@ import { getServerClient } from "@/lib/appwrite-server";
import { getEnvConfig } from "@/lib/appwrite-core";
import { getActualMemberCounts } from "@/lib/membership-count";
import { mapServerDocument } from "@/lib/server-metadata";
-import { logger } from "@/lib/newrelic-utils";
+import { logger } from "@/lib/posthog-utils";
import type { Server } from "@/lib/types";
type ServerDocument = Models.Document & {
diff --git a/apps/web/src/app/api/servers/route.ts b/apps/web/src/app/api/servers/route.ts
index 31f01f1..fa867fc 100644
--- a/apps/web/src/app/api/servers/route.ts
+++ b/apps/web/src/app/api/servers/route.ts
@@ -5,7 +5,7 @@ import { Query } from "node-appwrite";
import { getServerSession } from "@/lib/auth-server";
import { getServerClient } from "@/lib/appwrite-server";
import { getEnvConfig } from "@/lib/appwrite-core";
-import { logger } from "@/lib/newrelic-utils";
+import { logger } from "@/lib/posthog-utils";
import type { Server } from "@/lib/types";
import { listPages } from "@/lib/appwrite-pagination";
import { getActualMemberCounts } from "@/lib/membership-count";
diff --git a/apps/web/src/app/api/session/route.ts b/apps/web/src/app/api/session/route.ts
index cd195c0..81ca646 100644
--- a/apps/web/src/app/api/session/route.ts
+++ b/apps/web/src/app/api/session/route.ts
@@ -3,7 +3,7 @@ import { cookies } from "next/headers";
import { Account, Client } from "node-appwrite";
import { getEnvConfig } from "@/lib/appwrite-core";
-import { logger } from "@/lib/newrelic-utils";
+import { logger } from "@/lib/posthog-utils";
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS ?? "")
.split(",")
@@ -109,9 +109,10 @@ export async function POST(request: Request) {
);
}
- const { session, project } = parsed as {
+ const { session, project, remember } = parsed as {
session?: unknown;
project?: unknown;
+ remember?: unknown;
};
if (typeof session !== "string" || typeof project !== "string") {
@@ -143,11 +144,15 @@ export async function POST(request: Request) {
}
const cookieStore = await cookies();
+ // Remember-me: persistent 1-year cookie by default. When unchecked the
+ // cookie is session-only (no maxAge) and clears when the browser closes.
+ const rememberMe = remember === undefined || remember === true;
+
cookieStore.set(`a_session_${env.project}`, session, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
- maxAge: 60 * 60 * 24 * 365,
+ ...(rememberMe ? { maxAge: 60 * 60 * 24 * 365 } : {}),
path: "/",
});
diff --git a/apps/web/src/app/api/sessions/route.ts b/apps/web/src/app/api/sessions/route.ts
new file mode 100644
index 0000000..e404ff9
--- /dev/null
+++ b/apps/web/src/app/api/sessions/route.ts
@@ -0,0 +1,152 @@
+import { NextResponse } from "next/server";
+import { cookies } from "next/headers";
+import { Account, Client } from "node-appwrite";
+
+import { getEnvConfig } from "@/lib/appwrite-core";
+import { invalidateSessionCacheForToken } from "@/lib/auth-server";
+import { logger } from "@/lib/posthog-utils";
+
+function getSessionClient(secret: string): Account {
+ const env = getEnvConfig();
+ const client = new Client()
+ .setEndpoint(env.endpoint)
+ .setProject(env.project)
+ .setSession(secret);
+ return new Account(client);
+}
+
+function mapSession(session: {
+ $id: string;
+ $createdAt?: string;
+ expire?: string;
+ osName?: string;
+ osVersion?: string;
+ clientName?: string;
+ clientType?: string;
+ clientVersion?: string;
+ deviceName?: string;
+ deviceModel?: string;
+ current?: boolean;
+}) {
+ return {
+ $id: session.$id,
+ createdAt: session.$createdAt,
+ expiresAt: session.expire,
+ current: session.current === true,
+ os: session.osName ?? null,
+ osVersion: session.osVersion ?? null,
+ client: session.clientName ?? null,
+ clientType: session.clientType ?? null,
+ device: session.deviceName ?? null,
+ deviceModel: session.deviceModel ?? null,
+ };
+}
+
+/**
+ * GET /api/sessions
+ *
+ * Lists sessions for the currently logged-in user (from the cookie), driven
+ * by the session client so a user can only ever see their own sessions.
+ */
+export async function GET() {
+ try {
+ const env = getEnvConfig();
+ const cookieStore = await cookies();
+ const sessionSecret = cookieStore.get(`a_session_${env.project}`)?.value;
+
+ if (!sessionSecret) {
+ return NextResponse.json(
+ { error: "No session found" },
+ { status: 401 },
+ );
+ }
+
+ const account = getSessionClient(sessionSecret);
+ const response = await account.listSessions();
+
+ return NextResponse.json({
+ sessions: (response.sessions ?? []).map(mapSession),
+ });
+ } catch (error) {
+ logger.error("Failed to list sessions", {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ return NextResponse.json(
+ { error: "Failed to list sessions" },
+ { status: 500 },
+ );
+ }
+}
+
+/**
+ * DELETE /api/sessions?sessionId=... | DELETE /api/sessions?revokeOthers=1
+ *
+ * Revokes a single session or every other session. Revoking "current" also
+ * clears the session cookie. (Callers send `clearCookie=1` alongside the
+ * current session id so the server knows to drop the cookie.)
+ */
+export async function DELETE(request: Request) {
+ try {
+ const env = getEnvConfig();
+ const cookieStore = await cookies();
+ const sessionSecret = cookieStore.get(`a_session_${env.project}`)?.value;
+
+ if (!sessionSecret) {
+ return NextResponse.json(
+ { error: "No session found" },
+ { status: 401 },
+ );
+ }
+
+ const url = new URL(request.url);
+ const sessionId = url.searchParams.get("sessionId");
+ const revokeOthers = url.searchParams.get("revokeOthers") === "1";
+ const clearCookie = url.searchParams.get("clearCookie") === "1";
+
+ const account = getSessionClient(sessionSecret);
+
+ if (revokeOthers) {
+ const response = await account.listSessions();
+ for (const session of response.sessions ?? []) {
+ if (session.current === true) continue;
+ try {
+ await account.deleteSession({ sessionId: session.$id });
+ } catch (error) {
+ logger.warn("Failed to revoke individual session", {
+ sessionId: session.$id,
+ error:
+ error instanceof Error
+ ? error.message
+ : String(error),
+ });
+ }
+ }
+ } else if (sessionId) {
+ await account.deleteSession({ sessionId });
+ } else {
+ return NextResponse.json(
+ { error: "sessionId or revokeOthers is required" },
+ { status: 400 },
+ );
+ }
+
+ if (clearCookie) {
+ cookieStore.delete(`a_session_${env.project}`);
+ invalidateSessionCacheForToken(
+ env.endpoint,
+ env.project,
+ sessionSecret,
+ );
+ }
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ logger.error("Failed to revoke session(s)", {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ return NextResponse.json(
+ { error: "Failed to revoke session(s)" },
+ { status: 500 },
+ );
+ }
+}
\ No newline at end of file
diff --git a/apps/web/src/app/api/status/batch/route.ts b/apps/web/src/app/api/status/batch/route.ts
index 277fba6..681360f 100644
--- a/apps/web/src/app/api/status/batch/route.ts
+++ b/apps/web/src/app/api/status/batch/route.ts
@@ -7,11 +7,9 @@ import { getServerSession } from "@/lib/auth-server";
import { apiCache } from "@/lib/cache-utils";
import {
logger,
- setTransactionName,
trackApiCall,
- addTransactionAttributes,
returnUnauthorized,
-} from "@/lib/newrelic-utils";
+} from "@/lib/posthog-utils";
import type { UserStatus } from "@/lib/types";
import {
normalizeStatus,
@@ -32,7 +30,6 @@ export async function POST(request: Request) {
const startTime = Date.now();
try {
- setTransactionName("POST /api/status/batch");
const session = await getServerSession();
if (!session?.$id) {
@@ -61,10 +58,6 @@ export async function POST(request: Request) {
);
}
- addTransactionAttributes({
- userCount: userIds.length,
- });
-
if (!STATUSES_COLLECTION) {
logger.error("Statuses collection not configured");
return NextResponse.json(
diff --git a/apps/web/src/app/api/status/route.ts b/apps/web/src/app/api/status/route.ts
index 467348e..c5be0fb 100644
--- a/apps/web/src/app/api/status/route.ts
+++ b/apps/web/src/app/api/status/route.ts
@@ -8,13 +8,11 @@ import { getServerSession } from "@/lib/auth-server";
import {
logger,
recordError,
- setTransactionName,
trackApiCall,
- addTransactionAttributes,
recordEvent,
returnUnauthorized,
returnForbidden,
-} from "@/lib/newrelic-utils";
+} from "@/lib/posthog-utils";
import {
ALLOWED_STATUSES,
normalizeStatus,
@@ -86,7 +84,6 @@ export async function POST(request: Request) {
const startTime = Date.now();
try {
- setTransactionName("POST /api/status");
const session = await getServerSession();
if (!session?.$id) {
@@ -115,12 +112,6 @@ export async function POST(request: Request) {
);
}
- addTransactionAttributes({
- userId,
- status,
- isManuallySet: !!isManuallySet,
- });
-
if (!STATUSES_COLLECTION) {
logger.error("Statuses collection not configured");
return NextResponse.json(
diff --git a/apps/web/src/app/api/stickers/route.ts b/apps/web/src/app/api/stickers/route.ts
index d57d455..b83aed0 100644
--- a/apps/web/src/app/api/stickers/route.ts
+++ b/apps/web/src/app/api/stickers/route.ts
@@ -3,13 +3,12 @@ import { NextResponse } from "next/server";
import { AuthError, requireAuth } from "@/lib/auth-server";
import { getBuiltinStickerPacks } from "@/lib/gif-sticker";
-import { setTransactionName, trackApiCall } from "@/lib/newrelic-utils";
+import { trackApiCall } from "@/lib/posthog-utils";
export async function GET(request: NextRequest) {
const startTime = Date.now();
try {
- setTransactionName("GET /api/stickers");
await requireAuth();
diff --git a/apps/web/src/app/api/thread-reads/route.ts b/apps/web/src/app/api/thread-reads/route.ts
index 1c22c59..4719195 100644
--- a/apps/web/src/app/api/thread-reads/route.ts
+++ b/apps/web/src/app/api/thread-reads/route.ts
@@ -3,7 +3,7 @@ import { NextResponse } from "next/server";
import { getServerSession } from "@/lib/auth-server";
import { getThreadReads, upsertThreadReads } from "@/lib/thread-read-store";
import { type ThreadReadContextType } from "@/lib/thread-read-states";
-import { logger } from "@/lib/newrelic-utils";
+import { logger } from "@/lib/posthog-utils";
const VALID_CONTEXT_TYPES: ThreadReadContextType[] = [
"channel",
diff --git a/apps/web/src/app/api/typing/route.ts b/apps/web/src/app/api/typing/route.ts
index 0806300..9595618 100644
--- a/apps/web/src/app/api/typing/route.ts
+++ b/apps/web/src/app/api/typing/route.ts
@@ -3,7 +3,7 @@ import { Permission, Presences, Role } from "node-appwrite";
import { getServerSession } from "@/lib/auth-server";
import { getServerClient } from "@/lib/appwrite-server";
-import { logger } from "@/lib/newrelic-utils";
+import { logger } from "@/lib/posthog-utils";
const DEFAULT_TYPING_EXPIRY_MS = 8000;
diff --git a/apps/web/src/app/api/upload-emoji/route.ts b/apps/web/src/app/api/upload-emoji/route.ts
index cf848e5..ea6689d 100644
--- a/apps/web/src/app/api/upload-emoji/route.ts
+++ b/apps/web/src/app/api/upload-emoji/route.ts
@@ -6,7 +6,7 @@ import { getServerClient } from "@/lib/appwrite-server";
import { getServerSession } from "@/lib/auth-server";
import { getEnvConfig } from "@/lib/appwrite-core";
import { checkRateLimit } from "@/lib/rate-limit";
-import { logger } from "@/lib/newrelic-utils";
+import { logger } from "@/lib/posthog-utils";
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS ?? "")
.split(",")
diff --git a/apps/web/src/app/api/upload-file/route.ts b/apps/web/src/app/api/upload-file/route.ts
index 2b2299b..d758f24 100644
--- a/apps/web/src/app/api/upload-file/route.ts
+++ b/apps/web/src/app/api/upload-file/route.ts
@@ -16,11 +16,9 @@ import { checkRateLimit } from "@/lib/rate-limit";
import {
logger,
recordError,
- setTransactionName,
trackApiCall,
- addTransactionAttributes,
recordEvent,
-} from "@/lib/newrelic-utils";
+} from "@/lib/posthog-utils";
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS ?? "")
.split(",")
@@ -514,8 +512,6 @@ export async function POST(request: NextRequest) {
return respond({ error: "Origin is not allowed" }, { status: 403 });
}
- setTransactionName("POST /api/upload-file");
-
logger.info("Starting file upload");
const session = await getServerSession();
if (!session?.$id) {
@@ -524,8 +520,6 @@ export async function POST(request: NextRequest) {
}
logger.info("Session verified", { userId: session.$id });
- addTransactionAttributes({ userId: session.$id });
-
// Rate limiting: 10 uploads per 5 minutes
const rateLimitResult = checkRateLimit(`upload:${session.$id}`, {
maxRequests: 10,
@@ -755,16 +749,12 @@ export async function DELETE(request: NextRequest) {
return respond({ error: "Origin is not allowed" }, { status: 403 });
}
- setTransactionName("DELETE /api/upload-file");
-
const session = await getServerSession();
if (!session?.$id) {
logger.warn("Unauthorized delete attempt");
return respond({ error: "Unauthorized" }, { status: 401 });
}
- addTransactionAttributes({ userId: session.$id });
-
const env = getEnvConfig();
const { searchParams } = new URL(request.url);
@@ -775,8 +765,6 @@ export async function DELETE(request: NextRequest) {
return respond({ error: "No fileId provided" }, { status: 400 });
}
- addTransactionAttributes({ fileId });
-
const deleteRateLimitResult = checkRateLimit(
`upload-delete:${session.$id}`,
{
diff --git a/apps/web/src/app/api/upload-image/route.ts b/apps/web/src/app/api/upload-image/route.ts
index 52a120d..36c21d4 100644
--- a/apps/web/src/app/api/upload-image/route.ts
+++ b/apps/web/src/app/api/upload-image/route.ts
@@ -9,11 +9,9 @@ import { checkRateLimit } from "@/lib/rate-limit";
import {
logger,
recordError,
- setTransactionName,
trackApiCall,
- addTransactionAttributes,
recordEvent,
-} from "@/lib/newrelic-utils";
+} from "@/lib/posthog-utils";
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS ?? "")
.split(",")
@@ -179,8 +177,6 @@ export async function POST(request: NextRequest) {
return respond({ error: "Origin is not allowed" }, { status: 403 });
}
- setTransactionName("POST /api/upload-image");
-
logger.info("Starting image upload");
const session = await getServerSession();
if (!session?.$id) {
@@ -189,8 +185,6 @@ export async function POST(request: NextRequest) {
}
logger.info("Session verified", { userId: session.$id });
- addTransactionAttributes({ userId: session.$id });
-
// Rate limiting: 10 uploads per 5 minutes
const rateLimitResult = checkRateLimit(`upload-image:${session.$id}`, {
maxRequests: 10,
@@ -372,8 +366,6 @@ export async function DELETE(request: NextRequest) {
return respond({ error: "Origin is not allowed" }, { status: 403 });
}
- setTransactionName("DELETE /api/upload-image");
-
const session = await getServerSession();
if (!session?.$id) {
logger.warn("Unauthorized delete attempt");
@@ -396,8 +388,6 @@ export async function DELETE(request: NextRequest) {
);
}
- addTransactionAttributes({ userId: session.$id });
-
const env = getEnvConfig();
const { searchParams } = new URL(request.url);
@@ -413,8 +403,6 @@ export async function DELETE(request: NextRequest) {
return respond({ error: "Invalid fileId" }, { status: 400 });
}
- addTransactionAttributes({ fileId });
-
const { storage } = getServerClient();
let filePermissions: unknown;
diff --git a/apps/web/src/app/api/users/[userId]/profile/route.ts b/apps/web/src/app/api/users/[userId]/profile/route.ts
index bca4931..b5f4ae4 100644
--- a/apps/web/src/app/api/users/[userId]/profile/route.ts
+++ b/apps/web/src/app/api/users/[userId]/profile/route.ts
@@ -9,7 +9,7 @@ import { getUserStatus } from "@/lib/appwrite-status";
import { logger,
returnUnauthorized,
returnForbidden,
-} from "@/lib/newrelic-utils";
+} from "@/lib/posthog-utils";
export async function GET(
_request: Request,
diff --git a/apps/web/src/app/api/users/search/route.ts b/apps/web/src/app/api/users/search/route.ts
index 3650a36..dc9a762 100644
--- a/apps/web/src/app/api/users/search/route.ts
+++ b/apps/web/src/app/api/users/search/route.ts
@@ -6,7 +6,7 @@ import { getAvatarUrl } from "@/lib/appwrite-profiles";
import { getServerSession } from "@/lib/auth-server";
import { getRelationshipMap } from "@/lib/appwrite-friendships";
import { apiCache } from "@/lib/cache-utils";
-import { logger } from "@/lib/newrelic-utils";
+import { logger } from "@/lib/posthog-utils";
const USERS_SEARCH_CACHE_TTL_MS = 10 * 1000;
diff --git a/apps/web/src/app/chat/hooks/useActivityTracking.ts b/apps/web/src/app/chat/hooks/useActivityTracking.ts
index 6209356..051f6c7 100644
--- a/apps/web/src/app/chat/hooks/useActivityTracking.ts
+++ b/apps/web/src/app/chat/hooks/useActivityTracking.ts
@@ -13,16 +13,16 @@ type UseActivityTrackingProps = {
/**
* Hook to track user activity and update status
- *
+ *
* NOTE: This hook is currently NOT in use to preserve manual status settings.
* Users can manually set their status via the profile/settings UI.
- *
+ *
* If enabled, this hook would:
* - Set status to "online" when active
* - Set status to "away" after 5 minutes of inactivity
* - Update lastSeen every 60 seconds
* - Set status to "offline" on unmount/logout
- *
+ *
* @deprecated Automatic status tracking disabled to preserve manual statuses
*/
export function useActivityTracking({ userId, enabled = true }: UseActivityTrackingProps) {
diff --git a/apps/web/src/app/chat/hooks/useDirectMessages.ts b/apps/web/src/app/chat/hooks/useDirectMessages.ts
index b0ecf69..4833f69 100644
--- a/apps/web/src/app/chat/hooks/useDirectMessages.ts
+++ b/apps/web/src/app/chat/hooks/useDirectMessages.ts
@@ -1086,7 +1086,6 @@ export function useDirectMessages({
[Query.equal("conversationId", conversationId)],
);
-
messageSubscriptionRef.current = subscription;
if (cancelled) {
diff --git a/apps/web/src/app/chat/hooks/useServers.ts b/apps/web/src/app/chat/hooks/useServers.ts
index d614552..c0d89af 100644
--- a/apps/web/src/app/chat/hooks/useServers.ts
+++ b/apps/web/src/app/chat/hooks/useServers.ts
@@ -74,7 +74,7 @@ export function useServers({ userId, membershipEnabled }: UseServersOptions) {
(async () => {
try {
setInitialLoading(true);
-
+
// Use SWR (stale-while-revalidate) to serve cached data instantly while revalidating
const serverReq = apiCache.swr(
`servers:initial:${userId}`,
@@ -83,7 +83,7 @@ export function useServers({ userId, membershipEnabled }: UseServersOptions) {
.then((data) => data as { servers: Server[]; nextCursor: string | null }),
CACHE_TTL.SERVERS
);
-
+
const membershipReq = membershipEnabled
? apiCache.swr(
`memberships:${userId}`,
@@ -93,7 +93,7 @@ export function useServers({ userId, membershipEnabled }: UseServersOptions) {
CACHE_TTL.MEMBERSHIPS
)
: Promise.resolve([]);
-
+
const [{ servers: first, nextCursor }, mems] = await Promise.all([
serverReq,
membershipReq,
@@ -301,22 +301,22 @@ export function useServers({ userId, membershipEnabled }: UseServersOptions) {
if (membershipEnabled) {
apiCache.clear(`memberships:${userId}`);
}
-
+
const serverReq = fetch("/api/servers?limit=25")
.then((res) => res.json())
.then((data) => data as { servers: Server[]; nextCursor: string | null });
-
+
const membershipReq = membershipEnabled
? fetch("/api/memberships")
.then((res) => res.json())
.then((data) => data.memberships as Membership[])
: Promise.resolve([]);
-
+
const [{ servers: first, nextCursor }, mems] = await Promise.all([
serverReq,
membershipReq,
]);
-
+
setCursor(nextCursor);
setMemberships(mems);
setServers(filterAllowedServers(first, mems));
diff --git a/apps/web/src/app/notifications/page.tsx b/apps/web/src/app/notifications/page.tsx
index da3987c..ee87f47 100644
--- a/apps/web/src/app/notifications/page.tsx
+++ b/apps/web/src/app/notifications/page.tsx
@@ -2,7 +2,7 @@ import { redirect } from "next/navigation";
import { NotificationsCenter } from "./notifications-center";
import { AuthError, requireAuth } from "@/lib/auth-server";
-import { logger } from "@/lib/newrelic-utils";
+import { logger } from "@/lib/posthog-utils";
const AUTH_ERROR_REGEX =
/\b(?:not authenticated|not authorized|unauthenticated|authentication|auth)\b/i;
diff --git a/apps/web/src/app/onboarding/actions.ts b/apps/web/src/app/onboarding/actions.ts
index bf7a359..9ca9d56 100644
--- a/apps/web/src/app/onboarding/actions.ts
+++ b/apps/web/src/app/onboarding/actions.ts
@@ -15,7 +15,7 @@ import {
DIRECT_MESSAGE_PRIVACY_VALUES,
NOTIFICATION_LEVEL_VALUES,
} from "@/lib/types";
-import { logger } from "@/lib/newrelic-utils";
+import { logger } from "@/lib/posthog-utils";
function isNotificationLevel(
value: FormDataEntryValue | null,
diff --git a/apps/web/src/app/reports/actions.ts b/apps/web/src/app/reports/actions.ts
index ac0accf..ce46eca 100644
--- a/apps/web/src/app/reports/actions.ts
+++ b/apps/web/src/app/reports/actions.ts
@@ -10,7 +10,7 @@ import {
DuplicateReportError,
type Report,
} from "@/lib/appwrite-reports";
-import { logger } from "@/lib/newrelic-utils";
+import { logger } from "@/lib/posthog-utils";
const RATE_LIMIT_MAX = 5;
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
diff --git a/apps/web/src/app/settings/actions.ts b/apps/web/src/app/settings/actions.ts
index 019d9bb..fbdf115 100644
--- a/apps/web/src/app/settings/actions.ts
+++ b/apps/web/src/app/settings/actions.ts
@@ -1,18 +1,25 @@
"use server";
import { revalidatePath } from "next/cache";
-import { ID } from "node-appwrite";
-import { requireAuth } from "@/lib/auth-server";
+import { cookies } from "next/headers";
+import { ID, Account, Client, Users } from "node-appwrite";
+import {
+ getSessionTokenFromCookie,
+ invalidateSessionCacheForToken,
+ requireAuth,
+} from "@/lib/auth-server";
import {
deleteAvatarFile,
deleteProfileBackgroundFile,
getOrCreateUserProfile,
+ tombstoneUserProfile,
updateProfileBackgroundImageState,
updateUserProfile,
} from "@/lib/appwrite-profiles";
import { getAdminClient } from "@/lib/appwrite-admin";
import { getEnvConfig } from "@/lib/appwrite-core";
-import { logger } from "@/lib/newrelic-utils";
+import { FEATURE_FLAGS, getFeatureFlag } from "@/lib/feature-flags";
+import { logger } from "@/lib/posthog-utils";
import {
getEligibleFramesForUser,
isUserEligibleForFrame,
@@ -455,3 +462,326 @@ export async function getAvailableFramesAction() {
eligibilityKnown: accountCreatedAt !== null,
};
}
+
+const EMAIL_ADDRESS_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+
+function getVerificationRedirectUrl(): string {
+ const configuredBaseUrl =
+ process.env.SERVER_URL?.trim() ||
+ process.env.NEXT_PUBLIC_BASE_URL?.trim() ||
+ "http://localhost:3000";
+
+ const normalizedBaseUrl = configuredBaseUrl.replace(/\/$/, "");
+ return `${normalizedBaseUrl}/api/auth/verify-email`;
+}
+
+/**
+ * Creates a short-lived session from the current password to confirm the
+ * caller really owns the account. The temp session is always deleted in the
+ * caller's finally block.
+ */
+async function verifyCurrentPassword(userId: string, password: string):
+ Promise<{ account: Account; users: Users; sessionSecret: string } | null> {
+ const env = getEnvConfig();
+ const apiKey = process.env.APPWRITE_API_KEY;
+
+ if (!apiKey) {
+ throw new Error("Server API key not configured");
+ }
+
+ const client = new Client()
+ .setEndpoint(env.endpoint)
+ .setProject(env.project)
+ .setKey(apiKey);
+ const account = new Account(client);
+ const users = new Users(client);
+
+ try {
+ const session = await account.createEmailPasswordSession({
+ email: (await users.get(userId)).email,
+ password,
+ });
+
+ if (session.userId !== userId) {
+ return null;
+ }
+
+ return { account, users, sessionSecret: session.secret ?? "" };
+ } catch {
+ return null;
+ }
+}
+
+async function isEmailVerificationEnabled(): Promise {
+ try {
+ return await getFeatureFlag(FEATURE_FLAGS.ENABLE_EMAIL_VERIFICATION);
+ } catch {
+ return false;
+ }
+}
+
+export type AccountActionResult =
+ | { success: true; message: string; verificationSent?: boolean }
+ | { success: false; error: string };
+
+/**
+ * Changes the account email. Requires the current password; after the change
+ * a verification email is sent when email verification is enabled.
+ */
+export async function changeEmailAction(
+ formData: FormData,
+): Promise {
+ const user = await requireAuth();
+ const newEmail = (formData.get("email") as string)?.trim().toLowerCase();
+ const password = formData.get("password") as string;
+
+ if (!newEmail || !password) {
+ return { success: false, error: "New email and password are required" };
+ }
+
+ if (!EMAIL_ADDRESS_PATTERN.test(newEmail)) {
+ return { success: false, error: "Invalid email address" };
+ }
+
+ if (newEmail === user.email) {
+ return { success: false, error: "That is already your email address" };
+ }
+
+ const verified = await verifyCurrentPassword(user.$id, password);
+ if (!verified) {
+ return { success: false, error: "Current password is incorrect" };
+ }
+
+ const env = getEnvConfig();
+ try {
+ await verified.users.updateEmail({
+ userId: user.$id,
+ email: newEmail,
+ });
+
+ let verificationSent = false;
+ if (await isEmailVerificationEnabled()) {
+ try {
+ await verified.account.createVerification({
+ url: getVerificationRedirectUrl(),
+ });
+ verificationSent = true;
+ } catch (verificationError) {
+ logger.warn("Failed to send email-change verification", {
+ error:
+ verificationError instanceof Error
+ ? verificationError.message
+ : String(verificationError),
+ });
+ }
+ }
+
+ if (verified.sessionSecret) {
+ invalidateSessionCacheForToken(
+ env.endpoint,
+ env.project,
+ verified.sessionSecret,
+ );
+ }
+
+ revalidatePath("/settings");
+
+ return {
+ success: true,
+ message: verificationSent
+ ? "Email updated. Check your inbox for a verification link."
+ : "Email updated.",
+ verificationSent,
+ };
+ } catch (error) {
+ logger.error("Email change failed", {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ return {
+ success: false,
+ error: "Email change failed. That address may already be in use.",
+ };
+ } finally {
+ try {
+ await verified.users.deleteSession({
+ userId: user.$id,
+ sessionId: "current",
+ });
+ } catch {
+ // Best-effort temp session cleanup.
+ }
+ }
+}
+
+/**
+ * Re-sends the email-change verification link using the caller's active
+ * session, so a settings user doesn't have to re-enter their password.
+ */
+export async function resendEmailVerificationAction(): Promise {
+ const user = await requireAuth();
+ const env = getEnvConfig();
+ const token = await getSessionTokenFromCookie();
+
+ try {
+ if (!token) {
+ return {
+ success: false,
+ error: "No active session found. Sign in and try again.",
+ };
+ }
+
+ if (!(await isEmailVerificationEnabled())) {
+ return {
+ success: false,
+ error: "Email verification is not enabled on this instance.",
+ };
+ }
+
+ const client = new Client()
+ .setEndpoint(env.endpoint)
+ .setProject(env.project);
+
+ if (token.startsWith("eyJ")) {
+ client.setJWT(token);
+ } else {
+ client.setSession(token);
+ }
+
+ await new Account(client).createVerification({
+ url: getVerificationRedirectUrl(),
+ });
+
+ return {
+ success: true,
+ message: "Verification email sent. Check your inbox.",
+ verificationSent: true,
+ };
+ } catch (error) {
+ logger.error("Email verification resend failed", {
+ userId: user.$id,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ return {
+ success: false,
+ error: "Could not send the verification email. Try again in a moment.",
+ };
+ }
+}
+
+/**
+ * Deactivates the account. Marked in Appwrite prefs so the login gate can
+ * reactivate automatically on the next successful sign-in ("take a break").
+ */
+export async function deactivateAccountAction(
+ formData: FormData,
+): Promise {
+ const user = await requireAuth();
+ const password = formData.get("password") as string;
+
+ if (!password) {
+ return { success: false, error: "Password is required" };
+ }
+
+ const verified = await verifyCurrentPassword(user.$id, password);
+ if (!verified) {
+ return { success: false, error: "Current password is incorrect" };
+ }
+
+ const env = getEnvConfig();
+ try {
+ await verified.users.updatePrefs({
+ userId: user.$id,
+ prefs: { disabled: true, disabledAt: new Date().toISOString() },
+ });
+ await verified.users.deleteSessions({ userId: user.$id });
+
+ const cookieStore = await cookies();
+ cookieStore.delete(`a_session_${env.project}`);
+
+ if (verified.sessionSecret) {
+ invalidateSessionCacheForToken(
+ env.endpoint,
+ env.project,
+ verified.sessionSecret,
+ );
+ }
+
+ return {
+ success: true,
+ message:
+ "Your account is deactivated. Sign back in anytime to reactivate it.",
+ };
+ } catch (error) {
+ logger.error("Account deactivation failed", {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ return { success: false, error: "Deactivation failed. Try again." };
+ }
+}
+
+/**
+ * Permanently deletes the account: wipes custom files (avatar, background),
+ * hard-deletes the Appwrite user, and replaces the profile with a permanent
+ * "Deleted User" tombstone so the userId can never be reused.
+ */
+export async function deleteAccountAction(
+ formData: FormData,
+): Promise {
+ const user = await requireAuth();
+ const password = formData.get("password") as string;
+
+ if (!password) {
+ return { success: false, error: "Password is required" };
+ }
+
+ const verified = await verifyCurrentPassword(user.$id, password);
+ if (!verified) {
+ return { success: false, error: "Current password is incorrect" };
+ }
+
+ const env = getEnvConfig();
+ const sessionSecret =
+ (await getSessionTokenFromCookie()) ?? verified.sessionSecret;
+
+ try {
+ const profile = await getOrCreateUserProfile(user.$id, user.name);
+
+ if (profile.avatarFileId) {
+ await deleteAvatarFile(profile.avatarFileId).catch(() => {});
+ }
+ if (profile.profileBackgroundImageFileId) {
+ await deleteProfileBackgroundFile(
+ profile.profileBackgroundImageFileId,
+ ).catch(() => {});
+ }
+
+ await tombstoneUserProfile(profile, user.$id, user.email);
+
+ await verified.users.delete({ userId: user.$id });
+
+ const cookieStore = await cookies();
+ cookieStore.delete(`a_session_${env.project}`);
+
+ if (sessionSecret) {
+ invalidateSessionCacheForToken(
+ env.endpoint,
+ env.project,
+ sessionSecret,
+ );
+ }
+
+ return {
+ success: true,
+ message: "Your account has been deleted.",
+ };
+ } catch (error) {
+ logger.error("Account deletion failed", {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ return {
+ success: false,
+ error:
+ "Account deletion failed. Try again or contact your administrator.",
+ };
+ }
+}
diff --git a/apps/web/src/app/settings/danger-zone.tsx b/apps/web/src/app/settings/danger-zone.tsx
new file mode 100644
index 0000000..6ae7d41
--- /dev/null
+++ b/apps/web/src/app/settings/danger-zone.tsx
@@ -0,0 +1,206 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+
+import { deactivateAccountAction, deleteAccountAction } from "./actions";
+
+export function DangerZone() {
+ const router = useRouter();
+ const [password, setPassword] = useState("");
+ const [busy, setBusy] = useState<"deactivate" | "delete" | null>(null);
+ const [confirmDelete, setConfirmDelete] = useState(false);
+ const [deletePassword, setDeletePassword] = useState("");
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+
+ const runAction = async (
+ kind: "deactivate" | "delete",
+ action: (formData: FormData) => Promise<
+ { success: true; message: string } | { success: false; error: string }
+ >,
+ ) => {
+ setBusy(kind);
+ try {
+ const formData = new FormData();
+ formData.set(
+ "password",
+ kind === "delete" ? deletePassword : password,
+ );
+ const result = await action(formData);
+ if (result.success) {
+ toast.success(result.message);
+ if (kind === "delete") {
+ setDeleteDialogOpen(false);
+ }
+ router.push("/login");
+ } else {
+ toast.error(result.error);
+ }
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "Action failed.");
+ } finally {
+ setBusy(null);
+ }
+ };
+
+ return (
+
+
+
+
+ Deactivate account
+
+
+ Take a break. Your account is hidden until you sign in
+ again, which reactivates it automatically.
+
+
+
+ void runAction("deactivate", deactivateAccountAction)
+ }
+ type="button"
+ variant="outline"
+ >
+ {busy === "deactivate" ? "Deactivating..." : "Deactivate"}
+
+
+
+
+
+
+
+ Delete account
+
+
+ Permanently deletes your account. This cannot be
+ undone.
+
+
+
+
+
+
+
+ Delete account
+
+
+
+
+
+ Permanently delete your account?
+
+
+ This wipes your profile, posts, direct
+ messages, attachments, uploads, and all
+ server data. Your user ID is reserved
+ forever as a "Deleted User"
+ record and can never be reused. There is no
+ way to undo this.
+
+
+
+
+
+ setDeleteDialogOpen(false)
+ }
+ disabled={busy !== null}
+ type="button"
+ variant="outline"
+ >
+ Cancel
+
+
+ void runAction(
+ "delete",
+ deleteAccountAction,
+ )
+ }
+ type="button"
+ variant="destructive"
+ >
+ {busy === "delete"
+ ? "Deleting..."
+ : "Delete permanently"}
+
+
+
+
+
+
+
+
+ Current password
+ setPassword(e.target.value)}
+ required
+ type="password"
+ value={password}
+ />
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/src/app/settings/email-change-form.tsx b/apps/web/src/app/settings/email-change-form.tsx
new file mode 100644
index 0000000..4d5b47c
--- /dev/null
+++ b/apps/web/src/app/settings/email-change-form.tsx
@@ -0,0 +1,121 @@
+"use client";
+
+import { useState } from "react";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+
+import { changeEmailAction, resendEmailVerificationAction } from "./actions";
+
+export function EmailChangeForm() {
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [verificationSent, setVerificationSent] = useState(false);
+ const [resending, setResending] = useState(false);
+
+ const onSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setLoading(true);
+ try {
+ const formData = new FormData();
+ formData.set("email", email);
+ formData.set("password", password);
+ const result = await changeEmailAction(formData);
+ if (result.success) {
+ toast.success(result.message);
+ setEmail("");
+ setPassword("");
+ setVerificationSent(Boolean(result.verificationSent));
+ } else {
+ toast.error(result.error);
+ }
+ } catch (err) {
+ toast.error(
+ err instanceof Error ? err.message : "Email change failed.",
+ );
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const onResend = async () => {
+ setResending(true);
+ try {
+ const result = await resendEmailVerificationAction();
+ if (result.success) {
+ toast.success(result.message);
+ } else {
+ toast.error(result.error);
+ }
+ } catch (err) {
+ toast.error(
+ err instanceof Error
+ ? err.message
+ : "Failed to resend verification email.",
+ );
+ } finally {
+ setResending(false);
+ }
+ };
+
+ return (
+
+
+
+ {verificationSent && (
+
+
+ A verification link is on its way to your new address.
+ Confirm it before you sign out, or you will not be able
+ to sign back in until it is verified.
+
+
void onResend()}
+ type="button"
+ variant="outline"
+ >
+ {resending
+ ? "Resending..."
+ : "Resend verification email"}
+
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/src/app/settings/page.tsx b/apps/web/src/app/settings/page.tsx
index d8351ed..bd1809b 100644
--- a/apps/web/src/app/settings/page.tsx
+++ b/apps/web/src/app/settings/page.tsx
@@ -38,6 +38,9 @@ import { PendingFriendRequestsBadge } from "@/components/pending-friend-requests
import { SettingsSectionNav } from "@/components/settings-section-nav";
import { TelemetrySettings } from "@/components/telemetry-settings";
import { FlushCaches } from "./FlushCaches";
+import { SessionManager } from "./session-manager";
+import { EmailChangeForm } from "./email-change-form";
+import { DangerZone } from "./danger-zone";
export default async function SettingsPage() {
const user = await requireAuth().catch(() => {
@@ -70,6 +73,11 @@ export default async function SettingsPage() {
href: "#profile-appearance",
title: "Appearance",
},
+ {
+ description: "Email, sign-in sessions, and account security.",
+ href: "#account-security",
+ title: "Security",
+ },
{
description: "How and when Firepit reaches you.",
href: "#notification-preferences",
@@ -95,6 +103,11 @@ export default async function SettingsPage() {
href: "#troubleshooting",
title: "Troubleshooting",
},
+ {
+ description: "Delete or temporarily deactivate your account.",
+ href: "#danger-zone",
+ title: "Danger zone",
+ },
] as const;
return (
@@ -436,6 +449,37 @@ export default async function SettingsPage() {
+
+
+
+
+ Email & sign-in security
+
+
+ Change your email address or review
+ the devices signed in to your account.
+
+
+
+
+
+
+ Active sessions
+
+
+
+
+
+
+
+
+
+
+
+ Danger zone
+
+ Deactivate or permanently delete your
+ account. Both actions require your
+ current password.
+
+
+
+
+
+
+
diff --git a/apps/web/src/app/settings/session-manager.tsx b/apps/web/src/app/settings/session-manager.tsx
new file mode 100644
index 0000000..f3ee037
--- /dev/null
+++ b/apps/web/src/app/settings/session-manager.tsx
@@ -0,0 +1,157 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import { useRouter } from "next/navigation";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+
+type SessionInfo = {
+ $id: string;
+ current?: boolean;
+ os?: string | null;
+ client?: string | null;
+ device?: string | null;
+ createdAt?: string;
+};
+
+export function SessionManager() {
+ const router = useRouter();
+ const [sessions, setSessions] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [revoking, setRevoking] = useState(false);
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ try {
+ const response = await fetch("/api/sessions");
+ const data = (await response.json()) as {
+ sessions?: SessionInfo[];
+ error?: string;
+ };
+ if (data.error) {
+ toast.error(data.error);
+ setSessions([]);
+ } else {
+ setSessions(data.sessions ?? []);
+ }
+ } catch {
+ toast.error("Failed to load sessions.");
+ setSessions([]);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ const revoke = async (sessionId: string, isCurrent: boolean) => {
+ setRevoking(true);
+ try {
+ const query = new URLSearchParams({ sessionId });
+ if (isCurrent) {
+ query.set("clearCookie", "1");
+ }
+ const response = await fetch(`/api/sessions?${query.toString()}`, {
+ method: "DELETE",
+ });
+ if (!response.ok) {
+ const data = (await response.json().catch(() => ({}))) as {
+ error?: string;
+ };
+ toast.error(data.error ?? "Failed to revoke session.");
+ return;
+ }
+ toast.success(isCurrent ? "Signed out of this device." : "Session revoked.");
+ if (isCurrent) {
+ router.push("/login");
+ } else {
+ await load();
+ }
+ } finally {
+ setRevoking(false);
+ }
+ };
+
+ const revokeOthers = async () => {
+ setRevoking(true);
+ try {
+ const response = await fetch("/api/sessions?revokeOthers=1", {
+ method: "DELETE",
+ });
+ if (!response.ok) {
+ const data = (await response.json().catch(() => ({}))) as {
+ error?: string;
+ };
+ toast.error(data.error ?? "Failed to revoke sessions.");
+ return;
+ }
+ toast.success("All other sessions revoked.");
+ await load();
+ } finally {
+ setRevoking(false);
+ }
+ };
+
+ const formatDate = (value?: string) =>
+ value ? new Date(value).toLocaleDateString() : "Unknown";
+
+ return (
+
+ {loading ? (
+
Loading sessions...
+ ) : sessions.length === 0 ? (
+
+ No active sessions found.
+
+ ) : (
+
+ {sessions.map((session) => (
+
+
+
+ {session.os || "Unknown device"}
+ {session.current ? (
+
+ This device
+
+ ) : null}
+
+
+ {session.client || "Firepit"} · Signed in{" "}
+ {formatDate(session.createdAt)}
+
+
+
+ void revoke(session.$id, session.current === true)
+ }
+ size="sm"
+ type="button"
+ variant={session.current ? "destructive" : "outline"}
+ >
+ {session.current ? "Sign out" : "Revoke"}
+
+
+ ))}
+
+ )}
+
+ {!loading && sessions.length > 1 ? (
+
void revokeOthers()}
+ type="button"
+ variant="outline"
+ >
+ Sign out all other devices
+
+ ) : null}
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/src/components/channel-permissions-editor.tsx b/apps/web/src/components/channel-permissions-editor.tsx
index 69a390e..36cc5f4 100644
--- a/apps/web/src/components/channel-permissions-editor.tsx
+++ b/apps/web/src/components/channel-permissions-editor.tsx
@@ -154,14 +154,14 @@ export function ChannelPermissionsEditor({
const toggleAllow = (permission: Permission) => {
const newAllow = new Set(allowPermissions);
const newDeny = new Set(denyPermissions);
-
+
if (newAllow.has(permission)) {
newAllow.delete(permission);
} else {
newAllow.add(permission);
newDeny.delete(permission); // Remove from deny if adding to allow
}
-
+
setAllowPermissions(newAllow);
setDenyPermissions(newDeny);
};
@@ -169,14 +169,14 @@ export function ChannelPermissionsEditor({
const toggleDeny = (permission: Permission) => {
const newAllow = new Set(allowPermissions);
const newDeny = new Set(denyPermissions);
-
+
if (newDeny.has(permission)) {
newDeny.delete(permission);
} else {
newDeny.add(permission);
newAllow.delete(permission); // Remove from allow if adding to deny
}
-
+
setAllowPermissions(newAllow);
setDenyPermissions(newDeny);
};
diff --git a/apps/web/src/components/create-server-dialog.tsx b/apps/web/src/components/create-server-dialog.tsx
index b35b48e..c47c773 100644
--- a/apps/web/src/components/create-server-dialog.tsx
+++ b/apps/web/src/components/create-server-dialog.tsx
@@ -52,8 +52,8 @@ export function CreateServerDialog({
}),
});
- const result = await response.json() as {
- success: boolean;
+ const result = await response.json() as {
+ success: boolean;
server?: { name: string };
error?: string;
};
diff --git a/apps/web/src/components/emoji-renderer.tsx b/apps/web/src/components/emoji-renderer.tsx
index e041970..e55bc4e 100644
--- a/apps/web/src/components/emoji-renderer.tsx
+++ b/apps/web/src/components/emoji-renderer.tsx
@@ -52,7 +52,7 @@ export const EmojiRenderer = memo(function EmojiRenderer({
} else {
// Try to convert to standard emoji using node-emoji
const standardEmoji = emoji.get(emojiName);
-
+
if (standardEmoji && standardEmoji !== `:${emojiName}:`) {
// Found a standard emoji, render as Unicode character
parts.push(standardEmoji);
diff --git a/apps/web/src/components/mention-help-tooltip.tsx b/apps/web/src/components/mention-help-tooltip.tsx
index ca55532..72dd76b 100644
--- a/apps/web/src/components/mention-help-tooltip.tsx
+++ b/apps/web/src/components/mention-help-tooltip.tsx
@@ -37,7 +37,7 @@ export function MentionHelpTooltip() {
💡 Tip: Mention users in your messages
- Type @
+ Type @
{" "}followed by a name to mention someone. They'll see a highlighted notification!
diff --git a/apps/web/src/components/notification-settings.tsx b/apps/web/src/components/notification-settings.tsx
index 2b1a9d0..c27fede 100644
--- a/apps/web/src/components/notification-settings.tsx
+++ b/apps/web/src/components/notification-settings.tsx
@@ -983,22 +983,11 @@ export function NotificationSettings({
className="rounded-lg border border-border/70 p-3"
>
-
-
-
- {labelEntry?.title ??
- overrideId}
-
-
- {
- status.label
- }
-
-
+
+
+ {labelEntry?.title ??
+ overrideId}
+
{labelEntry?.subtitle ? (
{
@@ -1013,57 +1002,66 @@ export function NotificationSettings({
}
) : null}
-
+
+ {overrideId}
+
+
+
+
+ {status.label}
+
+
{formatNotificationLevel(
override.level,
)}
-
+
{formatMutedUntil(
override.mutedUntil,
)}
-
- {overrideId}
-
-
-
-
- openManageOverrideDialog(
- section.key,
- overrideId,
- override,
- )
- }
- disabled={
- overrideMutationKey ===
- section.key
- }
- >
- Manage
-
-
- void clearOverride(
- section.key,
- overrideId,
- )
- }
- disabled={
- overrideMutationKey ===
- section.key
- }
- aria-label={`Clear notification override ${overrideId}`}
- >
-
-
+
+
+ openManageOverrideDialog(
+ section.key,
+ overrideId,
+ override,
+ )
+ }
+ disabled={
+ overrideMutationKey ===
+ section.key
+ }
+ >
+ Manage
+
+
+ void clearOverride(
+ section.key,
+ overrideId,
+ )
+ }
+ disabled={
+ overrideMutationKey ===
+ section.key
+ }
+ aria-label={`Clear notification override ${overrideId}`}
+ >
+
+
+
diff --git a/apps/web/src/components/reaction-button.tsx b/apps/web/src/components/reaction-button.tsx
index 26a6f0b..74cb28a 100644
--- a/apps/web/src/components/reaction-button.tsx
+++ b/apps/web/src/components/reaction-button.tsx
@@ -26,7 +26,7 @@ export function ReactionButton({
}: ReactionButtonProps) {
const [loading, setLoading] = useState(false);
const isMountedRef = useRef(true);
-
+
const hasReacted = currentUserId
? reaction.userIds.includes(currentUserId)
: false;
diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx
index b484215..15e3d2b 100644
--- a/apps/web/src/components/ui/dialog.tsx
+++ b/apps/web/src/components/ui/dialog.tsx
@@ -114,8 +114,6 @@ DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
-
-
DialogTrigger,
DialogContent,
DialogHeader,
diff --git a/apps/web/src/components/ui/dropdown-menu.tsx b/apps/web/src/components/ui/dropdown-menu.tsx
index be1511b..e944c58 100644
--- a/apps/web/src/components/ui/dropdown-menu.tsx
+++ b/apps/web/src/components/ui/dropdown-menu.tsx
@@ -1,6 +1,5 @@
"use client";
-
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import type * as React from "react";
diff --git a/apps/web/src/components/user-profile-modal.tsx b/apps/web/src/components/user-profile-modal.tsx
index 49504d4..1e0c398 100644
--- a/apps/web/src/components/user-profile-modal.tsx
+++ b/apps/web/src/components/user-profile-modal.tsx
@@ -47,6 +47,8 @@ const UserProfileSchema = z.object({
status: UserStatusSchema.optional(),
});
+const CompiledUserProfileSchema = z.compile(UserProfileSchema);
+
type UserProfile = z.infer;
type UserProfileModalProps = {
@@ -60,7 +62,7 @@ type UserProfileModalProps = {
};
function isUserProfile(value: unknown): value is UserProfile {
- return UserProfileSchema.safeParse(value).success;
+ return CompiledUserProfileSchema.safeParse(value).success;
}
function getStatusColor(
diff --git a/apps/web/src/hooks/useDebounce.ts b/apps/web/src/hooks/useDebounce.ts
index 0523600..4339612 100644
--- a/apps/web/src/hooks/useDebounce.ts
+++ b/apps/web/src/hooks/useDebounce.ts
@@ -4,7 +4,7 @@ import { useEffect, useRef, useCallback } from "react";
/**
* Debounced batch update hook
* Batches multiple state updates within a time window to reduce re-renders
- *
+ *
* @param callback - Function to call with batched updates
* @param delay - Debounce delay in milliseconds (default: 150ms)
* @returns Function to schedule updates
diff --git a/apps/web/src/lib/appwrite-admin.ts b/apps/web/src/lib/appwrite-admin.ts
index 4c35369..f9ea2ef 100644
--- a/apps/web/src/lib/appwrite-admin.ts
+++ b/apps/web/src/lib/appwrite-admin.ts
@@ -2,7 +2,7 @@ import { AppwriteException, Query } from "node-appwrite";
import { getEnvConfig } from "./appwrite-core";
import { getServerClient } from "./appwrite-server";
-import { logger } from "./newrelic-utils";
+import { logger } from "./posthog-utils";
import type { FileAttachment } from "./types";
/**
diff --git a/apps/web/src/lib/appwrite-announcements.ts b/apps/web/src/lib/appwrite-announcements.ts
index 7475278..ecbda5c 100644
--- a/apps/web/src/lib/appwrite-announcements.ts
+++ b/apps/web/src/lib/appwrite-announcements.ts
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
import { getEnvConfig } from "@/lib/appwrite-core";
import { listPages } from "@/lib/appwrite-pagination";
-import { logger } from "@/lib/newrelic-utils";
+import { logger } from "@/lib/posthog-utils";
import { getServerClient } from "@/lib/appwrite-server";
import type {
Announcement,
diff --git a/apps/web/src/lib/appwrite-audit.ts b/apps/web/src/lib/appwrite-audit.ts
index 4836026..27b2f65 100644
--- a/apps/web/src/lib/appwrite-audit.ts
+++ b/apps/web/src/lib/appwrite-audit.ts
@@ -4,7 +4,7 @@ import { getBrowserDatabases, getEnvConfig } from "./appwrite-core";
import { getServerClient } from "./appwrite-server";
import { getAdminClient } from "./appwrite-admin";
import { getFeatureFlag, FEATURE_FLAGS } from "./feature-flags";
-import { logger } from "./newrelic-utils";
+import { logger } from "./posthog-utils";
/**
* Returns databases.
diff --git a/apps/web/src/lib/appwrite-invites.ts b/apps/web/src/lib/appwrite-invites.ts
index d83ee74..9997f21 100644
--- a/apps/web/src/lib/appwrite-invites.ts
+++ b/apps/web/src/lib/appwrite-invites.ts
@@ -1,7 +1,7 @@
import { ID, Query } from "node-appwrite";
import { createHash } from "node:crypto";
import { nanoid } from "nanoid";
-import { logger } from "@/lib/newrelic-utils";
+import { logger } from "@/lib/posthog-utils";
import { listPages } from "@/lib/appwrite-pagination";
import type { ServerInvite } from "./types";
import { getEnvConfig } from "./appwrite-core";
diff --git a/apps/web/src/lib/appwrite-profiles.ts b/apps/web/src/lib/appwrite-profiles.ts
index 154525e..23c70b5 100644
--- a/apps/web/src/lib/appwrite-profiles.ts
+++ b/apps/web/src/lib/appwrite-profiles.ts
@@ -7,7 +7,7 @@
import { ID, Query } from "node-appwrite";
import { getAdminClient } from "./appwrite-admin";
import { getEnvConfig } from "./appwrite-core";
-import { logger } from "./newrelic-utils";
+import { logger } from "./posthog-utils";
import {
getPresetFrameImageUrl,
getPresetFrameStorageFileId,
@@ -37,6 +37,8 @@ type UserProfile = {
profileBackgroundImageChangedAt?: string;
avatarFramePreset?: string;
dmEncryptionPublicKey?: string;
+ deletedAt?: string;
+ deletedEmail?: string;
$createdAt: string;
$updatedAt: string;
};
@@ -449,6 +451,66 @@ export async function deleteAvatarFile(fileId: string): Promise {
}
}
+/**
+ * Converts a profile into a permanent "Deleted User" tombstone kept for the
+ * account. Keeps the userId claimed forever so the account ID can't
+ * be reused by a future signup.
+ *
+ * @param {UserProfile | null} profile - The profile doc to tombstone.
+ * @param {string} email - The account email at deletion time, recorded for audit.
+ * @param {string} userId - The userId used to create a tombstone if no profile exists yet.
+ * @returns {Promise} The return value.
+ */
+export async function tombstoneUserProfile(
+ profile: UserProfile | null,
+ userId: string,
+ email: string,
+): Promise {
+ const { databases } = getAdminClient();
+ const env = getEnvConfig();
+ const now = new Date().toISOString();
+
+ if (profile) {
+ await databases.updateDocument(
+ env.databaseId,
+ env.collections.profiles,
+ profile.$id,
+ {
+ userName: null,
+ displayName: "Deleted User",
+ bio: null,
+ pronouns: null,
+ avatarFileId: null,
+ location: null,
+ website: null,
+ profileBackgroundColor: null,
+ profileBackgroundGradient: null,
+ profileBackgroundImageFileId: null,
+ profileBackgroundImageChangedAt: null,
+ avatarFramePreset: null,
+ dmEncryptionPublicKey: null,
+ deletedAt: now,
+ deletedEmail: email,
+ },
+ );
+ invalidateProfileCache(profile.$id, userId);
+ return;
+ }
+
+ await databases.createDocument(
+ env.databaseId,
+ env.collections.profiles,
+ ID.unique(),
+ {
+ userId,
+ displayName: "Deleted User",
+ deletedAt: now,
+ deletedEmail: email,
+ },
+ );
+ invalidateProfileCache(undefined, userId);
+}
+
/**
* Delete a user's profile background image file
*
diff --git a/apps/web/src/lib/appwrite-reports.ts b/apps/web/src/lib/appwrite-reports.ts
index 607cc7f..0ed249d 100644
--- a/apps/web/src/lib/appwrite-reports.ts
+++ b/apps/web/src/lib/appwrite-reports.ts
@@ -1,6 +1,6 @@
import { ID, Permission, Query, Role } from "node-appwrite";
-import { logger } from "@/lib/newrelic-utils";
+import { logger } from "@/lib/posthog-utils";
import { getEnvConfig } from "./appwrite-core";
import { getServerClient } from "./appwrite-server";
diff --git a/apps/web/src/lib/appwrite-servers.ts b/apps/web/src/lib/appwrite-servers.ts
index 2234569..4068b52 100644
--- a/apps/web/src/lib/appwrite-servers.ts
+++ b/apps/web/src/lib/appwrite-servers.ts
@@ -12,7 +12,7 @@ import {
getActualMemberCount,
getActualMemberCounts,
} from "./membership-count";
-import { logger } from "./newrelic-utils";
+import { logger } from "./posthog-utils";
import {
mapServerDocument,
normalizeServerDescription,
diff --git a/apps/web/src/lib/auth-server.ts b/apps/web/src/lib/auth-server.ts
index d4f6f6e..79ebb3c 100644
--- a/apps/web/src/lib/auth-server.ts
+++ b/apps/web/src/lib/auth-server.ts
@@ -379,6 +379,20 @@ export async function checkUserRoles(userId: string) {
return getUserRoles(userId);
}
+/**
+ * Returns the raw session cookie secret, or null if there is no session
+ * cookie. Used to drive session-scoped Account calls (e.g. listSessions).
+ */
+export async function getSessionTokenFromCookie(): Promise {
+ try {
+ const env = getEnvConfig();
+ const cookieStore = await cookies();
+ return cookieStore.get(`a_session_${env.project}`)?.value ?? null;
+ } catch {
+ return null;
+ }
+}
+
/**
* Require authentication - throws if no session.
* @returns {Promise<{ $id: string; name: string; email: string; $createdAt?: string; }>} The return value.
diff --git a/apps/web/src/lib/client-logger.ts b/apps/web/src/lib/client-logger.ts
index 49016a8..b5bb5d0 100644
--- a/apps/web/src/lib/client-logger.ts
+++ b/apps/web/src/lib/client-logger.ts
@@ -1,17 +1,9 @@
/**
- * Client-side logger with telemetry routing.
- * Dispatches events to browser New Relic and/or PostHog
- * based on NEXT_PUBLIC_TELEMETRY_PROVIDER.
- * Falls back to console in development.
+ * Client-side logger with PostHog routing.
+ * Sends events to PostHog (when hydrated in the browser) and falls back
+ * to console in development.
*/
-type ClientTelemetryProvider = "newrelic" | "posthog" | "both" | "none";
-
-type BrowserNewRelic = {
- addPageAction: (name: string, attrs?: Record) => void;
- noticeError: (error: Error, attrs?: Record) => void;
-};
-
type BrowserPostHog = {
capture: (event: string, properties?: Record) => void;
captureException?: (
@@ -20,45 +12,6 @@ type BrowserPostHog = {
) => void;
};
-function getClientTelemetryProvider(): ClientTelemetryProvider {
- const rawProvider =
- process.env.NEXT_PUBLIC_TELEMETRY_PROVIDER?.toLowerCase();
- if (
- rawProvider === "newrelic" ||
- rawProvider === "posthog" ||
- rawProvider === "both" ||
- rawProvider === "none"
- ) {
- return rawProvider;
- }
-
- return "newrelic";
-}
-
-function shouldSendToNewRelic() {
- const provider = getClientTelemetryProvider();
- return provider === "newrelic" || provider === "both";
-}
-
-function shouldSendToPostHog() {
- const provider = getClientTelemetryProvider();
- return provider === "posthog" || provider === "both";
-}
-
-function getBrowserNewRelic(): BrowserNewRelic | null {
- if (typeof window === "undefined") {
- return null;
- }
-
- return (
- (
- window as unknown as {
- newrelic?: BrowserNewRelic;
- }
- ).newrelic ?? null
- );
-}
-
function getBrowserPostHog(): BrowserPostHog | null {
if (typeof window === "undefined") {
return null;
@@ -77,13 +30,8 @@ export function recordClientAction(
action: string,
attributes?: Record,
) {
- const newrelic = getBrowserNewRelic();
- if (shouldSendToNewRelic() && newrelic) {
- newrelic.addPageAction(action, attributes);
- }
-
const posthog = getBrowserPostHog();
- if (shouldSendToPostHog() && posthog) {
+ if (posthog) {
posthog.capture(action, attributes);
}
}
@@ -92,23 +40,20 @@ export function recordClientError(
error: Error,
attributes?: Record,
) {
- const newrelic = getBrowserNewRelic();
- if (shouldSendToNewRelic() && newrelic) {
- newrelic.noticeError(error, attributes);
+ const posthog = getBrowserPostHog();
+ if (!posthog) {
+ return;
}
- const posthog = getBrowserPostHog();
- if (shouldSendToPostHog() && posthog) {
- if (posthog.captureException) {
- posthog.captureException(error, attributes);
- } else {
- posthog.capture("client_error", {
- errorMessage: error.message,
- errorName: error.name,
- errorStack: error.stack,
- ...attributes,
- });
- }
+ if (posthog.captureException) {
+ posthog.captureException(error, attributes);
+ } else {
+ posthog.capture("client_error", {
+ errorMessage: error.message,
+ errorName: error.name,
+ errorStack: error.stack,
+ ...attributes,
+ });
}
}
@@ -164,4 +109,4 @@ class ClientLogger {
}
}
-export const logger = new ClientLogger();
+export const logger = new ClientLogger();
\ No newline at end of file
diff --git a/apps/web/src/lib/default-role.ts b/apps/web/src/lib/default-role.ts
index e06b065..12673c9 100644
--- a/apps/web/src/lib/default-role.ts
+++ b/apps/web/src/lib/default-role.ts
@@ -5,7 +5,7 @@ import { getEnvConfig } from "./appwrite-core";
import { getServerClient } from "./appwrite-server";
import { getBrowserDatabases } from "./appwrite-core";
import { listPages } from "./appwrite-pagination";
-import { logger } from "./newrelic-utils";
+import { logger } from "./posthog-utils";
const ROLES_COLLECTION_ID = "roles";
const ROLE_ASSIGNMENTS_COLLECTION_ID = "role_assignments";
diff --git a/apps/web/src/lib/feature-flags.ts b/apps/web/src/lib/feature-flags.ts
index 6913aaa..ecdd4a9 100644
--- a/apps/web/src/lib/feature-flags.ts
+++ b/apps/web/src/lib/feature-flags.ts
@@ -8,7 +8,7 @@ import {
getFeatureFlagDescription,
type FeatureFlagKey,
} from "./feature-flags-definitions";
-import { logger } from "./newrelic-utils";
+import { logger } from "./posthog-utils";
import { getServerClient } from "./appwrite-server";
import type { FeatureFlag } from "./types";
diff --git a/apps/web/src/lib/inbox.ts b/apps/web/src/lib/inbox.ts
index eda770d..584dd2a 100644
--- a/apps/web/src/lib/inbox.ts
+++ b/apps/web/src/lib/inbox.ts
@@ -5,7 +5,7 @@ import { getEnvConfig } from "@/lib/appwrite-core";
import { getAvatarUrl } from "@/lib/appwrite-profiles";
import { listPages } from "@/lib/appwrite-pagination";
import { getServerClient } from "@/lib/appwrite-server";
-import { logger, recordEvent, recordMetric } from "@/lib/newrelic-utils";
+import { logger, recordEvent, recordMetric } from "@/lib/posthog-utils";
import {
getEffectiveNotificationLevel,
getNotificationSettings,
diff --git a/apps/web/src/lib/membership-count.ts b/apps/web/src/lib/membership-count.ts
index bb57863..7e13b17 100644
--- a/apps/web/src/lib/membership-count.ts
+++ b/apps/web/src/lib/membership-count.ts
@@ -1,7 +1,7 @@
import { Query } from "appwrite";
import { getEnvConfig } from "./appwrite-core";
import { listPages, chunkValues } from "./appwrite-pagination";
-import { logger } from "@/lib/newrelic-utils";
+import { logger } from "@/lib/posthog-utils";
type MemberCountDatabases = {
listDocuments: {
diff --git a/apps/web/src/lib/notification-settings.ts b/apps/web/src/lib/notification-settings.ts
index 1d78a25..db48237 100644
--- a/apps/web/src/lib/notification-settings.ts
+++ b/apps/web/src/lib/notification-settings.ts
@@ -7,7 +7,7 @@ import { ID, Query } from "node-appwrite";
import { getAdminClient } from "./appwrite-admin";
import { getEnvConfig, perms } from "./appwrite-core";
import { apiCache } from "./cache-utils";
-import { logger } from "./newrelic-utils";
+import { logger } from "./posthog-utils";
import type {
Conversation,
NotificationSettings,
diff --git a/apps/web/src/lib/polls-server.ts b/apps/web/src/lib/polls-server.ts
index 69e64f0..1bf8569 100644
--- a/apps/web/src/lib/polls-server.ts
+++ b/apps/web/src/lib/polls-server.ts
@@ -2,7 +2,7 @@ import { Query } from "node-appwrite";
import type { Databases } from "node-appwrite";
import type { EnvConfig } from "@/lib/appwrite-core";
-import { logger } from "@/lib/newrelic-utils";
+import { logger } from "@/lib/posthog-utils";
import { chunkValues, listPages } from "@/lib/appwrite-pagination";
import {
buildMessagePoll,
diff --git a/apps/web/src/lib/newrelic-utils.ts b/apps/web/src/lib/posthog-utils.ts
similarity index 68%
rename from apps/web/src/lib/newrelic-utils.ts
rename to apps/web/src/lib/posthog-utils.ts
index 378838c..cec7064 100644
--- a/apps/web/src/lib/newrelic-utils.ts
+++ b/apps/web/src/lib/posthog-utils.ts
@@ -1,8 +1,8 @@
/**
- * New Relic Utilities
+ * PostHog utilities.
*
- * Comprehensive utilities for logging, error tracking, and custom instrumentation
- * with New Relic APM.
+ * Server-side logging, error tracking, and event capture for Firepit's
+ * PostHog instance. Single telemetry provider: PostHog.
*/
import { NextResponse } from "next/server";
@@ -19,8 +19,8 @@ import {
import { PostHog } from "posthog-node";
-// Inlined from posthog-logs.ts — OTLP log pipeline to PostHog.
-// Resolved lazily so importing this module touches no env or telemetry state.
+// OTLP log pipeline to PostHog. Resolved lazily so importing this module
+// touches no env or telemetry state.
function getPostHogLogsConfig() {
const token =
process.env.POSTHOG_PROJECT_API_KEY ??
@@ -37,7 +37,6 @@ function getPostHogLogsConfig() {
};
}
-let otlpLogExporter: OTLPLogExporter | null = null;
let loggerProvider: LoggerProvider | null = null;
let serverLogger: Logger | null = null;
@@ -74,7 +73,6 @@ function getLoggerProvider(): LoggerProvider | null {
],
});
- otlpLogExporter = exporter;
loggerProvider = provider;
return provider;
}
@@ -143,6 +141,67 @@ export function registerPostHogLoggerProvider() {
}
}
+const SENSITIVE_ATTRIBUTE_KEYS = new Set([
+ "email",
+ "token",
+ "api_key",
+ "apikey",
+ "api_secret",
+ "secret",
+ "password",
+ "passphrase",
+ "authorization",
+ "cookie",
+ "set-cookie",
+ "session_id",
+ "session",
+ "ip_address",
+ "request_body",
+]);
+
+function isSensitiveAttributeKey(key: string): boolean {
+ const normalized = key.toLowerCase().replace(/\s+/g, "_");
+ if (SENSITIVE_ATTRIBUTE_KEYS.has(normalized)) {
+ return true;
+ }
+ return (
+ normalized.includes("token") ||
+ normalized.includes("secret") ||
+ normalized.includes("password") ||
+ normalized.includes("authorization") ||
+ normalized.includes("cookie") ||
+ normalized === "ip" ||
+ normalized.includes("ip_address") ||
+ normalized.endsWith("_ip")
+ );
+}
+
+function redactValue(key: string, value: unknown): unknown {
+ if (isSensitiveAttributeKey(key)) {
+ return "[REDACTED]";
+ }
+ if (Array.isArray(value)) {
+ return value.map((item, index) => redactValue(String(index), item));
+ }
+ if (value && typeof value === "object") {
+ return redactAttributes(value as Record);
+ }
+ return value;
+}
+
+function redactAttributes(
+ attributes?: Record,
+): Record | undefined {
+ if (!attributes) {
+ return undefined;
+ }
+ const redacted: Record = {};
+ for (const [key, value] of Object.entries(attributes)) {
+ redacted[key] = redactValue(key, value);
+ }
+ return redacted;
+}
+
function emitPostHogLog(params: {
body: string;
severityNumber: SeverityNumber;
@@ -197,8 +256,6 @@ function schedulePostHogLogFlush() {
}
}
-// Inlined from posthog-server.ts — PostHog Node client singleton.
-
type PostHogShim = {
capture: (...args: Parameters) => void;
captureException: (
@@ -219,24 +276,32 @@ function createNoOpShim(): PostHogShim {
let posthogClient: PostHog | PostHogShim | null = null;
-function toError(value: unknown): Error {
- if (value instanceof Error) {
- return value;
+// ponytail: test-only reset for the PostHog singleton. No-op in production.
+export function __resetPostHogClient() {
+ if (process.env.NODE_ENV === "production") {
+ return;
}
- return new Error(typeof value === "string" ? value : String(value));
+ posthogClient = null;
}
-function toErrorMetadata(value: unknown) {
- if (value instanceof Error) {
- return {
- errorMessage: value.message,
- errorName: value.name,
- errorStack: value.stack,
- };
+function hasPostHogCredentials() {
+ const projectToken =
+ process.env.POSTHOG_PROJECT_API_KEY ??
+ process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN;
+
+ return Boolean(projectToken);
+}
+
+function shouldSendToPostHog() {
+ if (process.env.NODE_ENV === "test") {
+ return process.env.ENABLE_POSTHOG_IN_TESTS === "true";
}
- return {
- errorMessage: typeof value === "string" ? value : String(value),
- };
+
+ if (typeof window !== "undefined") {
+ return false;
+ }
+
+ return hasPostHogCredentials();
}
export function getPostHogClient() {
@@ -287,6 +352,26 @@ function schedulePostHogClientFlush() {
}
}
+function toError(value: unknown): Error {
+ if (value instanceof Error) {
+ return value;
+ }
+ return new Error(typeof value === "string" ? value : String(value));
+}
+
+function toErrorMetadata(value: unknown) {
+ if (value instanceof Error) {
+ return {
+ errorMessage: value.message,
+ errorName: value.name,
+ errorStack: value.stack,
+ };
+ }
+ return {
+ errorMessage: typeof value === "string" ? value : String(value),
+ };
+}
+
function capturePostHogServerError(
error: unknown,
properties?: Record,
@@ -311,14 +396,6 @@ const capturedUnhandledRejectionErrors = new WeakSet();
const POSTHOG_FLUSH_TIMEOUT_MS = 5_000;
-// ponytail: test-only reset for the PostHog singleton. No-op in production.
-export function __resetPostHogClient() {
- if (process.env.NODE_ENV === "production") {
- return;
- }
- posthogClient = null;
-}
-
export function registerPostHogProcessHandlers() {
if (posthogProcessHandlersRegistered || process.env.NODE_ENV === "test") {
return;
@@ -331,9 +408,8 @@ export function registerPostHogProcessHandlers() {
return;
}
- const errorObj = toError(error);
try {
- getPostHogClient().captureException(errorObj, "server", {
+ getPostHogClient().captureException(toError(error), "server", {
origin: `uncaught_exception:${origin}`,
...toErrorMetadata(error),
});
@@ -394,164 +470,6 @@ export function registerPostHogProcessHandlers() {
});
}
-type NewRelicAgent = {
- recordCustomEvent: (
- _eventType: string,
- _attributes: Record,
- ) => void;
- recordMetric: (_name: string, _value: number) => void;
- incrementMetric: (_name: string, _value?: number) => void;
- noticeError: (
- _error: Error | string,
- _customAttributes?: Record,
- ) => void;
- addCustomAttribute: (
- _key: string,
- _value: string | number | boolean,
- ) => void;
- addCustomAttributes: (
- _attributes: Record,
- ) => void;
- setTransactionName: (_name: string) => void;
- getTransaction: () => Transaction | null;
- startBackgroundTransaction: (
- _name: string,
- _group: string | null,
- _handle: () => void,
- ) => void;
- startWebTransaction: (_url: string, _handle: () => void) => void;
- endTransaction: () => void;
- getBrowserTimingHeader: () => string;
- setLlmTokenCountCallback: (
- _callback: (_model: string, _content: string) => number,
- ) => void;
-};
-
-type Transaction = {
- end: () => void;
- ignore: () => void;
- acceptDistributedTraceHeaders: (
- _transportType: string,
- _headers: Record,
- ) => void;
- insertDistributedTraceHeaders: (_headers: Record) => void;
-};
-
-type TelemetryProvider = "newrelic" | "posthog" | "both" | "none";
-
-let newrelic: NewRelicAgent | null = null;
-
-function getTelemetryProvider(): TelemetryProvider {
- const rawProvider = process.env.TELEMETRY_PROVIDER?.toLowerCase();
- if (
- rawProvider === "newrelic" ||
- rawProvider === "posthog" ||
- rawProvider === "both" ||
- rawProvider === "none"
- ) {
- return rawProvider;
- }
-
- if (rawProvider) {
- console.warn(
- `[telemetry] Unrecognized TELEMETRY_PROVIDER "${rawProvider}", falling back to "newrelic"`,
- );
- }
-
- return "newrelic";
-}
-
-function shouldSendToNewRelic() {
- const provider = getTelemetryProvider();
- return provider === "newrelic" || provider === "both";
-}
-
-function hasPostHogCredentials() {
- const projectToken =
- process.env.POSTHOG_PROJECT_API_KEY ??
- process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN;
-
- return Boolean(projectToken);
-}
-
-function shouldSendToPostHog() {
- if (process.env.NODE_ENV === "test") {
- return process.env.ENABLE_POSTHOG_IN_TESTS === "true";
- }
-
- if (typeof window !== "undefined") {
- return false;
- }
-
- const provider = getTelemetryProvider();
- if (provider !== "posthog" && provider !== "both") {
- return false;
- }
-
- return hasPostHogCredentials();
-}
-
-const SENSITIVE_ATTRIBUTE_KEYS = new Set([
- "email",
- "token",
- "api_key",
- "apikey",
- "api_secret",
- "secret",
- "password",
- "passphrase",
- "authorization",
- "cookie",
- "set-cookie",
- "session_id",
- "session",
- "ip_address",
- "request_body",
-]);
-
-function isSensitiveAttributeKey(key: string): boolean {
- const normalized = key.toLowerCase().replace(/\s+/g, "_");
- if (SENSITIVE_ATTRIBUTE_KEYS.has(normalized)) {
- return true;
- }
- return (
- normalized.includes("token") ||
- normalized.includes("secret") ||
- normalized.includes("password") ||
- normalized.includes("authorization") ||
- normalized.includes("cookie") ||
- normalized === "ip" ||
- normalized.includes("ip_address") ||
- normalized.endsWith("_ip")
- );
-}
-
-function redactValue(key: string, value: unknown): unknown {
- if (isSensitiveAttributeKey(key)) {
- return "[REDACTED]";
- }
- if (Array.isArray(value)) {
- return value.map((item, index) => redactValue(String(index), item));
- }
- if (value && typeof value === "object") {
- return redactAttributes(value as Record);
- }
- return value;
-}
-
-function redactAttributes(
- attributes?: Record,
-): Record | undefined {
- if (!attributes) {
- return undefined;
- }
- const redacted: Record = {};
- for (const [key, value] of Object.entries(attributes)) {
- redacted[key] = redactValue(key, value);
- }
- return redacted;
-}
-
function getDistinctId(attributes?: Record) {
const candidate =
attributes?.distinctId ??
@@ -623,51 +541,6 @@ function capturePostHogEvent(
}
}
-function getNewRelicForDispatch() {
- return getNewRelicSync();
-}
-
-let newrelicInitPromise: Promise | null = null;
-
-/**
- * Initialize New Relic (should be called once by instrumentation.ts at startup)
- * @returns {Promise} The return value.
- */
-export async function initNewRelic(): Promise {
- if (typeof window !== "undefined") {
- // New Relic doesn't run in the browser (only server-side)
- return null;
- }
-
- if (newrelic) {
- return newrelic;
- }
-
- if (!newrelicInitPromise) {
- newrelicInitPromise = (async () => {
- try {
- // Dynamic import for New Relic (server-side only)
- const nr = await import("newrelic");
- newrelic = nr.default as NewRelicAgent;
- return newrelic;
- } catch {
- // New Relic not available (development mode or not configured)
- return null;
- }
- })();
- }
-
- return newrelicInitPromise;
-}
-
-/**
- * Get the New Relic agent instance synchronously (may return null if not initialized)
- * @returns {NewRelicAgent | null} The return value.
- */
-function getNewRelicSync(): NewRelicAgent | null {
- return newrelic;
-}
-
/**
* Log levels for structured logging
*/
@@ -698,30 +571,13 @@ const severityByLevel: Record = {
};
/**
- * Structured log entry (for internal use)
- */
-type _LogEntry = {
- level: LogLevelType;
- message: string;
- timestamp: string;
- attributes?: Record;
-};
-
-/**
- * Log a message with New Relic
- * In production, this forwards to New Relic. In development, it also logs to console.
- *
- * @param {'debug' | 'info' | 'warn' | 'error'} level - The level value.
- * @param {string} message - The message value.
- * @param {Record | undefined} attributes - The attributes value, if provided.
- * @returns {void} The return value.
+ * Log a structured message to PostHog (and console outside production).
*/
function log(
level: LogLevelType,
message: string,
attributes?: Record,
) {
- // Console logging (development and as fallback)
if (process.env.NODE_ENV !== "production") {
consoleMethodByLevel[level](
`[${String(level).toUpperCase()}]`,
@@ -744,17 +600,6 @@ function log(
});
schedulePostHogLogFlush();
- // New Relic custom event
- const nr = getNewRelicForDispatch();
- if (shouldSendToNewRelic() && nr) {
- nr.recordCustomEvent("ApplicationLog", {
- level,
- message,
- timestamp,
- ...attributes,
- });
- }
-
capturePostHogEvent("application_log", {
level,
message,
@@ -781,17 +626,12 @@ export const logger = {
};
/**
- * Record an error with New Relic
- *
- * @param {string | Error} error - The error value.
- * @param {Record | undefined} customAttributes - The custom attributes value, if provided.
- * @returns {void} The return value.
+ * Record an error with PostHog
*/
export function recordError(
error: Error | string,
customAttributes?: Record,
) {
- // Console error as fallback (development only)
if (process.env.NODE_ENV !== "production") {
console.error("[ERROR]", error, customAttributes || "");
}
@@ -811,110 +651,33 @@ export function recordError(
});
schedulePostHogLogFlush();
- const nr = getNewRelicForDispatch();
- if (shouldSendToNewRelic() && nr) {
- nr.noticeError(errorObject, customAttributes);
- }
-
if (shouldSendToPostHog()) {
capturePostHogServerError(errorObject, customAttributes);
}
}
/**
- * Record a custom event in New Relic
- *
- * @param {string} eventType - The event type value.
- * @param {{ [x: string]: unknown; }} attributes - The attributes value.
- * @returns {void} The return value.
+ * Record a custom event in PostHog
*/
export function recordEvent(
eventType: string,
attributes: Record,
) {
- const nr = getNewRelicForDispatch();
- if (shouldSendToNewRelic() && nr) {
- nr.recordCustomEvent(eventType, attributes);
- }
-
capturePostHogEvent(eventType, attributes);
}
/**
- * Record a custom metric in New Relic
- *
- * @param {string} name - The name value.
- * @param {number} value - The value value.
- * @returns {void} The return value.
+ * Record a custom metric in PostHog
*/
export function recordMetric(name: string, value: number) {
- const nr = getNewRelicForDispatch();
- if (shouldSendToNewRelic() && nr) {
- nr.recordMetric(name, value);
- }
-
capturePostHogEvent("metric_recorded", {
metricName: name,
value,
});
}
-/**
- * Increment a counter metric in New Relic
- *
- * @param {string} name - The name value.
- * @param {number} value - The value value, if provided.
- * @returns {void} The return value.
- */
-function incrementMetric(name: string, value = 1) {
- const nr = getNewRelicForDispatch();
- if (shouldSendToNewRelic() && nr) {
- nr.incrementMetric(name, value);
- }
-
- capturePostHogEvent("metric_incremented", {
- metricName: name,
- incrementBy: value,
- });
-}
-
-/**
- * Add custom attributes to the current transaction
- *
- * @param {{ [x: string]: string | number | boolean; }} attributes - The attributes value.
- * @returns {void} The return value.
- */
-export function addTransactionAttributes(
- attributes: Record,
-) {
- const nr = getNewRelicForDispatch();
- if (shouldSendToNewRelic() && nr) {
- nr.addCustomAttributes(attributes);
- }
-}
-
-/**
- * Set the transaction name for better organization in New Relic
- *
- * @param {string} name - The name value.
- * @returns {void} The return value.
- */
-export function setTransactionName(name: string) {
- const nr = getNewRelicForDispatch();
- if (shouldSendToNewRelic() && nr) {
- nr.setTransactionName(name);
- }
-}
-
/**
* Track API endpoint performance
- *
- * @param {string} endpoint - The endpoint value.
- * @param {string} method - The method value.
- * @param {number} statusCode - The status code value.
- * @param {number} duration - The duration value.
- * @param {Record | undefined} attributes - The attributes value, if provided.
- * @returns {void} The return value.
*/
export function trackApiCall(
endpoint: string,
@@ -937,11 +700,6 @@ export function trackApiCall(
/**
* Track message events
- *
- * @param {'sent' | 'edited' | 'deleted'} type - The type value.
- * @param {'channel' | 'dm'} channelType - The channel type value.
- * @param {Record | undefined} attributes - The attributes value, if provided.
- * @returns {void} The return value.
*/
export function trackMessage(
type: "sent" | "edited" | "deleted",
@@ -953,16 +711,10 @@ export function trackMessage(
channelType,
...attributes,
});
-
- incrementMetric(`Custom/Message/${type}/${channelType}`);
}
/**
* Return a 401 Unauthorized response with logging
- * Use this instead of direct NextResponse.json() for auth failures
- *
- * @param {Record | undefined} attributes - Additional attributes to log
- * @returns {NextResponse} The return value.
*/
export function returnUnauthorized(attributes?: Record) {
logger.warn("Unauthorized request", attributes);
@@ -974,14 +726,8 @@ export function returnUnauthorized(attributes?: Record) {
/**
* Return a 403 Forbidden response with logging
- * Use this instead of direct NextResponse.json() for permission failures
- *
- * @param {Record | undefined} attributes - Additional attributes to log
- * @returns {NextResponse} The return value.
*/
export function returnForbidden(attributes?: Record) {
logger.warn("Forbidden request", attributes);
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
-}
-
-
+}
\ No newline at end of file
diff --git a/apps/web/src/lib/push-notifications.ts b/apps/web/src/lib/push-notifications.ts
index 8d60ad8..1d46117 100644
--- a/apps/web/src/lib/push-notifications.ts
+++ b/apps/web/src/lib/push-notifications.ts
@@ -3,7 +3,7 @@ import Expo from "expo-server-sdk";
import { getServerClient } from "@/lib/appwrite-server";
import { getEnvConfig } from "@/lib/appwrite-core";
-import { logger } from "@/lib/newrelic-utils";
+import { logger } from "@/lib/posthog-utils";
export type PushNotificationData = {
type: "message" | "mention" | "dm";
diff --git a/apps/web/src/lib/rate-limit.ts b/apps/web/src/lib/rate-limit.ts
index 89f7f24..4fe325e 100644
--- a/apps/web/src/lib/rate-limit.ts
+++ b/apps/web/src/lib/rate-limit.ts
@@ -1,7 +1,7 @@
import { createHash } from "node:crypto";
import { isIP } from "node:net";
-import { logger } from "./newrelic-utils";
+import { logger } from "./posthog-utils";
interface RateLimitEntry {
count: number;
diff --git a/apps/web/src/lib/signup-policy.ts b/apps/web/src/lib/signup-policy.ts
new file mode 100644
index 0000000..8ad8d9e
--- /dev/null
+++ b/apps/web/src/lib/signup-policy.ts
@@ -0,0 +1,225 @@
+// Signup policy + account approval state.
+// Backed by the feature_flags collection (value: "open" | "approval" | "disabled")
+// for the policy, and Appwrite user prefs (approvalStatus) for per-account state.
+// SERVER-ONLY — uses the admin SDK.
+
+import { ID, Query, Users } from "node-appwrite";
+import { unstable_cache, revalidateTag } from "next/cache";
+
+import { getServerClient } from "./appwrite-server";
+import { getEnvConfig } from "./appwrite-core";
+import { logger } from "./posthog-utils";
+
+export type SignupPolicy = "open" | "approval" | "disabled";
+export type ApprovalStatus = "approved" | "pending" | "rejected";
+
+export const SIGNUP_POLICY_KEY = "signup_policy";
+export const SIGNUP_POLICY_DESCRIPTION =
+ "Signup policy: open, individual approval, or signups disabled";
+
+const SIGNUP_POLICIES: SignupPolicy[] = ["open", "approval", "disabled"];
+const DEFAULT_POLICY: SignupPolicy = "open";
+
+export function isSignupPolicy(value: unknown): value is SignupPolicy {
+ return (
+ typeof value === "string" &&
+ (SIGNUP_POLICIES as string[]).includes(value)
+ );
+}
+
+export type PendingSignup = {
+ userId: string;
+ name: string;
+ email: string;
+ createdAt: string;
+};
+
+function normalizePrefs(prefs: unknown): Record {
+ return typeof prefs === "object" && prefs !== null
+ ? (prefs as Record)
+ : {};
+}
+
+async function fetchSignupPolicy(): Promise {
+ try {
+ const { databases } = getServerClient();
+ const env = getEnvConfig();
+ const response = await databases.listDocuments(
+ env.databaseId,
+ env.collections.featureFlags,
+ [Query.equal("key", SIGNUP_POLICY_KEY), Query.limit(1)],
+ );
+ const doc = response.documents[0] as { value?: unknown } | undefined;
+ if (doc && isSignupPolicy(doc.value)) {
+ return doc.value;
+ }
+ } catch (error) {
+ logger.error("Failed to read signup policy", {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+ return DEFAULT_POLICY;
+}
+
+const getCachedSignupPolicy = unstable_cache(
+ fetchSignupPolicy,
+ ["signup-policy"],
+ { revalidate: 60, tags: ["signup-policy"] },
+);
+
+/**
+ * Returns the current instance signup policy.
+ */
+export async function getSignupPolicy(): Promise {
+ try {
+ return await getCachedSignupPolicy();
+ } catch {
+ return DEFAULT_POLICY;
+ }
+}
+
+/**
+ * Sets the signup policy (admin only). The document is created on first
+ * write if setup hasn't seeded it yet.
+ */
+export async function setSignupPolicy(
+ policy: SignupPolicy,
+ userId: string,
+): Promise {
+ try {
+ const { databases } = getServerClient();
+ const env = getEnvConfig();
+ const now = new Date().toISOString();
+ const response = await databases.listDocuments(
+ env.databaseId,
+ env.collections.featureFlags,
+ [Query.equal("key", SIGNUP_POLICY_KEY), Query.limit(1)],
+ );
+
+ if (response.documents.length === 0) {
+ await databases.createDocument(
+ env.databaseId,
+ env.collections.featureFlags,
+ ID.unique(),
+ {
+ key: SIGNUP_POLICY_KEY,
+ value: policy,
+ enabled: true,
+ description: SIGNUP_POLICY_DESCRIPTION,
+ updatedAt: now,
+ updatedBy: userId,
+ },
+ );
+ } else {
+ await databases.updateDocument(
+ env.databaseId,
+ env.collections.featureFlags,
+ response.documents[0].$id,
+ {
+ value: policy,
+ updatedAt: now,
+ updatedBy: userId,
+ },
+ );
+ }
+
+ clearSignupPolicyCache();
+ return true;
+ } catch (error) {
+ logger.error("Failed to set signup policy", {
+ policy,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ return false;
+ }
+}
+
+export function clearSignupPolicyCache(): void {
+ try {
+ revalidateTag("signup-policy", "max");
+ } catch (error) {
+ logger.warn("Signup policy cache revalidation skipped", {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+}
+
+/**
+ * Reads approval state from user prefs. Missing prefs means the account was
+ * created before approval was required, so it is treated as approved.
+ */
+export function getApprovalStatusFromPrefs(prefs: unknown): ApprovalStatus {
+ const status = normalizePrefs(prefs).approvalStatus;
+ if (status === "pending") return "pending";
+ if (status === "rejected") return "rejected";
+ return "approved";
+}
+
+async function updateUserPrefs(
+ userId: string,
+ patch: Record,
+): Promise {
+ const { client } = getServerClient();
+ const users = new Users(client);
+ const user = await users.get(userId);
+ await users.updatePrefs({
+ userId,
+ prefs: { ...normalizePrefs(user.prefs), ...patch },
+ });
+}
+
+/**
+ * Marks a freshly-registered account as pending admin approval.
+ */
+export async function markSignupPending(userId: string): Promise {
+ await updateUserPrefs(userId, { approvalStatus: "pending" });
+}
+
+/**
+ * Marks a pending account as approved.
+ */
+export async function approveSignup(userId: string): Promise {
+ await updateUserPrefs(userId, { approvalStatus: "approved" });
+}
+
+/**
+ * Lists all accounts awaiting admin approval.
+ *
+ * ponytail: prefs aren't queryable in Appwrite, so this scans all users and
+ * filters in app code. Fine for light-enterprise instances; swap for a
+ * dedicated approvals collection if user counts grow.
+ */
+export async function listPendingSignups(
+ limit = 100,
+): Promise {
+ try {
+ const { client } = getServerClient();
+ const users = new Users(client);
+ const pending: PendingSignup[] = [];
+ let offset = 0;
+ while (offset < limit) {
+ const pageSize = Math.min(100, limit - offset);
+ const page = await users.list({
+ queries: [Query.limit(pageSize), Query.offset(offset)],
+ });
+ for (const user of page.users ?? []) {
+ if (getApprovalStatusFromPrefs(user.prefs) === "pending") {
+ pending.push({
+ userId: user.$id,
+ name: user.name,
+ email: user.email,
+ createdAt: user.$createdAt ?? "",
+ });
+ }
+ }
+ offset += page.users.length;
+ if (page.users.length < pageSize) break;
+ }
+ return pending;
+ } catch (error) {
+ logger.error("Failed to list pending signups", {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ return [];
+ }
+}
\ No newline at end of file
diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts
index cd585c0..ac73352 100644
--- a/apps/web/src/lib/types.ts
+++ b/apps/web/src/lib/types.ts
@@ -198,6 +198,8 @@ export type FeatureFlag = {
description?: string;
updatedAt?: string;
updatedBy?: string;
+ // Optional string value for flags that carry a state (e.g. signup policy).
+ value?: string;
};
const ANNOUNCEMENT_PRIORITY_VALUES = ["normal", "urgent"] as const;
@@ -492,6 +494,10 @@ export type UserProfileData = {
profileBackgroundImageFileId?: string;
profileBackgroundImageChangedAt?: string;
dmEncryptionPublicKey?: string;
+ // Tombstone: set when the account was deleted (this profile becomes the
+ // "Deleted User" record that keeps the userId from being reused).
+ deletedAt?: string;
+ deletedEmail?: string;
status?: {
status: PresenceStatus;
customMessage?: string;
diff --git a/apps/web/src/proxy.ts b/apps/web/src/proxy.ts
index ffc5426..e64b8c1 100644
--- a/apps/web/src/proxy.ts
+++ b/apps/web/src/proxy.ts
@@ -9,6 +9,7 @@ const PUBLIC_ROUTES = [
"/",
"/login",
"/register",
+ "/reset-password",
"/docs",
"/manifest.json",
"/manifest.webmanifest",
diff --git a/bun.lock b/bun.lock
index 2109f98..5a551a7 100644
--- a/bun.lock
+++ b/bun.lock
@@ -45,7 +45,7 @@
"expo-status-bar": "~57.0.1",
"expo-symbols": "~57.0.2",
"expo-web-browser": "~57.0.2",
- "lucide-react-native": "^1.35.0",
+ "lucide-react-native": "^1.37.0",
"react": "19.2.3",
"react-native": "0.86.2",
"react-native-appwrite": "^0.34.0",
@@ -73,7 +73,7 @@
},
"apps/web": {
"name": "firepit-web",
- "version": "2.0.3",
+ "version": "2.1.0",
"dependencies": {
"@opentelemetry/api-logs": "^0.221.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.221.0",
@@ -88,7 +88,7 @@
"@radix-ui/react-slot": "^1.3.3",
"@radix-ui/react-switch": "^1.3.7",
"@radix-ui/react-tabs": "^1.1.21",
- "@tanstack/react-query": "^5.101.4",
+ "@tanstack/react-query": "^5.102.8",
"@testing-library/dom": "^10.4.1",
"appwrite": "^26.2.0",
"class-variance-authority": "^0.7.1",
@@ -99,39 +99,39 @@
"libsodium-wrappers": "^0.8.4",
"lucide-react": "^0.554.0",
"nanoid": "^5.1.16",
- "newrelic": "^13.20.0",
"next": "^16.3.3",
"next-themes": "^0.4.6",
"node-appwrite": "^27.1.0",
"node-emoji": "^2.2.0",
- "posthog-js": "^1.417.1",
- "posthog-node": "^5.49.1",
+ "posthog-js": "^1.422.5",
+ "posthog-node": "^5.51.4",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-markdown": "^10.1.0",
- "react-virtuoso": "^4.18.11",
+ "react-virtuoso": "^4.18.12",
"remark-gfm": "^4.0.1",
"server-only": "^0.0.1",
"sonner": "^2.0.8",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0",
"yaml": "^2.9.0",
- "zod": "^4.4.3",
+ "zod": "^4.5.4",
},
"devDependencies": {
"@eslint/js": "^9.39.5",
+ "@happy-dom/global-registrator": "^20.12.0",
"@next/bundle-analyzer": "16.3.1",
- "@posthog/nextjs-config": "^1.9.69",
+ "@posthog/nextjs-config": "^1.10.0",
"@tailwindcss/postcss": "^4.3.3",
"@testing-library/jest-dom": "^6.9.1",
- "@testing-library/react": "^16.3.2",
- "@testing-library/user-event": "^14.6.4",
+ "@testing-library/react": "^16.3.3",
+ "@testing-library/user-event": "^14.6.6",
"@types/jsdom": "^27.0.0",
"@types/node": "^20.19.43",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.4",
- "@typescript-eslint/eslint-plugin": "^8.67.0",
- "@typescript-eslint/parser": "^8.67.0",
+ "@typescript-eslint/eslint-plugin": "^8.68.0",
+ "@typescript-eslint/parser": "^8.68.0",
"@vitejs/plugin-react": "^5.2.0",
"@vitest/coverage-v8": "^3.2.7",
"dotenv": "^17.4.2",
@@ -141,13 +141,13 @@
"eslint-plugin-react-hooks": "^6.1.1",
"eslint-plugin-unused-imports": "^4.4.1",
"globals": "^16.5.0",
- "happy-dom": "^20.11.2",
+ "happy-dom": "^20.12.0",
"jsdom": "^27.4.0",
- "knip": "^6.32.2",
+ "knip": "^6.33.0",
"postcss": "^8.5.26",
"tailwindcss": "^4.3.3",
"typescript": "7.0.2",
- "vitest": "^4.1.10",
+ "vitest": "^4.1.11",
},
},
},
@@ -167,10 +167,6 @@
"@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
- "@apm-js-collab/code-transformer": ["@apm-js-collab/code-transformer@0.13.0", "", { "dependencies": { "@types/estree": "^1.0.8", "astring": "^1.9.0", "esquery": "^1.7.0", "meriyah": "^6.1.4", "semifies": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-JPUR9mNUJV3SP0l6XQ5xGG/3IMOELzNy86vCq/+GOkIUsxEWC6AMIviAQ5sxrfQQEbQofjIzU3kshx4RQnRq7A=="],
-
- "@apm-js-collab/tracing-hooks": ["@apm-js-collab/tracing-hooks@0.7.0", "", { "dependencies": { "@apm-js-collab/code-transformer": "^0.13.0", "debug": "^4.4.1", "module-details-from-path": "^1.0.4" } }, "sha512-ETZbwnF3+nw6ORKW5gQnLyDgvQKg7gmshevAV34a87rQIIJoazZBRnLd8wkBaU4HUru3leAkFCwxGbeksvVKaQ=="],
-
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@4.1.2", "", { "dependencies": { "@csstools/css-calc": "^3.0.0", "@csstools/css-color-parser": "^4.0.1", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0", "lru-cache": "^11.2.5" } }, "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg=="],
"@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@6.8.1", "", { "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.1.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.6" } }, "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ=="],
@@ -323,8 +319,6 @@
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="],
- "@colors/colors": ["@colors/colors@1.6.0", "", {}, "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA=="],
-
"@csstools/color-helpers": ["@csstools/color-helpers@6.1.1", "", {}, "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w=="],
"@csstools/css-calc": ["@csstools/css-calc@3.3.0", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ=="],
@@ -337,8 +331,6 @@
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="],
- "@datadog/pprof": ["@datadog/pprof@5.18.0", "", { "dependencies": { "node-gyp-build": "^4.8.4", "pprof-format": "^2.3.1", "source-map": "^0.8.0" } }, "sha512-KSXy+kD8Ofl7etRL42vHnKjFcHNMwOcC12Il+2HLbekHo3wy6RQ3CFoSTDFU/xqZw8PdcxCJURYF60BuWKegfw=="],
-
"@discoveryjs/json-ext": ["@discoveryjs/json-ext@0.5.7", "", {}, "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw=="],
"@egjs/hammerjs": ["@egjs/hammerjs@2.0.17", "", { "dependencies": { "@types/hammerjs": "^2.0.36" } }, "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A=="],
@@ -449,7 +441,9 @@
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.4", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ=="],
- "@grpc/proto-loader": ["@grpc/proto-loader@0.7.15", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.2.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ=="],
+ "@grpc/proto-loader": ["@grpc/proto-loader@0.8.1", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg=="],
+
+ "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.12.0", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.12.0" } }, "sha512-BUE55Rew3oMwBzwCwmUnV+Oxk51V3xolM39Ts6kGiBXNELjujiESwk9qc99JRr6cVrj3OTd9MJ5zQ2fpn+jy6g=="],
"@hiraku-ai/react-native-emoji-picker": ["@hiraku-ai/react-native-emoji-picker@1.2.4", "", { "peerDependencies": { "@react-native-async-storage/async-storage": ">=1.0.0", "react": ">=16.0.0", "react-native": ">=0.60.0", "react-native-svg": ">=12.0.0" } }, "sha512-JuTJKhQA4y19m0zRDL0gmK9/mz8xGtDVYVwE7LktCG8lgHH/Gs1Mu6CM1JeBp39sPgiEHimUx6EF7Cgfp9iYVw=="],
@@ -545,12 +539,6 @@
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q=="],
- "@newrelic/fn-inspect": ["@newrelic/fn-inspect@4.4.0", "", { "dependencies": { "nan": "^2.22.2", "node-gyp-build": "^4.8.1", "prebuildify": "^6.0.1" } }, "sha512-VgoXZp3zqP1167XvrA772EHDFUNuYGQh14whFq1d2sE6dC3ZL46tXI9JY0yZdAAeyCERi7mhq7CPx9BYiTOl/A=="],
-
- "@newrelic/native-metrics": ["@newrelic/native-metrics@12.0.0", "", { "dependencies": { "nan": "^2.22.2", "node-gyp-build": "^4.8.1", "prebuildify": "^6.0.1" } }, "sha512-l0MTkuazDMaEDWJk4ufHVgco4ssWhk/uSUYTn33dFOHAqpHLyxZHxxzKJb4vyZkhRy11UaKNhmNiAiXH557SJQ=="],
-
- "@newrelic/security-agent": ["@newrelic/security-agent@3.0.4", "", { "dependencies": { "check-disk-space": "^3.4.0", "content-type": "^1.0.5", "fast-safe-stringify": "^2.1.1", "find-package-json": "^1.2.0", "hash.js": "^1.1.7", "html-entities": "^2.3.6", "https-proxy-agent": "^7.0.4", "is-invalid-path": "^1.0.2", "log4js": "^6.9.1", "node-cron": "^4.2.1", "request-ip": "^3.3.0", "ringbufferjs": "^2.0.0", "semver": "^7.5.4", "undici": "^7.19.0", "unescape": "^1.0.1", "unescape-js": "^1.1.4", "ws": "^8.17.1" } }, "sha512-em1kkd08yWh4i7qi7QMzLU3aAb9yg0KbbUwFiGlhgoDPNvEfGCJFGifnERDZzZGJJSfGVegEPQCu7zaZs/hx1g=="],
-
"@next/bundle-analyzer": ["@next/bundle-analyzer@16.3.1", "", { "dependencies": { "webpack-bundle-analyzer": "4.10.1" } }, "sha512-/6XQeYPHM6jF1gTeJ82Mu6yXOHQIFVxzAn3DkPtHDp5v6SDXoCicBMTHloN+2h4WKFCQujSLCgLZHItJ3jN1uw=="],
"@next/env": ["@next/env@16.3.3", "", {}, "sha512-U2eYQRwXj+dsqxV79zFqExDdatnNY/ZWc2nsJU1p/OgT7fd3dXwlF6OjYaFQCfMoeTA19PWq+wVmYgimVA+V+g=="],
@@ -731,8 +719,6 @@
"@posthog/webpack-plugin": ["@posthog/webpack-plugin@1.6.0", "", { "dependencies": { "@posthog/cli": "~0.14.1", "@posthog/core": "^1.48.8", "@posthog/plugin-utils": "^1.2.0" }, "peerDependencies": { "webpack": "^5" } }, "sha512-bzecfl7al1xyzjC/hZZv6j8Q+jBEe5FIY1p6ggmMwl614Np9HjqV0Am18Gdc0ImEaL12iBxbpx7i9zUc3c/+bw=="],
- "@prisma/prisma-fmt-wasm": ["@prisma/prisma-fmt-wasm@4.17.0-16.27eb2449f178cd9fe1a4b892d732cc4795f75085", "", {}, "sha512-zYz3rFwPB82mVlHGknAPdnSY/a308dhPOblxQLcZgZTDRtDXOE1MgxoRAys+jekwR4/bm3+rZDPs1xsFMsPZig=="],
-
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
@@ -1041,8 +1027,6 @@
"@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="],
- "@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="],
-
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
@@ -1115,8 +1099,6 @@
"@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="],
- "@tyriar/fibonacci-heap": ["@tyriar/fibonacci-heap@2.0.9", "", {}, "sha512-bYuSNomfn4hu2tPiDN+JZtnzCpSpbJ/PNeulmocDy3xN2X5OkJL65zo6rPZp65cPPhLF9vfT/dgE+RtFRCSxOA=="],
-
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.3", "", {}, "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg=="],
"@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.12.2", "", { "os": "android", "cpu": "arm" }, "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w=="],
@@ -1223,8 +1205,6 @@
"acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
- "acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="],
-
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"acorn-walk": ["acorn-walk@8.3.5", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw=="],
@@ -1279,8 +1259,6 @@
"ast-v8-to-istanbul": ["ast-v8-to-istanbul@0.3.12", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g=="],
- "astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="],
-
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
@@ -1321,8 +1299,6 @@
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
- "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
-
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
"bplist-creator": ["bplist-creator@0.1.0", "", { "dependencies": { "stream-buffers": "2.2.x" } }, "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg=="],
@@ -1375,10 +1351,6 @@
"character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
- "check-disk-space": ["check-disk-space@3.4.0", "", {}, "sha512-drVkSqfwA+TvuEhFipiR1OC9boEGZL5RrWvVsOthdcvQNXyCCuKkEiTOTXZ7qxSf/GLwq4GvzfrQD/Wz325hgw=="],
-
- "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
-
"chrome-launcher": ["chrome-launcher@0.15.2", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0" }, "bin": { "print-chrome-path": "bin/print-chrome-path.js" } }, "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ=="],
"chrome-trace-event": ["chrome-trace-event@1.0.4", "", {}, "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ=="],
@@ -1387,7 +1359,7 @@
"ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="],
- "cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="],
+ "cjs-module-lexer": ["cjs-module-lexer@2.2.1", "", {}, "sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
@@ -1421,12 +1393,8 @@
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
- "concat-stream": ["concat-stream@2.0.0", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A=="],
-
"connect": ["connect@3.7.0", "", { "dependencies": { "debug": "2.6.9", "finalhandler": "1.1.2", "parseurl": "~1.3.3", "utils-merge": "1.0.1" } }, "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ=="],
- "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
-
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"core-js": ["core-js@3.50.0", "", {}, "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw=="],
@@ -1461,8 +1429,6 @@
"date-fns": ["date-fns@4.4.0", "", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="],
- "date-format": ["date-format@4.0.14", "", {}, "sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg=="],
-
"debounce": ["debounce@1.2.1", "", {}, "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
@@ -1531,8 +1497,6 @@
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
- "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
-
"enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="],
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
@@ -1693,16 +1657,12 @@
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
- "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="],
-
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
- "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="],
-
"fast-uri": ["fast-uri@3.1.5", "", {}, "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw=="],
"fb-dotslash": ["fb-dotslash@0.5.8", "", { "bin": { "dotslash": "bin/dotslash" } }, "sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA=="],
@@ -1713,8 +1673,6 @@
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
- "fecha": ["fecha@4.2.3", "", {}, "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw=="],
-
"fetch-nodeshim": ["fetch-nodeshim@0.4.10", "", {}, "sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w=="],
"fflate": ["fflate@0.4.9", "", {}, "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw=="],
@@ -1727,8 +1685,6 @@
"finalhandler": ["finalhandler@1.1.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "on-finished": "~2.3.0", "parseurl": "~1.3.3", "statuses": "~1.5.0", "unpipe": "~1.0.0" } }, "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA=="],
- "find-package-json": ["find-package-json@1.2.0", "", {}, "sha512-+SOGcLGYDJHtyqHd87ysBhmaeQ95oWspDKnMXBrnQ9Eq4OkLNqejgoaD8xVWu6GPa0B6roa6KinCMEMcVeqONw=="],
-
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"firepit-mobile": ["firepit-mobile@workspace:apps/mobile"],
@@ -1753,10 +1709,6 @@
"fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="],
- "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
-
- "fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
-
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
@@ -1797,7 +1749,7 @@
"gzip-size": ["gzip-size@6.0.0", "", { "dependencies": { "duplexer": "^0.1.2" } }, "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q=="],
- "happy-dom": ["happy-dom@20.11.14", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-iMIMAWOt/D00kFzSCARbY+9ih0VhDzieeEQjTIc4JiF9vIym4ghA9ktz4Rpr1oiPQGh+oeNP314TgnT5D0Venw=="],
+ "happy-dom": ["happy-dom@20.12.0", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-7uMYJu2SEwwL8vVcKp0C0lnt6d2LSGGe+T+oY79PiCJNNSgFpbxW8n5KuzpDQvrU4mt+fYiK1+Jy7Z2v39YR6g=="],
"has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="],
@@ -1811,8 +1763,6 @@
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
- "hash.js": ["hash.js@1.1.7", "", { "dependencies": { "inherits": "^2.0.3", "minimalistic-assert": "^1.0.1" } }, "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA=="],
-
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
"hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
@@ -1831,8 +1781,6 @@
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
- "html-entities": ["html-entities@2.6.0", "", {}, "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ=="],
-
"html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
@@ -1853,7 +1801,7 @@
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
- "import-in-the-middle": ["import-in-the-middle@1.15.0", "", { "dependencies": { "acorn": "^8.14.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^1.2.2", "module-details-from-path": "^1.0.3" } }, "sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA=="],
+ "import-in-the-middle": ["import-in-the-middle@3.3.3", "", { "dependencies": { "cjs-module-lexer": "^2.2.0", "es-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-AiohS3H80sXO6owEltjGX+glb7qXaDhBoJb9XcQVH4UI207xu/bDLUcadVKp7Qe576reg9yr/PXZjV5qx8gfbA=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
@@ -1897,8 +1845,6 @@
"is-document.all": ["is-document.all@1.0.0", "", { "dependencies": { "call-bound": "^1.0.4" } }, "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g=="],
- "is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="],
-
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="],
@@ -1911,8 +1857,6 @@
"is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
- "is-invalid-path": ["is-invalid-path@1.0.2", "", {}, "sha512-6KLcFrPCEP3AFXMfnWrIFkZpYNBVzZAoBJJDEZKtI3LXkaDjM3uFMJQjxiizUuZTZ9Oh9FNv/soXbx5TcpaDmA=="],
-
"is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="],
"is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="],
@@ -1993,12 +1937,8 @@
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
- "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="],
-
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
- "jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
-
"jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
@@ -2061,10 +2001,6 @@
"log-symbols": ["log-symbols@2.2.0", "", { "dependencies": { "chalk": "^2.0.1" } }, "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg=="],
- "log4js": ["log4js@6.9.1", "", { "dependencies": { "date-format": "^4.0.14", "debug": "^4.3.4", "flatted": "^3.2.7", "rfdc": "^1.3.0", "streamroller": "^3.1.5" } }, "sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g=="],
-
- "logform": ["logform@2.7.0", "", { "dependencies": { "@colors/colors": "1.6.0", "@types/triple-beam": "^1.3.2", "fecha": "^4.2.0", "ms": "^2.1.1", "safe-stable-stringify": "^2.3.1", "triple-beam": "^1.3.0" } }, "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ=="],
-
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
@@ -2075,7 +2011,7 @@
"lucide-react": ["lucide-react@0.554.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-St+z29uthEJVx0Is7ellNkgTEhaeSoA42I7JjOCBCrc5X6LYMGSv0P/2uS5HDLTExP5tpiqRD2PyUEOS6s9UXA=="],
- "lucide-react-native": ["lucide-react-native@1.35.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-native": "*", "react-native-svg": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0" } }, "sha512-JMuFruu6QpILw0tMdgYmGfO6PVDd1HcoJfm9EGqiPTkcm64eeCxV8/mlehO+OscTy0RjL+1rm18/F0f7nQfsOQ=="],
+ "lucide-react-native": ["lucide-react-native@1.37.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-native": "*", "react-native-svg": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0" } }, "sha512-1vsejW6lQZzFU4nkym/Khg91LSuENYR+W9SNJRTQ07qYzBaLWdIpp2OjVZJr+vZz3FV5nVpWkJme8xwdanDF2Q=="],
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
@@ -2133,8 +2069,6 @@
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
- "meriyah": ["meriyah@6.1.4", "", {}, "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ=="],
-
"metro": ["metro@0.84.5", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/core": "^7.25.2", "@babel/generator": "^7.29.1", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "accepts": "^2.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^4.4.0", "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "hermes-parser": "0.35.0", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.84.5", "metro-cache": "0.84.5", "metro-cache-key": "0.84.5", "metro-config": "0.84.5", "metro-core": "0.84.5", "metro-file-map": "0.84.5", "metro-resolver": "0.84.5", "metro-runtime": "0.84.5", "metro-source-map": "0.84.5", "metro-symbolicate": "0.84.5", "metro-transform-plugins": "0.84.5", "metro-transform-worker": "0.84.5", "mime-types": "^3.0.1", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", "source-map": "^0.5.6", "throat": "^5.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "bin": { "metro": "src/cli.js" } }, "sha512-r1liLkyFZMVSEMNjU1CJU5pRzs3NdkxHqXS60O25c0rCIqAR+cGk7rPydw/g0WAIKVXojIBIF45yYBPagJGcgw=="],
"metro-babel-transformer": ["metro-babel-transformer@0.84.5", "", { "dependencies": { "@babel/core": "^7.25.2", "flow-enums-runtime": "^0.0.6", "hermes-parser": "0.35.0", "metro-cache-key": "0.84.5", "nullthrows": "^1.1.1" } }, "sha512-2WbHILKMiJUzfdjmGOQOqU1bWi9//gqiclc/tkk/AIsrrVw3efhZ1uhkOwMTxUEPOzqoo091H0olLmVZH5FHGQ=="],
@@ -2231,8 +2165,6 @@
"min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
- "minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="],
-
"minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
@@ -2243,8 +2175,6 @@
"mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="],
- "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
-
"module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="],
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
@@ -2253,8 +2183,6 @@
"multitars": ["multitars@1.0.2", "", {}, "sha512-6GwVw5eLi9sThdtlS4PKwC7yRLaf45pYhIEzKBHdKxi+YOXGKFX8acIniH+Uh/+k9mS2lQOupTccjoe5r0/1IQ=="],
- "nan": ["nan@2.28.0", "", {}, "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ=="],
-
"nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="],
"napi-postinstall": ["napi-postinstall@0.3.4", "", { "bin": { "napi-postinstall": "lib/cli.js" } }, "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ=="],
@@ -2265,18 +2193,12 @@
"neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="],
- "newrelic": ["newrelic@13.20.0", "", { "dependencies": { "@apm-js-collab/tracing-hooks": "^0.7.0", "@grpc/grpc-js": "^1.13.2", "@grpc/proto-loader": "^0.7.5", "@newrelic/security-agent": "^3.0.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.203.0", "@opentelemetry/core": "^2.0.0", "@opentelemetry/exporter-metrics-otlp-proto": "^0.201.1", "@opentelemetry/resources": "^2.0.1", "@opentelemetry/sdk-logs": "^0.203.0", "@opentelemetry/sdk-metrics": "^2.0.1", "@opentelemetry/sdk-trace-base": "^2.0.0", "@tyriar/fibonacci-heap": "^2.0.7", "concat-stream": "^2.0.0", "https-proxy-agent": "^7.0.1", "import-in-the-middle": "^1.13.0", "json-bigint": "^1.0.0", "json-stringify-safe": "^5.0.0", "module-details-from-path": "^1.0.3", "readable-stream": "^3.6.1", "require-in-the-middle": "^7.4.0", "semver": "^7.5.2", "winston-transport": "^4.5.0" }, "optionalDependencies": { "@datadog/pprof": "^5.13.3", "@newrelic/fn-inspect": "^4.4.0", "@newrelic/native-metrics": "^12.0.0", "@prisma/prisma-fmt-wasm": "^4.17.0-16.27eb2449f178cd9fe1a4b892d732cc4795f75085" }, "bin": { "newrelic-naming-rules": "bin/test-naming-rules.js" } }, "sha512-+sk27ouQgGcR/Gqb+495whGAOTthCwwmNoFnqDMWwUM12u5qLOdw3QplAIN7VkcH6I4O1Vr4dlypnHKeV5CTEw=="],
-
"next": ["next@16.3.3", "", { "dependencies": { "@next/env": "16.3.3", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.3", "@next/swc-darwin-x64": "16.3.3", "@next/swc-linux-arm64-gnu": "16.3.3", "@next/swc-linux-arm64-musl": "16.3.3", "@next/swc-linux-x64-gnu": "16.3.3", "@next/swc-linux-x64-musl": "16.3.3", "@next/swc-win32-arm64-msvc": "16.3.3", "@next/swc-win32-x64-msvc": "16.3.3", "sharp": "^0.35.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-tuRTx1nQ/yVw83cwJBo9F+njGUgMn3UHQycreWHB8XsStvvAh1AthbI8/4IpKnFaF58F+iSiHejYOlMQ/eq83g=="],
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
- "node-abi": ["node-abi@3.94.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g=="],
-
"node-appwrite": ["node-appwrite@27.1.0", "", { "dependencies": { "json-bigint": "1.0.0", "undici": "^6.27.0" } }, "sha512-YUxhpqAcTryaO7jSo6CKBqTpBD3U7bPhEGjzoEWX5kSZGHnkQ0s3iJn5dUfJ4xID+afH4/j+8bU3Cacs5gbjXw=="],
- "node-cron": ["node-cron@4.6.0", "", {}, "sha512-Si/bzYiKRHOB8/a99T2+SDGN582ONDMSTlJr5oCkT6GtnqPjZ2s10eoQRYkW9ZHwjVxONL+W8Fb+qR0AHMQsdg=="],
-
"node-emoji": ["node-emoji@2.2.0", "", { "dependencies": { "@sindresorhus/is": "^4.6.0", "char-regex": "^1.0.2", "emojilib": "^2.4.0", "skin-tone": "^2.0.0" } }, "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw=="],
"node-exports-info": ["node-exports-info@1.6.2", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag=="],
@@ -2285,16 +2207,12 @@
"node-forge": ["node-forge@1.4.0", "", {}, "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ=="],
- "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="],
-
"node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="],
"node-releases": ["node-releases@2.0.53", "", {}, "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ=="],
"npm-package-arg": ["npm-package-arg@11.0.3", "", { "dependencies": { "hosted-git-info": "^7.0.0", "proc-log": "^4.0.0", "semver": "^7.3.5", "validate-npm-package-name": "^5.0.0" } }, "sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw=="],
- "npm-run-path": ["npm-run-path@3.1.0", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-Dbl4A/VfiVGLgQv29URL9xshU8XDY1GeLy+fsaZ1AA8JDSfjvr5P5+pzRbWqRSBxk6/DW7MIh8lTM/PaGnP2kg=="],
-
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
"nullthrows": ["nullthrows@1.1.1", "", {}, "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw=="],
@@ -2323,8 +2241,6 @@
"on-headers": ["on-headers@1.1.0", "", {}, "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A=="],
- "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
-
"onetime": ["onetime@2.0.1", "", { "dependencies": { "mimic-fn": "^1.0.0" } }, "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ=="],
"open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="],
@@ -2387,12 +2303,8 @@
"posthog-node": ["posthog-node@5.51.4", "", { "dependencies": { "@posthog/core": "^1.49.1" }, "peerDependencies": { "rxjs": "^7.0.0" }, "optionalPeers": ["rxjs"] }, "sha512-gI6JMBnU3vjDNclUBWonw3y7k8Y0UPIAVO4AQ2zu9eyW+7sY8UQRofx4JIA7IGYLMEbs4gykfogOmXp16+oUdg=="],
- "pprof-format": ["pprof-format@2.3.1", "", {}, "sha512-y51Z83qG2vEQBACPu6lkGFREVkHwQaCaNDdSFEMLIqSo3bmpADsbP6J3F2SSk7tYB741oTQ9Kt5YAdQsmiCRkA=="],
-
"preact": ["preact@10.29.8", "", { "peerDependencies": { "preact-render-to-string": ">=5" }, "optionalPeers": ["preact-render-to-string"] }, "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q=="],
- "prebuildify": ["prebuildify@6.0.1", "", { "dependencies": { "minimist": "^1.2.5", "mkdirp-classic": "^0.5.3", "node-abi": "^3.3.0", "npm-run-path": "^3.1.0", "pump": "^3.0.0", "tar-fs": "^2.1.0" }, "bin": { "prebuildify": "bin.js" } }, "sha512-8Y2oOOateom/s8dNBsGIcnm6AxPmLH4/nanQzL5lQMU+sC0CMhzARZHizwr36pUPLdvBnOkCNQzxg4djuFSgIw=="],
-
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="],
@@ -2417,8 +2329,6 @@
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
- "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
-
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"query-selector-shadow-dom": ["query-selector-shadow-dom@1.0.1", "", {}, "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw=="],
@@ -2481,8 +2391,6 @@
"react-virtuoso": ["react-virtuoso@4.18.12", "", { "peerDependencies": { "react": ">=16 || >=17 || >= 18 || >= 19", "react-dom": ">=16 || >=17 || >= 18 || >=19" } }, "sha512-6c1SnRicSBfG+WnbhcyJUxzDHvvxD3vsux/EpcfVbgB7clyP1Od2r81TAGTgwbL7U2qP889RR4u+CH6WjgpW0g=="],
- "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
-
"redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
@@ -2509,13 +2417,11 @@
"remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
- "request-ip": ["request-ip@3.3.0", "", {}, "sha512-cA6Xh6e0fDBBBwH77SLJaJPBmD3nWVAcF9/XAcsrIHdjhFzFiB5aNQFytdjCGPezU3ROwrR11IddKAM08vohxA=="],
-
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
- "require-in-the-middle": ["require-in-the-middle@7.5.2", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3", "resolve": "^1.22.8" } }, "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ=="],
+ "require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="],
"resolve": ["resolve@2.0.0-next.7", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.2", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ=="],
@@ -2529,10 +2435,6 @@
"retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
- "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
-
- "ringbufferjs": ["ringbufferjs@2.0.0", "", {}, "sha512-GCOqTzUsTHF7nrqcgtNGAFotXztLgiePpIDpyWZ7R5I02tmfJWV+/yuJc//Hlsd8G+WzI1t/dc2y/w2imDZdog=="],
-
"rolldown": ["rolldown@1.2.4", "", { "dependencies": { "@oxc-project/types": "=0.144.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.2.4", "@rolldown/binding-darwin-arm64": "1.2.4", "@rolldown/binding-darwin-x64": "1.2.4", "@rolldown/binding-freebsd-x64": "1.2.4", "@rolldown/binding-linux-arm-gnueabihf": "1.2.4", "@rolldown/binding-linux-arm64-gnu": "1.2.4", "@rolldown/binding-linux-arm64-musl": "1.2.4", "@rolldown/binding-linux-ppc64-gnu": "1.2.4", "@rolldown/binding-linux-s390x-gnu": "1.2.4", "@rolldown/binding-linux-x64-gnu": "1.2.4", "@rolldown/binding-linux-x64-musl": "1.2.4", "@rolldown/binding-openharmony-arm64": "1.2.4", "@rolldown/binding-win32-arm64-msvc": "1.2.4", "@rolldown/binding-win32-x64-msvc": "1.2.4" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w=="],
"safe-array-concat": ["safe-array-concat@1.1.4", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg=="],
@@ -2543,8 +2445,6 @@
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
- "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
-
"sandbox-cli-detector": ["sandbox-cli-detector@0.2.0", "", { "bin": { "sandbox-cli-detector": "dist/cli.js" } }, "sha512-4lyHX0ZU0AZKwjgZ1InxZAa3PNpyEb8rOQ+Zss1ReYmhNzW0Q+h1zE5nvniXN0HaAWZaZE1zgVNEirb0R7LmNg=="],
"sax": ["sax@1.6.1", "", {}, "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q=="],
@@ -2555,8 +2455,6 @@
"schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="],
- "semifies": ["semifies@1.0.0", "", {}, "sha512-xXR3KGeoxTNWPD4aBvL5NUpMTT7WMANr3EWnaS190QVkY52lqqcVRD7Q05UVbBhiWDGWMlJEUam9m7uFFGVScw=="],
-
"semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="],
@@ -2645,16 +2543,12 @@
"stream-buffers": ["stream-buffers@2.2.0", "", {}, "sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg=="],
- "streamroller": ["streamroller@3.1.5", "", { "dependencies": { "date-format": "^4.0.14", "debug": "^4.3.4", "fs-extra": "^8.1.0" } }, "sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw=="],
-
"strict-uri-encode": ["strict-uri-encode@2.0.0", "", {}, "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
- "string.fromcodepoint": ["string.fromcodepoint@0.2.1", "", {}, "sha512-n69H31OnxSGSZyZbgBlvYIXlrMhJQ0dQAX1js1QDhpaUH6zmU3QYlj07bCwCNlPOu3oRXIubGPl2gDGnHsiCqg=="],
-
"string.prototype.matchall": ["string.prototype.matchall@4.0.12", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="],
"string.prototype.repeat": ["string.prototype.repeat@1.0.0", "", { "dependencies": { "define-properties": "^1.1.3", "es-abstract": "^1.17.5" } }, "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w=="],
@@ -2665,8 +2559,6 @@
"string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="],
- "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
-
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
"strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="],
@@ -2701,10 +2593,6 @@
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
- "tar-fs": ["tar-fs@2.1.5", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw=="],
-
- "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
-
"terminal-link": ["terminal-link@2.1.1", "", { "dependencies": { "ansi-escapes": "^4.2.1", "supports-hyperlinks": "^2.0.0" } }, "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ=="],
"terser": ["terser@5.50.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-CN9BVxWhgS/hRxtUMjtC2uRWSTcSfQFHMDWma6sKKfIivCD91sM+FOPfvwoaRMqCSrUpe1nv3jDamd9eEQ4y+w=="],
@@ -2741,8 +2629,6 @@
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
- "triple-beam": ["triple-beam@1.4.1", "", {}, "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg=="],
-
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
"ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
@@ -2767,8 +2653,6 @@
"typed-array-length": ["typed-array-length@1.0.8", "", { "dependencies": { "call-bind": "^1.0.9", "for-each": "^0.3.5", "gopd": "^1.2.0", "is-typed-array": "^1.1.15", "possible-typed-array-names": "^1.1.0", "reflect.getprototypeof": "^1.0.10" } }, "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g=="],
- "typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="],
-
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"ua-parser-js": ["ua-parser-js@0.7.41", "", { "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg=="],
@@ -2783,10 +2667,6 @@
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
- "unescape": ["unescape@1.0.1", "", { "dependencies": { "extend-shallow": "^2.0.1" } }, "sha512-O0+af1Gs50lyH1nUu3ZyYS1cRh01Q/kUKatTOkSs7jukXE6/NebucDVxyiDsA9AQ4JC1V1jUH9EO8JX2nMDgGQ=="],
-
- "unescape-js": ["unescape-js@1.1.4", "", { "dependencies": { "string.fromcodepoint": "^0.2.1" } }, "sha512-42SD8NOQEhdYntEiUQdYq/1V/YHwr1HLwlHuTJB5InVVdOSbgI6xu8jK5q65yIzuFCfczzyDF/7hbGzVbyCw0g=="],
-
"unicode-canonical-property-names-ecmascript": ["unicode-canonical-property-names-ecmascript@2.0.1", "", {}, "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg=="],
"unicode-emoji-modifier-base": ["unicode-emoji-modifier-base@1.0.0", "", {}, "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g=="],
@@ -2809,8 +2689,6 @@
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
- "universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
-
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"unrs-resolver": ["unrs-resolver@1.12.2", "", { "dependencies": { "napi-postinstall": "^0.3.4" }, "optionalDependencies": { "@unrs/resolver-binding-android-arm-eabi": "1.12.2", "@unrs/resolver-binding-android-arm64": "1.12.2", "@unrs/resolver-binding-darwin-arm64": "1.12.2", "@unrs/resolver-binding-darwin-x64": "1.12.2", "@unrs/resolver-binding-freebsd-x64": "1.12.2", "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", "@unrs/resolver-binding-linux-x64-musl": "1.12.2", "@unrs/resolver-binding-openharmony-arm64": "1.12.2", "@unrs/resolver-binding-wasm32-wasi": "1.12.2", "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" } }, "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ=="],
@@ -2825,8 +2703,6 @@
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
- "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
-
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
"uuid": ["uuid@7.0.3", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg=="],
@@ -2893,16 +2769,12 @@
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
- "winston-transport": ["winston-transport@4.9.0", "", { "dependencies": { "logform": "^2.7.0", "readable-stream": "^3.6.2", "triple-beam": "^1.3.0" } }, "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A=="],
-
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
- "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
-
"ws": ["ws@7.5.13", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA=="],
"xcode": ["xcode@3.0.1", "", { "dependencies": { "simple-plist": "^1.1.0", "uuid": "^7.0.3" } }, "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA=="],
@@ -2927,14 +2799,12 @@
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
- "zod": ["zod@4.5.1", "", {}, "sha512-P3GqeAEOEDqKvafVGLezbg+39YW/ze4xrD4qdS0adOY8eoI3zfjv1PQM4UwQzgT8mZKXEbqtVt8K+ZKGqkMFKg=="],
+ "zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="],
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
- "@apm-js-collab/code-transformer/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
-
"@asamuzakjp/dom-selector/css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
"@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
@@ -2953,8 +2823,6 @@
"@babel/plugin-transform-runtime/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
- "@datadog/pprof/source-map": ["source-map@0.8.0", "", {}, "sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ=="],
-
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
@@ -2979,22 +2847,12 @@
"@expo/ws-tunnel/ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
- "@grpc/grpc-js/@grpc/proto-loader": ["@grpc/proto-loader@0.8.1", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg=="],
-
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"@isaacs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
- "@newrelic/security-agent/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
-
- "@newrelic/security-agent/ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
-
- "@opentelemetry/instrumentation/import-in-the-middle": ["import-in-the-middle@3.3.3", "", { "dependencies": { "cjs-module-lexer": "^2.2.0", "es-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-AiohS3H80sXO6owEltjGX+glb7qXaDhBoJb9XcQVH4UI207xu/bDLUcadVKp7Qe576reg9yr/PXZjV5qx8gfbA=="],
-
- "@opentelemetry/instrumentation/require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="],
-
"@posthog/webpack-plugin/@posthog/core": ["@posthog/core@1.48.11", "", { "dependencies": { "@posthog/types": "^1.405.3" } }, "sha512-fvKbxGaUM8RuCDB1jdhSqAHYjk42INfJDWT1KVezn74sCrfnr1YaUafxzmcS+87D5FzbA2tu7IT0GQRV2WkkRg=="],
"@react-native/babel-plugin-codegen/@react-native/codegen": ["@react-native/codegen@0.86.3", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/parser": "^7.29.0", "hermes-parser": "0.36.0", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "tinyglobby": "^0.2.15", "yargs": "^17.6.2" } }, "sha512-Ux4jHi0fh+bdtVEcL0gaPLbY56V+SvFUDl/8sRAE1jdb4k+o7fT/4Nc29yz4X+qfjstkSqObQTMBGhdzxH9JvA=="],
@@ -3193,12 +3051,6 @@
"minimizer-webpack-plugin/jest-worker": ["jest-worker@27.5.1", "", { "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg=="],
- "newrelic/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.203.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-9B9RU0H7Ya1Dx/Rkyc4stuBZSGVQF27WigitInx2QQoj6KUpEFYPKoWjdFTunJYxmXmh17HeBvbMa1EhGyPmqQ=="],
-
- "newrelic/@opentelemetry/exporter-metrics-otlp-proto": ["@opentelemetry/exporter-metrics-otlp-proto@0.201.1", "", { "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/exporter-metrics-otlp-http": "0.201.1", "@opentelemetry/otlp-exporter-base": "0.201.1", "@opentelemetry/otlp-transformer": "0.201.1", "@opentelemetry/resources": "2.0.1", "@opentelemetry/sdk-metrics": "2.0.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-9ie2jcaUQZdIoe6B02r0rF4Gz+JsZ9mev/2pYou1N0woOUkFM8xwO6BAlORnrFVslqF/XO5WG3q5FsTbuC5iiw=="],
-
- "newrelic/@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.203.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.203.0", "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-vM2+rPq0Vi3nYA5akQD2f3QwossDnTDLvKbea6u/A2NZ3XDkPxMfo/PNrDoXhDUD/0pPo2CdH5ce/thn9K0kLw=="],
-
"next/postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="],
"node-exports-info/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
@@ -3231,8 +3083,6 @@
"react-native-appwrite/expo-file-system": ["expo-file-system@18.1.11", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-HJw/m0nVOKeqeRjPjGdvm+zBi5/NxcdPf8M8P3G2JFvH5Z8vBWqVDic2O58jnT1OFEy0XXzoH9UqFu7cHg9DTQ=="],
- "require-in-the-middle/resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
-
"restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"rolldown/@oxc-project/types": ["@oxc-project/types@0.144.0", "", {}, "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg=="],
@@ -3295,8 +3145,6 @@
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
- "@opentelemetry/instrumentation/import-in-the-middle/cjs-module-lexer": ["cjs-module-lexer@2.2.1", "", {}, "sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q=="],
-
"@posthog/webpack-plugin/@posthog/core/@posthog/types": ["@posthog/types@1.407.0", "", {}, "sha512-7J/aFVi7JWFt/ekGVsMFvkTcADoE9MTNf1N9cpBSMUQ/SPLiBjbg386Fzk5OlgWG/u5OemBy4wlgD7YebqeppQ=="],
"@react-native/babel-plugin-codegen/@react-native/codegen/hermes-parser": ["hermes-parser@0.36.0", "", { "dependencies": { "hermes-estree": "0.36.0" } }, "sha512-GdpwMmH5x6IpC1cijvcvYnlPB60Mh6kTSF/NFdYV/j56gYdi+0RIakYs+eqOV+bbO0SW7mgVVGSsTJxyPQfo3w=="],
@@ -3477,22 +3325,6 @@
"minimizer-webpack-plugin/jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
- "newrelic/@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/core": ["@opentelemetry/core@2.0.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw=="],
-
- "newrelic/@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/exporter-metrics-otlp-http": ["@opentelemetry/exporter-metrics-otlp-http@0.201.1", "", { "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/otlp-exporter-base": "0.201.1", "@opentelemetry/otlp-transformer": "0.201.1", "@opentelemetry/resources": "2.0.1", "@opentelemetry/sdk-metrics": "2.0.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-LMRVg2yTev28L51RLLUK3gY0avMa1RVBq7IkYNtXDBxJRcd0TGGq/0rqfk7Y4UIM9NCJhDIUFHeGg8NpSgSWcw=="],
-
- "newrelic/@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.201.1", "", { "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/otlp-transformer": "0.201.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-FiS/mIWmZXyRxYGyXPHY+I/4+XrYVTD7Fz/zwOHkVPQsA1JTakAOP9fAi6trXMio0dIpzvQujLNiBqGM7ExrQw=="],
-
- "newrelic/@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.201.1", "", { "dependencies": { "@opentelemetry/api-logs": "0.201.1", "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1", "@opentelemetry/sdk-logs": "0.201.1", "@opentelemetry/sdk-metrics": "2.0.1", "@opentelemetry/sdk-trace-base": "2.0.1", "protobufjs": "^7.3.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-+q/8Yuhtu9QxCcjEAXEO8fXLjlSnrnVwfzi9jiWaMAppQp69MoagHHomQj02V2WnGjvBod5ajgkbK4IoWab50A=="],
-
- "newrelic/@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/resources": ["@opentelemetry/resources@2.0.1", "", { "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw=="],
-
- "newrelic/@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.0.1", "", { "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g=="],
-
- "newrelic/@opentelemetry/sdk-logs/@opentelemetry/core": ["@opentelemetry/core@2.0.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw=="],
-
- "newrelic/@opentelemetry/sdk-logs/@opentelemetry/resources": ["@opentelemetry/resources@2.0.1", "", { "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw=="],
-
"next/postcss/nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
"node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
@@ -3575,12 +3407,6 @@
"log-symbols/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="],
- "newrelic/@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/otlp-transformer/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.201.1", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IxcFDP1IGMDemVFG2by/AMK+/o6EuBQ8idUq3xZ6MxgQGeumYZuX5OwR0h9HuvcUc/JPjQGfU5OHKIKYDJcXeA=="],
-
- "newrelic/@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/otlp-transformer/@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.201.1", "", { "dependencies": { "@opentelemetry/api-logs": "0.201.1", "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-Ug8gtpssUNUnfpotB9ZhnSsPSGDu+7LngTMgKl31mmVJwLAKyl6jC8diZrMcGkSgBh0o5dbg9puvLyR25buZfw=="],
-
- "newrelic/@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/otlp-transformer/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.0.1", "", { "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ=="],
-
"ora/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
"ora/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="],
diff --git a/package.json b/package.json
index 95dd206..2b67cac 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "firepit",
- "version": "2.0.0",
+ "version": "2.1.0",
"private": true,
"type": "module",
"packageManager": "bun@1.4.0",