diff --git a/.gitignore b/.gitignore index 948237f..2dd33d9 100644 --- a/.gitignore +++ b/.gitignore @@ -50,7 +50,6 @@ next-env.d.ts # testing /test-results /app/test -/app/og-generator /api-index # Agents @@ -59,3 +58,9 @@ skills-lock.json studio_audit_report.md server_audit_report.md github_issues_to_create.md +portfolio_subdomains_plan.md +portfolio_static_rendering.md +portfolio_nextjs_architecture.md +portfolio_nextjs_static_production.md +portfolio_production_comparison.md +portfolio_system_specification.md \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..2046692 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "apps/portfolio/template-library"] + path = apps/portfolio/template-library + url = https://github.com/VeriWorkly/portfolio-templates.git + branch = main diff --git a/DESIGN.md b/DESIGN.md index d504223..af85bc2 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -88,3 +88,20 @@ The Geist font family is used throughout for its modern, clean appearance. - **Max-Width**: `1280px` (`max-w-7xl`) - **Horizontal Padding**: `px-4` (Mobile), `px-6` (Tablet), `px-8` (Desktop) - **Vertical Spacing**: `space-y-16` to `space-y-24` between sections. + +## Portfolio Studio + +The portfolio product is a system-managed app surface. It extends the platform +system without introducing a separate brand. + +- **Marketing pages**: asymmetric editorial product tour with a structured + template gallery. +- **App pages**: workbench layout with grouped editing cards, contextual help, + publish readiness, and a persistent private preview. +- **Public templates**: may use distinct local palettes and typography to give + portfolio owners a real creative choice. Template palettes must be declared as + named CSS tokens in each template stylesheet. +- **Motion**: CSS-first reveal, hover lift, and state transitions. Spatial motion + collapses under `prefers-reduced-motion`. +- **Editor stance**: section-aware inputs, plain-language guidance, compatible + snapshot parsing, visible focus, and no destructive action without a label. diff --git a/apps/blog-platform/package.json b/apps/blog-platform/package.json index 9e1cfd1..e873156 100644 --- a/apps/blog-platform/package.json +++ b/apps/blog-platform/package.json @@ -1,6 +1,6 @@ { "name": "@veriworkly/blog-platform", - "version": "3.10.2", + "version": "3.11.0", "private": true, "scripts": { "dev": "next dev", diff --git a/apps/docs-platform/package.json b/apps/docs-platform/package.json index c310c39..41be754 100644 --- a/apps/docs-platform/package.json +++ b/apps/docs-platform/package.json @@ -1,6 +1,6 @@ { "name": "@veriworkly/docs-platform", - "version": "3.10.2", + "version": "3.11.0", "private": true, "scripts": { "dev": "next dev", diff --git a/apps/server/.env.example b/apps/server/.env.example index 15fc75e..56ed305 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -4,7 +4,7 @@ NODE_ENV=development PORT=8080 -TRUST_PROXY=true +TRUST_PROXY=1 # Max in-memory entries (leave empty for default) MAX_MEMORY_ENTRIES= @@ -71,8 +71,8 @@ AUTH_BASE_URL=http://localhost:8080 # Session TTL (30 days) AUTH_SESSION_TTL_SECONDS=2592000 -# Reset TTL on activity (true | false) -AUTH_SESSION_RESET_TTL_ON_USE=true +# Reset TTL on activity (seconds) +AUTH_SESSION_RESET_TTL_ON_USE=86400 # Enable session caching AUTH_SESSION_CACHE_ENABLED=true @@ -141,15 +141,43 @@ GITHUB_SYNC_TIMEZONE= # Internal API protection key INTERNAL_SYNC_API_KEY= +# ========================================================= +# 🌐 Portfolio Subdomain Builder +# ========================================================= +PORTFOLIO_URL=http://localhost:3004 +PORTFOLIO_REVALIDATE_SECRET=dev-revalidate-secret + +# ========================================================= +# Portfolio Pro Billing (Dodo Payments) +# ========================================================= + +PORTFOLIO_GRACE_DAYS=7 +DODO_PAYMENTS_API_KEY= +DODO_PAYMENTS_WEBHOOK_SECRET= +DODO_PAYMENTS_ENVIRONMENT=test_mode +DODO_PAYMENTS_MONTHLY_PRODUCT_ID= +DODO_PAYMENTS_ANNUAL_PRODUCT_ID= +DODO_PAYMENTS_CHECKOUT_RETURN_URL=http://portfolio.localhost:3004/billing?checkout=complete +DODO_PAYMENTS_CHECKOUT_CANCEL_URL=http://portfolio.localhost:3004/billing?checkout=cancelled +DODO_PAYMENTS_PORTAL_RETURN_URL=http://portfolio.localhost:3004/billing + +# ========================================================= +# Portfolio Media (Cloudflare R2) +# ========================================================= +R2_ENDPOINT= +R2_BUCKET= +R2_ACCESS_KEY_ID= +R2_SECRET_ACCESS_KEY= +R2_PUBLIC_BASE_URL= # ========================================================= # 🚪 API Key Configuration # ========================================================= -hashSecret= "dev-api-key-secret" -authCacheTtlSeconds= 300 -lastUsedTouchIntervalSeconds= 300 -defaultRateLimit: 20 -defaultScopes: user:read -defaultKeyLifetimeDays: 365 +API_KEY_HASH_SECRET=dev-api-key-secret +API_KEY_AUTH_CACHE_TTL_SECONDS=300 +API_KEY_LAST_USED_TOUCH_INTERVAL_SECONDS=300 +API_KEY_DEFAULT_RATE_LIMIT=20 +API_KEY_DEFAULT_SCOPES=user:read +API_KEY_DEFAULT_LIFETIME_DAYS=365 diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile index 3b5bda4..10e420f 100644 --- a/apps/server/Dockerfile +++ b/apps/server/Dockerfile @@ -7,38 +7,37 @@ WORKDIR /app ENV NODE_ENV=production COPY package*.json ./ -COPY prisma ./prisma +COPY apps/server/package.json ./apps/server/package.json -# 1. Install all dependencies -RUN npm ci --include=dev --legacy-peer-deps +# Build with repository root as context: +# docker build -f apps/server/Dockerfile . +RUN npm ci --workspace=@veriworkly/server --include=dev --legacy-peer-deps -COPY tsconfig.json ./ -COPY src ./src +COPY apps/server/prisma ./apps/server/prisma +COPY apps/server/tsconfig.json ./apps/server/tsconfig.json +COPY apps/server/src ./apps/server/src -# 2. Generate Prisma, Build, and Fix Extensions -RUN npx prisma generate \ - && npm run build \ - && npx resolve-tspaths \ +RUN npm run db:generate --workspace=@veriworkly/server \ + && npm run build --workspace=@veriworkly/server \ && npm prune --omit=dev --legacy-peer-deps FROM node:${NODE_VERSION}-alpine AS runner -WORKDIR /app +WORKDIR /app/apps/server ENV NODE_ENV=production ENV PORT=8080 -# 3. UPDATED FIX: Install OpenSSL 1.1 from the v3.16 repository RUN apk add --no-cache openssl -# Security: Run as non-root user RUN addgroup -S app && adduser -S app -G app -COPY --from=build /app/package*.json ./ -COPY --from=build /app/node_modules ./node_modules -COPY --from=build /app/dist ./dist -COPY --from=build /app/prisma ./prisma +COPY --from=build /app/package*.json /app/ +COPY --from=build /app/node_modules /app/node_modules +COPY --from=build /app/apps/server/package.json ./package.json +COPY --from=build /app/apps/server/dist ./dist +COPY --from=build /app/apps/server/prisma ./prisma USER app EXPOSE 8080 -CMD ["node", "dist/index.js"] \ No newline at end of file +CMD ["node", "dist/index.js"] diff --git a/apps/server/package.json b/apps/server/package.json index b4f33f6..731bdbe 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "@veriworkly/server", - "version": "3.10.2", + "version": "3.11.0", "description": "VeriWorkly Resume Backend API", "main": "dist/index.js", "type": "module", @@ -18,44 +18,44 @@ "db:push": "prisma db push", "db:migrate": "prisma migrate dev", "db:studio": "prisma studio", + "db:generate": "prisma generate", "sync:github": "tsx src/jobs/runGithubSyncOnce.ts", "test": "vitest run", "test:watch": "vitest --watch" }, "dependencies": { - "@better-auth/prisma-adapter": "^1.6.11", - "@opentelemetry/api": "^1.9.1", + "@aws-sdk/client-s3": "^3.1057.0", + "@aws-sdk/s3-request-presigner": "^3.1057.0", + "@better-auth/prisma-adapter": "^1.6.12", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "@prisma/config": "^7.8.0", - "better-auth": "^1.6.11", + "better-auth": "^1.6.12", "cors": "^2.8.6", + "dodopayments": "^2.32.1", "dotenv": "^17.4.2", - "express": "^4.19.2", - "express-rate-limit": "^8.4.1", - "helmet": "^7.1.0", - "ioredis": "^5.10.1", + "express": "^4.22.2", + "helmet": "^7.2.0", "node-cron": "^4.2.1", - "nodemailer": "^8.0.6", - "pg": "^8.20.0", - "rate-limit-redis": "^4.3.1", + "nodemailer": "^8.0.10", + "pg": "^8.21.0", "redis": "^5.12.1", "uuid": "^14.0.0", - "vitest": "^4.1.5", "zod": "^3.25.76" }, "devDependencies": { "@types/cors": "^2.8.19", - "@types/express": "^4.17.21", - "@types/node": "^20.19.39", + "@types/express": "^4.17.25", + "@types/node": "^20.19.41", "@types/nodemailer": "^7.0.11", "@types/pg": "^8.20.0", "eslint": "^9", "prettier": "^3.8.3", "prisma": "^7.8.0", "resolve-tspaths": "^0.8.23", - "tsc-alias": "^1.8.16", - "tsx": "^4.21.0", - "typescript": "^5.3.3" + "tsc-alias": "^1.8.17", + "tsx": "^4.22.3", + "typescript": "^5.9.3", + "vitest": "^4.1.7" } } diff --git a/apps/server/prisma/migrations/20260531111914_init/migration.sql b/apps/server/prisma/migrations/20260531111914_init/migration.sql new file mode 100644 index 0000000..57713c0 --- /dev/null +++ b/apps/server/prisma/migrations/20260531111914_init/migration.sql @@ -0,0 +1,614 @@ +-- CreateEnum +CREATE TYPE "DocumentType" AS ENUM ('RESUME', 'COVER_LETTER', 'PORTFOLIO', 'LINK_IN_BIO'); + +-- CreateEnum +CREATE TYPE "Visibility" AS ENUM ('PRIVATE', 'UNLISTED', 'PUBLIC'); + +-- CreateEnum +CREATE TYPE "SubscriptionStatus" AS ENUM ('INACTIVE', 'TRIALING', 'ACTIVE', 'PAST_DUE', 'CANCELED'); + +-- CreateEnum +CREATE TYPE "BillingInterval" AS ENUM ('MONTHLY', 'ANNUAL'); + +-- CreateEnum +CREATE TYPE "PortfolioPublicationStatus" AS ENUM ('LIVE', 'GRACE', 'SUSPENDED'); + +-- CreateEnum +CREATE TYPE "BillingWebhookStatus" AS ENUM ('PROCESSING', 'PROCESSED', 'FAILED'); + +-- CreateEnum +CREATE TYPE "PortfolioAssetKind" AS ENUM ('AVATAR', 'PROJECT_COVER', 'SOCIAL_IMAGE'); + +-- CreateEnum +CREATE TYPE "PortfolioAssetStatus" AS ENUM ('PENDING', 'READY'); + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "name" TEXT, + "username" TEXT, + "emailVerified" BOOLEAN NOT NULL DEFAULT false, + "image" TEXT, + "autoSyncEnabled" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Session" ( + "id" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "token" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "ipAddress" TEXT, + "userAgent" TEXT, + "userId" TEXT NOT NULL, + + CONSTRAINT "Session_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Account" ( + "id" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "providerId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "accessToken" TEXT, + "refreshToken" TEXT, + "accessTokenExpiresAt" TIMESTAMP(3), + "refreshTokenExpiresAt" TIMESTAMP(3), + "scope" TEXT, + "idToken" TEXT, + "password" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Account_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Verification" ( + "id" TEXT NOT NULL, + "identifier" TEXT NOT NULL, + "value" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3), + + CONSTRAINT "Verification_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Document" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "type" "DocumentType" NOT NULL DEFAULT 'RESUME', + "title" TEXT NOT NULL DEFAULT 'Untitled Document', + "slug" TEXT NOT NULL, + "tags" TEXT[] DEFAULT ARRAY[]::TEXT[], + "content" JSONB NOT NULL, + "metadata" JSONB, + "templateId" TEXT NOT NULL DEFAULT 'modern', + "schemaVersion" INTEGER NOT NULL DEFAULT 1, + "revision" INTEGER NOT NULL DEFAULT 1, + "visibility" "Visibility" NOT NULL DEFAULT 'PRIVATE', + "lastSyncedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "Document_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Subscription" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "providerCustomerId" TEXT, + "providerPriceId" TEXT, + "providerSubId" TEXT, + "status" "SubscriptionStatus" NOT NULL DEFAULT 'INACTIVE', + "interval" "BillingInterval", + "rawStatus" TEXT, + "currentPeriodEnd" TIMESTAMP(3), + "cancelAtPeriodEnd" BOOLEAN NOT NULL DEFAULT false, + "graceEndsAt" TIMESTAMP(3), + "lastWebhookAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Subscription_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PortfolioPublication" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "documentId" TEXT NOT NULL, + "subdomain" TEXT NOT NULL, + "templateId" TEXT NOT NULL, + "snapshot" JSONB NOT NULL, + "status" "PortfolioPublicationStatus" NOT NULL DEFAULT 'LIVE', + "publishedRevision" INTEGER NOT NULL DEFAULT 1, + "suspensionReason" TEXT, + "suspendedAt" TIMESTAMP(3), + "publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PortfolioPublication_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "BillingWebhookEvent" ( + "id" TEXT NOT NULL, + "providerEventId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "payload" JSONB NOT NULL, + "status" "BillingWebhookStatus" NOT NULL DEFAULT 'PROCESSING', + "error" TEXT, + "processedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "retryCount" INTEGER NOT NULL DEFAULT 0, + "lastAttemptAt" TIMESTAMP(3), + + CONSTRAINT "BillingWebhookEvent_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PortfolioAsset" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "key" TEXT NOT NULL, + "kind" "PortfolioAssetKind" NOT NULL, + "mimeType" TEXT NOT NULL, + "sizeBytes" INTEGER NOT NULL, + "checksum" TEXT, + "status" "PortfolioAssetStatus" NOT NULL DEFAULT 'PENDING', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PortfolioAsset_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PortfolioViewDaily" ( + "id" TEXT NOT NULL, + "publicationId" TEXT NOT NULL, + "date" TIMESTAMP(3) NOT NULL, + "referrerHost" TEXT NOT NULL DEFAULT '', + "count" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PortfolioViewDaily_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "MasterProfile" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "content" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "MasterProfile_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ShareLink" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "documentId" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "snapshot" JSONB NOT NULL, + "passwordHash" TEXT, + "expiresAt" TIMESTAMP(3), + "viewCount" INTEGER NOT NULL DEFAULT 0, + "lastViewedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ShareLink_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ShareView" ( + "id" TEXT NOT NULL, + "shareLinkId" TEXT NOT NULL, + "ipAddress" TEXT, + "userAgent" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ShareView_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "RoadmapFeature" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'todo', + "eta" TEXT, + "tags" TEXT[] DEFAULT ARRAY[]::TEXT[], + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "startedAt" TIMESTAMP(3), + "completedAt" TIMESTAMP(3), + "completedQuarter" TEXT, + "updatedAt" TIMESTAMP(3) NOT NULL, + "fullDescription" TEXT, + "whyItMatters" TEXT, + "timeline" TEXT, + "details" JSONB, + + CONSTRAINT "RoadmapFeature_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "RoadmapInteraction" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "featureId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "value" INTEGER, + "comment" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "RoadmapInteraction_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "UsageMetricDaily" ( + "id" TEXT NOT NULL, + "date" TIMESTAMP(3) NOT NULL, + "event" TEXT NOT NULL, + "count" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "UsageMetricDaily_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "UsageMetricFlushBatch" ( + "id" TEXT NOT NULL, + "date" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "UsageMetricFlushBatch_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ViewFlushBatch" ( + "id" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ViewFlushBatch_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "GitHubSync" ( + "id" TEXT NOT NULL, + "projectName" TEXT NOT NULL, + "projectUrl" TEXT NOT NULL, + "issueCount" INTEGER NOT NULL DEFAULT 0, + "onlyIssueCount" INTEGER NOT NULL DEFAULT 0, + "prCount" INTEGER NOT NULL DEFAULT 0, + "todoCount" INTEGER NOT NULL DEFAULT 0, + "inProgressCount" INTEGER NOT NULL DEFAULT 0, + "doneCount" INTEGER NOT NULL DEFAULT 0, + "data" JSONB NOT NULL, + "etag" TEXT, + "lastSyncStatus" TEXT, + "lastError" TEXT, + "nextSyncAt" TIMESTAMP(3), + "syncedAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "GitHubSync_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "GitHubSyncItem" ( + "id" TEXT NOT NULL, + "syncId" TEXT NOT NULL, + "githubId" TEXT NOT NULL, + "number" INTEGER NOT NULL, + "title" TEXT NOT NULL, + "status" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "url" TEXT NOT NULL, + "labels" TEXT[] DEFAULT ARRAY[]::TEXT[], + "createdAt" TIMESTAMP(3) NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + "insertedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "GitHubSyncItem_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ApiKey" ( + "id" TEXT NOT NULL, + "keyHash" TEXT NOT NULL, + "keyPrefix" TEXT NOT NULL, + "keySuffix" TEXT NOT NULL, + "name" TEXT NOT NULL, + "scopes" TEXT[] DEFAULT ARRAY['user:read']::TEXT[], + "userId" TEXT NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "rateLimit" INTEGER NOT NULL DEFAULT 20, + "expiresAt" TIMESTAMP(3), + "revokedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "lastUsed" TIMESTAMP(3), + + CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AuditLog" ( + "id" TEXT NOT NULL, + "method" TEXT NOT NULL, + "path" TEXT NOT NULL, + "status" INTEGER NOT NULL, + "ip" TEXT, + "userAgent" TEXT, + "error" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); + +-- CreateIndex +CREATE INDEX "User_email_idx" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "Session_token_key" ON "Session"("token"); + +-- CreateIndex +CREATE INDEX "Session_userId_idx" ON "Session"("userId"); + +-- CreateIndex +CREATE INDEX "Session_expiresAt_idx" ON "Session"("expiresAt"); + +-- CreateIndex +CREATE INDEX "Account_userId_idx" ON "Account"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Account_providerId_accountId_key" ON "Account"("providerId", "accountId"); + +-- CreateIndex +CREATE INDEX "Verification_identifier_idx" ON "Verification"("identifier"); + +-- CreateIndex +CREATE INDEX "Verification_expiresAt_idx" ON "Verification"("expiresAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "Verification_identifier_value_key" ON "Verification"("identifier", "value"); + +-- CreateIndex +CREATE INDEX "Document_userId_type_deletedAt_updatedAt_idx" ON "Document"("userId", "type", "deletedAt", "updatedAt"); + +-- CreateIndex +CREATE INDEX "Document_userId_deletedAt_updatedAt_idx" ON "Document"("userId", "deletedAt", "updatedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "Document_userId_slug_key" ON "Document"("userId", "slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "Subscription_providerSubId_key" ON "Subscription"("providerSubId"); + +-- CreateIndex +CREATE INDEX "Subscription_userId_status_idx" ON "Subscription"("userId", "status"); + +-- CreateIndex +CREATE INDEX "Subscription_userId_updatedAt_idx" ON "Subscription"("userId", "updatedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "PortfolioPublication_userId_key" ON "PortfolioPublication"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "PortfolioPublication_documentId_key" ON "PortfolioPublication"("documentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "PortfolioPublication_subdomain_key" ON "PortfolioPublication"("subdomain"); + +-- CreateIndex +CREATE INDEX "PortfolioPublication_status_updatedAt_idx" ON "PortfolioPublication"("status", "updatedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "BillingWebhookEvent_providerEventId_key" ON "BillingWebhookEvent"("providerEventId"); + +-- CreateIndex +CREATE INDEX "BillingWebhookEvent_type_createdAt_idx" ON "BillingWebhookEvent"("type", "createdAt"); + +-- CreateIndex +CREATE INDEX "BillingWebhookEvent_status_createdAt_idx" ON "BillingWebhookEvent"("status", "createdAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "PortfolioAsset_key_key" ON "PortfolioAsset"("key"); + +-- CreateIndex +CREATE INDEX "PortfolioAsset_userId_status_idx" ON "PortfolioAsset"("userId", "status"); + +-- CreateIndex +CREATE INDEX "PortfolioViewDaily_publicationId_date_idx" ON "PortfolioViewDaily"("publicationId", "date"); + +-- CreateIndex +CREATE UNIQUE INDEX "PortfolioViewDaily_publicationId_date_referrerHost_key" ON "PortfolioViewDaily"("publicationId", "date", "referrerHost"); + +-- CreateIndex +CREATE UNIQUE INDEX "MasterProfile_userId_key" ON "MasterProfile"("userId"); + +-- CreateIndex +CREATE INDEX "ShareLink_documentId_idx" ON "ShareLink"("documentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ShareLink_userId_documentId_key" ON "ShareLink"("userId", "documentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ShareLink_userId_slug_key" ON "ShareLink"("userId", "slug"); + +-- CreateIndex +CREATE INDEX "ShareView_shareLinkId_idx" ON "ShareView"("shareLinkId"); + +-- CreateIndex +CREATE INDEX "ShareView_createdAt_idx" ON "ShareView"("createdAt"); + +-- CreateIndex +CREATE INDEX "RoadmapFeature_status_idx" ON "RoadmapFeature"("status"); + +-- CreateIndex +CREATE INDEX "RoadmapFeature_createdAt_idx" ON "RoadmapFeature"("createdAt"); + +-- CreateIndex +CREATE INDEX "RoadmapFeature_status_createdAt_idx" ON "RoadmapFeature"("status", "createdAt"); + +-- CreateIndex +CREATE INDEX "RoadmapFeature_completedAt_updatedAt_idx" ON "RoadmapFeature"("completedAt", "updatedAt"); + +-- CreateIndex +CREATE INDEX "RoadmapFeature_status_completedAt_updatedAt_idx" ON "RoadmapFeature"("status", "completedAt", "updatedAt"); + +-- CreateIndex +CREATE INDEX "RoadmapInteraction_userId_idx" ON "RoadmapInteraction"("userId"); + +-- CreateIndex +CREATE INDEX "RoadmapInteraction_featureId_idx" ON "RoadmapInteraction"("featureId"); + +-- CreateIndex +CREATE UNIQUE INDEX "RoadmapInteraction_userId_featureId_type_key" ON "RoadmapInteraction"("userId", "featureId", "type"); + +-- CreateIndex +CREATE INDEX "UsageMetricDaily_event_date_idx" ON "UsageMetricDaily"("event", "date"); + +-- CreateIndex +CREATE UNIQUE INDEX "UsageMetricDaily_date_event_key" ON "UsageMetricDaily"("date", "event"); + +-- CreateIndex +CREATE INDEX "UsageMetricFlushBatch_date_idx" ON "UsageMetricFlushBatch"("date"); + +-- CreateIndex +CREATE INDEX "ViewFlushBatch_kind_createdAt_idx" ON "ViewFlushBatch"("kind", "createdAt"); + +-- CreateIndex +CREATE INDEX "GitHubSync_syncedAt_idx" ON "GitHubSync"("syncedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "GitHubSync_projectUrl_key" ON "GitHubSync"("projectUrl"); + +-- CreateIndex +CREATE INDEX "GitHubSyncItem_syncId_idx" ON "GitHubSyncItem"("syncId"); + +-- CreateIndex +CREATE INDEX "GitHubSyncItem_status_idx" ON "GitHubSyncItem"("status"); + +-- CreateIndex +CREATE INDEX "GitHubSyncItem_kind_idx" ON "GitHubSyncItem"("kind"); + +-- CreateIndex +CREATE INDEX "GitHubSyncItem_createdAt_idx" ON "GitHubSyncItem"("createdAt"); + +-- CreateIndex +CREATE INDEX "GitHubSyncItem_updatedAt_idx" ON "GitHubSyncItem"("updatedAt"); + +-- CreateIndex +CREATE INDEX "GitHubSyncItem_syncId_updatedAt_idx" ON "GitHubSyncItem"("syncId", "updatedAt"); + +-- CreateIndex +CREATE INDEX "GitHubSyncItem_syncId_status_updatedAt_idx" ON "GitHubSyncItem"("syncId", "status", "updatedAt"); + +-- CreateIndex +CREATE INDEX "GitHubSyncItem_syncId_kind_updatedAt_idx" ON "GitHubSyncItem"("syncId", "kind", "updatedAt"); + +-- CreateIndex +CREATE INDEX "GitHubSyncItem_syncId_status_kind_updatedAt_idx" ON "GitHubSyncItem"("syncId", "status", "kind", "updatedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "GitHubSyncItem_syncId_githubId_key" ON "GitHubSyncItem"("syncId", "githubId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ApiKey_keyHash_key" ON "ApiKey"("keyHash"); + +-- CreateIndex +CREATE INDEX "ApiKey_userId_idx" ON "ApiKey"("userId"); + +-- CreateIndex +CREATE INDEX "ApiKey_userId_isActive_idx" ON "ApiKey"("userId", "isActive"); + +-- CreateIndex +CREATE INDEX "ApiKey_isActive_idx" ON "ApiKey"("isActive"); + +-- CreateIndex +CREATE INDEX "ApiKey_expiresAt_idx" ON "ApiKey"("expiresAt"); + +-- CreateIndex +CREATE INDEX "ApiKey_keyHash_idx" ON "ApiKey"("keyHash"); + +-- CreateIndex +CREATE INDEX "AuditLog_createdAt_idx" ON "AuditLog"("createdAt"); + +-- CreateIndex +CREATE INDEX "AuditLog_method_path_idx" ON "AuditLog"("method", "path"); + +-- AddForeignKey +ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Document" ADD CONSTRAINT "Document_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PortfolioPublication" ADD CONSTRAINT "PortfolioPublication_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PortfolioPublication" ADD CONSTRAINT "PortfolioPublication_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PortfolioAsset" ADD CONSTRAINT "PortfolioAsset_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PortfolioViewDaily" ADD CONSTRAINT "PortfolioViewDaily_publicationId_fkey" FOREIGN KEY ("publicationId") REFERENCES "PortfolioPublication"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MasterProfile" ADD CONSTRAINT "MasterProfile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ShareLink" ADD CONSTRAINT "ShareLink_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ShareLink" ADD CONSTRAINT "ShareLink_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ShareView" ADD CONSTRAINT "ShareView_shareLinkId_fkey" FOREIGN KEY ("shareLinkId") REFERENCES "ShareLink"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RoadmapInteraction" ADD CONSTRAINT "RoadmapInteraction_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RoadmapInteraction" ADD CONSTRAINT "RoadmapInteraction_featureId_fkey" FOREIGN KEY ("featureId") REFERENCES "RoadmapFeature"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "GitHubSyncItem" ADD CONSTRAINT "GitHubSyncItem_syncId_fkey" FOREIGN KEY ("syncId") REFERENCES "GitHubSync"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/server/prisma/migrations/20260331145654_dev/migration.sql b/apps/server/prisma/migrations_backup/20260331145654_dev/migration.sql similarity index 100% rename from apps/server/prisma/migrations/20260331145654_dev/migration.sql rename to apps/server/prisma/migrations_backup/20260331145654_dev/migration.sql diff --git a/apps/server/prisma/migrations/20260411103552_add_indexs/migration.sql b/apps/server/prisma/migrations_backup/20260411103552_add_indexs/migration.sql similarity index 100% rename from apps/server/prisma/migrations/20260411103552_add_indexs/migration.sql rename to apps/server/prisma/migrations_backup/20260411103552_add_indexs/migration.sql diff --git a/apps/server/prisma/migrations/20260411161000_perf_query_indexes/migration.sql b/apps/server/prisma/migrations_backup/20260411161000_perf_query_indexes/migration.sql similarity index 100% rename from apps/server/prisma/migrations/20260411161000_perf_query_indexes/migration.sql rename to apps/server/prisma/migrations_backup/20260411161000_perf_query_indexes/migration.sql diff --git a/apps/server/prisma/migrations/20260505044004_sync_schema/migration.sql b/apps/server/prisma/migrations_backup/20260505044004_sync_schema/migration.sql similarity index 100% rename from apps/server/prisma/migrations/20260505044004_sync_schema/migration.sql rename to apps/server/prisma/migrations_backup/20260505044004_sync_schema/migration.sql diff --git a/apps/server/prisma/migrations/20260505071802_api_key_hash/migration.sql b/apps/server/prisma/migrations_backup/20260505071802_api_key_hash/migration.sql similarity index 100% rename from apps/server/prisma/migrations/20260505071802_api_key_hash/migration.sql rename to apps/server/prisma/migrations_backup/20260505071802_api_key_hash/migration.sql diff --git a/apps/server/prisma/migrations/20260505101500_fix_api_key_user_link/migration.sql b/apps/server/prisma/migrations_backup/20260505101500_fix_api_key_user_link/migration.sql similarity index 100% rename from apps/server/prisma/migrations/20260505101500_fix_api_key_user_link/migration.sql rename to apps/server/prisma/migrations_backup/20260505101500_fix_api_key_user_link/migration.sql diff --git a/apps/server/prisma/migrations/20260505135000_api_key_hash_scopes/migration.sql b/apps/server/prisma/migrations_backup/20260505135000_api_key_hash_scopes/migration.sql similarity index 100% rename from apps/server/prisma/migrations/20260505135000_api_key_hash_scopes/migration.sql rename to apps/server/prisma/migrations_backup/20260505135000_api_key_hash_scopes/migration.sql diff --git a/apps/server/prisma/migrations/20260522090000_usernames_document_slugs_tags/migration.sql b/apps/server/prisma/migrations_backup/20260522090000_usernames_document_slugs_tags/migration.sql similarity index 100% rename from apps/server/prisma/migrations/20260522090000_usernames_document_slugs_tags/migration.sql rename to apps/server/prisma/migrations_backup/20260522090000_usernames_document_slugs_tags/migration.sql diff --git a/apps/server/prisma/migrations/20260522103000_slug_based_share_links/migration.sql b/apps/server/prisma/migrations_backup/20260522103000_slug_based_share_links/migration.sql similarity index 100% rename from apps/server/prisma/migrations/20260522103000_slug_based_share_links/migration.sql rename to apps/server/prisma/migrations_backup/20260522103000_slug_based_share_links/migration.sql diff --git a/apps/server/prisma/migrations_backup/20260530120000_portfolio_publications/migration.sql b/apps/server/prisma/migrations_backup/20260530120000_portfolio_publications/migration.sql new file mode 100644 index 0000000..efce578 --- /dev/null +++ b/apps/server/prisma/migrations_backup/20260530120000_portfolio_publications/migration.sql @@ -0,0 +1,38 @@ +CREATE TYPE "SubscriptionStatus" AS ENUM ('INACTIVE', 'TRIALING', 'ACTIVE', 'PAST_DUE', 'CANCELED'); + +CREATE TABLE "Subscription" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "providerCustomerId" TEXT, + "providerPriceId" TEXT, + "providerSubId" TEXT, + "status" "SubscriptionStatus" NOT NULL DEFAULT 'INACTIVE', + "currentPeriodEnd" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "Subscription_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "PortfolioPublication" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "documentId" TEXT NOT NULL, + "subdomain" TEXT NOT NULL, + "templateId" TEXT NOT NULL, + "snapshot" JSONB NOT NULL, + "publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "PortfolioPublication_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "Subscription_providerSubId_key" ON "Subscription"("providerSubId"); +CREATE INDEX "Subscription_userId_status_idx" ON "Subscription"("userId", "status"); +CREATE UNIQUE INDEX "PortfolioPublication_userId_key" ON "PortfolioPublication"("userId"); +CREATE UNIQUE INDEX "PortfolioPublication_documentId_key" ON "PortfolioPublication"("documentId"); +CREATE UNIQUE INDEX "PortfolioPublication_subdomain_key" ON "PortfolioPublication"("subdomain"); +CREATE INDEX "PortfolioPublication_subdomain_idx" ON "PortfolioPublication"("subdomain"); + +ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "PortfolioPublication" ADD CONSTRAINT "PortfolioPublication_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "PortfolioPublication" ADD CONSTRAINT "PortfolioPublication_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/server/prisma/migrations_backup/20260530180000_portfolio_production_launch/migration.sql b/apps/server/prisma/migrations_backup/20260530180000_portfolio_production_launch/migration.sql new file mode 100644 index 0000000..3688d15 --- /dev/null +++ b/apps/server/prisma/migrations_backup/20260530180000_portfolio_production_launch/migration.sql @@ -0,0 +1,68 @@ +CREATE TYPE "BillingInterval" AS ENUM ('MONTHLY', 'ANNUAL'); +CREATE TYPE "PortfolioPublicationStatus" AS ENUM ('LIVE', 'GRACE', 'SUSPENDED'); +CREATE TYPE "BillingWebhookStatus" AS ENUM ('PROCESSING', 'PROCESSED', 'FAILED'); +CREATE TYPE "PortfolioAssetKind" AS ENUM ('AVATAR', 'PROJECT_COVER', 'SOCIAL_IMAGE'); +CREATE TYPE "PortfolioAssetStatus" AS ENUM ('PENDING', 'READY'); + +ALTER TABLE "Subscription" +ADD COLUMN "interval" "BillingInterval", +ADD COLUMN "rawStatus" TEXT, +ADD COLUMN "cancelAtPeriodEnd" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "graceEndsAt" TIMESTAMP(3), +ADD COLUMN "lastWebhookAt" TIMESTAMP(3); + +ALTER TABLE "PortfolioPublication" +ADD COLUMN "status" "PortfolioPublicationStatus" NOT NULL DEFAULT 'LIVE', +ADD COLUMN "publishedRevision" INTEGER NOT NULL DEFAULT 1, +ADD COLUMN "suspensionReason" TEXT, +ADD COLUMN "suspendedAt" TIMESTAMP(3); + +CREATE TABLE "BillingWebhookEvent" ( + "id" TEXT NOT NULL, + "providerEventId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "payload" JSONB NOT NULL, + "status" "BillingWebhookStatus" NOT NULL DEFAULT 'PROCESSING', + "error" TEXT, + "processedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "BillingWebhookEvent_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "PortfolioAsset" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "key" TEXT NOT NULL, + "kind" "PortfolioAssetKind" NOT NULL, + "mimeType" TEXT NOT NULL, + "sizeBytes" INTEGER NOT NULL, + "checksum" TEXT, + "status" "PortfolioAssetStatus" NOT NULL DEFAULT 'PENDING', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "PortfolioAsset_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "PortfolioViewDaily" ( + "id" TEXT NOT NULL, + "publicationId" TEXT NOT NULL, + "date" TIMESTAMP(3) NOT NULL, + "referrerHost" TEXT NOT NULL DEFAULT '', + "count" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "PortfolioViewDaily_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "BillingWebhookEvent_providerEventId_key" ON "BillingWebhookEvent"("providerEventId"); +CREATE INDEX "BillingWebhookEvent_type_createdAt_idx" ON "BillingWebhookEvent"("type", "createdAt"); +CREATE INDEX "BillingWebhookEvent_status_createdAt_idx" ON "BillingWebhookEvent"("status", "createdAt"); +CREATE UNIQUE INDEX "PortfolioAsset_key_key" ON "PortfolioAsset"("key"); +CREATE INDEX "PortfolioAsset_userId_status_idx" ON "PortfolioAsset"("userId", "status"); +CREATE UNIQUE INDEX "PortfolioViewDaily_publicationId_date_referrerHost_key" ON "PortfolioViewDaily"("publicationId", "date", "referrerHost"); +CREATE INDEX "PortfolioViewDaily_publicationId_date_idx" ON "PortfolioViewDaily"("publicationId", "date"); +CREATE INDEX "PortfolioPublication_status_subdomain_idx" ON "PortfolioPublication"("status", "subdomain"); + +ALTER TABLE "PortfolioAsset" ADD CONSTRAINT "PortfolioAsset_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "PortfolioViewDaily" ADD CONSTRAINT "PortfolioViewDaily_publicationId_fkey" FOREIGN KEY ("publicationId") REFERENCES "PortfolioPublication"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/server/prisma/migrations_backup/20260531013000_server_query_indexes/migration.sql b/apps/server/prisma/migrations_backup/20260531013000_server_query_indexes/migration.sql new file mode 100644 index 0000000..1fd7c21 --- /dev/null +++ b/apps/server/prisma/migrations_backup/20260531013000_server_query_indexes/migration.sql @@ -0,0 +1,16 @@ +DROP INDEX IF EXISTS "Document_userId_type_idx"; +DROP INDEX IF EXISTS "Document_userId_updatedAt_idx"; +DROP INDEX IF EXISTS "PortfolioPublication_subdomain_idx"; +DROP INDEX IF EXISTS "PortfolioPublication_status_subdomain_idx"; + +CREATE INDEX "Document_userId_type_deletedAt_updatedAt_idx" +ON "Document"("userId", "type", "deletedAt", "updatedAt"); + +CREATE INDEX "Document_userId_deletedAt_updatedAt_idx" +ON "Document"("userId", "deletedAt", "updatedAt"); + +CREATE INDEX "Subscription_userId_updatedAt_idx" +ON "Subscription"("userId", "updatedAt"); + +CREATE INDEX "PortfolioPublication_status_updatedAt_idx" +ON "PortfolioPublication"("status", "updatedAt"); diff --git a/apps/server/prisma/migrations_backup/20260531014500_usage_metric_flush_batches/migration.sql b/apps/server/prisma/migrations_backup/20260531014500_usage_metric_flush_batches/migration.sql new file mode 100644 index 0000000..694c8bd --- /dev/null +++ b/apps/server/prisma/migrations_backup/20260531014500_usage_metric_flush_batches/migration.sql @@ -0,0 +1,8 @@ +CREATE TABLE "UsageMetricFlushBatch" ( + "id" TEXT NOT NULL, + "date" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "UsageMetricFlushBatch_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "UsageMetricFlushBatch_date_idx" ON "UsageMetricFlushBatch"("date"); diff --git a/apps/server/prisma/migrations_backup/20260531020000_view_flush_durability/migration.sql b/apps/server/prisma/migrations_backup/20260531020000_view_flush_durability/migration.sql new file mode 100644 index 0000000..114853a --- /dev/null +++ b/apps/server/prisma/migrations_backup/20260531020000_view_flush_durability/migration.sql @@ -0,0 +1,12 @@ +ALTER TABLE "BillingWebhookEvent" +ADD COLUMN "retryCount" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "lastAttemptAt" TIMESTAMP(3); + +CREATE TABLE "ViewFlushBatch" ( + "id" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "ViewFlushBatch_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "ViewFlushBatch_kind_createdAt_idx" ON "ViewFlushBatch"("kind", "createdAt"); diff --git a/apps/server/prisma/migrations/add_unique_resume_share_link/migration.sql b/apps/server/prisma/migrations_backup/add_unique_resume_share_link/migration.sql similarity index 100% rename from apps/server/prisma/migrations/add_unique_resume_share_link/migration.sql rename to apps/server/prisma/migrations_backup/add_unique_resume_share_link/migration.sql diff --git a/apps/server/prisma/migrations_backup/migration_lock.toml b/apps/server/prisma/migrations_backup/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/apps/server/prisma/migrations_backup/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma index ffc2653..0a868f8 100644 --- a/apps/server/prisma/schema.prisma +++ b/apps/server/prisma/schema.prisma @@ -2,7 +2,7 @@ // learn more about it in the docs: https://pris.ly/d/prisma-schema generator client { - provider = "prisma-client-js" + provider = "prisma-client-js" binaryTargets = ["native", "linux-musl-openssl-3.0.x"] } @@ -12,22 +12,25 @@ datasource db { // User model for authentication and resume ownership model User { - id String @id @default(cuid()) - email String @unique - name String? - username String? @unique - emailVerified Boolean @default(false) - image String? - autoSyncEnabled Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - sessions Session[] - accounts Account[] - resumes Document[] - masterProfile MasterProfile? - shareLinks ShareLink[] - roadmapInteractions RoadmapInteraction[] - apiKeys ApiKey[] + id String @id @default(cuid()) + email String @unique + name String? + username String? @unique + emailVerified Boolean @default(false) + image String? + autoSyncEnabled Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + sessions Session[] + accounts Account[] + resumes Document[] + masterProfile MasterProfile? + shareLinks ShareLink[] + roadmapInteractions RoadmapInteraction[] + apiKeys ApiKey[] + subscriptions Subscription[] + portfolioPublication PortfolioPublication? + portfolioAssets PortfolioAsset[] @@index([email]) } @@ -97,38 +100,164 @@ enum Visibility { // Unified Document model for all product types model Document { - id String @id @default(cuid()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - type DocumentType @default(RESUME) - title String @default("Untitled Document") - slug String // Current editable document slug - tags String[] @default([]) - - content Json // The main content (JSON Resume standard) - metadata Json? // UI state, favorites, tags, etc. - - templateId String @default("modern") - schemaVersion Int @default(1) - revision Int @default(1) // Optimistic concurrency control - - visibility Visibility @default(PRIVATE) - + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + type DocumentType @default(RESUME) + title String @default("Untitled Document") + slug String // Current editable document slug + tags String[] @default([]) + + content Json // The main content (JSON Resume standard) + metadata Json? // UI state, favorites, tags, etc. + + templateId String @default("modern") + schemaVersion Int @default(1) + revision Int @default(1) // Optimistic concurrency control + + visibility Visibility @default(PRIVATE) + // Sync metadata - lastSyncedAt DateTime? - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - deletedAt DateTime? // Soft delete + lastSyncedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? // Soft delete - shareLinks ShareLink[] + shareLinks ShareLink[] + portfolioPublication PortfolioPublication? @@unique([userId, slug]) - @@index([userId, type]) + @@index([userId, type, deletedAt, updatedAt]) + @@index([userId, deletedAt, updatedAt]) +} + +enum SubscriptionStatus { + INACTIVE + TRIALING + ACTIVE + PAST_DUE + CANCELED +} + +enum BillingInterval { + MONTHLY + ANNUAL +} + +model Subscription { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + provider String + providerCustomerId String? + providerPriceId String? + providerSubId String? @unique + status SubscriptionStatus @default(INACTIVE) + interval BillingInterval? + rawStatus String? + currentPeriodEnd DateTime? + cancelAtPeriodEnd Boolean @default(false) + graceEndsAt DateTime? + lastWebhookAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId, status]) @@index([userId, updatedAt]) } +enum PortfolioPublicationStatus { + LIVE + GRACE + SUSPENDED +} + +model PortfolioPublication { + id String @id @default(cuid()) + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + documentId String @unique + document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) + subdomain String @unique + templateId String + snapshot Json + status PortfolioPublicationStatus @default(LIVE) + publishedRevision Int @default(1) + suspensionReason String? + suspendedAt DateTime? + publishedAt DateTime @default(now()) + updatedAt DateTime @updatedAt + views PortfolioViewDaily[] + + @@index([status, updatedAt]) +} + +enum BillingWebhookStatus { + PROCESSING + PROCESSED + FAILED +} + +model BillingWebhookEvent { + id String @id @default(cuid()) + providerEventId String @unique + type String + payload Json + status BillingWebhookStatus @default(PROCESSING) + error String? + processedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + retryCount Int @default(0) + lastAttemptAt DateTime? + + @@index([type, createdAt]) + @@index([status, createdAt]) +} + +enum PortfolioAssetKind { + AVATAR + PROJECT_COVER + SOCIAL_IMAGE +} + +enum PortfolioAssetStatus { + PENDING + READY +} + +model PortfolioAsset { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + key String @unique + kind PortfolioAssetKind + mimeType String + sizeBytes Int + checksum String? + status PortfolioAssetStatus @default(PENDING) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId, status]) +} + +model PortfolioViewDaily { + id String @id @default(cuid()) + publicationId String + publication PortfolioPublication @relation(fields: [publicationId], references: [id], onDelete: Cascade) + date DateTime + referrerHost String @default("") + count Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([publicationId, date, referrerHost]) + @@index([publicationId, date]) +} + model MasterProfile { id String @id @default(cuid()) userId String @unique @@ -139,24 +268,24 @@ model MasterProfile { } model ShareLink { - id String @id @default(cuid()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - documentId String - document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) - - slug String // Stable public URL slug - snapshot Json // Static snapshot of document at share time - + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + documentId String + document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) + + slug String // Stable public URL slug + snapshot Json // Static snapshot of document at share time + passwordHash String? expiresAt DateTime? viewCount Int @default(0) lastViewedAt DateTime? - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - views ShareView[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + views ShareView[] @@unique([userId, documentId]) @@unique([userId, slug]) @@ -164,12 +293,12 @@ model ShareLink { } model ShareView { - id String @id @default(cuid()) - shareLinkId String - shareLink ShareLink @relation(fields: [shareLinkId], references: [id], onDelete: Cascade) - ipAddress String? - userAgent String? - createdAt DateTime @default(now()) + id String @id @default(cuid()) + shareLinkId String + shareLink ShareLink @relation(fields: [shareLinkId], references: [id], onDelete: Cascade) + ipAddress String? + userAgent String? + createdAt DateTime @default(now()) @@index([shareLinkId]) @@index([createdAt]) @@ -232,24 +361,40 @@ model UsageMetricDaily { @@index([event, date]) } +model UsageMetricFlushBatch { + id String @id + date DateTime + createdAt DateTime @default(now()) + + @@index([date]) +} + +model ViewFlushBatch { + id String @id + kind String + createdAt DateTime @default(now()) + + @@index([kind, createdAt]) +} + // GitHub Stats sync record model GitHubSync { - id String @id @default(cuid()) + id String @id @default(cuid()) projectName String projectUrl String - issueCount Int @default(0) - onlyIssueCount Int @default(0) - prCount Int @default(0) - todoCount Int @default(0) - inProgressCount Int @default(0) - doneCount Int @default(0) + issueCount Int @default(0) + onlyIssueCount Int @default(0) + prCount Int @default(0) + todoCount Int @default(0) + inProgressCount Int @default(0) + doneCount Int @default(0) data Json // Full GitHub data etag String? lastSyncStatus String? // "success", "failed", "not-modified" lastError String? nextSyncAt DateTime? - syncedAt DateTime @updatedAt - createdAt DateTime @default(now()) + syncedAt DateTime @updatedAt + createdAt DateTime @default(now()) items GitHubSyncItem[] @@unique([projectUrl]) @@ -271,6 +416,7 @@ model GitHubSyncItem { updatedAt DateTime insertedAt DateTime @default(now()) + @@unique([syncId, githubId]) @@index([syncId]) @@index([status]) @@index([kind]) @@ -280,26 +426,25 @@ model GitHubSyncItem { @@index([syncId, status, updatedAt]) @@index([syncId, kind, updatedAt]) @@index([syncId, status, kind, updatedAt]) - @@unique([syncId, githubId]) } // API Keys for rate limiting and access control model ApiKey { - id String @id @default(cuid()) - keyHash String @unique - keyPrefix String - keySuffix String - name String - scopes String[] @default(["user:read"]) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - isActive Boolean @default(true) - rateLimit Int @default(20) - expiresAt DateTime? - revokedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - lastUsed DateTime? + id String @id @default(cuid()) + keyHash String @unique + keyPrefix String + keySuffix String + name String + scopes String[] @default(["user:read"]) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + isActive Boolean @default(true) + rateLimit Int @default(20) + expiresAt DateTime? + revokedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastUsed DateTime? @@index([userId]) @@index([userId, isActive]) diff --git a/apps/server/src/auth/mailer.ts b/apps/server/src/auth/mailer.ts index 23b859d..ba0018a 100644 --- a/apps/server/src/auth/mailer.ts +++ b/apps/server/src/auth/mailer.ts @@ -1,6 +1,7 @@ import nodemailer from "nodemailer"; -import { config } from "#config"; +import { config, isDevelopment } from "#config"; + import { logger } from "#utils/logger"; interface AuthOtpEmailPayload { @@ -126,6 +127,10 @@ export async function sendAuthOtpEmail(payload: AuthOtpEmailPayload): Promise 0, `${name} must be a positive integer`); +} + export function validateAuthRuntimeConfig(): void { ensure(Boolean(config.admin.email), "ADMIN_EMAIL must be configured for admin auth"); ensure(Boolean(config.auth.secret), "AUTH_SECRET must be configured"); ensure(Boolean(config.auth.baseUrl), "AUTH_BASE_URL must be configured"); + ensurePositiveInteger(config.auth.sessionTtlSeconds, "AUTH_SESSION_TTL_SECONDS"); + ensurePositiveInteger(config.auth.sessionResetTtlOnUse, "AUTH_SESSION_RESET_TTL_ON_USE"); + ensurePositiveInteger( + config.auth.sessionCacheMaxAgeSeconds, + "AUTH_SESSION_CACHE_MAX_AGE_SECONDS", + ); + ensurePositiveInteger(config.auth.otpTtlSeconds, "AUTH_OTP_TTL_SECONDS"); + ensurePositiveInteger(config.auth.otpAllowedAttempts, "AUTH_OTP_ALLOWED_ATTEMPTS"); + ensurePositiveInteger(config.apiKeys.authCacheTtlSeconds, "API_KEY_AUTH_CACHE_TTL_SECONDS"); + ensurePositiveInteger( + config.apiKeys.lastUsedTouchIntervalSeconds, + "API_KEY_LAST_USED_TOUCH_INTERVAL_SECONDS", + ); if (config.auth.emailProvider === "smtp") { ensure(Boolean(config.auth.smtpHost), "AUTH_SMTP_HOST must be configured when using SMTP"); @@ -21,6 +38,7 @@ export function validateAuthRuntimeConfig(): void { } if (isProduction) { + ensure(config.auth.emailProvider === "smtp", "AUTH_EMAIL_PROVIDER must be smtp in production"); ensure( config.auth.secret !== "dev-auth-secret", "AUTH_SECRET must be a strong non-default value in production", @@ -29,6 +47,11 @@ export function validateAuthRuntimeConfig(): void { config.auth.baseUrl.startsWith("https://"), "AUTH_BASE_URL must use https in production", ); + ensure(config.server.trustProxy !== true, "TRUST_PROXY must use an explicit hop count or CIDR"); + ensure( + Boolean(process.env.API_KEY_HASH_SECRET) && config.apiKeys.hashSecret !== config.auth.secret, + "API_KEY_HASH_SECRET must be a dedicated non-empty value in production", + ); } } diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 1f4a4ad..8bde2cd 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -11,10 +11,31 @@ function parseBoolean(value: string | undefined, fallback: boolean): boolean { const defaultAuthSessionCacheEnabled = (process.env.NODE_ENV || "development") === "production" ? "true" : "false"; +function parseTrustProxy(value: string | undefined): boolean | string | number { + if (value == null) return false; + + const trimmed = value.trim(); + const lower = trimmed.toLowerCase(); + + if (["true", "yes", "on"].includes(lower)) return true; + if (["false", "no", "off"].includes(lower)) return false; + + const num = Number(trimmed); + + if (!Number.isNaN(num)) return num; + + return trimmed; +} + export const config = { nodeEnv: process.env.NODE_ENV || "development", + port: parseInt(process.env.PORT || "8080", 10), - allowedOrigins: (process.env.ALLOWED_ORIGINS || "http://localhost:3000") + + allowedOrigins: ( + process.env.ALLOWED_ORIGINS || + "http://localhost:3000,http://localhost:3001,http://localhost:3004" + ) .split(",") .map((origin) => origin.trim()) .filter(Boolean), @@ -45,8 +66,10 @@ export const config = { .filter(Boolean), sessionTtlSeconds: parseInt(process.env.AUTH_SESSION_TTL_SECONDS || "2592000", 10), sessionResetTtlOnUse: parseInt(process.env.AUTH_SESSION_RESET_TTL_ON_USE || "86400", 10), - sessionCacheEnabled: - (process.env.AUTH_SESSION_CACHE_ENABLED || defaultAuthSessionCacheEnabled) === "true", + sessionCacheEnabled: parseBoolean( + process.env.AUTH_SESSION_CACHE_ENABLED, + defaultAuthSessionCacheEnabled === "true", + ), sessionCacheMaxAgeSeconds: parseInt( process.env.AUTH_SESSION_CACHE_MAX_AGE_SECONDS || "900", 10, @@ -57,7 +80,7 @@ export const config = { emailFrom: process.env.AUTH_EMAIL_FROM || "VeriWorkly ", smtpHost: process.env.AUTH_SMTP_HOST || "", smtpPort: parseInt(process.env.AUTH_SMTP_PORT || "587", 10), - smtpSecure: (process.env.AUTH_SMTP_SECURE || "false") === "true", + smtpSecure: parseBoolean(process.env.AUTH_SMTP_SECURE, false), smtpUser: process.env.AUTH_SMTP_USER || "", smtpPass: process.env.AUTH_SMTP_PASS || "", cookieDomain: process.env.AUTH_COOKIE_DOMAIN || undefined, @@ -79,7 +102,7 @@ export const config = { }, server: { - trustProxy: parseBoolean(process.env.TRUST_PROXY, false), + trustProxy: parseTrustProxy(process.env.TRUST_PROXY), }, admin: { @@ -117,9 +140,40 @@ export const config = { projectUrl: process.env.GITHUB_PROJECT_URL || "", syncCron: process.env.GITHUB_SYNC_CRON || "0 0,12 * * *", syncTimezone: process.env.GITHUB_SYNC_TIMEZONE || "UTC", - syncEnabled: (process.env.GITHUB_SYNC_ENABLED || "true") === "true", + syncEnabled: parseBoolean(process.env.GITHUB_SYNC_ENABLED, true), syncApiKey: process.env.INTERNAL_SYNC_API_KEY || "", }, + + portfolio: { + graceDays: parseInt(process.env.PORTFOLIO_GRACE_DAYS || "7", 10), + url: process.env.PORTFOLIO_URL || "http://localhost:3004", + revalidateSecret: process.env.PORTFOLIO_REVALIDATE_SECRET || "dev-revalidate-secret", + }, + + dodo: { + apiKey: process.env.DODO_PAYMENTS_API_KEY || "", + webhookSecret: process.env.DODO_PAYMENTS_WEBHOOK_SECRET || "", + environment: (process.env.DODO_PAYMENTS_ENVIRONMENT || "test_mode") as + | "test_mode" + | "live_mode", + monthlyProductId: process.env.DODO_PAYMENTS_MONTHLY_PRODUCT_ID || "", + annualProductId: process.env.DODO_PAYMENTS_ANNUAL_PRODUCT_ID || "", + checkoutReturnUrl: + process.env.DODO_PAYMENTS_CHECKOUT_RETURN_URL || + "http://localhost:3004/billing?checkout=complete", + checkoutCancelUrl: + process.env.DODO_PAYMENTS_CHECKOUT_CANCEL_URL || + "http://localhost:3004/billing?checkout=cancelled", + portalReturnUrl: process.env.DODO_PAYMENTS_PORTAL_RETURN_URL || "http://localhost:3004/billing", + }, + + r2: { + endpoint: process.env.R2_ENDPOINT || "", + bucket: process.env.R2_BUCKET || "", + accessKeyId: process.env.R2_ACCESS_KEY_ID || "", + secretAccessKey: process.env.R2_SECRET_ACCESS_KEY || "", + publicBaseUrl: (process.env.R2_PUBLIC_BASE_URL || "").replace(/\/+$/, ""), + }, }; export const isDevelopment = config.nodeEnv === "development"; diff --git a/apps/server/src/controllers/billingController.ts b/apps/server/src/controllers/billingController.ts new file mode 100644 index 0000000..bd5ff0e --- /dev/null +++ b/apps/server/src/controllers/billingController.ts @@ -0,0 +1,70 @@ +import type { NextFunction, Request, Response } from "express"; + +import { z } from "zod"; + +import { requireAuthUser } from "#middleware/auth"; + +import { BillingService } from "#services/billingService"; + +import { ApiError, createSuccessResponse, handleValidationError } from "#utils/errors"; + +import { checkoutSchema, dodoWebhookHeaderSchema } from "#validators/billingValidator"; + +export class BillingController { + static async getMe(req: Request, res: Response, next: NextFunction) { + try { + res.json(createSuccessResponse(await BillingService.getSummary(requireAuthUser(req).id))); + } catch (error) { + next(error); + } + } + + static async checkout(req: Request, res: Response, next: NextFunction) { + try { + const input = checkoutSchema.parse(req.body); + + res.json( + createSuccessResponse( + await BillingService.createCheckout( + requireAuthUser(req).id, + input.interval, + input.redirectUrl, + ), + ), + ); + } catch (error) { + next(error instanceof z.ZodError ? handleValidationError(error) : error); + } + } + + static async portal(req: Request, res: Response, next: NextFunction) { + try { + res.json(createSuccessResponse(await BillingService.createPortal(requireAuthUser(req).id))); + } catch (error) { + next(error); + } + } + + static async dodoWebhook(req: Request, res: Response, next: NextFunction) { + try { + const rawBody = Buffer.isBuffer(req.body) ? req.body.toString("utf8") : ""; + + if (!rawBody) throw new ApiError(400, "Webhook body is required."); + + const headers = Object.fromEntries( + Object.entries(req.headers).flatMap(([key, value]) => + typeof value === "string" ? [[key, value]] : [], + ), + ); + + const parsedHeaders = dodoWebhookHeaderSchema.parse(headers); + const providerEventId = parsedHeaders["webhook-id"]; + + const event = BillingService.unwrapWebhook(rawBody, headers); + + res.json(createSuccessResponse(await BillingService.processWebhook(providerEventId, event))); + } catch (error) { + next(error instanceof z.ZodError ? handleValidationError(error) : error); + } + } +} diff --git a/apps/server/src/controllers/portfolioAssetController.ts b/apps/server/src/controllers/portfolioAssetController.ts new file mode 100644 index 0000000..a2f7e69 --- /dev/null +++ b/apps/server/src/controllers/portfolioAssetController.ts @@ -0,0 +1,65 @@ +import type { NextFunction, Request, Response } from "express"; + +import { z } from "zod"; + +import { requireAuthUser } from "#middleware/auth"; + +import { PortfolioAssetService } from "#services/portfolioAssetService"; + +import { createSuccessResponse, handleValidationError } from "#utils/errors"; + +const uploadSchema = z.object({ + kind: z.enum(["AVATAR", "PROJECT_COVER", "SOCIAL_IMAGE"]), + mimeType: z.string().min(1), + sizeBytes: z.number().int().positive(), +}); + +const completeSchema = z.object({ + assetId: z.string().min(1), + checksum: z.string().max(256).optional(), +}); + +export class PortfolioAssetController { + static async uploadUrl(req: Request, res: Response, next: NextFunction) { + try { + res.json( + createSuccessResponse( + await PortfolioAssetService.createUploadUrl( + requireAuthUser(req).id, + uploadSchema.parse(req.body), + ), + ), + ); + } catch (error) { + next(error instanceof z.ZodError ? handleValidationError(error) : error); + } + } + + static async complete(req: Request, res: Response, next: NextFunction) { + try { + const input = completeSchema.parse(req.body); + + res.json( + createSuccessResponse( + await PortfolioAssetService.complete( + requireAuthUser(req).id, + input.assetId, + input.checksum, + ), + ), + ); + } catch (error) { + next(error instanceof z.ZodError ? handleValidationError(error) : error); + } + } + + static async delete(req: Request, res: Response, next: NextFunction) { + try { + await PortfolioAssetService.delete(requireAuthUser(req).id, req.params.id); + + res.json(createSuccessResponse(null)); + } catch (error) { + next(error); + } + } +} diff --git a/apps/server/src/controllers/portfolioController.ts b/apps/server/src/controllers/portfolioController.ts new file mode 100644 index 0000000..3ff3925 --- /dev/null +++ b/apps/server/src/controllers/portfolioController.ts @@ -0,0 +1,130 @@ +import type { NextFunction, Request, Response } from "express"; + +import { z } from "zod"; + +import { + portfolioPublishSchema, + portfolioSaveDraftSchema, + portfolioSubdomainParamsSchema, +} from "#validators/portfolioValidator"; + +import { requireAuthUser } from "#middleware/auth"; + +import { PortfolioService } from "#services/portfolioService"; + +import { ApiError, createSuccessResponse, handleValidationError } from "#utils/errors"; + +const publicPortfolioListQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(100).optional(), + offset: z.coerce.number().int().min(0).max(5000).optional(), +}); + +export class PortfolioController { + static async listPublic(req: Request, res: Response, next: NextFunction) { + try { + const { limit, offset } = publicPortfolioListQuerySchema.parse(req.query); + + res.json(createSuccessResponse(await PortfolioService.listPublicPortfolios(limit, offset))); + } catch (error) { + next(error instanceof z.ZodError ? handleValidationError(error) : error); + } + } + + static async getPublic(req: Request, res: Response, next: NextFunction) { + try { + const publication = await PortfolioService.getPublicPortfolio(req.params.subdomain); + + if (!publication) throw new ApiError(404, "Portfolio not found"); + + res.json(createSuccessResponse(publication)); + } catch (error) { + next(error); + } + } + + static async recordView(req: Request, res: Response, next: NextFunction) { + try { + await PortfolioService.recordView(req.params.subdomain, req.body?.referrer); + + res.status(202).json(createSuccessResponse(null)); + } catch (error) { + next(error); + } + } + + static async getMe(req: Request, res: Response, next: NextFunction) { + try { + res.json(createSuccessResponse(await PortfolioService.getMe(requireAuthUser(req).id))); + } catch (error) { + next(error); + } + } + + static async preview(req: Request, res: Response, next: NextFunction) { + try { + res.json( + createSuccessResponse( + await PortfolioService.getPreview(requireAuthUser(req).id, req.params.documentId), + ), + ); + } catch (error) { + next(error); + } + } + + static async subdomainAvailability(req: Request, res: Response, next: NextFunction) { + try { + const { slug } = portfolioSubdomainParamsSchema.parse(req.params); + + res.json( + createSuccessResponse( + await PortfolioService.isSubdomainAvailable(requireAuthUser(req).id, slug), + ), + ); + } catch (error) { + next(error instanceof z.ZodError ? handleValidationError(error) : error); + } + } + + static async saveDraft(req: Request, res: Response, next: NextFunction) { + try { + const input = portfolioSaveDraftSchema.parse(req.body); + + res.json( + createSuccessResponse(await PortfolioService.saveDraft(requireAuthUser(req).id, input)), + ); + } catch (error) { + next(error instanceof z.ZodError ? handleValidationError(error) : error); + } + } + + static async publish(req: Request, res: Response, next: NextFunction) { + try { + const input = portfolioPublishSchema.parse(req.body); + + res + .status(201) + .json( + createSuccessResponse(await PortfolioService.publish(requireAuthUser(req).id, input)), + ); + } catch (error) { + next(error instanceof z.ZodError ? handleValidationError(error) : error); + } + } + + static async unpublish(req: Request, res: Response, next: NextFunction) { + try { + res.json(createSuccessResponse(await PortfolioService.unpublish(requireAuthUser(req).id))); + } catch (error) { + next(error); + } + } + + static async analytics(req: Request, res: Response, next: NextFunction) { + try { + res.json(createSuccessResponse(await PortfolioService.getAnalytics(requireAuthUser(req).id))); + } catch (error) { + next(error); + } + } +} diff --git a/apps/server/src/controllers/statsController.ts b/apps/server/src/controllers/statsController.ts index 248efb4..ef2e8f4 100644 --- a/apps/server/src/controllers/statsController.ts +++ b/apps/server/src/controllers/statsController.ts @@ -1,18 +1,24 @@ import { z } from "zod"; import { Request, Response, NextFunction } from "express"; -import { createSuccessResponse } from "#utils/errors"; +import { config } from "#config"; +import { createSuccessResponse, createErrorResponse } from "#utils/errors"; +import { + KNOWN_EVENTS, + incrementUsageMetric, + getAdminDashboardMetrics, +} from "#services/analyticsService"; import { getGitHubStats } from "#services/githubService"; -import { getAdminDashboardMetrics, incrementUsageMetric } from "#services/analyticsService"; /** * Validation schema for incoming usage metrics. - * Ensures the event name is a non-empty string and the value is a positive integer. + * Ensures the event name is a non-empty string with a maximum length of 64 characters, + * and the value is a positive integer. */ const usageMetricEventSchema = z.object({ - event: z.string().trim().min(1), + event: z.string().trim().min(1).max(64), value: z.number().int().positive().max(1000).optional(), }); @@ -28,12 +34,30 @@ export class StatsController { static async recordUsageMetric(req: Request, res: Response, next: NextFunction) { try { - if (process.env.NODE_ENV === "development") { + if (process.env.NODE_ENV === "development") return res.status(202).json("Skipping metric recording in development mode"); - } const payload = usageMetricEventSchema.parse(req.body); + const isInternal = !!( + req.apiKey || + (req.headers["x-internal-key"] && + req.headers["x-internal-key"] === config.github.syncApiKey) + ); + + const normalizedEvent = payload.event.trim().toLowerCase().replace(/\s+/g, "_"); + + if (!isInternal && !(KNOWN_EVENTS as readonly string[]).includes(normalizedEvent)) { + return res + .status(400) + .json( + createErrorResponse( + 400, + `Event '${payload.event}' is not allowlisted for public access.`, + ), + ); + } + await incrementUsageMetric(payload); res.status(202).json(createSuccessResponse({ accepted: true }, "Metric accepted")); diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 8bcd973..1d54021 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -1,5 +1,5 @@ import helmet from "helmet"; -import express from "express"; +import express, { raw } from "express"; import { config, isDevelopment } from "#config"; @@ -20,14 +20,20 @@ import healthRoutes from "#routes/health"; import sharesRoutes from "#routes/shares"; import apiKeyRoutes from "#routes/apiKeys"; import roadmapRoutes from "#routes/roadmap"; +import billingRoutes from "#routes/billing"; import profileRoutes from "#routes/profiles"; import documentRoutes from "#routes/documents"; +import portfolioRoutes from "#routes/portfolios"; +import portfolioAssetRoutes from "#routes/portfolioAssets"; import { authNodeHandler } from "#auth/index"; +import { BillingController } from "#controllers/billingController"; import { ensureAdminUserExists, validateAuthRuntimeConfig } from "#auth/runtime"; import { startGitHubSyncJob, stopGitHubSyncJob } from "#jobs/githubSyncJob"; +import { startViewsFlushJob, stopViewsFlushJob } from "#jobs/viewsFlushJob"; import { startUsageMetricsJob, stopUsageMetricsJob } from "#jobs/usageMetricsJob"; +import { startPortfolioAccessJob, stopPortfolioAccessJob } from "#jobs/portfolioAccessJob"; const app = express(); @@ -44,6 +50,12 @@ app.use(rateLimitMiddleware); app.use(loggingMiddleware); // Body parser middleware +app.post( + "/api/v1/billing/webhooks/dodo", + raw({ type: "application/json", limit: "1mb" }), + BillingController.dodoWebhook, +); + app.use(express.json({ limit: "4mb" })); app.use(express.urlencoded({ extended: true, limit: "4mb" })); @@ -58,8 +70,11 @@ app.use("/api/v1/health", healthRoutes); app.use("/api/v1/shares", sharesRoutes); app.use("/api/v1/roadmap", roadmapRoutes); app.use("/api/v1/api-keys", apiKeyRoutes); +app.use("/api/v1/billing", billingRoutes); app.use("/api/v1/profiles", profileRoutes); app.use("/api/v1/documents", documentRoutes); +app.use("/api/v1/portfolios", portfolioRoutes); +app.use("/api/v1/portfolio-assets", portfolioAssetRoutes); app.all("/api/v1/auth", authRequestDiagnosticsMiddleware, authNodeHandler); app.all("/api/v1/auth/*", authRequestDiagnosticsMiddleware, authNodeHandler); @@ -78,13 +93,33 @@ app.use(notFoundHandler); // Error handler (must be last) app.use(errorHandler); +let serverInstance: ReturnType | null = null; + // Graceful shutdown async function shutdown() { logger.info("Shutting down gracefully..."); + if (serverInstance) { + logger.info("Stopping HTTP server from accepting new requests..."); + await new Promise((resolve) => { + serverInstance!.close(() => { + logger.info("HTTP server stopped."); + resolve(); + }); + + // Force timeout shutdown in 10s + setTimeout(() => { + logger.warn("Graceful HTTP shutdown timeout reached. Continuing."); + resolve(); + }, 10000); + }); + } + try { stopGitHubSyncJob(); + stopViewsFlushJob(); stopUsageMetricsJob(); + stopPortfolioAccessJob(); await closeRedis(); await prisma.$disconnect(); @@ -110,14 +145,18 @@ async function main() { await ensureAdminUserExists(); - const server = app.listen(config.port, () => { + serverInstance = app.listen(config.port, () => { logger.info(`Server running on port ${config.port} (${config.nodeEnv})`); + logger.info("IP/rate-limit configuration", { trustProxy: config.server.trustProxy, authIpAddressHeaders: config.auth.ipAddressHeaders, }); + startGitHubSyncJob(); + startViewsFlushJob(); startUsageMetricsJob(); + startPortfolioAccessJob(); if (isDevelopment) { logger.info(`Allowed origins: ${config.allowedOrigins.join(", ")}`); @@ -125,7 +164,7 @@ async function main() { } }); - server.on("error", (err) => { + serverInstance.on("error", (err) => { logger.error("Server error:", err); process.exit(1); }); diff --git a/apps/server/src/jobs/githubSyncJob.ts b/apps/server/src/jobs/githubSyncJob.ts index 61176f2..60bd47c 100644 --- a/apps/server/src/jobs/githubSyncJob.ts +++ b/apps/server/src/jobs/githubSyncJob.ts @@ -1,37 +1,15 @@ -import { v4 as uuidv4 } from "uuid"; import cron, { ScheduledTask } from "node-cron"; import { config } from "#config"; import { logger } from "#utils/logger"; -import { getRedis } from "#utils/redis"; +import { ApiError } from "#utils/errors"; import { shouldSyncGitHubStats, syncGitHubStatsFromGitHub } from "#services/githubService"; let job: ScheduledTask | null = null; async function runSync(reason: "startup" | "cron") { - const redis = getRedis(); - const lockValue = uuidv4(); - - const lockKey = "github:sync:lock"; - - const lockTTL = 600; - - let lockAcquired = false; - try { - const lockResult = await redis.set(lockKey, lockValue, { - NX: true, - EX: lockTTL, - }); - - lockAcquired = lockResult === "OK"; - - if (!lockAcquired) { - logger.debug(`GitHub sync (${reason}) locked by another instance. Skipping.`); - return; - } - const needsSync = await shouldSyncGitHubStats(); if (!needsSync) { @@ -39,28 +17,23 @@ async function runSync(reason: "startup" | "cron") { return; } - const result = await syncGitHubStatsFromGitHub(); + const result = await syncGitHubStatsFromGitHub(reason === "cron"); logger.info(`GitHub sync (${reason}) success`, { - itemsSynced: result.issueCount, - syncedAt: result.syncedAt, + itemsSynced: + typeof result === "object" && result && "issueCount" in result ? result.issueCount : 0, + syncedAt: + typeof result === "object" && result && "syncedAt" in result ? result.syncedAt : new Date(), }); } catch (error) { + if (error instanceof ApiError && error.statusCode === 409) { + logger.debug(`GitHub sync (${reason}) locked by another instance. Skipping.`); + return; + } + logger.error(`GitHub sync (${reason}) failed`, { message: error instanceof Error ? error.message : "Unknown error", }); - } finally { - if (lockAcquired) { - try { - const currentLockValue = await redis.get(lockKey); - - if (currentLockValue === lockValue) { - await redis.del(lockKey); - } - } catch (err) { - logger.error("Lock release error", err); - } - } } } diff --git a/apps/server/src/jobs/portfolioAccessJob.ts b/apps/server/src/jobs/portfolioAccessJob.ts new file mode 100644 index 0000000..1e9eb39 --- /dev/null +++ b/apps/server/src/jobs/portfolioAccessJob.ts @@ -0,0 +1,119 @@ +import { v4 as uuidv4 } from "uuid"; +import cron from "node-cron"; + +import { logger } from "#utils/logger"; +import { prisma } from "#utils/prisma"; +import { getRedis } from "#utils/redis"; +import { PortfolioAssetService } from "#services/portfolioAssetService"; +import { + invalidatePublicPortfolioCaches, + revalidatePublicPortfolios, +} from "#utils/portfolioPublicationCache"; + +let job: ReturnType | null = null; + +const RELEASE_LOCK_LUA_SCRIPT = ` + if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("del", KEYS[1]) + else + return 0 + end +`; + +async function suspendExpiredGracePeriods() { + const redis = getRedis(); + const lockKey = "portfolio:suspension:lock"; + const lockValue = uuidv4(); + const lockTTL = 300; // 5 minutes + + let lockAcquired = false; + + try { + const lockResult = await redis.set(lockKey, lockValue, { + NX: true, + EX: lockTTL, + }); + + lockAcquired = lockResult === "OK"; + + if (!lockAcquired) { + logger.debug("Portfolio grace suspension lock already held by another instance. Skipping."); + return; + } + + const now = new Date(); + const expiredPublications = await prisma.portfolioPublication.findMany({ + where: { + status: "GRACE", + }, + select: { + id: true, + subdomain: true, + user: { + select: { + subscriptions: { + orderBy: { updatedAt: "desc" }, + take: 1, + select: { graceEndsAt: true }, + }, + }, + }, + }, + }); + + const expired = expiredPublications.filter((publication) => { + const graceEndsAt = publication.user.subscriptions[0]?.graceEndsAt; + return !graceEndsAt || graceEndsAt <= now; + }); + + if (expired.length === 0) return; + + const ids = expired.map((publication) => publication.id); + const subdomains = expired.map((publication) => publication.subdomain); + + await prisma.portfolioPublication.updateMany({ + where: { id: { in: ids } }, + data: { status: "SUSPENDED", suspensionReason: "grace_expired", suspendedAt: now }, + }); + + await invalidatePublicPortfolioCaches(subdomains); + void revalidatePublicPortfolios(subdomains); + + logger.info("Suspended expired portfolio grace periods", { count: expired.length }); + } finally { + if (lockAcquired) { + try { + await redis.eval(RELEASE_LOCK_LUA_SCRIPT, { + keys: [lockKey], + arguments: [lockValue], + }); + } catch (err) { + logger.error("Failed to release portfolio suspension lock", err); + } + } + } +} + +function runPortfolioAccessSweep() { + void suspendExpiredGracePeriods().catch((error) => { + logger.error("Failed to suspend expired portfolio grace periods", { + error: error instanceof Error ? error.message : error, + }); + }); + + void PortfolioAssetService.cleanupStaleAssets().catch((error) => { + logger.error("Failed to cleanup stale pending portfolio assets", { + error: error instanceof Error ? error.message : error, + }); + }); +} + +export function startPortfolioAccessJob() { + runPortfolioAccessSweep(); + job = cron.schedule("0 * * * *", runPortfolioAccessSweep); +} + +export function stopPortfolioAccessJob() { + job?.stop(); + job = null; +} diff --git a/apps/server/src/jobs/usageMetricsJob.ts b/apps/server/src/jobs/usageMetricsJob.ts index 7c612c2..7f1d2e8 100644 --- a/apps/server/src/jobs/usageMetricsJob.ts +++ b/apps/server/src/jobs/usageMetricsJob.ts @@ -1,4 +1,5 @@ import cron, { ScheduledTask } from "node-cron"; +import { v4 as uuidv4 } from "uuid"; import { config } from "#config"; import { logger } from "#utils/logger"; @@ -8,6 +9,14 @@ import { flushUsageMetricsForDate, getPendingUsageMetricDates } from "#services/ let job: ScheduledTask | null = null; +const RELEASE_LOCK_LUA_SCRIPT = ` + if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("del", KEYS[1]) + else + return 0 + end +`; + /** * Returns a Date object representing the start of yesterday in UTC. * This ensures we only flush complete day cycles. @@ -27,13 +36,13 @@ async function runFlush(reason: "startup" | "cron") { const redis = getRedis(); const lockKey = "usage:flush:lock"; - + const lockValue = uuidv4(); const lockTTL = 60 * 5; let lockAcquired = false; try { - const lockResult = await redis.set(lockKey, "locked", { + const lockResult = await redis.set(lockKey, lockValue, { NX: true, EX: lockTTL, }); @@ -67,9 +76,14 @@ async function runFlush(reason: "startup" | "cron") { }); } finally { if (lockAcquired) { - await redis - .del(lockKey) - .catch((err) => logger.error("Failed to release metrics flush lock", err)); + try { + await redis.eval(RELEASE_LOCK_LUA_SCRIPT, { + keys: [lockKey], + arguments: [lockValue], + }); + } catch (err) { + logger.error("Failed to release metrics flush lock", err); + } } } } diff --git a/apps/server/src/jobs/viewsFlushJob.ts b/apps/server/src/jobs/viewsFlushJob.ts new file mode 100644 index 0000000..bd5afb5 --- /dev/null +++ b/apps/server/src/jobs/viewsFlushJob.ts @@ -0,0 +1,93 @@ +import cron, { ScheduledTask } from "node-cron"; +import { randomUUID } from "node:crypto"; + +import { logger } from "#utils/logger"; +import { getRedis } from "#utils/redis"; + +import { ShareService } from "#services/shareService"; +import { PortfolioService } from "#services/portfolioService"; + +let job: ScheduledTask | null = null; +const releaseLockLuaScript = ` + if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("del", KEYS[1]) + end + return 0 +`; + +async function runFlush(reason: "startup" | "cron") { + const redis = getRedis(); + const lockKey = "views:flush:lock"; + const lockTTL = 60 * 5; // 5 minutes + const lockValue = randomUUID(); + + let lockAcquired = false; + + try { + const lockResult = await redis.set(lockKey, lockValue, { + NX: true, + EX: lockTTL, + }); + + lockAcquired = lockResult === "OK"; + + if (!lockAcquired) { + logger.warn(`Skipping views flush (${reason}): lock already held`); + return; + } + + // 1. Flush Portfolio Views + const dateKeys = await PortfolioService.getPendingViewDates(); + let flushedPortfolioViews = 0; + + for (const dateKey of dateKeys) { + const result = await PortfolioService.flushViewsForDate(dateKey); + flushedPortfolioViews += result.flushedCount; + } + + // 2. Flush Share Views + const shareFlushResult = await ShareService.flushShareViews(); + + if ( + flushedPortfolioViews > 0 || + (shareFlushResult && (shareFlushResult.flushedViews > 0 || shareFlushResult.flushedLinks > 0)) + ) { + logger.info(`Views flush (${reason}) completed`, { + flushedPortfolioViews, + flushedShareViews: shareFlushResult?.flushedViews ?? 0, + flushedShareLinks: shareFlushResult?.flushedLinks ?? 0, + }); + } + } catch (error) { + logger.error(`Views flush (${reason}) failed`, { + error: error instanceof Error ? error.message : error, + }); + } finally { + if (lockAcquired) + await redis + .eval(releaseLockLuaScript, { keys: [lockKey], arguments: [lockValue] }) + .catch((err) => logger.error("Failed to release views flush lock", err)); + } +} + +export function startViewsFlushJob() { + if (job) return; + + // Run every 10 minutes + job = cron.schedule("*/10 * * * *", () => { + void runFlush("cron"); + }); + + logger.info("Views flush job scheduled (every 10 minutes)"); + + // Check on startup + void runFlush("startup"); +} + +export function stopViewsFlushJob() { + if (job) { + job.stop(); + job = null; + logger.info("Views flush job stopped"); + } +} diff --git a/apps/server/src/middleware/apiKeyRateLimit.ts b/apps/server/src/middleware/apiKeyRateLimit.ts index 0e1b4b1..7a0299e 100644 --- a/apps/server/src/middleware/apiKeyRateLimit.ts +++ b/apps/server/src/middleware/apiKeyRateLimit.ts @@ -12,7 +12,8 @@ const INCREMENT_WITH_EXPIRY_SCRIPT = ` if count == 1 then redis.call("PEXPIRE", KEYS[1], ARGV[1]) end - return count + local ttl = redis.call("PTTL", KEYS[1]) + return {count, ttl} `; /** @@ -37,19 +38,16 @@ export const apiKeyRateLimit = async (req: Request, res: Response, next: NextFun if (!redis.isOpen) throw new Error("Redis not connected"); - const count = Number( - await redis.eval(INCREMENT_WITH_EXPIRY_SCRIPT, { - keys: [redisKey], - arguments: [String(WINDOW_MS)], - }), - ); + const [count, ttl] = (await redis.eval(INCREMENT_WITH_EXPIRY_SCRIPT, { + keys: [redisKey], + arguments: [String(WINDOW_MS)], + })) as [number, number]; const limit = apiKey.rateLimit || MAX_REQUESTS; if (count > limit) { logger.warn(`API Key rate limit exceeded for key: ${apiKey.name} (${keyId})`); - const ttl = Number(await redis.pTTL(redisKey)); const retryAfter = Math.ceil(ttl / 1000); res.set("Retry-After", String(retryAfter)); @@ -69,8 +67,6 @@ export const apiKeyRateLimit = async (req: Request, res: Response, next: NextFun res.set("X-RateLimit-Limit", String(limit)); res.set("X-RateLimit-Remaining", String(limit - count)); - - const ttl = Number(await redis.pTTL(redisKey)); res.set("X-RateLimit-Reset", String(Math.ceil((now + ttl) / 1000))); next(); diff --git a/apps/server/src/middleware/cors.ts b/apps/server/src/middleware/cors.ts index 351ab57..829b801 100644 --- a/apps/server/src/middleware/cors.ts +++ b/apps/server/src/middleware/cors.ts @@ -4,26 +4,31 @@ import { config } from "#config"; import { ApiError } from "#utils/errors"; -export const corsMiddleware = cors({ - origin: (origin, callback) => { - if (!origin) { - callback(null, true); - return; - } +export const corsMiddleware = cors((req, callback) => { + const origin = typeof req.headers.origin === "string" ? req.headers.origin : undefined; - const trustedPortfolioOrigin = - /^https:\/\/[a-z0-9-]+\.veriworkly\.com$/i.test(origin) || - /^http:\/\/[a-z0-9-]+\.localhost:3004$/i.test(origin); + const trustedPortfolioOrigin = Boolean( + origin && + (/^https:\/\/[a-z0-9-]+\.veriworkly\.com$/i.test(origin) || + /^http:\/\/[a-z0-9-]+\.localhost:3004$/i.test(origin)), + ); - if (config.allowedOrigins.includes(origin) || trustedPortfolioOrigin) { - callback(null, true); - } else { - callback(new ApiError(403, "Not allowed by CORS")); - } - }, + callback(null, { + origin: (requestOrigin, originCallback) => { + if (!requestOrigin) { + originCallback(null, true); + return; + } - credentials: true, - methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], - allowedHeaders: ["Content-Type", "Authorization", "X-API-KEY"], - maxAge: 86400, + if (config.allowedOrigins.includes(requestOrigin) || trustedPortfolioOrigin) { + originCallback(null, true); + } else { + originCallback(new ApiError(403, "Not allowed by CORS")); + } + }, + credentials: !trustedPortfolioOrigin, + methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + allowedHeaders: ["Content-Type", "Authorization", "X-API-KEY"], + maxAge: 86400, + }); }); diff --git a/apps/server/src/middleware/flexibleAuth.ts b/apps/server/src/middleware/flexibleAuth.ts index e03a30e..a2b9424 100644 --- a/apps/server/src/middleware/flexibleAuth.ts +++ b/apps/server/src/middleware/flexibleAuth.ts @@ -66,16 +66,35 @@ async function handleFlexibleAuth( const clientIp = req.ip || ""; const isLocal = clientIp === "::1" || clientIp === "127.0.0.1" || clientIp.includes("localhost"); + let parsedOrigin = ""; + + if (origin) { + try { + parsedOrigin = new URL(origin).origin; + } catch { + // Invalid URL/origin + } + } + + let parsedRefererOrigin = ""; + + if (referer) { + try { + parsedRefererOrigin = new URL(referer).origin; + } catch { + // Invalid URL + } + } + const isWhitelisted = - (origin && config.allowedOrigins.some((o) => origin.startsWith(o))) || - (referer && config.allowedOrigins.some((o) => referer.startsWith(o))) || - (!origin && !referer) || + (parsedOrigin && config.allowedOrigins.includes(parsedOrigin)) || + (parsedRefererOrigin && config.allowedOrigins.includes(parsedRefererOrigin)) || (isDevelopment && (isLocal || - referer.includes("localhost:") || - origin.includes("localhost:") || - referer.includes("127.0.0.1:") || - origin.includes("127.0.0.1:"))); + (referer && referer.includes("localhost:")) || + (origin && origin.includes("localhost:")) || + (referer && referer.includes("127.0.0.1:")) || + (origin && origin.includes("127.0.0.1:")))); if (!isWhitelisted) { logger.warn("Request rejected by flexibleAuth: Not whitelisted and no API key", { diff --git a/apps/server/src/middleware/logging.ts b/apps/server/src/middleware/logging.ts index 1152abe..0dc9139 100644 --- a/apps/server/src/middleware/logging.ts +++ b/apps/server/src/middleware/logging.ts @@ -17,7 +17,7 @@ export function loggingMiddleware(req: Request, res: Response, next: NextFunctio if (res.statusCode >= 400) { const duration = Math.round(performance.now() - startTime); - logger.error(`[ERROR] ${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`); + logger.error(`[ERROR] ${req.method} ${req.path} ${res.statusCode} ${duration}ms`); const shouldPersistAuditLog = config.nodeEnv === "production" && @@ -29,7 +29,7 @@ export function loggingMiddleware(req: Request, res: Response, next: NextFunctio .create({ data: { method: req.method, - path: req.originalUrl, + path: req.path, status: res.statusCode, ip, userAgent, diff --git a/apps/server/src/middleware/rateLimit.ts b/apps/server/src/middleware/rateLimit.ts index 652f73b..a480eb2 100644 --- a/apps/server/src/middleware/rateLimit.ts +++ b/apps/server/src/middleware/rateLimit.ts @@ -40,6 +40,23 @@ function getSanitizedPath(path: string): string { function getRouteLimitConfig(req: Request) { const isAuthRoute = req.path.startsWith("/api/v1/auth"); + const isStatsEventsRoute = req.path.startsWith("/api/v1/stats/events"); + + const isShareVerifyRoute = /\/shares\/public\/[^/]+\/[^/]+\/verify$/.test(req.path); + + if (isShareVerifyRoute) { + return { + windowMs: 60 * 5000, // 5 minutes + maxRequests: 3, // 3 requests per 5 minutes + }; + } + + if (isStatsEventsRoute) { + return { + windowMs: 60 * 1000, // 1 minute + maxRequests: 15, // 15 requests per minute + }; + } return { windowMs: isAuthRoute ? config.rateLimit.authWindowMs : config.rateLimit.windowMs, @@ -65,7 +82,8 @@ const INCREMENT_WITH_EXPIRY_SCRIPT = ` if count == 1 then redis.call("PEXPIRE", KEYS[1], ARGV[1]) end - return count + local ttl = redis.call("PTTL", KEYS[1]) + return {count, ttl} `; export const rateLimitMiddleware = (req: Request, res: Response, next: NextFunction) => { @@ -81,18 +99,18 @@ export const rateLimitMiddleware = (req: Request, res: Response, next: NextFunct const sanitizedPath = getSanitizedPath(req.path); const redisKey = `rate-limit:${req.method}:${sanitizedPath}:${key}`; - const checkWithFallback = async () => { + const checkWithFallback = async (): Promise<{ count: number; ttl: number }> => { try { const redis = getRedis(); if (!redis.isOpen) throw new Error("Redis not open"); - return Number( - await redis.eval(INCREMENT_WITH_EXPIRY_SCRIPT, { - keys: [redisKey], - arguments: [String(windowMs)], - }), - ); + const [count, ttl] = (await redis.eval(INCREMENT_WITH_EXPIRY_SCRIPT, { + keys: [redisKey], + arguments: [String(windowMs)], + })) as [number, number]; + + return { count, ttl }; } catch { const current = bucket.get(redisKey); @@ -107,16 +125,16 @@ export const rateLimitMiddleware = (req: Request, res: Response, next: NextFunct bucket.set(redisKey, { count: 1, resetAt: now + windowMs }); - return 1; + return { count: 1, ttl: windowMs }; } current.count += 1; - return current.count; + return { count: current.count, ttl: Math.max(0, current.resetAt - now) }; } }; checkWithFallback() - .then(async (count) => { + .then(({ count, ttl }) => { if (count <= maxRequests) { next(); return; @@ -124,15 +142,7 @@ export const rateLimitMiddleware = (req: Request, res: Response, next: NextFunct logger.warn(`Rate limit exceeded for IP: ${key}`); - let retryAfter = Math.ceil(windowMs / 1000); - - try { - const ttl = await getRedis().pTTL(redisKey); - if (ttl > 0) retryAfter = Math.ceil(ttl / 1000); - } catch { - const resetAt = bucket.get(redisKey)?.resetAt; - if (resetAt) retryAfter = Math.max(1, Math.ceil((resetAt - now) / 1000)); - } + const retryAfter = ttl > 0 ? Math.ceil(ttl / 1000) : Math.ceil(windowMs / 1000); res.set("Retry-After", String(retryAfter)); res.status(429).json(createErrorResponse(429, "Too many requests. Please try again later.")); diff --git a/apps/server/src/routes/billing.ts b/apps/server/src/routes/billing.ts new file mode 100644 index 0000000..b5ab045 --- /dev/null +++ b/apps/server/src/routes/billing.ts @@ -0,0 +1,14 @@ +import { Router } from "express"; + +import { authMiddleware } from "#middleware/auth"; + +import { BillingController } from "#controllers/billingController"; + +const router = Router(); + +router.get("/me", authMiddleware, BillingController.getMe); + +router.post("/portal", authMiddleware, BillingController.portal); +router.post("/checkout", authMiddleware, BillingController.checkout); + +export default router; diff --git a/apps/server/src/routes/portfolioAssets.ts b/apps/server/src/routes/portfolioAssets.ts new file mode 100644 index 0000000..09cf775 --- /dev/null +++ b/apps/server/src/routes/portfolioAssets.ts @@ -0,0 +1,16 @@ +import { Router } from "express"; + +import { authMiddleware } from "#middleware/auth"; + +import { PortfolioAssetController } from "#controllers/portfolioAssetController"; + +const router = Router(); + +router.use(authMiddleware); + +router.post("/complete", PortfolioAssetController.complete); +router.post("/upload-url", PortfolioAssetController.uploadUrl); + +router.delete("/:id", PortfolioAssetController.delete); + +export default router; diff --git a/apps/server/src/routes/portfolios.ts b/apps/server/src/routes/portfolios.ts new file mode 100644 index 0000000..0bc3fe3 --- /dev/null +++ b/apps/server/src/routes/portfolios.ts @@ -0,0 +1,29 @@ +import { Router } from "express"; + +import { authMiddleware } from "#middleware/auth"; + +import { PortfolioController } from "#controllers/portfolioController"; + +const router = Router(); + +router.get("/public", PortfolioController.listPublic); +router.get("/public/:subdomain", PortfolioController.getPublic); + +router.post("/public/:subdomain/view", PortfolioController.recordView); + +router.get("/me", authMiddleware, PortfolioController.getMe); +router.put("/draft", authMiddleware, PortfolioController.saveDraft); + +router.get( + "/subdomains/:slug/availability", + authMiddleware, + PortfolioController.subdomainAvailability, +); +router.get("/preview/:documentId", authMiddleware, PortfolioController.preview); + +router.post("/publish", authMiddleware, PortfolioController.publish); +router.post("/unpublish", authMiddleware, PortfolioController.unpublish); + +router.get("/analytics", authMiddleware, PortfolioController.analytics); + +export default router; diff --git a/apps/server/src/services/analyticsService.ts b/apps/server/src/services/analyticsService.ts index dd242c8..29ffb8f 100644 --- a/apps/server/src/services/analyticsService.ts +++ b/apps/server/src/services/analyticsService.ts @@ -11,7 +11,7 @@ import { cacheDel, cacheGet, cacheSet, getRedis } from "#utils/redis"; * List of officially tracked events to ensure consistency. */ -const KNOWN_EVENTS = [ +export const KNOWN_EVENTS = [ "resume_created", "resume_deleted", "resume_exported", @@ -19,6 +19,9 @@ const KNOWN_EVENTS = [ "auth_login_success", "dashboard_opened", "roadmap_viewed", + "share_link_created", + "share_link_updated", + "share_link_revoked", ] as const; type KnownEvent = (typeof KNOWN_EVENTS)[number]; diff --git a/apps/server/src/services/apiKeyService.ts b/apps/server/src/services/apiKeyService.ts index ac4c479..471fa2f 100644 --- a/apps/server/src/services/apiKeyService.ts +++ b/apps/server/src/services/apiKeyService.ts @@ -27,6 +27,7 @@ type ApiKeyAuthUser = { id: string; email: string | null; name: string | null; + subscriptions: { status: string }[]; }; type ApiKeyAuthRecord = { @@ -153,6 +154,10 @@ async function invalidateAuthCache(keyHash: string) { } } +async function invalidateProfileCache(userId: string) { + await cacheDel(`user:profile:v2:${userId}`); +} + async function setAuthCache(record: ApiKeyAuthRecord) { try { await cacheSet(`apikey:auth:${record.keyHash}`, record, AUTH_CACHE_TTL_SECONDS); @@ -211,6 +216,7 @@ export class ApiKeyService { }, }); + await invalidateProfileCache(userId); return { ...created, key: rawKey } satisfies ApiKeyCreateResult; } @@ -221,7 +227,20 @@ export class ApiKeyService { const cached = await getAuthCache(keyHash); if (cached && cached.isActive && !isExpired(cached.expiresAt) && !cached.revokedAt) { + const userSubscription = cached.user.subscriptions?.[0]; + + if ( + userSubscription && + (userSubscription.status === "CANCELED" || userSubscription.status === "INACTIVE") + ) { + logger.warn( + `API key validation rejected: User account ${cached.userId} subscription status is ${userSubscription.status}`, + ); + return null; + } + void this.touchLastUsed(cached.id); + return cached; } @@ -238,6 +257,11 @@ export class ApiKeyService { id: true, email: true, name: true, + subscriptions: { + orderBy: { updatedAt: "desc" }, + take: 1, + select: { status: true }, + }, }, }, }, @@ -247,6 +271,17 @@ export class ApiKeyService { return null; } + const userSubscription = apiKey.user.subscriptions?.[0]; + if ( + userSubscription && + (userSubscription.status === "CANCELED" || userSubscription.status === "INACTIVE") + ) { + logger.warn( + `API key validation rejected: User account ${apiKey.userId} subscription status is ${userSubscription.status}`, + ); + return null; + } + const authRecord: ApiKeyAuthRecord = { id: apiKey.id, keyHash: apiKey.keyHash, @@ -262,7 +297,12 @@ export class ApiKeyService { createdAt: apiKey.createdAt.toISOString(), updatedAt: apiKey.updatedAt.toISOString(), lastUsed: apiKey.lastUsed ? apiKey.lastUsed.toISOString() : null, - user: apiKey.user, + user: { + id: apiKey.user.id, + email: apiKey.user.email, + name: apiKey.user.name, + subscriptions: apiKey.user.subscriptions.map((sub) => ({ status: sub.status })), + }, }; void setAuthCache(authRecord); @@ -364,6 +404,7 @@ export class ApiKeyService { }); await invalidateAuthCache(existing.keyHash); + await invalidateProfileCache(userId); return result.count; } @@ -436,6 +477,7 @@ export class ApiKeyService { }); await invalidateAuthCache(current.keyHash); + await invalidateProfileCache(userId); return { ...rotated, key: rawKey, rotatedFromId: current.id }; } @@ -458,6 +500,7 @@ export class ApiKeyService { }); await invalidateAuthCache(existing.keyHash); + await invalidateProfileCache(userId); return deleted.count; } diff --git a/apps/server/src/services/billingService.ts b/apps/server/src/services/billingService.ts new file mode 100644 index 0000000..36bf594 --- /dev/null +++ b/apps/server/src/services/billingService.ts @@ -0,0 +1,346 @@ +import { z } from "zod"; +import DodoPayments from "dodopayments"; + +import { Prisma, type Subscription as PrismaSubscription } from "@prisma/client"; + +import { config } from "#config"; + +import { dodoWebhookEventSchema, dodoSubscriptionSchema } from "#validators/billingValidator"; + +import { prisma } from "#utils/prisma"; +import { logger } from "#utils/logger"; +import { ApiError } from "#utils/errors"; +import { + revalidatePublicPortfolios, + invalidatePublicPortfolioCaches, +} from "#utils/portfolioPublicationCache"; + +type BillingIntervalInput = "monthly" | "annual"; + +function getDodoClient() { + if (!config.dodo.apiKey) throw new ApiError(503, "Portfolio billing is not configured."); + + return new DodoPayments({ + bearerToken: config.dodo.apiKey, + webhookKey: config.dodo.webhookSecret || undefined, + environment: config.dodo.environment, + }); +} + +function addDays(date: Date, days: number) { + return new Date(date.getTime() + days * 24 * 60 * 60 * 1000); +} + +function statusFromDodo(rawStatus: string) { + switch (rawStatus) { + case "active": + return "ACTIVE" as const; + case "trialing": + return "TRIALING" as const; + case "on_hold": + case "failed": + return "PAST_DUE" as const; + case "cancelled": + case "expired": + return "CANCELED" as const; + default: + return "INACTIVE" as const; + } +} + +function intervalFromProduct(productId: string) { + if (productId === config.dodo.annualProductId) return "ANNUAL" as const; + if (productId === config.dodo.monthlyProductId) return "MONTHLY" as const; + + return null; +} + +function accessStatus( + subscription: Pick | null, +) { + if (!subscription) return { canPublish: false, publicationStatus: "SUSPENDED" as const }; + + const now = new Date(); + + if ( + (subscription.status === "ACTIVE" || subscription.status === "TRIALING") && + (!subscription.currentPeriodEnd || subscription.currentPeriodEnd > now) + ) + return { canPublish: true, publicationStatus: "LIVE" as const }; + + if (subscription.graceEndsAt && subscription.graceEndsAt > now) + return { canPublish: false, publicationStatus: "GRACE" as const }; + + return { canPublish: false, publicationStatus: "SUSPENDED" as const }; +} + +export class BillingService { + static async getLatestSubscription(userId: string) { + return prisma.subscription.findFirst({ where: { userId }, orderBy: { updatedAt: "desc" } }); + } + + static async getSummary(userId: string) { + const subscription = await this.getLatestSubscription(userId); + const access = accessStatus(subscription); + + return { + plan: subscription ? "PORTFOLIO_PRO" : "FREE", + status: subscription?.status ?? "INACTIVE", + interval: subscription?.interval ?? null, + currentPeriodEnd: subscription?.currentPeriodEnd ?? null, + cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd ?? false, + graceEndsAt: subscription?.graceEndsAt ?? null, + canPublish: access.canPublish, + publicationStatus: access.publicationStatus, + pricing: { monthly: 12, annual: 120, currency: "USD" }, + }; + } + + static async requirePublishAccess(userId: string) { + const subscription = await this.getLatestSubscription(userId); + + if (!accessStatus(subscription).canPublish) + throw new ApiError( + 402, + "Publishing requires an active VeriWorkly Portfolio Pro subscription.", + ); + + return subscription!; + } + + static async createCheckout( + userId: string, + interval: BillingIntervalInput, + redirectUrl?: string, + ) { + const productId = + interval === "annual" ? config.dodo.annualProductId : config.dodo.monthlyProductId; + + if (!productId) throw new ApiError(503, "Portfolio billing product is not configured."); + + const user = await prisma.user.findUnique({ where: { id: userId } }); + + if (!user) throw new ApiError(404, "User not found"); + + const buildRedirectUrl = (base: string, callback?: string) => { + if (!callback) return base; + + try { + const url = new URL(base); + url.searchParams.set("callbackURL", callback); + + return url.toString(); + } catch { + const separator = base.includes("?") ? "&" : "?"; + + return `${base}${separator}callbackURL=${encodeURIComponent(callback)}`; + } + }; + + const checkout = await getDodoClient().checkoutSessions.create({ + product_cart: [{ product_id: productId, quantity: 1 }], + customer: { email: user.email, name: user.name || "VeriWorkly User" }, + metadata: { veriworkly_user_id: userId, veriworkly_product: "portfolio_pro" }, + return_url: buildRedirectUrl(config.dodo.checkoutReturnUrl, redirectUrl), + cancel_url: buildRedirectUrl(config.dodo.checkoutCancelUrl, redirectUrl), + }); + + if (!checkout.checkout_url) + throw new ApiError(502, "Billing provider did not return a checkout URL."); + + return { url: checkout.checkout_url }; + } + + static async createPortal(userId: string) { + const subscription = await this.getLatestSubscription(userId); + + if (!subscription?.providerCustomerId) + throw new ApiError(409, "No billing account exists yet."); + + const portal = await getDodoClient().customers.customerPortal.create( + subscription.providerCustomerId, + { return_url: config.dodo.portalReturnUrl }, + ); + + return { url: portal.link }; + } + + static unwrapWebhook(body: string, headers: Record) { + if (!config.dodo.webhookSecret) { + if (config.nodeEnv === "development") { + try { + return JSON.parse(body); + } catch { + throw new ApiError(400, "Invalid JSON body in development bypass."); + } + } + + throw new ApiError(503, "Billing webhook secret is not configured."); + } + + return getDodoClient().webhooks.unwrap(body, { headers, key: config.dodo.webhookSecret }); + } + + static async processWebhook( + providerEventId: string, + event: ReturnType, + ) { + const parsedEvent = dodoWebhookEventSchema.parse(event); + + let stored; + + try { + stored = await prisma.billingWebhookEvent.create({ + data: { + providerEventId, + type: parsedEvent.type, + payload: parsedEvent as unknown as Prisma.InputJsonValue, + status: "PROCESSING", + lastAttemptAt: new Date(), + }, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { + const existing = await prisma.billingWebhookEvent.findUnique({ + where: { providerEventId }, + }); + + if (!existing) throw error; + + if (existing.status === "PROCESSED") return { duplicate: true }; + + stored = await prisma.billingWebhookEvent.update({ + where: { id: existing.id }, + data: { + status: "PROCESSING", + retryCount: { increment: 1 }, + lastAttemptAt: new Date(), + }, + }); + } else { + throw error; + } + } + + try { + if (parsedEvent.type.startsWith("subscription.")) { + const subscriptionData = dodoSubscriptionSchema.parse(parsedEvent.data); + await this.applySubscriptionEvent(subscriptionData, new Date(parsedEvent.timestamp)); + } + + await prisma.billingWebhookEvent.update({ + where: { id: stored.id }, + data: { status: "PROCESSED", processedAt: new Date() }, + }); + + return { duplicate: false }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const updatedEvent = await prisma.billingWebhookEvent.update({ + where: { id: stored.id }, + data: { status: "FAILED", error: errorMessage }, + }); + + if (updatedEvent.retryCount >= 5) { + logger.error( + `CRITICAL: Webhook event ${providerEventId} exceeded max retries. Dead-letter alert!`, + { + providerEventId, + error: errorMessage, + retryCount: updatedEvent.retryCount, + }, + ); + } + + throw error; + } + } + + private static async applySubscriptionEvent( + subscription: z.infer, + eventTime: Date, + ) { + const existing = await prisma.subscription.findUnique({ + where: { providerSubId: subscription.subscription_id }, + }); + + const userId = subscription.metadata.veriworkly_user_id || existing?.userId; + + if (!userId) + throw new ApiError(400, "Subscription webhook is missing VeriWorkly user metadata."); + + if (existing?.lastWebhookAt && existing.lastWebhookAt >= eventTime) return; + + const normalizedStatus = statusFromDodo(subscription.status); + const pastDue = normalizedStatus === "PAST_DUE"; + const graceEndsAt = pastDue ? addDays(eventTime, config.portfolio.graceDays) : null; + const currentPeriodEnd = subscription.next_billing_date + ? new Date(subscription.next_billing_date) + : null; + + await prisma.$transaction(async (tx) => { + const updateResult = await tx.subscription.updateMany({ + where: { + providerSubId: subscription.subscription_id, + OR: [{ lastWebhookAt: null }, { lastWebhookAt: { lt: eventTime } }], + }, + data: { + providerCustomerId: subscription.customer.customer_id, + providerPriceId: subscription.product_id, + interval: intervalFromProduct(subscription.product_id), + rawStatus: subscription.status, + status: normalizedStatus, + currentPeriodEnd, + cancelAtPeriodEnd: subscription.cancel_at_next_billing_date, + graceEndsAt, + lastWebhookAt: eventTime, + }, + }); + + if (updateResult.count === 0) { + const currentSub = await tx.subscription.findUnique({ + where: { providerSubId: subscription.subscription_id }, + select: { id: true, lastWebhookAt: true }, + }); + + if (currentSub) return; + + await tx.subscription.create({ + data: { + userId, + provider: "dodo", + providerCustomerId: subscription.customer.customer_id, + providerPriceId: subscription.product_id, + providerSubId: subscription.subscription_id, + interval: intervalFromProduct(subscription.product_id), + rawStatus: subscription.status, + status: normalizedStatus, + currentPeriodEnd, + cancelAtPeriodEnd: subscription.cancel_at_next_billing_date, + graceEndsAt, + lastWebhookAt: eventTime, + }, + }); + } + + const access = accessStatus({ status: normalizedStatus, currentPeriodEnd, graceEndsAt }); + await tx.portfolioPublication.updateMany({ + where: { userId }, + data: + access.publicationStatus === "SUSPENDED" + ? { status: "SUSPENDED", suspensionReason: normalizedStatus, suspendedAt: new Date() } + : { status: access.publicationStatus, suspensionReason: null, suspendedAt: null }, + }); + }); + + const publication = await prisma.portfolioPublication.findUnique({ + where: { userId }, + select: { subdomain: true }, + }); + + if (publication) { + await invalidatePublicPortfolioCaches([publication.subdomain]); + void revalidatePublicPortfolios([publication.subdomain]); + } + } +} diff --git a/apps/server/src/services/documentService.ts b/apps/server/src/services/documentService.ts index c00fb2d..1c68c7a 100644 --- a/apps/server/src/services/documentService.ts +++ b/apps/server/src/services/documentService.ts @@ -5,7 +5,7 @@ import { ShareService } from "#services/shareService"; import { prisma } from "#utils/prisma"; import { logger } from "#utils/logger"; import { ApiError } from "#utils/errors"; -import { normalizeSlug } from "#utils/slugs"; +import { buildUniqueSlugHelper } from "#utils/slugs"; import { cacheGet, cacheSet, cacheDel, cacheDelByPrefix } from "#utils/redis"; export type DocumentCreateInput = { @@ -34,12 +34,7 @@ export type DocumentUpdateInput = { export class DocumentService { private static async buildUniqueSlug(userId: string, title: string, documentId?: string) { - const base = normalizeSlug(title); - - for (let attempt = 0; attempt < 20; attempt += 1) { - const suffix = attempt === 0 ? "" : `-${attempt + 1}`; - const candidate = `${base.slice(0, 255 - suffix.length)}${suffix}`; - + return buildUniqueSlugHelper(title, async (candidate) => { const existing = await prisma.document.findFirst({ where: { userId, @@ -49,10 +44,8 @@ export class DocumentService { select: { id: true }, }); - if (!existing) return candidate; - } - - return `${base.slice(0, 246)}-${Date.now().toString(36)}`; + return !!existing; + }); } /** @@ -201,30 +194,35 @@ export class DocumentService { } try { - const updated = await prisma.document.update({ - where: { - id: documentId, - userId, - revision: revision, - }, + const updated = await prisma.$transaction(async (tx) => { + const doc = await tx.document.update({ + where: { + id: documentId, + userId, + revision: revision, + }, + + data: { + ...updateData, + revision: { increment: 1 }, + lastSyncedAt: new Date(), + }, + }); - data: { - ...updateData, - revision: { increment: 1 }, - lastSyncedAt: new Date(), - }, + if (shareLinkSlugUpdate) { + await tx.shareLink.update({ + where: { id: shareLinkSlugUpdate.id }, + data: { slug: shareLinkSlugUpdate.slug }, + }); + } + + return doc; }); await cacheDel(`document:${userId}:${documentId}`); await cacheDel(`documents:list:${userId}:all`); await cacheDel(`documents:list:${userId}:${updated.type}`); - if (shareLinkSlugUpdate) - await prisma.shareLink.update({ - where: { id: shareLinkSlugUpdate.id }, - data: { slug: shareLinkSlugUpdate.slug }, - }); - await Promise.all([ ...[...readableShareCacheKeys].map((cacheKey) => cacheDel(cacheKey)), ...(shareLinkSlugUpdate ? [cacheDelByPrefix(`share:list:${userId}:${documentId}:`)] : []), @@ -233,13 +231,14 @@ export class DocumentService { return updated; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") { - const current = await prisma.document.findUnique({ where: { id: documentId } }); + const current = await prisma.document.findFirst({ where: { id: documentId, userId } }); - if (!current) throw new Error("Document not found"); + if (!current) throw new ApiError(404, "Document not found"); if (current.revision !== revision) - throw new Error( - `CONFLICTOR: Revision mismatch. Client: ${revision}, Server: ${current.revision}`, + throw new ApiError( + 409, + `Revision mismatch. Client: ${revision}, Server: ${current.revision}`, ); } @@ -264,10 +263,17 @@ export class DocumentService { }, }); - const document = await prisma.document.update({ - where: { id: documentId, userId }, - data: { deletedAt: new Date() }, - }); + let document; + try { + document = await prisma.document.update({ + where: { id: documentId, userId }, + data: { deletedAt: new Date() }, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") + throw new ApiError(404, "Document not found"); + throw error; + } await cacheDel(`document:${userId}:${documentId}`); await cacheDel(`documents:list:${userId}:all`); @@ -298,10 +304,17 @@ export class DocumentService { }, }); - const document = await prisma.document.update({ - where: { id: documentId, userId }, - data: { deletedAt: null }, - }); + let document; + try { + document = await prisma.document.update({ + where: { id: documentId, userId }, + data: { deletedAt: null }, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") + throw new ApiError(404, "Document not found"); + throw error; + } await cacheDel(`document:${userId}:${documentId}`); await cacheDel(`documents:list:${userId}:all`); @@ -332,9 +345,16 @@ export class DocumentService { }, }); - const document = await prisma.document.delete({ - where: { id: documentId, userId }, - }); + let document; + try { + document = await prisma.document.delete({ + where: { id: documentId, userId }, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") + throw new ApiError(404, "Document not found"); + throw error; + } await cacheDel(`document:${userId}:${documentId}`); await cacheDel(`documents:list:${userId}:all`); diff --git a/apps/server/src/services/githubService.ts b/apps/server/src/services/githubService.ts index b9dcd0c..0fe8a58 100644 --- a/apps/server/src/services/githubService.ts +++ b/apps/server/src/services/githubService.ts @@ -1,8 +1,11 @@ +import { v4 as uuidv4 } from "uuid"; +import type { Prisma } from "@prisma/client"; import { config } from "#config"; import { prisma } from "#utils/prisma"; import { ApiError } from "#utils/errors"; -import { cacheDel, cacheDelByPrefix, cacheGet, cacheSet } from "#utils/redis"; +import { cacheDel, cacheDelByPrefix, cacheGet, cacheSet, getRedis } from "#utils/redis"; +import { logger } from "#utils/logger"; export type GitHubStatus = "todo" | "in-progress" | "done"; export type GitHubItemKind = "issue" | "pull-request"; @@ -146,6 +149,7 @@ async function fetchGitHubIssuesPage(url: string, token: string) { Authorization: `Bearer ${token}`, "X-GitHub-Api-Version": "2022-11-28", }, + signal: AbortSignal.timeout(10000), }); if (response.ok) { @@ -341,148 +345,223 @@ const shouldSyncGitHubStats = async () => { * Sync GitHub issues from GitHub API into DB and refresh caches. */ -const syncGitHubStatsFromGitHub = async () => { - const { owner, repo, token } = config.github; - - const existingSync = await prisma.gitHubSync.findUnique({ - where: { projectUrl: PROJECT_URL }, - select: { syncedAt: true, id: true }, +const RELEASE_LOCK_LUA_SCRIPT = ` + if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("del", KEYS[1]) + else + return 0 + end +`; + +const syncGitHubStatsFromGitHub = async (forceFullSync = false) => { + const redis = getRedis(); + const lockKey = "github:sync:lock"; + const lockValue = uuidv4(); + const lockTTL = 600; // 10 minutes + + const lockResult = await redis.set(lockKey, lockValue, { + NX: true, + EX: lockTTL, }); - const sinceDate = existingSync?.syncedAt ? new Date(existingSync.syncedAt) : undefined; - - const rawIssues = await fetchAllGitHubIssues(owner, repo, token, sinceDate); - - if (rawIssues.length === 0 && existingSync) { - const updatedSync = await prisma.gitHubSync.update({ - where: { id: existingSync.id }, - data: { syncedAt: new Date(), nextSyncAt: new Date(Date.now() + 43200000) }, - }); - - await cacheDel(REDIS_STATS_KEY); - - return updatedSync; + if (lockResult !== "OK") { + throw new ApiError(409, "GitHub sync is already in progress"); } - const snapshot = buildGitHubIssuesSnapshot(rawIssues); - const CHUNK_SIZE = 50; - - const syncRecord = await prisma.$transaction( - async (tx) => { - const sync = await tx.gitHubSync.upsert({ - where: { projectUrl: PROJECT_URL }, - create: { - projectName: `${owner}/${repo}`, - projectUrl: PROJECT_URL, - issueCount: 0, - todoCount: 0, - inProgressCount: 0, - doneCount: 0, - data: { lastSyncedBy: "System" }, - nextSyncAt: new Date(Date.now() + 43200000), - }, - update: { - syncedAt: new Date(), - nextSyncAt: new Date(Date.now() + 43200000), - }, - }); + try { + const { owner, repo, token } = config.github; - const chunks = []; + const existingSync = await prisma.gitHubSync.findUnique({ + where: { projectUrl: PROJECT_URL }, + select: { syncedAt: true, id: true }, + }); - for (let i = 0; i < snapshot.issues.length; i += CHUNK_SIZE) { - chunks.push(snapshot.issues.slice(i, i + CHUNK_SIZE)); - } + const sinceDate = + existingSync?.syncedAt && !forceFullSync ? new Date(existingSync.syncedAt) : undefined; - for (const chunk of chunks) { - await Promise.all( - chunk.map((item) => { - const githubId = item.id.replace("gh-", ""); - return tx.gitHubSyncItem.upsert({ - where: { - syncId_githubId: { syncId: sync.id, githubId }, - }, - - create: { - syncId: sync.id, - githubId, - number: item.number, - title: item.title, - status: item.status, - kind: item.kind, - url: item.url, - labels: item.labels, - createdAt: new Date(item.createdAt), - updatedAt: new Date(item.updatedAt), - }, - - update: { - title: item.title, - status: item.status, - labels: item.labels, - updatedAt: new Date(item.updatedAt), - }, - }); - }), - ); - } + const rawIssues = await fetchAllGitHubIssues(owner, repo, token, sinceDate); - const groupedStats = await tx.gitHubSyncItem.groupBy({ - by: ["status", "kind"], - where: { - syncId: sync.id, - }, - _count: { - id: true, - }, + if (rawIssues.length === 0 && existingSync) { + const updatedSync = await prisma.gitHubSync.update({ + where: { id: existingSync.id }, + data: { syncedAt: new Date(), nextSyncAt: new Date(Date.now() + 43200000) }, }); - let todoCount = 0; - let inProgressCount = 0; - let doneCount = 0; + await cacheDel(REDIS_STATS_KEY); - let onlyIssueCount = 0; - let prCount = 0; - - for (const item of groupedStats) { - const count = item._count.id; + return updatedSync; + } - //status counts - if (item.status === "todo") { - todoCount += count; + const snapshot = buildGitHubIssuesSnapshot(rawIssues); + + const syncRecord = await prisma.$transaction( + async (tx) => { + const sync = await tx.gitHubSync.upsert({ + where: { projectUrl: PROJECT_URL }, + create: { + projectName: `${owner}/${repo}`, + projectUrl: PROJECT_URL, + issueCount: 0, + todoCount: 0, + inProgressCount: 0, + doneCount: 0, + data: { lastSyncedBy: "System" }, + nextSyncAt: new Date(Date.now() + 43200000), + }, + update: { + syncedAt: new Date(), + nextSyncAt: new Date(Date.now() + 43200000), + }, + }); + + // 1. Fetch current sync items to determine differences + const existingItems = await tx.gitHubSyncItem.findMany({ + where: { syncId: sync.id }, + select: { githubId: true, updatedAt: true, status: true, title: true, labels: true }, + }); + + const existingMap = new Map(existingItems.map((item) => [item.githubId, item])); + + const itemsToCreate: Prisma.GitHubSyncItemCreateManyInput[] = []; + const itemsToUpdate: Prisma.GitHubSyncItemCreateManyInput[] = []; + + for (const item of snapshot.issues) { + const githubId = item.id.replace("gh-", ""); + const existing = existingMap.get(githubId); + + const itemData = { + syncId: sync.id, + githubId, + number: item.number, + title: item.title, + status: item.status, + kind: item.kind, + url: item.url, + labels: item.labels, + createdAt: new Date(item.createdAt), + updatedAt: new Date(item.updatedAt), + }; + + if (!existing) { + itemsToCreate.push(itemData); + } else { + // Only write an update if GitHub attributes actually changed + const hasChanged = + existing.title !== item.title || + existing.status !== item.status || + new Date(existing.updatedAt).getTime() !== new Date(item.updatedAt).getTime() || + JSON.stringify(existing.labels) !== JSON.stringify(item.labels); + + if (hasChanged) { + itemsToUpdate.push(itemData); + } + } } - if (item.status === "in-progress") { - inProgressCount += count; + // 2. Perform bulk creation in 1 query + if (itemsToCreate.length > 0) { + await tx.gitHubSyncItem.createMany({ + data: itemsToCreate, + }); } - if (item.status === "done") { - doneCount += count; + // 3. Update only modified items + if (itemsToUpdate.length > 0) { + await Promise.all( + itemsToUpdate.map((item) => + tx.gitHubSyncItem.update({ + where: { + syncId_githubId: { syncId: sync.id, githubId: item.githubId }, + }, + data: { + title: item.title, + status: item.status, + labels: item.labels, + updatedAt: item.updatedAt, + }, + }), + ), + ); } - //kind counts - if (item.kind === "issue") { - onlyIssueCount += count; + // 4. If full sync, reconcile deleted items + if (!sinceDate) { + const fetchedGithubIds = snapshot.issues.map((i) => i.id.replace("gh-", "")); + await tx.gitHubSyncItem.deleteMany({ + where: { + syncId: sync.id, + githubId: { notIn: fetchedGithubIds }, + }, + }); } - if (item.kind === "pull-request") { - prCount += count; + const groupedStats = await tx.gitHubSyncItem.groupBy({ + by: ["status", "kind"], + where: { + syncId: sync.id, + }, + _count: { + id: true, + }, + }); + + let todoCount = 0; + let inProgressCount = 0; + let doneCount = 0; + + let onlyIssueCount = 0; + let prCount = 0; + + for (const item of groupedStats) { + const count = item._count.id; + + //status counts + if (item.status === "todo") { + todoCount += count; + } + + if (item.status === "in-progress") { + inProgressCount += count; + } + + if (item.status === "done") { + doneCount += count; + } + + //kind counts + if (item.kind === "issue") { + onlyIssueCount += count; + } + + if (item.kind === "pull-request") { + prCount += count; + } } - } - const issueCount = onlyIssueCount + prCount; - - return tx.gitHubSync.update({ - where: { id: sync.id }, - data: { issueCount, todoCount, inProgressCount, doneCount, onlyIssueCount, prCount }, - }); - }, - { timeout: 30000 }, - ); + const issueCount = onlyIssueCount + prCount; - await cacheDel(REDIS_STATS_KEY); - await cacheDelByPrefix(ISSUES_CACHE_PREFIX); + return tx.gitHubSync.update({ + where: { id: sync.id }, + data: { issueCount, todoCount, inProgressCount, doneCount, onlyIssueCount, prCount }, + }); + }, + { timeout: 30000 }, + ); - return syncRecord; + await cacheDel(REDIS_STATS_KEY); + await cacheDelByPrefix(ISSUES_CACHE_PREFIX); + + return syncRecord; + } finally { + try { + await redis.eval(RELEASE_LOCK_LUA_SCRIPT, { + keys: [lockKey], + arguments: [lockValue], + }); + } catch (err) { + logger.error("Failed to release GitHub sync lock", err); + } + } }; export { getGitHubStats, getGitHubIssues, shouldSyncGitHubStats, syncGitHubStatsFromGitHub }; diff --git a/apps/server/src/services/portfolioAssetService.ts b/apps/server/src/services/portfolioAssetService.ts new file mode 100644 index 0000000..0ec3c6c --- /dev/null +++ b/apps/server/src/services/portfolioAssetService.ts @@ -0,0 +1,179 @@ +import { + S3Client, + PutObjectCommand, + HeadObjectCommand, + DeleteObjectCommand, +} from "@aws-sdk/client-s3"; +import { randomUUID } from "node:crypto"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; + +import { config } from "#config"; + +import { prisma } from "#utils/prisma"; +import { ApiError } from "#utils/errors"; +import { logger } from "#utils/logger"; +import { getRedis } from "#utils/redis"; + +const allowedTypes = new Map([ + ["image/jpeg", "jpg"], + ["image/png", "png"], + ["image/webp", "webp"], +]); + +const maxBytes = 5 * 1024 * 1024; +const releaseLockLuaScript = ` + if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("del", KEYS[1]) + end + return 0 +`; +let client: S3Client | null = null; + +function getClient() { + if ( + !config.r2.endpoint || + !config.r2.bucket || + !config.r2.accessKeyId || + !config.r2.secretAccessKey + ) + throw new ApiError(503, "Portfolio media uploads are not configured."); + + client ??= new S3Client({ + region: "auto", + endpoint: config.r2.endpoint, + credentials: { accessKeyId: config.r2.accessKeyId, secretAccessKey: config.r2.secretAccessKey }, + }); + + return client; +} + +export class PortfolioAssetService { + static async createUploadUrl( + userId: string, + input: { + kind: "AVATAR" | "PROJECT_COVER" | "SOCIAL_IMAGE"; + mimeType: string; + sizeBytes: number; + }, + ) { + const extension = allowedTypes.get(input.mimeType); + + if (!extension) throw new ApiError(400, "Upload a JPG, PNG, or WebP image."); + if (input.sizeBytes <= 0 || input.sizeBytes > maxBytes) + throw new ApiError(400, "Image must be 5 MB or smaller."); + + const key = `portfolio/${userId}/${randomUUID()}.${extension}`; + const asset = await prisma.portfolioAsset.create({ data: { userId, key, ...input } }); + + const uploadUrl = await getSignedUrl( + getClient(), + new PutObjectCommand({ + Bucket: config.r2.bucket, + Key: key, + ContentType: input.mimeType, + ContentLength: input.sizeBytes, + }), + { expiresIn: 600 }, + ); + + return { assetId: asset.id, uploadUrl, expiresInSeconds: 600 }; + } + + static async complete(userId: string, assetId: string, checksum?: string) { + const asset = await prisma.portfolioAsset.findFirst({ where: { id: assetId, userId } }); + + if (!asset) throw new ApiError(404, "Portfolio asset not found."); + + if (!config.r2.publicBaseUrl) + throw new ApiError(503, "Portfolio media delivery is not configured."); + + const meta = await getClient().send( + new HeadObjectCommand({ Bucket: config.r2.bucket, Key: asset.key }), + ); + + if (meta.ContentLength !== asset.sizeBytes) { + throw new ApiError( + 400, + `Uploaded file size (${meta.ContentLength} bytes) does not match expected size (${asset.sizeBytes} bytes).`, + ); + } + + if (meta.ContentType !== asset.mimeType) { + throw new ApiError( + 400, + `Uploaded file type (${meta.ContentType}) does not match expected type (${asset.mimeType}).`, + ); + } + + if (checksum) { + const s3Checksum = meta.ETag ? meta.ETag.replace(/"/g, "") : null; + if (s3Checksum && s3Checksum !== checksum) { + throw new ApiError( + 400, + `Uploaded file checksum (${s3Checksum}) does not match expected checksum (${checksum}).`, + ); + } + } + + const updated = await prisma.portfolioAsset.update({ + where: { id: asset.id }, + data: { status: "READY", checksum }, + }); + + return { id: updated.id, url: `${config.r2.publicBaseUrl}/${updated.key}` }; + } + + static async cleanupStaleAssets() { + const redis = getRedis(); + const lockKey = "portfolio:assets:cleanup:lock"; + const lockValue = randomUUID(); + const lockAcquired = (await redis.set(lockKey, lockValue, { NX: true, EX: 300 })) === "OK"; + + if (!lockAcquired) return; + + try { + const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); + const staleAssets = await prisma.portfolioAsset.findMany({ + where: { + status: "PENDING", + createdAt: { lt: oneDayAgo }, + }, + }); + + if (staleAssets.length === 0) return; + + const s3 = getClient(); + const deletedIds: string[] = []; + for (const asset of staleAssets) { + try { + await s3.send(new DeleteObjectCommand({ Bucket: config.r2.bucket, Key: asset.key })); + deletedIds.push(asset.id); + } catch (error) { + logger.warn("Failed to delete stale R2 asset; retaining metadata for retry", { + assetId: asset.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + if (deletedIds.length === 0) return; + + await prisma.portfolioAsset.deleteMany({ + where: { id: { in: deletedIds } }, + }); + } finally { + await redis + .eval(releaseLockLuaScript, { keys: [lockKey], arguments: [lockValue] }) + .catch((error) => logger.warn("Failed to release portfolio asset cleanup lock", error)); + } + } + + static async delete(userId: string, assetId: string) { + const asset = await prisma.portfolioAsset.findFirst({ where: { id: assetId, userId } }); + + if (!asset) throw new ApiError(404, "Portfolio asset not found."); + + await getClient().send(new DeleteObjectCommand({ Bucket: config.r2.bucket, Key: asset.key })); + await prisma.portfolioAsset.delete({ where: { id: asset.id } }); + } +} diff --git a/apps/server/src/services/portfolioService.ts b/apps/server/src/services/portfolioService.ts new file mode 100644 index 0000000..ec92db1 --- /dev/null +++ b/apps/server/src/services/portfolioService.ts @@ -0,0 +1,625 @@ +import { Prisma } from "@prisma/client"; +import { randomUUID } from "node:crypto"; + +import { config } from "#config"; +import { BillingService } from "#services/billingService"; + +import { + invalidatePublicPortfolioCaches, + revalidatePublicPortfolios, +} from "#utils/portfolioPublicationCache"; + +import { prisma } from "#utils/prisma"; +import { logger } from "#utils/logger"; +import { ApiError } from "#utils/errors"; +import { normalizeSlug, RESERVED_USERNAMES } from "#utils/slugs"; +import { cacheGet, cacheSet, cacheDel, getRedis } from "#utils/redis"; + +import { portfolioContentSchema, type PortfolioContentInput } from "#validators/portfolioValidator"; + +const VIEW_BUFFER_TTL_SECONDS = 14 * 24 * 60 * 60; +const MAX_VIEW_BUFFER_FIELDS = 10_000; + +function normalizeSubdomain(value: string) { + const subdomain = normalizeSlug(value, "").replace(/_/g, "-").slice(0, 63); + + if (!subdomain || RESERVED_USERNAMES.has(subdomain)) + throw new ApiError(400, "Choose a valid portfolio subdomain."); + + return subdomain; +} + +function utcDay(date = new Date()) { + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); +} + +function normalizeReferrerHost(referrer?: string) { + if (!referrer) return ""; + + try { + return new URL(referrer).hostname.toLowerCase().slice(0, 255); + } catch { + return ""; + } +} + +export class PortfolioService { + static async listPublicPortfolios(limit?: number, offset?: number) { + const cacheKey = + limit !== undefined || offset !== undefined + ? `portfolio:public:list:${limit || 0}:${offset || 0}` + : "portfolio:public:list"; + const cached = await cacheGet>(cacheKey); + + if (cached) return cached; + + const result = await prisma.portfolioPublication.findMany({ + where: { status: { in: ["LIVE", "GRACE"] } }, + select: { subdomain: true, updatedAt: true }, + orderBy: { updatedAt: "desc" }, + take: limit, + skip: offset, + }); + + await cacheSet(cacheKey, result, 300); + + return result; + } + + static async getPublicPortfolio(subdomain: string) { + const normalized = normalizeSubdomain(subdomain); + const cacheKey = `portfolio:public:${normalized}`; + + const cached = await cacheGet<{ + id: string; + subdomain: string; + templateId: string; + snapshot: unknown; + status: string; + updatedAt: string; + }>(cacheKey); + + if (cached) return cached; + + const publication = await prisma.portfolioPublication.findUnique({ + where: { subdomain: normalized }, + select: { + id: true, + subdomain: true, + templateId: true, + snapshot: true, + status: true, + updatedAt: true, + user: { + select: { + subscriptions: { + orderBy: { updatedAt: "desc" }, + take: 1, + select: { graceEndsAt: true }, + }, + }, + }, + }, + }); + + if (!publication || publication.status === "SUSPENDED") return null; + + const graceEndsAt = publication.user.subscriptions[0]?.graceEndsAt; + + if (publication.status === "GRACE" && (!graceEndsAt || graceEndsAt <= new Date())) { + await prisma.portfolioPublication.update({ + where: { id: publication.id }, + data: { status: "SUSPENDED", suspendedAt: new Date(), suspensionReason: "grace_expired" }, + }); + + await invalidatePublicPortfolioCaches([normalized]); + void revalidatePublicPortfolios([normalized]); + + return null; + } + + const result = { + id: publication.id, + subdomain: publication.subdomain, + templateId: publication.templateId, + snapshot: publication.snapshot, + status: publication.status, + updatedAt: publication.updatedAt.toISOString(), + }; + + let ttl = 600; + + if (publication.status === "GRACE" && graceEndsAt) { + const msLeft = new Date(graceEndsAt).getTime() - Date.now(); + + if (msLeft > 0) { + ttl = Math.min(600, Math.ceil(msLeft / 1000)); + } else { + ttl = 0; + } + } + + if (ttl > 0) await cacheSet(cacheKey, result, ttl); + + return result; + } + + static async getDraft(userId: string) { + return prisma.document.findFirst({ + where: { userId, type: "PORTFOLIO", deletedAt: null }, + orderBy: { updatedAt: "desc" }, + select: { + id: true, + slug: true, + templateId: true, + content: true, + revision: true, + updatedAt: true, + }, + }); + } + + static async getMe(userId: string) { + const [draft, publication, billing] = await Promise.all([ + this.getDraft(userId), + prisma.portfolioPublication.findUnique({ + where: { userId }, + select: { + subdomain: true, + status: true, + publishedRevision: true, + publishedAt: true, + updatedAt: true, + }, + }), + BillingService.getSummary(userId), + ]); + + return { draft, publication, billing }; + } + + static async getPreview(userId: string, documentId: string) { + const document = await prisma.document.findFirst({ + where: { id: documentId, userId, type: "PORTFOLIO", deletedAt: null }, + select: { id: true, templateId: true, content: true, revision: true, updatedAt: true }, + }); + + if (!document) throw new ApiError(404, "Portfolio draft not found."); + + return document; + } + + static async isSubdomainAvailable(userId: string, value: string) { + const subdomain = normalizeSubdomain(value); + + const existing = await prisma.portfolioPublication.findUnique({ + where: { subdomain }, + select: { userId: true }, + }); + + return { subdomain, available: !existing || existing.userId === userId }; + } + + static async saveDraft( + userId: string, + input: { + documentId?: string; + subdomain: string; + revision?: number; + snapshot: PortfolioContentInput; + }, + ) { + const subdomain = normalizeSubdomain(input.subdomain); + + const existing = input.documentId + ? await prisma.document.findFirst({ + where: { id: input.documentId, userId, type: "PORTFOLIO", deletedAt: null }, + select: { id: true, revision: true }, + }) + : await prisma.document.findFirst({ + where: { userId, type: "PORTFOLIO", deletedAt: null }, + orderBy: { updatedAt: "desc" }, + select: { id: true, revision: true }, + }); + + if (input.documentId && !existing) throw new ApiError(404, "Portfolio draft not found."); + + if (existing && input.revision !== existing.revision) + throw new ApiError(409, "Portfolio draft changed in another session.", { + revision: existing.revision, + }); + + try { + const document = existing + ? await prisma.document.update({ + where: { id: existing.id }, + data: { + content: input.snapshot as Prisma.InputJsonValue, + templateId: input.snapshot.templateId, + slug: subdomain, + revision: { increment: 1 }, + lastSyncedAt: new Date(), + }, + select: { + id: true, + slug: true, + templateId: true, + content: true, + revision: true, + updatedAt: true, + }, + }) + : await prisma.document.create({ + data: { + userId, + type: "PORTFOLIO", + title: "Portfolio", + slug: subdomain, + content: input.snapshot as Prisma.InputJsonValue, + templateId: input.snapshot.templateId, + visibility: "PRIVATE", + lastSyncedAt: new Date(), + }, + select: { + id: true, + slug: true, + templateId: true, + content: true, + revision: true, + updatedAt: true, + }, + }); + + await Promise.all([ + cacheDel(`document:${userId}:${document.id}`), + cacheDel(`documents:list:${userId}:all`), + cacheDel(`documents:list:${userId}:PORTFOLIO`), + cacheDel(`user:profile:v2:${userId}`), + ]); + + return document; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") + throw new ApiError( + 409, + "That portfolio subdomain conflicts with an existing document slug.", + ); + + throw error; + } + } + + static async publish( + userId: string, + input: { documentId: string; subdomain: string; revision: number }, + ) { + await BillingService.requirePublishAccess(userId); + + const subdomain = normalizeSubdomain(input.subdomain); + + const document = await prisma.document.findFirst({ + where: { id: input.documentId, userId, type: "PORTFOLIO", deletedAt: null }, + }); + + if (!document) throw new ApiError(404, "Portfolio draft not found."); + + if (document.revision !== input.revision) + throw new ApiError(409, "Save the latest draft before publishing."); + + const publishable = portfolioContentSchema.safeParse(document.content); + + if (!publishable.success) { + throw new ApiError( + 400, + "Complete the required portfolio details before publishing.", + publishable.error.flatten(), + ); + } + + const existingPublication = await prisma.portfolioPublication.findUnique({ + where: { userId }, + select: { documentId: true, subdomain: true }, + }); + + try { + const publication = await prisma.$transaction(async (tx) => { + if (existingPublication && existingPublication.documentId !== document.id) { + await tx.document.update({ + where: { id: existingPublication.documentId }, + data: { visibility: "PRIVATE" }, + }); + } + + await tx.document.update({ + where: { id: document.id }, + data: { visibility: "PUBLIC" }, + }); + + return tx.portfolioPublication.upsert({ + where: { userId }, + create: { + userId, + documentId: document.id, + subdomain, + templateId: document.templateId, + snapshot: publishable.data as Prisma.InputJsonValue, + publishedRevision: document.revision, + }, + update: { + documentId: document.id, + subdomain, + templateId: document.templateId, + snapshot: publishable.data as Prisma.InputJsonValue, + publishedRevision: document.revision, + status: "LIVE", + suspensionReason: null, + suspendedAt: null, + publishedAt: new Date(), + }, + }); + }); + + const subdomains = [subdomain, existingPublication?.subdomain ?? ""]; + + await Promise.all([ + invalidatePublicPortfolioCaches(subdomains), + cacheDel(`document:${userId}:${document.id}`), + cacheDel(`documents:list:${userId}:all`), + cacheDel(`documents:list:${userId}:PORTFOLIO`), + ...(existingPublication && existingPublication.documentId !== document.id + ? [cacheDel(`document:${userId}:${existingPublication.documentId}`)] + : []), + ]); + + void revalidatePublicPortfolios(subdomains); + + const publicUrl = + config.nodeEnv === "development" + ? `http://${publication.subdomain}.localhost:3004` + : `https://${publication.subdomain}.veriworkly.com`; + + return { ...publication, publicUrl }; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") + throw new ApiError(409, "That portfolio subdomain is already in use."); + + throw error; + } + } + + static async unpublish(userId: string) { + const publication = await prisma.portfolioPublication.findUnique({ where: { userId } }); + + if (!publication) return null; + + const result = await prisma.$transaction(async (tx) => { + await tx.document.update({ + where: { id: publication.documentId }, + data: { visibility: "PRIVATE" }, + }); + + return tx.portfolioPublication.update({ + where: { userId }, + data: { + status: "SUSPENDED", + suspensionReason: "user_unpublished", + suspendedAt: new Date(), + }, + }); + }); + + await Promise.all([ + invalidatePublicPortfolioCaches([publication.subdomain]), + cacheDel(`document:${userId}:${publication.documentId}`), + cacheDel(`documents:list:${userId}:all`), + cacheDel(`documents:list:${userId}:PORTFOLIO`), + ]); + + void revalidatePublicPortfolios([publication.subdomain]); + + return result; + } + + static async recordView(subdomain: string, referrer?: string) { + const publication = await this.getPublicPortfolio(subdomain); + + if (!publication) throw new ApiError(404, "Portfolio not found"); + + const dateKey = utcDay().toISOString().slice(0, 10); + const referrerHost = normalizeReferrerHost(referrer); + + const redis = getRedis(); + const key = `portfolio:views:buffer:${dateKey}`; + let field = `${publication.id}:${referrerHost}`; + + if (!(await redis.hExists(key, field)) && (await redis.hLen(key)) >= MAX_VIEW_BUFFER_FIELDS) { + field = `${publication.id}:`; + } + await redis.hIncrBy(key, field, 1); + await redis.hIncrBy(`portfolio:views:pending:${publication.id}`, dateKey, 1); + await redis.expire(key, VIEW_BUFFER_TTL_SECONDS); + await redis.expire(`portfolio:views:pending:${publication.id}`, VIEW_BUFFER_TTL_SECONDS); + } + + static async getPendingViewDates() { + const redis = getRedis(); + const dates = new Set(); + + for await (const keys of redis.scanIterator({ + MATCH: "portfolio:views:buffer:*", + COUNT: 100, + })) { + const keyList = Array.isArray(keys) ? keys : [keys]; + + for (const key of keyList) { + const match = /^portfolio:views:buffer:(\d{4}-\d{2}-\d{2})(?::processing)?$/.exec(key); + + if (match) dates.add(match[1]); + } + } + + return [...dates].sort(); + } + + static async flushViewsForDate(dateKey: string) { + const redis = getRedis(); + const key = `portfolio:views:buffer:${dateKey}`; + const processingKey = `${key}:processing`; + const batchKey = `${processingKey}:batch-id`; + const pendingAdjustedKey = `${processingKey}:pending-adjusted`; + + if (!(await redis.exists(processingKey))) { + try { + await redis.rename(key, processingKey); + } catch (error) { + if (error instanceof Error && error.message.includes("no such key")) + return { dateKey, flushedCount: 0 }; + + throw error; + } + } + + const data = await redis.hGetAll(processingKey); + const entries = Object.entries(data); + + if (entries.length === 0) { + await redis.del([processingKey, batchKey, pendingAdjustedKey]); + return { dateKey, flushedCount: 0 }; + } + + const date = new Date(`${dateKey}T00:00:00.000Z`); + await redis.set(batchKey, randomUUID(), { NX: true }); + const batchId = await redis.get(batchKey); + + if (!batchId) throw new Error(`Failed to initialize portfolio views batch for ${dateKey}`); + + const publicationIds = [ + ...new Set(entries.map(([field]) => field.slice(0, field.indexOf(":"))).filter(Boolean)), + ]; + const survivingPublications = new Set( + ( + await prisma.portfolioPublication.findMany({ + where: { id: { in: publicationIds } }, + select: { id: true }, + }) + ).map((publication) => publication.id), + ); + let flushedCount = 0; + const pendingAdjustments = new Map(); + + try { + await prisma.$transaction(async (tx) => { + await tx.viewFlushBatch.create({ data: { id: batchId, kind: "portfolio" } }); + + for (const [field, rawCount] of entries) { + const separator = field.indexOf(":"); + const publicationId = field.slice(0, separator); + const referrerHost = field.slice(separator + 1); + if (!publicationId || !survivingPublications.has(publicationId)) continue; + const count = parseInt(rawCount, 10) || 0; + if (count <= 0) continue; + const pendingKey = `portfolio:views:pending:${publicationId}`; + pendingAdjustments.set(pendingKey, (pendingAdjustments.get(pendingKey) ?? 0) + count); + + await tx.portfolioViewDaily.upsert({ + where: { + publicationId_date_referrerHost: { + publicationId, + date, + referrerHost: referrerHost || "", + }, + }, + create: { + publicationId, + date, + referrerHost: referrerHost || "", + count, + }, + update: { + count: { increment: count }, + }, + }); + + flushedCount += count; + } + }); + } catch (error) { + if (!(error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002")) { + throw error; + } + } + + const staleCount = publicationIds.length - survivingPublications.size; + if (staleCount > 0) + logger.warn("Discarded portfolio views for deleted publications", { staleCount }); + + if (!(await redis.exists(pendingAdjustedKey))) { + const adjustment = redis.multi(); + for (const [pendingKey, count] of pendingAdjustments) { + adjustment.hIncrBy(pendingKey, dateKey, -count); + } + adjustment.set(pendingAdjustedKey, "1"); + await adjustment.exec(); + } + + await redis.del([processingKey, batchKey, pendingAdjustedKey]); + return { dateKey, flushedCount }; + } + + static async getAnalytics(userId: string) { + const publication = await prisma.portfolioPublication.findUnique({ + where: { userId }, + select: { id: true }, + }); + + if (!publication) return { totalViews: 0, daily: [] }; + + const [totals, daily] = await Promise.all([ + prisma.portfolioViewDaily.aggregate({ + where: { publicationId: publication.id }, + _sum: { count: true }, + }), + + prisma.portfolioViewDaily.groupBy({ + by: ["date"], + where: { publicationId: publication.id }, + _sum: { count: true }, + orderBy: { date: "desc" }, + take: 30, + }), + ]); + + let totalViews = totals._sum.count ?? 0; + const dailyMap = new Map(); + + daily.forEach((item) => { + const dateStr = item.date.toISOString().slice(0, 10); + dailyMap.set(dateStr, item._sum.count ?? 0); + }); + + try { + const pending = await getRedis().hGetAll(`portfolio:views:pending:${publication.id}`); + + for (const [dateStr, rawCount] of Object.entries(pending)) { + const count = Math.max(0, parseInt(rawCount, 10) || 0); + totalViews += count; + dailyMap.set(dateStr, (dailyMap.get(dateStr) ?? 0) + count); + } + } catch (err) { + logger.error("Failed to read buffered portfolio views from Redis", err); + } + + const sortedDaily = Array.from(dailyMap.entries()) + .map(([dateStr, count]) => ({ + date: new Date(`${dateStr}T00:00:00.000Z`), + count, + })) + .sort((a, b) => b.date.getTime() - a.date.getTime()) + .slice(0, 30); + + return { + totalViews, + daily: sortedDaily, + }; + } +} diff --git a/apps/server/src/services/profileService.ts b/apps/server/src/services/profileService.ts index 668c5e2..530f965 100644 --- a/apps/server/src/services/profileService.ts +++ b/apps/server/src/services/profileService.ts @@ -49,23 +49,23 @@ export class ProfileService { if (!user) throw new ApiError(404, "User not found"); - const [existingProfile, shareResumeCount] = await prisma.$transaction([ + const [profileRecord, shareResumeCount] = await Promise.all([ prisma.masterProfile.findUnique({ where: { userId: user.id }, }), + prisma.shareLink.count({ where: { userId: user.id }, }), ]); - const profile = - existingProfile ?? - (await prisma.masterProfile.create({ - data: { - userId: user.id, - content: {}, - }, - })); + const profile = profileRecord ?? { + id: "", + userId: user.id, + content: {}, + createdAt: user.createdAt, + updatedAt: user.createdAt, + }; const responseData = { profile, diff --git a/apps/server/src/services/shareService.ts b/apps/server/src/services/shareService.ts index 8e3884e..bcd6fb5 100644 --- a/apps/server/src/services/shareService.ts +++ b/apps/server/src/services/shareService.ts @@ -1,15 +1,21 @@ import { promisify } from "node:util"; -import { randomBytes, scrypt, timingSafeEqual } from "node:crypto"; +import { randomBytes, randomUUID, scrypt, timingSafeEqual, createHash } from "node:crypto"; import { Prisma } from "@prisma/client"; +import { config } from "#config"; + import { prisma } from "#utils/prisma"; +import { getRedis } from "#utils/redis"; import { ApiError } from "#utils/errors"; -import { normalizeSlug, normalizeUsername } from "#utils/slugs"; +import { logger } from "#utils/logger"; +import { normalizeSlug, normalizeUsername, buildUniqueSlugHelper } from "#utils/slugs"; import { UserService } from "#services/userService"; const scryptAsync = promisify(scrypt); +const VIEW_BUFFER_TTL_SECONDS = 14 * 24 * 60 * 60; +const MAX_BUFFERED_SHARE_VIEWS = 10_000; export class ShareService { static async hashPassword(password: string) { @@ -39,19 +45,154 @@ export class ShareService { } static async recordShareView(shareLinkId: string, ipAddress?: string, userAgent?: string | null) { - return prisma.shareLink.update({ - where: { id: shareLinkId }, - data: { - viewCount: { increment: 1 }, - lastViewedAt: new Date(), - views: { - create: { - ipAddress, - userAgent: userAgent ?? null, - }, - }, - }, + const hashedIp = ipAddress + ? createHash("sha256") + .update(ipAddress + (config.jwt.secret || "fallback-salt")) + .digest("hex") + : null; + + const redis = getRedis(); + const timestamp = Date.now(); + + const viewRecord = JSON.stringify({ + shareLinkId, + ipAddress: hashedIp, + userAgent: userAgent ?? null, + timestamp, }); + + await redis.lPush("share:views:buffer", viewRecord); + await redis.lTrim("share:views:buffer", 0, MAX_BUFFERED_SHARE_VIEWS - 1); + await redis.hIncrBy("share:links:view_count", shareLinkId, 1); + await redis.hSet("share:links:last_viewed", shareLinkId, String(timestamp)); + await redis + .multi() + .expire("share:views:buffer", VIEW_BUFFER_TTL_SECONDS) + .expire("share:links:view_count", VIEW_BUFFER_TTL_SECONDS) + .expire("share:links:last_viewed", VIEW_BUFFER_TTL_SECONDS) + .exec(); + } + + static async flushShareViews() { + const redis = getRedis(); + + const hasViews = (await redis.exists("share:views:buffer")) > 0; + const hasCounts = (await redis.exists("share:links:view_count")) > 0; + + if (!hasViews && !hasCounts) return { flushedViews: 0, flushedLinks: 0 }; + + const viewsProcessingKey = "share:views:buffer:processing"; + const countsProcessingKey = "share:links:view_count:processing"; + const lastViewedProcessingKey = "share:links:last_viewed:processing"; + const batchKey = "share:views:processing:batch-id"; + + if (hasViews && !(await redis.exists(viewsProcessingKey))) { + try { + await redis.rename("share:views:buffer", viewsProcessingKey); + } catch { + // Ignore + } + } + + if (hasCounts && !(await redis.exists(countsProcessingKey))) { + try { + await redis.rename("share:links:view_count", countsProcessingKey); + await redis.rename("share:links:last_viewed", lastViewedProcessingKey); + } catch { + // Ignore + } + } + + const rawViews = await redis.lRange(viewsProcessingKey, 0, -1); + const parsedViews: Array<{ + shareLinkId: string; + ipAddress: string | null; + userAgent: string | null; + timestamp: number; + }> = []; + + for (const raw of rawViews) { + try { + parsedViews.push(JSON.parse(raw)); + } catch { + // Skip malformed + } + } + + const counts = await redis.hGetAll(countsProcessingKey); + const lastViewed = await redis.hGetAll(lastViewedProcessingKey); + await redis.set(batchKey, randomUUID(), { NX: true }); + const batchId = await redis.get(batchKey); + + if (!batchId) throw new Error("Failed to initialize share views batch"); + + const shareLinkIds = [ + ...new Set([...parsedViews.map((view) => view.shareLinkId), ...Object.keys(counts)]), + ]; + const survivingShareLinks = new Set( + ( + await prisma.shareLink.findMany({ + where: { id: { in: shareLinkIds } }, + select: { id: true }, + }) + ).map((shareLink) => shareLink.id), + ); + const validViews = parsedViews.filter((view) => survivingShareLinks.has(view.shareLinkId)); + const validCounts = Object.entries(counts).filter(([shareLinkId]) => + survivingShareLinks.has(shareLinkId), + ); + const flushedViews = validViews.length; + const flushedLinks = validCounts.length; + + try { + await prisma.$transaction(async (tx) => { + await tx.viewFlushBatch.create({ data: { id: batchId, kind: "share" } }); + + if (validViews.length > 0) { + await tx.shareView.createMany({ + data: validViews.map((v) => ({ + shareLinkId: v.shareLinkId, + ipAddress: v.ipAddress, + userAgent: v.userAgent, + createdAt: new Date(v.timestamp), + })), + }); + } + + for (const [shareLinkId, rawCount] of validCounts) { + const count = parseInt(rawCount, 10) || 0; + const tsString = lastViewed[shareLinkId]; + const lastViewedAt = tsString ? new Date(parseInt(tsString, 10)) : new Date(); + + await tx.shareLink.update({ + where: { id: shareLinkId }, + data: { + viewCount: { increment: count }, + lastViewedAt, + }, + }); + } + + const retentionLimit = new Date(); + retentionLimit.setDate(retentionLimit.getDate() - 30); + await tx.shareView.deleteMany({ + where: { + createdAt: { lt: retentionLimit }, + }, + }); + }); + } catch (error) { + if (!(error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002")) { + throw error; + } + } + + const staleCount = shareLinkIds.length - survivingShareLinks.size; + if (staleCount > 0) logger.warn("Discarded share views for deleted links", { staleCount }); + + await redis.del([viewsProcessingKey, countsProcessingKey, lastViewedProcessingKey, batchKey]); + + return { flushedViews, flushedLinks }; } static async createShareLink( @@ -217,12 +358,7 @@ export class ShareService { } public static async buildUniqueShareSlug(userId: string, slug: string, shareLinkId?: string) { - const base = normalizeSlug(slug); - - for (let attempt = 0; attempt < 20; attempt += 1) { - const suffix = attempt === 0 ? "" : `-${attempt + 1}`; - const candidate = `${base.slice(0, 255 - suffix.length)}${suffix}`; - + return buildUniqueSlugHelper(slug, async (candidate) => { const existing = await prisma.shareLink.findFirst({ where: { userId, @@ -231,10 +367,7 @@ export class ShareService { }, select: { id: true }, }); - - if (!existing) return candidate; - } - - return `${base.slice(0, 246)}-${Date.now().toString(36)}`; + return !!existing; + }); } } diff --git a/apps/server/src/utils/portfolioPublicationCache.ts b/apps/server/src/utils/portfolioPublicationCache.ts new file mode 100644 index 0000000..79b34f5 --- /dev/null +++ b/apps/server/src/utils/portfolioPublicationCache.ts @@ -0,0 +1,43 @@ +import { config } from "#config"; + +import { logger } from "#utils/logger"; +import { cacheDel, cacheDelByPrefix } from "#utils/redis"; + +function uniqueSubdomains(subdomains: string[]) { + return [...new Set(subdomains.filter(Boolean))]; +} + +export async function invalidatePublicPortfolioCaches(subdomains: string[]) { + const unique = uniqueSubdomains(subdomains); + + await Promise.all([ + cacheDelByPrefix("portfolio:public:list"), + ...unique.map((subdomain) => cacheDel(`portfolio:public:${subdomain}`)), + ]); +} + +export async function revalidatePublicPortfolios(subdomains: string[]) { + const unique = uniqueSubdomains(subdomains); + + try { + const response = await fetch(`${config.portfolio.url}/api/revalidate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + paths: ["/sitemap.xml", ...unique.map((subdomain) => `/portfolios/${subdomain}`)], + tags: ["portfolios-list", ...unique.map((subdomain) => `portfolio-${subdomain}`)], + secret: config.portfolio.revalidateSecret, + }), + signal: AbortSignal.timeout(10000), + }); + + if (!response.ok) { + logger.warn("Failed to revalidate public portfolio paths", { + status: response.status, + body: await response.text(), + }); + } + } catch (error) { + logger.warn("Failed to trigger public portfolio revalidation", error); + } +} diff --git a/apps/server/src/utils/prisma.ts b/apps/server/src/utils/prisma.ts index 8953f57..ad10158 100644 --- a/apps/server/src/utils/prisma.ts +++ b/apps/server/src/utils/prisma.ts @@ -1,4 +1,5 @@ import pg from "pg"; + import { PrismaClient } from "@prisma/client"; import { PrismaPg } from "@prisma/adapter-pg"; @@ -11,9 +12,10 @@ const isProduction = process.env.NODE_ENV === "production"; const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, - max: parsePositiveInt(process.env.DB_POOL_MAX, isProduction ? 10 : 5), - idleTimeoutMillis: parsePositiveInt(process.env.DB_POOL_IDLE_TIMEOUT_MS, 10_000), - connectionTimeoutMillis: parsePositiveInt(process.env.DB_POOL_CONNECTION_TIMEOUT_MS, 10_000), + max: parsePositiveInt(process.env.DB_POOL_MAX, isProduction ? 30 : 5), + idleTimeoutMillis: parsePositiveInt(process.env.DB_POOL_IDLE_TIMEOUT_MS, 15_000), + connectionTimeoutMillis: parsePositiveInt(process.env.DB_POOL_CONNECTION_TIMEOUT_MS, 5_000), + statement_timeout: parsePositiveInt(process.env.DB_POOL_STATEMENT_TIMEOUT_MS, 30_000), allowExitOnIdle: true, }); diff --git a/apps/server/src/utils/slugs.ts b/apps/server/src/utils/slugs.ts index bbade16..395ec41 100644 --- a/apps/server/src/utils/slugs.ts +++ b/apps/server/src/utils/slugs.ts @@ -15,6 +15,7 @@ export const RESERVED_USERNAMES = new Set([ "logout", "me", "profile", + "portfolio", "public", "settings", "share", @@ -22,6 +23,20 @@ export const RESERVED_USERNAMES = new Set([ "signup", "support", "users", + "www", + "veriworkly", + "job", + "work", + "billing", + "payment", + "webhook", + "developer", + "designer", + "server", + "github", + "studio", + "roadmap", + "portal", ]); export function normalizeSlug(value: string, fallback = "document") { @@ -83,3 +98,21 @@ export function buildUsernameBase(input: { email?: string | null; name?: string export function randomSuffix(bytes = 3) { return randomBytes(bytes).toString("hex"); } + +export async function buildUniqueSlugHelper( + baseValue: string, + checkExisting: (candidate: string) => Promise, +): Promise { + const base = normalizeSlug(baseValue); + + for (let attempt = 0; attempt < 20; attempt += 1) { + const suffix = attempt === 0 ? "" : `-${attempt + 1}`; + const candidate = `${base.slice(0, 255 - suffix.length)}${suffix}`; + + const exists = await checkExisting(candidate); + + if (!exists) return candidate; + } + + return `${base.slice(0, 246)}-${Date.now().toString(36)}`; +} diff --git a/apps/server/src/validators/billingValidator.ts b/apps/server/src/validators/billingValidator.ts new file mode 100644 index 0000000..d3d05dd --- /dev/null +++ b/apps/server/src/validators/billingValidator.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; + +export const checkoutSchema = z.object({ + interval: z.enum(["monthly", "annual"]), + redirectUrl: z + .string() + .trim() + .regex(/^\/(?!\/)/, "Redirect URL must be a relative path") + .optional(), +}); + +export const dodoWebhookHeaderSchema = z.object({ + "webhook-id": z.string().trim().min(1, "Webhook ID is required"), +}); + +export const dodoSubscriptionSchema = z.object({ + subscription_id: z.string().trim().min(1, "Subscription ID is required"), + customer: z.object({ + customer_id: z.string().trim().min(1, "Customer ID is required"), + }), + product_id: z.string().trim().min(1, "Product ID is required"), + status: z.string().trim().min(1, "Status is required"), + cancel_at_next_billing_date: z.boolean(), + next_billing_date: z.string().trim().nullable().optional(), + metadata: z + .object({ + veriworkly_user_id: z.string().trim().optional(), + }) + .catchall(z.any()) + .default({}), +}); + +export const dodoWebhookEventSchema = z.object({ + type: z.string().trim().min(1, "Event type is required"), + timestamp: z.string().trim().datetime("Invalid event timestamp"), + data: z.unknown(), +}); diff --git a/apps/server/src/validators/portfolioValidator.ts b/apps/server/src/validators/portfolioValidator.ts new file mode 100644 index 0000000..7198f4c --- /dev/null +++ b/apps/server/src/validators/portfolioValidator.ts @@ -0,0 +1,112 @@ +import { z } from "zod"; + +const webUrlSchema = z + .string() + .trim() + .url() + .max(2048) + .refine((value) => /^https?:\/\//i.test(value), "Use an HTTP or HTTPS URL."); + +const assetReferenceSchema = z.object({ + id: z.string().min(1).max(128), + url: webUrlSchema, +}); + +const linkSchema = z.object({ + id: z.string().min(1).max(128), + label: z.string().trim().min(1).max(80), + url: webUrlSchema, +}); + +const sectionTypeSchema = z.enum([ + "projects", + "experience", + "services", + "skills", + "education", + "writing", + "testimonials", + "awards", + "contact", +]); + +export const portfolioContentSchema = z.object({ + schemaVersion: z.literal(1), + + templateId: z.enum(["signal", "atelier"]), + + identity: z.object({ + name: z.string().trim().min(1).max(120), + headline: z.string().trim().min(1).max(240), + bio: z.string().trim().min(1).max(1600), + location: z.string().trim().max(120), + email: z.string().trim().email().max(254), + availability: z.string().trim().max(160), + avatar: assetReferenceSchema.nullable(), + }), + + seo: z.object({ + title: z.string().trim().max(120), + description: z.string().trim().max(300), + socialImage: assetReferenceSchema.nullable(), + }), + + socialLinks: z.array(linkSchema).max(12), + + sections: z + .array( + z.object({ + id: z.string().min(1).max(128), + type: sectionTypeSchema, + title: z.string().trim().min(1).max(120), + visible: z.boolean(), + items: z.array(z.record(z.unknown())).max(24), + }), + ) + .max(24), +}); + +export const portfolioDraftContentSchema = portfolioContentSchema.extend({ + identity: portfolioContentSchema.shape.identity.extend({ + name: z.string().trim().max(120), + headline: z.string().trim().max(240), + bio: z.string().trim().max(1600), + email: z.union([z.literal(""), z.string().trim().email().max(254)]), + }), + + socialLinks: z + .array( + linkSchema.extend({ + label: z.string().trim().max(80), + url: z.union([z.literal(""), webUrlSchema]), + }), + ) + .max(12), + + sections: z + .array( + portfolioContentSchema.shape.sections.element.extend({ + title: z.string().trim().max(120), + }), + ) + .max(24), +}); + +export const portfolioSaveDraftSchema = z.object({ + documentId: z.string().min(1).optional(), + subdomain: z.string().trim().min(1).max(63), + revision: z.number().int().min(1).optional(), + snapshot: portfolioDraftContentSchema, +}); + +export const portfolioPublishSchema = z.object({ + documentId: z.string().min(1), + subdomain: z.string().trim().min(1).max(63), + revision: z.number().int().min(1), +}); + +export const portfolioSubdomainParamsSchema = z.object({ + slug: z.string().trim().min(1).max(63), +}); + +export type PortfolioContentInput = z.infer; diff --git a/apps/server/tests/auth/api-key.test.ts b/apps/server/tests/auth/api-key.test.ts new file mode 100644 index 0000000..df40588 --- /dev/null +++ b/apps/server/tests/auth/api-key.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const cacheGetMock = vi.fn(); +const cacheSetMock = vi.fn(); +vi.mock("../../src/config", () => ({ + config: { + apiKeys: { + defaultScopes: ["user:read"], + defaultRateLimit: 20, + authCacheTtlSeconds: 300, + defaultKeyLifetimeDays: 365, + lastUsedTouchIntervalSeconds: 300, + hashSecret: "test-secret", + }, + }, +})); + +vi.mock("../../src/utils/redis", () => ({ + cacheGet: cacheGetMock, + cacheSet: cacheSetMock, + getRedis: () => ({ + set: vi.fn(), + }), +})); + +const prismaFindMock = vi.fn(); +vi.mock("../../src/utils/prisma", () => ({ + prisma: { + apiKey: { + findFirst: (...args: unknown[]) => prismaFindMock(...args), + }, + }, +})); + +describe("API Key Service Validation", () => { + beforeEach(() => { + cacheGetMock.mockReset(); + cacheSetMock.mockReset(); + prismaFindMock.mockReset(); + }); + + it("permits validation if user has active/trialing subscription or no subscription", async () => { + cacheGetMock.mockResolvedValue(null); + prismaFindMock.mockResolvedValue({ + id: "key-1", + keyHash: "hashed-key", + keyPrefix: "vw_12345", + keySuffix: "67890", + name: "Test Key", + userId: "user-1", + isActive: true, + rateLimit: 20, + scopes: ["user:read"], + expiresAt: null, + revokedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + lastUsed: null, + user: { + id: "user-1", + email: "user@example.com", + name: "Test User", + subscriptions: [ + { + status: "ACTIVE", + }, + ], + }, + }); + + const { ApiKeyService } = await import("../../src/services/apiKeyService"); + const result = await ApiKeyService.validateKey("vw_1234567890"); + + expect(result).not.toBeNull(); + expect(result?.userId).toBe("user-1"); + }); + + it("rejects validation if user subscription status is CANCELED", async () => { + cacheGetMock.mockResolvedValue(null); + prismaFindMock.mockResolvedValue({ + id: "key-2", + keyHash: "hashed-key-canceled", + keyPrefix: "vw_22345", + keySuffix: "67890", + name: "Canceled Test Key", + userId: "user-2", + isActive: true, + rateLimit: 20, + scopes: ["user:read"], + expiresAt: null, + revokedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + lastUsed: null, + user: { + id: "user-2", + email: "user-canceled@example.com", + name: "Canceled User", + subscriptions: [ + { + status: "CANCELED", + }, + ], + }, + }); + + const { ApiKeyService } = await import("../../src/services/apiKeyService"); + const result = await ApiKeyService.validateKey("vw_2234567890"); + + expect(result).toBeNull(); + }); +}); diff --git a/apps/server/tests/auth/flexible-auth.test.ts b/apps/server/tests/auth/flexible-auth.test.ts new file mode 100644 index 0000000..2228135 --- /dev/null +++ b/apps/server/tests/auth/flexible-auth.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { flexibleAuth } from "../../src/middleware/flexibleAuth"; +import type { Request, Response, NextFunction } from "express"; + +vi.mock("../../src/config", () => ({ + config: { + allowedOrigins: ["https://trusted.example.com"], + }, + isDevelopment: false, +})); + +vi.mock("../../src/middleware/apiKeyAuth", () => ({ + apiKeyAuth: vi.fn((req, res, next) => next()), +})); + +vi.mock("../../src/middleware/auth", () => ({ + getSessionUserFromRequest: vi.fn().mockResolvedValue(null), +})); + +vi.mock("../../src/middleware/apiKeyRateLimit", () => ({ + apiKeyRateLimit: vi.fn((req, res, next) => next()), +})); + +describe("flexibleAuth middleware", () => { + let req: Partial & { headers: Record }; + let res: Partial; + let next: NextFunction; + + beforeEach(() => { + req = { + headers: {}, + ip: "192.168.1.1", + path: "/test-route", + }; + res = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + }; + next = vi.fn(); + }); + + it("passes for exact allowed origin", async () => { + req.headers.origin = "https://trusted.example.com"; + await flexibleAuth(req, res, next); + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it("rejects malicious origin prefix-matching", async () => { + req.headers.origin = "https://trusted.example.com.attacker.com"; + await flexibleAuth(req, res, next); + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(401); + }); + + it("rejects malicious origin suffix-injections", async () => { + req.headers.origin = "https://trusted.example.attacker.example"; + await flexibleAuth(req, res, next); + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(401); + }); + + it("rejects headerless requests (no origin/referer)", async () => { + await flexibleAuth(req, res, next); + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(401); + }); + + it("passes for exact referer origin", async () => { + req.headers.referer = "https://trusted.example.com/dashboard"; + await flexibleAuth(req, res, next); + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it("rejects invalid referer origin", async () => { + req.headers.referer = "https://trusted.example.attacker.com/dashboard"; + await flexibleAuth(req, res, next); + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(401); + }); +}); diff --git a/apps/server/tests/billing/webhook.test.ts b/apps/server/tests/billing/webhook.test.ts new file mode 100644 index 0000000..3cb67a8 --- /dev/null +++ b/apps/server/tests/billing/webhook.test.ts @@ -0,0 +1,192 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Prisma } from "@prisma/client"; + +const prismaMock = { + billingWebhookEvent: { + create: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + }, + subscription: { + findUnique: vi.fn(), + updateMany: vi.fn(), + create: vi.fn(), + }, + portfolioPublication: { + updateMany: vi.fn(), + findUnique: vi.fn(), + }, + $transaction: vi.fn((cb) => cb(prismaMock)), +}; + +vi.mock("../../src/utils/prisma", () => ({ + prisma: prismaMock, + default: prismaMock, +})); + +vi.mock("../../src/utils/portfolioPublicationCache", () => ({ + invalidatePublicPortfolioCaches: vi.fn().mockResolvedValue(undefined), + revalidatePublicPortfolios: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("../../src/config", () => ({ + config: { + portfolio: { + graceDays: 7, + }, + dodo: { + webhookSecret: "secret", + monthlyProductId: "prod_monthly", + annualProductId: "prod_annual", + }, + nodeEnv: "test", + }, +})); + +describe("billing webhook processing and idempotency", () => { + beforeEach(() => { + vi.restoreAllMocks(); + prismaMock.billingWebhookEvent.create.mockReset(); + prismaMock.billingWebhookEvent.findUnique.mockReset(); + prismaMock.billingWebhookEvent.update.mockReset(); + prismaMock.subscription.findUnique.mockReset(); + prismaMock.subscription.updateMany.mockReset(); + prismaMock.subscription.create.mockReset(); + prismaMock.portfolioPublication.updateMany.mockReset(); + prismaMock.portfolioPublication.findUnique.mockReset(); + }); + + const validEvent = { + type: "subscription.created", + timestamp: "2026-05-31T12:00:00.000Z", + data: { + subscription_id: "sub_123", + customer: { + customer_id: "cust_123", + }, + product_id: "prod_monthly", + status: "active", + cancel_at_next_billing_date: false, + metadata: { + veriworkly_user_id: "user_123", + }, + }, + }; + + it("successfully processes and marks webhook as PROCESSED", async () => { + const { BillingService } = await import("../../src/services/billingService"); + + prismaMock.billingWebhookEvent.create.mockResolvedValue({ + id: "evt_1", + status: "PROCESSING", + retryCount: 0, + }); + prismaMock.subscription.findUnique.mockResolvedValue(null); + prismaMock.subscription.updateMany.mockResolvedValue({ count: 1 }); + prismaMock.portfolioPublication.updateMany.mockResolvedValue({ count: 1 }); + prismaMock.portfolioPublication.findUnique.mockResolvedValue({ subdomain: "my-portfolio" }); + + const result = await BillingService.processWebhook("evt_123", validEvent); + + expect(result).toEqual({ duplicate: false }); + expect(prismaMock.billingWebhookEvent.create).toHaveBeenCalled(); + expect(prismaMock.billingWebhookEvent.update).toHaveBeenCalledWith({ + where: { id: "evt_1" }, + data: { status: "PROCESSED", processedAt: expect.any(Date) }, + }); + }); + + it("handles retry for FAILED event, increments retryCount, and attempts reprocessing", async () => { + const { BillingService } = await import("../../src/services/billingService"); + + // First, simulate P2002 duplicate key violation on create + const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint", { + code: "P2002", + clientVersion: "x.y.z", + }); + prismaMock.billingWebhookEvent.create.mockRejectedValue(prismaError); + + // Mock finding the existing failed event + prismaMock.billingWebhookEvent.findUnique.mockResolvedValue({ + id: "evt_1", + providerEventId: "evt_123", + status: "FAILED", + retryCount: 1, + }); + + // Mock updating retry parameters + prismaMock.billingWebhookEvent.update.mockResolvedValue({ + id: "evt_1", + status: "PROCESSING", + retryCount: 2, + }); + + prismaMock.subscription.findUnique.mockResolvedValue(null); + prismaMock.subscription.updateMany.mockResolvedValue({ count: 1 }); + prismaMock.portfolioPublication.updateMany.mockResolvedValue({ count: 1 }); + prismaMock.portfolioPublication.findUnique.mockResolvedValue({ subdomain: "my-portfolio" }); + + const result = await BillingService.processWebhook("evt_123", validEvent); + + expect(result).toEqual({ duplicate: false }); + expect(prismaMock.billingWebhookEvent.findUnique).toHaveBeenCalledWith({ + where: { providerEventId: "evt_123" }, + }); + expect(prismaMock.billingWebhookEvent.update).toHaveBeenCalledWith({ + where: { id: "evt_1" }, + data: { + status: "PROCESSING", + retryCount: { increment: 1 }, + lastAttemptAt: expect.any(Date), + }, + }); + }); + + it("returns duplicate: true and skips processing if existing event is PROCESSED", async () => { + const { BillingService } = await import("../../src/services/billingService"); + + const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint", { + code: "P2002", + clientVersion: "x.y.z", + }); + prismaMock.billingWebhookEvent.create.mockRejectedValue(prismaError); + + prismaMock.billingWebhookEvent.findUnique.mockResolvedValue({ + id: "evt_1", + providerEventId: "evt_123", + status: "PROCESSED", + retryCount: 1, + }); + + const result = await BillingService.processWebhook("evt_123", validEvent); + + expect(result).toEqual({ duplicate: true }); + expect(prismaMock.subscription.updateMany).not.toHaveBeenCalled(); + }); + + it("ignores older events and does not overwrite newer subscription state in DB", async () => { + const { BillingService } = await import("../../src/services/billingService"); + + // Existing subscription lastWebhookAt is T2 (newer) + prismaMock.subscription.findUnique.mockResolvedValue({ + id: "sub_1", + providerSubId: "sub_123", + userId: "user_123", + lastWebhookAt: new Date("2026-05-31T13:00:00.000Z"), + }); + + prismaMock.billingWebhookEvent.create.mockResolvedValue({ + id: "evt_1", + status: "PROCESSING", + retryCount: 0, + }); + + // Try processing an event with timestamp T1 (older: 12:00:00 vs 13:00:00) + const result = await BillingService.processWebhook("evt_123", validEvent); + + expect(result).toEqual({ duplicate: false }); + // Since existing subscription lastWebhookAt is newer, updateMany/create should not be called inside transaction + expect(prismaMock.subscription.updateMany).not.toHaveBeenCalled(); + expect(prismaMock.subscription.create).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/tests/portfolio/portfolio-validator.test.ts b/apps/server/tests/portfolio/portfolio-validator.test.ts new file mode 100644 index 0000000..4679192 --- /dev/null +++ b/apps/server/tests/portfolio/portfolio-validator.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; + +import { + portfolioContentSchema, + portfolioDraftContentSchema, +} from "../../src/validators/portfolioValidator"; + +const validPortfolio = { + schemaVersion: 1, + templateId: "signal", + identity: { + name: "Gautam Raj", + headline: "Product engineer", + bio: "Builds considered software.", + location: "India", + email: "gautam@veriworkly.com", + availability: "Available", + avatar: null, + }, + seo: { title: "Gautam Raj | Portfolio", description: "Portfolio", socialImage: null }, + socialLinks: [], + sections: [{ id: "projects", type: "projects", title: "Work", visible: true, items: [] }], +}; + +describe("portfolio validator", () => { + it("accepts a modular portfolio snapshot", () => { + expect(portfolioContentSchema.parse(validPortfolio)).toEqual(validPortfolio); + }); + + it("rejects malformed public identity data", () => { + expect(() => + portfolioContentSchema.parse({ + ...validPortfolio, + identity: { ...validPortfolio.identity, email: "not-an-email" }, + }), + ).toThrow(); + }); + + it("accepts incomplete fields while a draft is being edited", () => { + expect(() => + portfolioDraftContentSchema.parse({ + ...validPortfolio, + identity: { ...validPortfolio.identity, headline: "", email: "" }, + socialLinks: [{ id: "new-link", label: "", url: "" }], + sections: [{ ...validPortfolio.sections[0], title: "" }], + }), + ).not.toThrow(); + }); + + it("rejects non-web links in public snapshots", () => { + expect(() => + portfolioContentSchema.parse({ + ...validPortfolio, + socialLinks: [{ id: "unsafe", label: "Unsafe", url: "javascript:alert(1)" }], + }), + ).toThrow(); + }); +}); diff --git a/apps/server/tests/stats/stats.test.ts b/apps/server/tests/stats/stats.test.ts new file mode 100644 index 0000000..a8abbab --- /dev/null +++ b/apps/server/tests/stats/stats.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { StatsController } from "../../src/controllers/statsController"; +import type { Request, Response, NextFunction } from "express"; + +const { mockIncrementUsageMetric } = vi.hoisted(() => { + return { + mockIncrementUsageMetric: vi.fn(), + }; +}); + +vi.mock("../../src/services/analyticsService", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + incrementUsageMetric: mockIncrementUsageMetric, + }; +}); + +vi.mock("../../src/config", () => ({ + config: { + github: { + syncApiKey: "secret-sync-key", + }, + rateLimit: { + authWindowMs: 60000, + authMaxRequests: 20, + windowMs: 900000, + maxRequests: 100, + }, + }, +})); + +describe("stats events endpoint validation and auth", () => { + let req: Partial & { apiKey?: unknown }; + let res: Partial; + let next: NextFunction; + + beforeEach(() => { + vi.restoreAllMocks(); + mockIncrementUsageMetric.mockReset(); + req = { + body: {}, + headers: {}, + }; + res = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + }; + next = vi.fn(); + process.env.NODE_ENV = "production"; // ensures metric recording runs (not skipped in dev mode check) + }); + + it("allows allowlisted events for public requests", async () => { + req.body = { event: "resume_created" }; + await StatsController.recordUsageMetric(req, res, next); + + expect(mockIncrementUsageMetric).toHaveBeenCalledWith({ event: "resume_created" }); + expect(res.status).toHaveBeenCalledWith(202); + }); + + it("rejects non-allowlisted event for public requests", async () => { + req.body = { event: "malicious_untracked_event" }; + await StatsController.recordUsageMetric(req, res, next); + + expect(mockIncrementUsageMetric).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + message: expect.stringContaining("not allowlisted"), + }), + ); + }); + + it("allows non-allowlisted custom events for internal requests with x-internal-key", async () => { + req.body = { event: "custom_funnel_event" }; + req.headers["x-internal-key"] = "secret-sync-key"; + await StatsController.recordUsageMetric(req, res, next); + + expect(mockIncrementUsageMetric).toHaveBeenCalledWith({ event: "custom_funnel_event" }); + expect(res.status).toHaveBeenCalledWith(202); + }); + + it("allows non-allowlisted custom events when authenticated via API Key", async () => { + req.body = { event: "another_custom_event" }; + req.apiKey = { id: "key_1", scopes: ["user:read"] }; + await StatsController.recordUsageMetric(req, res, next); + + expect(mockIncrementUsageMetric).toHaveBeenCalledWith({ event: "another_custom_event" }); + expect(res.status).toHaveBeenCalledWith(202); + }); + + it("rejects events with names exceeding 64 characters", async () => { + req.body = { event: "a".repeat(65) }; + await StatsController.recordUsageMetric(req, res, next); + + expect(mockIncrementUsageMetric).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(expect.any(Error)); + }); +}); diff --git a/apps/server/tests/views/views.test.ts b/apps/server/tests/views/views.test.ts new file mode 100644 index 0000000..a4f2251 --- /dev/null +++ b/apps/server/tests/views/views.test.ts @@ -0,0 +1,309 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const prismaMock = { + portfolioPublication: { + findUnique: vi.fn(), + findMany: vi.fn(), + }, + + portfolioViewDaily: { + upsert: vi.fn(), + aggregate: vi.fn(), + groupBy: vi.fn(), + }, + + shareView: { + createMany: vi.fn(), + deleteMany: vi.fn(), + }, + + shareLink: { + findMany: vi.fn(), + update: vi.fn(), + }, + + viewFlushBatch: { + create: vi.fn(), + }, + + $transaction: vi.fn((cb) => cb(prismaMock)), +}; + +vi.mock("../../src/utils/prisma", () => ({ + prisma: prismaMock, + default: prismaMock, +})); + +const mockRedisStore = new Map(); // eslint-disable-line @typescript-eslint/no-explicit-any + +const mockRedis = { + set: vi.fn((key: string, value: string, options?: { NX?: boolean }) => { + if (options?.NX && mockRedisStore.has(key)) return null; + mockRedisStore.set(key, value); + return "OK"; + }), + + get: vi.fn((key: string) => mockRedisStore.get(key) ?? null), + + hExists: vi.fn((key: string, field: string) => Boolean(mockRedisStore.get(key)?.[field])), + + hLen: vi.fn((key: string) => Object.keys(mockRedisStore.get(key) || {}).length), + + hIncrBy: vi.fn((key: string, field: string, value: number) => { + if (!mockRedisStore.has(key)) mockRedisStore.set(key, {}); + const obj = mockRedisStore.get(key); + obj[field] = (obj[field] || 0) + value; + return obj[field]; + }), + + hSet: vi.fn((key: string, field: string, value: string) => { + if (!mockRedisStore.has(key)) mockRedisStore.set(key, {}); + mockRedisStore.get(key)[field] = value; + return 1; + }), + + hGetAll: vi.fn((key: string) => { + return mockRedisStore.get(key) || {}; + }), + + exists: vi.fn((key: string) => { + return mockRedisStore.has(key) ? 1 : 0; + }), + + rename: vi.fn((oldKey: string, newKey: string) => { + if (!mockRedisStore.has(oldKey)) { + throw new Error("ERR no such key"); + } + mockRedisStore.set(newKey, mockRedisStore.get(oldKey)); + mockRedisStore.delete(oldKey); + }), + + del: vi.fn((keys: string | string[]) => { + const arr = Array.isArray(keys) ? keys : [keys]; + for (const k of arr) { + mockRedisStore.delete(k); + } + }), + + lPush: vi.fn((key: string, value: string) => { + if (!mockRedisStore.has(key)) mockRedisStore.set(key, []); + mockRedisStore.get(key).unshift(value); + return mockRedisStore.get(key).length; + }), + + lRange: vi.fn((key: string, start: number, stop: number) => { + const list = mockRedisStore.get(key) || []; + if (stop === -1) return list.slice(start); + return list.slice(start, stop + 1); + }), + + lTrim: vi.fn((key: string, start: number, stop: number) => { + const list = mockRedisStore.get(key) || []; + mockRedisStore.set(key, list.slice(start, stop + 1)); + }), + + expire: vi.fn(), + + multi: vi.fn(() => { + const chain = { + expire: vi.fn(() => chain), + hIncrBy: vi.fn(() => chain), + set: vi.fn(() => chain), + exec: vi.fn(), + }; + return chain; + }), + + scanIterator: vi.fn(function* (options: { MATCH: string }) { + for (const key of mockRedisStore.keys()) { + if (key.startsWith(options.MATCH.replace("*", ""))) { + yield [key]; + } + } + }), +}; + +vi.mock("../../src/utils/redis", () => ({ + getRedis: () => mockRedis, + cacheGet: vi.fn(), + cacheSet: vi.fn(), + cacheDel: vi.fn(), +})); + +vi.mock("../../src/config", () => ({ + config: { + jwt: { + secret: "test-jwt-secret", + }, + }, +})); + +describe("Views tracking buffering and flushing", () => { + beforeEach(() => { + vi.restoreAllMocks(); + mockRedisStore.clear(); + prismaMock.portfolioPublication.findUnique.mockReset(); + prismaMock.portfolioPublication.findMany.mockReset(); + prismaMock.portfolioViewDaily.upsert.mockReset(); + prismaMock.shareView.createMany.mockReset(); + prismaMock.shareView.deleteMany.mockReset(); + prismaMock.shareLink.findMany.mockReset(); + prismaMock.shareLink.update.mockReset(); + prismaMock.viewFlushBatch.create.mockReset(); + prismaMock.portfolioPublication.findMany.mockImplementation(({ where }) => + where.id.in.map((id: string) => ({ id })), + ); + prismaMock.shareLink.findMany.mockImplementation(({ where }) => + where.id.in.map((id: string) => ({ id })), + ); + }); + + describe("Portfolio Views", () => { + it("buffers portfolio view count in Redis on recordView", async () => { + const { PortfolioService } = await import("../../src/services/portfolioService"); + + prismaMock.portfolioPublication.findUnique.mockResolvedValue({ + id: "pub_123", + subdomain: "testsub", + updatedAt: new Date(), + user: { + subscriptions: [], + }, + }); + + await PortfolioService.recordView("testsub", "https://google.com"); + + // Verify Redis contains the entry + const dateKey = new Date().toISOString().slice(0, 10); + const redisKey = `portfolio:views:buffer:${dateKey}`; + + expect(mockRedis.hIncrBy).toHaveBeenCalled(); + expect(mockRedisStore.get(redisKey)).toBeDefined(); + expect(mockRedisStore.get(redisKey)["pub_123:google.com"]).toBe(1); + }); + + it("flushes portfolio views from Redis to database", async () => { + const { PortfolioService } = await import("../../src/services/portfolioService"); + + // Populate Redis buffer + const dateKey = "2026-05-31"; + const redisKey = `portfolio:views:buffer:${dateKey}`; + mockRedisStore.set(redisKey, { + "pub_123:google.com": "5", + "pub_123:linkedin.com": "3", + }); + + const dates = await PortfolioService.getPendingViewDates(); + expect(dates).toEqual([dateKey]); + + const result = await PortfolioService.flushViewsForDate(dateKey); + expect(result.flushedCount).toBe(8); + + expect(prismaMock.portfolioViewDaily.upsert).toHaveBeenCalledTimes(2); + expect(prismaMock.portfolioViewDaily.upsert).toHaveBeenCalledWith({ + where: { + publicationId_date_referrerHost: { + publicationId: "pub_123", + date: new Date("2026-05-31T00:00:00.000Z"), + referrerHost: "google.com", + }, + }, + create: { + publicationId: "pub_123", + date: new Date("2026-05-31T00:00:00.000Z"), + referrerHost: "google.com", + count: 5, + }, + update: { + count: { increment: 5 }, + }, + }); + + // Verify Redis buffer is cleared + expect(mockRedisStore.has(redisKey)).toBe(false); + }); + }); + + describe("Share Link Views", () => { + it("buffers share view count, last viewed timestamp, and raw view records in Redis", async () => { + const { ShareService } = await import("../../src/services/shareService"); + + await ShareService.recordShareView("link_456", "1.2.3.4", "Mozilla/5.0"); + + expect(mockRedis.lPush).toHaveBeenCalled(); + expect(mockRedis.hIncrBy).toHaveBeenCalledWith("share:links:view_count", "link_456", 1); + expect(mockRedis.hSet).toHaveBeenCalledWith( + "share:links:last_viewed", + "link_456", + expect.any(String), + ); + + const bufferList = mockRedisStore.get("share:views:buffer"); + + expect(bufferList).toHaveLength(1); + + const parsedRecord = JSON.parse(bufferList[0]); + + expect(parsedRecord.shareLinkId).toBe("link_456"); + expect(parsedRecord.ipAddress).not.toBe("1.2.3.4"); + expect(parsedRecord.userAgent).toBe("Mozilla/5.0"); + }); + + it("flushes buffered share views and increments database counts with 30-day retention cleanup", async () => { + const { ShareService } = await import("../../src/services/shareService"); + + const timestamp = Date.now(); + + mockRedisStore.set("share:views:buffer", [ + JSON.stringify({ + shareLinkId: "link_456", + ipAddress: "hashed_ip_1", + userAgent: "Mozilla/5.0", + timestamp, + }), + ]); + + mockRedisStore.set("share:links:view_count", { + link_456: "2", + }); + + mockRedisStore.set("share:links:last_viewed", { + link_456: String(timestamp), + }); + + const result = await ShareService.flushShareViews(); + + expect(result.flushedViews).toBe(1); + expect(result.flushedLinks).toBe(1); + + expect(prismaMock.shareView.createMany).toHaveBeenCalledWith({ + data: [ + { + shareLinkId: "link_456", + ipAddress: "hashed_ip_1", + userAgent: "Mozilla/5.0", + createdAt: new Date(timestamp), + }, + ], + }); + + expect(prismaMock.shareLink.update).toHaveBeenCalledWith({ + where: { id: "link_456" }, + data: { + viewCount: { increment: 2 }, + lastViewedAt: new Date(timestamp), + }, + }); + + expect(prismaMock.shareView.deleteMany).toHaveBeenCalledWith({ + where: { + createdAt: { lt: expect.any(Date) }, + }, + }); + + expect(mockRedisStore.has("share:views:buffer")).toBe(false); + expect(mockRedisStore.has("share:links:view_count")).toBe(false); + expect(mockRedisStore.has("share:links:last_viewed")).toBe(false); + }); + }); +}); diff --git a/apps/site/package.json b/apps/site/package.json index f3bb0aa..f1d3798 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -1,6 +1,6 @@ { "name": "@veriworkly/site", - "version": "3.10.2", + "version": "3.11.0", "private": true, "scripts": { "dev": "next dev", diff --git a/apps/studio/components/dashboard/StudioShell.tsx b/apps/studio/components/dashboard/StudioShell.tsx index ca4bd60..af405d8 100644 --- a/apps/studio/components/dashboard/StudioShell.tsx +++ b/apps/studio/components/dashboard/StudioShell.tsx @@ -35,7 +35,7 @@ interface StudioShellProps { mainClassName?: string; } -const STUDIO_VERSION = "v3.10.2"; +const STUDIO_VERSION = "v3.11.0"; const StudioShell = ({ children, mainClassName }: StudioShellProps) => { const router = useRouter(); diff --git a/apps/studio/package.json b/apps/studio/package.json index 3f987d9..1b69cfa 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -1,6 +1,6 @@ { "name": "@veriworkly/studio", - "version": "3.10.2", + "version": "3.11.0", "private": true, "scripts": { "dev": "next dev", diff --git a/package-lock.json b/package-lock.json index d117bbf..12e8f86 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "veriworkly", - "version": "3.8.0", + "version": "3.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "veriworkly", - "version": "3.8.0", + "version": "3.11.0", "workspaces": [ "apps/site", "apps/studio", + "apps/portfolio", "apps/blog-platform", "apps/docs-platform", "apps/server", @@ -26,7 +27,7 @@ }, "apps/blog-platform": { "name": "@veriworkly/blog-platform", - "version": "3.8.0", + "version": "3.11.0", "dependencies": { "@veriworkly/ui": "*", "fumadocs-core": "^16.8.3", @@ -50,18 +51,9 @@ "typescript": "^6" } }, - "apps/blog-platform/node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "apps/docs-platform": { "name": "@veriworkly/docs-platform", - "version": "3.8.0", + "version": "3.11.0", "dependencies": { "@veriworkly/ui": "*", "fumadocs-core": "^16.8.3", @@ -85,44 +77,95 @@ "typescript": "^6" } }, + "apps/portfolio": { + "name": "@veriworkly/portfolio", + "version": "3.11.0", + "dependencies": { + "lucide-react": "^1.16.0", + "next": "16.2.6", + "react": "^19.2.5", + "react-dom": "^19.2.5", + "zustand": "^5.0.14" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^25", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.2.6", + "postcss": "8.5.14", + "tailwindcss": "^4", + "typescript": "^6", + "vitest": "^4.1.5" + } + }, + "apps/portfolio/node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "apps/server": { "name": "@veriworkly/server", - "version": "3.8.0", + "version": "3.11.0", "dependencies": { - "@better-auth/prisma-adapter": "^1.6.11", - "@opentelemetry/api": "^1.9.1", + "@aws-sdk/client-s3": "^3.1057.0", + "@aws-sdk/s3-request-presigner": "^3.1057.0", + "@better-auth/prisma-adapter": "^1.6.12", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "@prisma/config": "^7.8.0", - "better-auth": "^1.6.11", + "better-auth": "^1.6.12", "cors": "^2.8.6", + "dodopayments": "^2.32.1", "dotenv": "^17.4.2", - "express": "^4.19.2", - "express-rate-limit": "^8.4.1", - "helmet": "^7.1.0", - "ioredis": "^5.10.1", + "express": "^4.22.2", + "helmet": "^7.2.0", "node-cron": "^4.2.1", - "nodemailer": "^8.0.6", - "pg": "^8.20.0", - "rate-limit-redis": "^4.3.1", + "nodemailer": "^8.0.10", + "pg": "^8.21.0", "redis": "^5.12.1", "uuid": "^14.0.0", - "vitest": "^4.1.5", "zod": "^3.25.76" }, "devDependencies": { "@types/cors": "^2.8.19", - "@types/express": "^4.17.21", - "@types/node": "^20.19.39", + "@types/express": "^4.17.25", + "@types/node": "^20.19.41", "@types/nodemailer": "^7.0.11", "@types/pg": "^8.20.0", "eslint": "^9", "prettier": "^3.8.3", "prisma": "^7.8.0", "resolve-tspaths": "^0.8.23", - "tsc-alias": "^1.8.16", - "tsx": "^4.21.0", - "typescript": "^5.3.3" + "tsc-alias": "^1.8.17", + "tsx": "^4.22.3", + "typescript": "^5.9.3", + "vitest": "^4.1.7" } }, "apps/server/node_modules/@types/node": { @@ -149,18 +192,9 @@ "node": ">=14.17" } }, - "apps/server/node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "apps/site": { "name": "@veriworkly/site", - "version": "3.8.0", + "version": "3.11.0", "dependencies": { "@veriworkly/ui": "*", "clsx": "^2.1.1", @@ -185,18 +219,38 @@ "typescript": "^6" } }, - "apps/site/node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "apps/site/node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" } }, "apps/studio": { "name": "@veriworkly/studio", - "version": "3.8.0", + "version": "3.11.0", "dependencies": { "@react-pdf/renderer": "^4.5.1", "@veriworkly/ui": "*", @@ -227,36 +281,567 @@ "vitest": "^4.1.5" } }, - "apps/studio/node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "apps/studio/node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, "license": "MIT", + "engines": { + "node": ">=10" + }, "funding": { - "url": "https://github.com/sponsors/colinhacks" + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/crc32c": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", + "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", + "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1057.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1057.0.tgz", + "integrity": "sha512-4MV5+ph7WSLEqStKYdWf2EIHIvLpPzV8xN98jWSVJfUpp5j7T8dyN3AROPPsKWvCme8hbx1ybCjtK76ALCZUYg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha1-browser": "5.2.0", + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/credential-provider-node": "^3.972.47", + "@aws-sdk/middleware-bucket-endpoint": "^3.972.17", + "@aws-sdk/middleware-expect-continue": "^3.972.14", + "@aws-sdk/middleware-flexible-checksums": "^3.974.23", + "@aws-sdk/middleware-location-constraint": "^3.972.11", + "@aws-sdk/middleware-sdk-s3": "^3.972.44", + "@aws-sdk/middleware-ssec": "^3.972.11", + "@aws-sdk/signature-v4-multi-region": "^3.996.30", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/fetch-http-handler": "^5.4.5", + "@smithy/node-http-handler": "^4.7.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.974.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.15.tgz", + "integrity": "sha512-UpA0rTGW/tHGITcCqHisbuuEPraYg9GG+mWmXjY5+RxZBMLGe6aL9oe0ix50LztwAcPIkGZLH0yWdMIkCM10hw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.9", + "@aws-sdk/xml-builder": "^3.972.26", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/core": "^3.24.5", + "@smithy/signature-v4": "^5.4.5", + "@smithy/types": "^4.14.2", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/crc64-nvme": { + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.9.tgz", + "integrity": "sha512-P+QGozmXn2mZZI7sDgk+aUm+RTI61MPSFB+Ir2vjEjEbEsE4e7hYtzrDvAUxZy9ko81h53e11+F/GYlvwDkaOQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.41.tgz", + "integrity": "sha512-n1EbJ98yvPWWdHZZv8bRBMqqDQJrtgtxyJ4xLy2Uqrh25BCOZQ7nnS1CsFXvuH8r0b0KVHDZEGEH5FxmEMP8jg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.43.tgz", + "integrity": "sha512-TT76RN1NkI9WoyZqCNxOw6/WBMF7pYOTJcXbMokNFU+euSG40Kaf/t/FhDACVZWP+43wEM6ZynIPIkzS1wR1iA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/fetch-http-handler": "^5.4.5", + "@smithy/node-http-handler": "^4.7.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.46.tgz", + "integrity": "sha512-hvcgcwOiS0nb2XFb5Op1Pz/vYaWz5K8kKullziGpdNRuG0NwzRXseuPt2CoBqknHGaSPVesu1aOn2OcctEYdCA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/credential-provider-env": "^3.972.41", + "@aws-sdk/credential-provider-http": "^3.972.43", + "@aws-sdk/credential-provider-login": "^3.972.45", + "@aws-sdk/credential-provider-process": "^3.972.41", + "@aws-sdk/credential-provider-sso": "^3.972.45", + "@aws-sdk/credential-provider-web-identity": "^3.972.45", + "@aws-sdk/nested-clients": "^3.997.13", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/credential-provider-imds": "^4.3.6", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.45", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.45.tgz", + "integrity": "sha512-MZQv4SNjByk1iOKmrqmzcUF/uCB05wjvEHyXKxmGQTUANTIVayX6HPUF0bzkWLvtnkH7sAn9kUCfkXbSpj9sDA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/nested-clients": "^3.997.13", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.47", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.47.tgz", + "integrity": "sha512-HrId+C0DWA5qDIyLG64/kjUB2RNtPypxmABnIctK+TA1P1kHlOYoE/Wf5T5tKOMKgb08P7k/zNyhvfJ3lh5Oag==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.41", + "@aws-sdk/credential-provider-http": "^3.972.43", + "@aws-sdk/credential-provider-ini": "^3.972.46", + "@aws-sdk/credential-provider-process": "^3.972.41", + "@aws-sdk/credential-provider-sso": "^3.972.45", + "@aws-sdk/credential-provider-web-identity": "^3.972.45", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/credential-provider-imds": "^4.3.6", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.41.tgz", + "integrity": "sha512-7I/n1zkysouLOWvkEhjNEP4vMnD2v4kzzr3/3QBdrripEpn7ap1/I5DF3Hou1SUqkKWo1f3oPGMyFAA1FAMvsQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.45", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.45.tgz", + "integrity": "sha512-oHgbz/eFD8IKiksqDsz9ZMU4A59BpQq4QwJedBnGD80ZqYcHPPHZBwjBnxLVkB7iRVVHWpDclR8yWdD2PkQIUA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/nested-clients": "^3.997.13", + "@aws-sdk/token-providers": "3.1056.0", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.45", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.45.tgz", + "integrity": "sha512-CDhzKdb2onv5bpnjn/acgdNmJOQthPDLsPizU7rZflsEcgMMp8Mlri+U5hdxf8ldvZJpvM3vLU6D56vfJm5AMQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/nested-clients": "^3.997.13", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-bucket-endpoint": { + "version": "3.972.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.17.tgz", + "integrity": "sha512-lbDmWuHenc+kiwCNrxz4MyN6nkxCWyTXPIWuspJN0ibziu+8CXci7vI1bK9MAkwy8cwJOEXNu0gBM5S0uTGRIg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-expect-continue": { + "version": "3.972.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.14.tgz", + "integrity": "sha512-3TNFEVGO4sWZj9TEXOCZLzGEctXHnaO4fk2EQ8KVaboTbwHmEPEQrm17Xb9koImUIXEw0sgi2xtHjg7LuTS3rA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-flexible-checksums": { + "version": "3.974.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.23.tgz", + "integrity": "sha512-4nPKARo2lfKvQGUt2fPA5NlS/mEohckdxpuC9ecbjVfj7B7NFFYHeTg+Bf5BEQwdn3yRfUIzFiEkPp8Yuaw3wA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@aws-crypto/crc32c": "5.2.0", + "@aws-crypto/util": "5.2.0", + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/crc64-nvme": "^3.972.9", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-location-constraint": { + "version": "3.972.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.11.tgz", + "integrity": "sha512-hkfspNUP4criAH6ton6BGKgnm5dZx+7bUOy1YqlTfejDeUPAM23D81q/IX+hdlS3KUsfwGz5ADTqZWKBEUpf4A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.9", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.44.tgz", + "integrity": "sha512-8HQsRg1NpX8vR4vNl1E8pyLnqZroq9VSL2vZQVSgBqp6wv6365LzYD08/c9FFh/9FTg7YRc7aTtEmXF0ir/pqg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/signature-v4-multi-region": "^3.996.30", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-ssec": { + "version": "3.972.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.11.tgz", + "integrity": "sha512-7PQvGNhtveKlvVqNahqWx5yrwxP7ecwAoB1dYBf8eKwfo2tzzCbNnW+q2nO3N066ktQaB4iBQbDRWtizm+amoQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.9", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.13.tgz", + "integrity": "sha512-2pA6eyb5nSo/ZD2cayhOTEMoGQYgspq0RI05GDLkzQ3ajZ6isS6waV6E92Am/hz4LIlLUTrbwPLurJ/fuiHvkg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/signature-v4-multi-region": "^3.996.30", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/fetch-http-handler": "^5.4.5", + "@smithy/node-http-handler": "^4.7.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/s3-request-presigner": { + "version": "3.1057.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1057.0.tgz", + "integrity": "sha512-mTxO9BCztlos7gzHIFKf3p3CakOmLhjfv6Llo9LIAF+UCu/eCoKMPaFi87WmYUWWf7rIGIyb20Wa8hd4puuUKg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/signature-v4-multi-region": "^3.996.30", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.30", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.30.tgz", + "integrity": "sha512-HULDLMVzkmTSEv6//7kx2kRevp/VYUpm8hJNNFbmhxDn0fUiGTxVcM9yg31TukvTq8nyOBDUN2gH0o5IRbKjdw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.9", + "@smithy/signature-v4": "^5.4.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1056.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1056.0.tgz", + "integrity": "sha512-81duvlltQlsfn5K+o8zILcystBRdbT1G2JJYVCML5NZHBz4CL/zf+sAemCtBh/uh6RQUMyInGeZLQ7/8igZhbA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/nested-clients": "^3.997.13", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.9.tgz", + "integrity": "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", + "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.26", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.26.tgz", + "integrity": "sha512-cDbrqvDS73whl6YAPSPq0U6whzG6UWI9PuWh0wrUuGoZexhWEqhdunbukV7iBoaWnFV1AODutM5hOD6rtn439g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.2", + "fast-xml-parser": "5.7.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" } }, - "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "dev": true, - "license": "MIT", + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "license": "Apache-2.0", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18.0.0" } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -265,9 +850,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", - "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -275,21 +860,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -306,14 +891,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -323,14 +908,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -340,9 +925,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -350,29 +935,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -382,9 +967,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -392,9 +977,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -402,9 +987,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -412,27 +997,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -442,42 +1027,42 @@ } }, "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -485,23 +1070,23 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@better-auth/core": { - "version": "1.6.11", - "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.6.11.tgz", - "integrity": "sha512-LrwidLCV8azdMGjvtwp30nj9tIv1BwI3VhtC0UaGSjQkAVWw4bN42I8qwbxRziPeSQoj+zUVkOpxZzAWBDARtQ==", + "version": "1.6.12", + "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.6.12.tgz", + "integrity": "sha512-6mXtYSYfo6TvHHCZAZmfjvIQQtBDWzWzwy9iIWPEoede2lP2SuJzkfIQNuTtIGzZcn7a9iuzIm1jWDBzfnBARg==", "license": "MIT", "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", @@ -509,13 +1094,13 @@ "zod": "^4.3.6" }, "peerDependencies": { - "@better-auth/utils": "0.4.0", + "@better-auth/utils": "0.4.1", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.3.5", "jose": "^6.1.0", - "kysely": "^0.28.5", + "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "peerDependenciesMeta": { @@ -527,14 +1112,23 @@ } } }, + "node_modules/@better-auth/core/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@better-auth/drizzle-adapter": { - "version": "1.6.11", - "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.6.11.tgz", - "integrity": "sha512-4jpkETIGZOHCf7BK4jnu22fdN6jjomH0/HhEzkaWy3+Eppi5PYlHTF/460jrTmA3Xc+Vqwp9t282ymHiEPypGw==", + "version": "1.6.12", + "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.6.12.tgz", + "integrity": "sha512-g0sKQstvXHH70s+TjAXo86cNyWV60ahhJm1sow27RyW41U10vfBehOFinU3GPESyxl/fEr9D27rk3jdl6E3l3A==", "license": "MIT", "peerDependencies": { - "@better-auth/core": "^1.6.11", - "@better-auth/utils": "0.4.0", + "@better-auth/core": "^1.6.12", + "@better-auth/utils": "0.4.1", "drizzle-orm": "^0.45.2" }, "peerDependenciesMeta": { @@ -544,14 +1138,14 @@ } }, "node_modules/@better-auth/kysely-adapter": { - "version": "1.6.11", - "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.6.11.tgz", - "integrity": "sha512-/g8M9RfIjdcZDnbstSUvQiINkvdNlCeZr248zwqx2/PVksQI1MhQofbzUn3RnQnbPKp0EPwpX/dR3oudRFenUg==", + "version": "1.6.12", + "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.6.12.tgz", + "integrity": "sha512-KhPwPmLj+MoTVGV6goPfCYf/7Fuiy2Q37GEWhvQdoFjkYKbGo995OoghBVNBnAYOakYvTYjG0JebCfiETBVX3g==", "license": "MIT", "peerDependencies": { - "@better-auth/core": "^1.6.11", - "@better-auth/utils": "0.4.0", - "kysely": "^0.28.17" + "@better-auth/core": "^1.6.12", + "@better-auth/utils": "0.4.1", + "kysely": "^0.28.17 || ^0.29.0" }, "peerDependenciesMeta": { "kysely": { @@ -560,23 +1154,23 @@ } }, "node_modules/@better-auth/memory-adapter": { - "version": "1.6.11", - "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.6.11.tgz", - "integrity": "sha512-hpdfw0BBf8MuzLkIdmbcUZICbY9r/bhLO2RxSnkzT5+/O+0I0u2I8+m0YUP7vNllP/ZCKASHOYgXPLO75Z0f9Q==", + "version": "1.6.12", + "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.6.12.tgz", + "integrity": "sha512-flblsePBCcB0DA6hewAOupxyypNTQczZvkNYvRrsVlBDIh0+vHBU/dTjoDmuQnZ3egTdFNnMeC+VrNnqt/GFUg==", "license": "MIT", "peerDependencies": { - "@better-auth/core": "^1.6.11", - "@better-auth/utils": "0.4.0" + "@better-auth/core": "^1.6.12", + "@better-auth/utils": "0.4.1" } }, "node_modules/@better-auth/mongo-adapter": { - "version": "1.6.11", - "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.6.11.tgz", - "integrity": "sha512-3Tor8rSv8vSEIMEaV2PFpPEuVhqc1gNoZ6eGvoh3LwExXXuj8madew6ob+H1pH7Aphn3Ar5PQ08AguT8TbwFAA==", + "version": "1.6.12", + "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.6.12.tgz", + "integrity": "sha512-IeiHZN9PtIyiqYgTDlrmm8sYI++5p1OI49uWB7LHg2+touiaNUGe0uWYymQpw1zq1e8FJxKlwvOc5vw6nGrI6g==", "license": "MIT", "peerDependencies": { - "@better-auth/core": "^1.6.11", - "@better-auth/utils": "0.4.0", + "@better-auth/core": "^1.6.12", + "@better-auth/utils": "0.4.1", "mongodb": "^6.0.0 || ^7.0.0" }, "peerDependenciesMeta": { @@ -586,13 +1180,13 @@ } }, "node_modules/@better-auth/prisma-adapter": { - "version": "1.6.11", - "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.6.11.tgz", - "integrity": "sha512-Pw+7q7zTp+VSci1V+CYMvuxIbAeVMZLe4lRo46LJoAKMHfjFl5T/ycsyFvWs/DkWC7n9gZZzRDEbHp0I5FiKKw==", + "version": "1.6.12", + "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.6.12.tgz", + "integrity": "sha512-+GvU8vZ3aJUHDBuR5PxtU5OpPQS2T9ND7s2JYm63bD6rnYztLwEo8bwHL3BvsTwSvCjFHZCtsn1A+6qyoOzTMw==", "license": "MIT", "peerDependencies": { - "@better-auth/core": "^1.6.11", - "@better-auth/utils": "0.4.0", + "@better-auth/core": "^1.6.12", + "@better-auth/utils": "0.4.1", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, @@ -606,20 +1200,20 @@ } }, "node_modules/@better-auth/telemetry": { - "version": "1.6.11", - "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.6.11.tgz", - "integrity": "sha512-hsjDHc8MZbm6/AHeNdtywrWedXevnBjmdvnHTcZub+rTVjOv+Td0roI8USKuC6uUibmrl//2rJfVCsGbopihNA==", + "version": "1.6.12", + "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.6.12.tgz", + "integrity": "sha512-g59qLPq9SROyku0X5tiZpXXiVrsbjB1QA6OctOt9svzj7NjCFBoCAO9QlBiOTUolo0l9CF6fLlc85PoBkY5RtA==", "license": "MIT", "peerDependencies": { - "@better-auth/core": "^1.6.11", - "@better-auth/utils": "0.4.0", + "@better-auth/core": "^1.6.12", + "@better-auth/utils": "0.4.1", "@better-fetch/fetch": "1.1.21" } }, "node_modules/@better-auth/utils": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.4.0.tgz", - "integrity": "sha512-RpMtLUIQAEWMgdPLNVbIF5ON2mm+CH0U3rCdUCU1VyeAUui4m38DyK7/aXMLZov2YDjG684pS1D0MBllrmgjQA==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.4.1.tgz", + "integrity": "sha512-SZBPRPF3z0nBvE5ygOkxae35wnnXPRShmqFo78S+qslLeFoPu/pMgnXAuNKFMMybac3tiLaVg1e3MQW5MC+1iA==", "license": "MIT", "dependencies": { "@noble/hashes": "^2.0.1" @@ -1347,7 +1941,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.2.tgz", "integrity": "sha512-tXlTi1h/4V7sDe7i97IVP+9re9ZU7wXZZggnR5ucCRclf1+AX6YhGStrR5w8bLj+3Mlyl0pKfBh9gqTqqnGKfQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=20" @@ -1936,12 +2530,6 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@ioredis/commands": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", - "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", - "license": "MIT" - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -2069,6 +2657,36 @@ "fast-glob": "3.3.1" } }, + "node_modules/@next/eslint-plugin-next/node_modules/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/@next/eslint-plugin-next/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/@next/swc-darwin-arm64": { "version": "16.2.6", "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.6.tgz", @@ -2233,6 +2851,18 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@nodable/entities": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.1.tgz", + "integrity": "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2281,15 +2911,6 @@ "node": ">=12.4.0" } }, - "node_modules/@opentelemetry/api": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", - "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", - "license": "Apache-2.0", - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/@opentelemetry/semantic-conventions": { "version": "1.41.1", "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", @@ -2312,6 +2933,7 @@ "version": "0.132.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", + "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" @@ -2403,6 +3025,19 @@ "zeptomatch": "2.1.0" } }, + "node_modules/@prisma/dev/node_modules/@hono/node-server": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", + "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@prisma/driver-adapter-utils": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.8.0.tgz", @@ -2672,24 +3307,6 @@ } } }, - "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", @@ -2756,24 +3373,6 @@ } } }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-direction": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", @@ -2947,24 +3546,6 @@ } } }, - "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-popper": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", @@ -3068,24 +3649,6 @@ } } }, - "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-roving-focus": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", @@ -3191,7 +3754,7 @@ } } }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -3209,24 +3772,6 @@ } } }, - "node_modules/@radix-ui/react-slot": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", - "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-tabs": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", @@ -3555,6 +4100,12 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@react-pdf/reconciler/node_modules/scheduler": { + "version": "0.25.0-rc-603e6108-20241029", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0-rc-603e6108-20241029.tgz", + "integrity": "sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==", + "license": "MIT" + }, "node_modules/@react-pdf/render": { "version": "4.5.1", "resolved": "https://registry.npmjs.org/@react-pdf/render/-/render-4.5.1.tgz", @@ -3679,6 +4230,15 @@ } } }, + "node_modules/@redis/client/node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/@redis/json": { "version": "5.12.1", "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.12.1.tgz", @@ -3979,6 +4539,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "devOptional": true, "license": "MIT" }, "node_modules/@rtsao/scc": { @@ -4075,17 +4636,143 @@ "integrity": "sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA==", "license": "MIT", "dependencies": { - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, + "node_modules/@smithy/core": { + "version": "3.24.5", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.5.tgz", + "integrity": "sha512-Kt8phUg45M15EjhYAbZ+fFikYneijLu9Liugz8ZsYz2i8j0hzGv27LWKpEHYRfvj+LyCOSijpcR/2i8RouV+cA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.6.tgz", + "integrity": "sha512-tHhdiWZfG1ZIh2YcRfPJmY2gHcBmqbAzqm3ER4TIDFYsSEqTD5tICT7cgQ/kI8LRakxp12myOYyK68XPn7MnHw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.5.tgz", + "integrity": "sha512-SK3VMeH0fibgdTg2QeB+O4p7Yy/2E5HBOHJeC58FshkDdeuX8lOgO7PfjYfLyPLP1ch55j91cQqKBzDS0mRjSQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.7.5", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.5.tgz", + "integrity": "sha512-3dA9TQ+ybRSZ/m0wnbZhiBy4Dezjgq1Ib/ZZrYTpJDBgpoLLU/SDzZc/g0x0MNAdOJe1wPcM+x2PBRmoOur+Sw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.5.tgz", + "integrity": "sha512-QBJKWGqIknH0dc9LWpfH1mkdokAx6iXYN3UcQ3eY6uIEyScuoQAhfl94ge7ozUy9WgFUdE8xsvwBjaYBbWmPNA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.14.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", + "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=20" + "node": ">=14.0.0" } }, - "node_modules/@shikijs/vscode-textmate": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", - "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", "license": "MIT" }, "node_modules/@standard-schema/spec": { @@ -4095,9 +4782,9 @@ "license": "MIT" }, "node_modules/@swc/helpers": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz", - "integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==", + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.8.0" @@ -4399,6 +5086,7 @@ "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "devOptional": true, "license": "MIT", "dependencies": { "@types/deep-eql": "*", @@ -4438,6 +5126,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "devOptional": true, "license": "MIT" }, "node_modules/@types/estree": { @@ -4649,17 +5338,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz", - "integrity": "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz", + "integrity": "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.59.4", - "@typescript-eslint/type-utils": "8.59.4", - "@typescript-eslint/utils": "8.59.4", - "@typescript-eslint/visitor-keys": "8.59.4", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/type-utils": "8.60.0", + "@typescript-eslint/utils": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -4672,7 +5361,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.59.4", + "@typescript-eslint/parser": "^8.60.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -4688,16 +5377,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.4.tgz", - "integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.0.tgz", + "integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.59.4", - "@typescript-eslint/types": "8.59.4", - "@typescript-eslint/typescript-estree": "8.59.4", - "@typescript-eslint/visitor-keys": "8.59.4", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", "debug": "^4.4.3" }, "engines": { @@ -4713,14 +5402,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.4.tgz", - "integrity": "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.0.tgz", + "integrity": "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.59.4", - "@typescript-eslint/types": "^8.59.4", + "@typescript-eslint/tsconfig-utils": "^8.60.0", + "@typescript-eslint/types": "^8.60.0", "debug": "^4.4.3" }, "engines": { @@ -4735,14 +5424,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz", - "integrity": "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz", + "integrity": "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.4", - "@typescript-eslint/visitor-keys": "8.59.4" + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4753,9 +5442,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz", - "integrity": "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz", + "integrity": "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==", "dev": true, "license": "MIT", "engines": { @@ -4770,15 +5459,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz", - "integrity": "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz", + "integrity": "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.4", - "@typescript-eslint/typescript-estree": "8.59.4", - "@typescript-eslint/utils": "8.59.4", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -4795,9 +5484,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.4.tgz", - "integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz", + "integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==", "devOptional": true, "license": "MIT", "engines": { @@ -4809,16 +5498,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz", - "integrity": "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz", + "integrity": "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.59.4", - "@typescript-eslint/tsconfig-utils": "8.59.4", - "@typescript-eslint/types": "8.59.4", - "@typescript-eslint/visitor-keys": "8.59.4", + "@typescript-eslint/project-service": "8.60.0", + "@typescript-eslint/tsconfig-utils": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -4889,16 +5578,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.4.tgz", - "integrity": "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.0.tgz", + "integrity": "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.59.4", - "@typescript-eslint/types": "8.59.4", - "@typescript-eslint/typescript-estree": "8.59.4" + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4913,13 +5602,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz", - "integrity": "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz", + "integrity": "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/types": "8.60.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -5300,6 +5989,10 @@ "resolved": "apps/docs-platform", "link": true }, + "node_modules/@veriworkly/portfolio": { + "resolved": "apps/portfolio", + "link": true + }, "node_modules/@veriworkly/server": { "resolved": "apps/server", "link": true @@ -5320,6 +6013,7 @@ "version": "4.1.7", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "devOptional": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", @@ -5337,6 +6031,7 @@ "version": "4.1.7", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", + "devOptional": true, "license": "MIT", "dependencies": { "@vitest/spy": "4.1.7", @@ -5363,6 +6058,7 @@ "version": "4.1.7", "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "devOptional": true, "license": "MIT", "dependencies": { "tinyrainbow": "^3.1.0" @@ -5375,6 +6071,7 @@ "version": "4.1.7", "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "devOptional": true, "license": "MIT", "dependencies": { "@vitest/utils": "4.1.7", @@ -5388,6 +6085,7 @@ "version": "4.1.7", "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "devOptional": true, "license": "MIT", "dependencies": { "@vitest/pretty-format": "4.1.7", @@ -5403,6 +6101,7 @@ "version": "4.1.7", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "devOptional": true, "license": "MIT", "funding": { "url": "https://opencollective.com/vitest" @@ -5412,6 +6111,7 @@ "version": "4.1.7", "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "devOptional": true, "license": "MIT", "dependencies": { "@vitest/pretty-format": "4.1.7", @@ -5519,19 +6219,6 @@ "node": ">= 8" } }, - "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -5740,6 +6427,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -5855,9 +6543,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.32", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", - "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", + "version": "2.10.33", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", + "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -5867,26 +6555,26 @@ } }, "node_modules/better-auth": { - "version": "1.6.11", - "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.6.11.tgz", - "integrity": "sha512-Wwt6+q07dwIhsp6XiM7L1qSXVUWBEtNl+eZvwM778CguFqDZFBN9Pt6LtFaHl55t8Z+Zc//5kxcbgDY8/79vFQ==", - "license": "MIT", - "dependencies": { - "@better-auth/core": "1.6.11", - "@better-auth/drizzle-adapter": "1.6.11", - "@better-auth/kysely-adapter": "1.6.11", - "@better-auth/memory-adapter": "1.6.11", - "@better-auth/mongo-adapter": "1.6.11", - "@better-auth/prisma-adapter": "1.6.11", - "@better-auth/telemetry": "1.6.11", - "@better-auth/utils": "0.4.0", + "version": "1.6.12", + "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.6.12.tgz", + "integrity": "sha512-vJG8hB+zcayZEJgcWGTzP2XODZuf/WKViOtam+uhhQ9879yc7fDWAV9O4jSs+R28noSXIAaB3zhIMN3DaDO3cA==", + "license": "MIT", + "dependencies": { + "@better-auth/core": "1.6.12", + "@better-auth/drizzle-adapter": "1.6.12", + "@better-auth/kysely-adapter": "1.6.12", + "@better-auth/memory-adapter": "1.6.12", + "@better-auth/mongo-adapter": "1.6.12", + "@better-auth/prisma-adapter": "1.6.12", + "@better-auth/telemetry": "1.6.12", + "@better-auth/utils": "0.4.1", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.5", "defu": "^6.1.4", "jose": "^6.1.3", - "kysely": "^0.28.17", + "kysely": "^0.28.17 || ^0.29.0", "nanostores": "^1.1.1", "zod": "^4.3.6" }, @@ -5971,6 +6659,15 @@ } } }, + "node_modules/better-auth/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/better-call": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.3.5.tgz", @@ -6059,10 +6756,16 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, "node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "license": "MIT", "dependencies": { @@ -6264,6 +6967,7 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -6390,15 +7094,6 @@ "node": ">=6" } }, - "node_modules/cluster-key-slot": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", - "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/collapse-white-space": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-2.1.0.tgz", @@ -6422,22 +7117,13 @@ "node": ">=7.0.0" } }, - "node_modules/color-convert/node_modules/color-name": { + "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true, "license": "MIT" }, - "node_modules/color-name": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", - "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", - "license": "MIT", - "engines": { - "node": ">=12.20" - } - }, "node_modules/color-string": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", @@ -6450,6 +7136,15 @@ "node": ">=18" } }, + "node_modules/color-string/node_modules/color-name": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -6514,6 +7209,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "devOptional": true, "license": "MIT" }, "node_modules/cookie": { @@ -6729,6 +7425,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=0.10" @@ -6772,6 +7469,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -6829,9 +7527,9 @@ } }, "node_modules/docx": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/docx/-/docx-9.6.1.tgz", - "integrity": "sha512-ZJja9/KBUuFC109sCMzovoq2GR2wCG/AuxivjA+OHj/q0TEgJIm3S7yrlUxIy3B+bV8YDj/BiHfWyrRFmyWpDQ==", + "version": "9.7.1", + "resolved": "https://registry.npmjs.org/docx/-/docx-9.7.1.tgz", + "integrity": "sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==", "license": "MIT", "dependencies": { "@types/node": "^25.2.3", @@ -6845,6 +7543,36 @@ "node": ">=10" } }, + "node_modules/docx/node_modules/nanoid": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz", + "integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/dodopayments": { + "version": "2.32.1", + "resolved": "https://registry.npmjs.org/dodopayments/-/dodopayments-2.32.1.tgz", + "integrity": "sha512-9PgIGAJDfnvzBd3xdr2qFwVfaJ1vB6J5wTGwSWoTsP3M7aRpXSMxjXOKUsOc5diozFqF6aaLIDV7hS1MsmZvYg==", + "license": "Apache-2.0", + "dependencies": { + "standardwebhooks": "^1.0.0" + }, + "bin": { + "dodopayments": "bin/cli" + } + }, "node_modules/dotenv": { "version": "17.4.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", @@ -6888,9 +7616,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.361", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz", - "integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==", + "version": "1.5.364", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz", + "integrity": "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==", "dev": true, "license": "ISC" }, @@ -6926,9 +7654,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.22.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.0.tgz", - "integrity": "sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==", + "version": "5.22.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.1.tgz", + "integrity": "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==", "dev": true, "license": "MIT", "dependencies": { @@ -7083,6 +7811,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "devOptional": true, "license": "MIT" }, "node_modules/es-object-atoms": { @@ -7404,9 +8133,9 @@ } }, "node_modules/eslint-module-utils": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", - "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.13.0.tgz", + "integrity": "sha512-bLohSkT6469rRs8czj0tLTD8vaeIS/whvPRJVjDr7IuoTT1k5DYDERlNycjDj/HkOlvQdYurmfZ/g3fG5bgeLQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7794,6 +8523,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=12.0.0" @@ -7845,24 +8575,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/express-rate-limit": { - "version": "8.5.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", - "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", - "license": "MIT", - "dependencies": { - "ip-address": "^10.2.0" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, "node_modules/express/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -7919,9 +8631,9 @@ "license": "MIT" }, "node_modules/fast-glob": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", - "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", "dev": true, "license": "MIT", "dependencies": { @@ -7962,6 +8674,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fast-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", @@ -7979,6 +8697,43 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", + "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.7", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -7989,23 +8744,6 @@ "reusify": "^1.0.4" } }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, "node_modules/fflate": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", @@ -8219,9 +8957,9 @@ } }, "node_modules/fumadocs-core": { - "version": "16.9.1", - "resolved": "https://registry.npmjs.org/fumadocs-core/-/fumadocs-core-16.9.1.tgz", - "integrity": "sha512-8VW4aD1iG5SrCChRq84QpNyjKY69i5WfUFFgtx/LSUuX1RewWb0qps7DhKLSjOWraemhZatzsZ7iceivJmYRTg==", + "version": "16.9.3", + "resolved": "https://registry.npmjs.org/fumadocs-core/-/fumadocs-core-16.9.3.tgz", + "integrity": "sha512-8RVzKnzBJR5o+tJCccY28ntekfMQYBoYiz7alnYb/d9YJc+XpnsINzTl63lQ1eBMZ9gdhm2MqRtgUjh/8rUrbw==", "license": "MIT", "dependencies": { "@orama/orama": "^3.1.18", @@ -8236,7 +8974,7 @@ "remark-gfm": "^4.0.1", "remark-rehype": "^11.1.2", "scroll-into-view-if-needed": "^3.1.0", - "shiki": "^4.0.2", + "shiki": "^4.1.0", "tinyglobby": "^0.2.16", "unified": "^11.0.5", "unist-util-visit": "^5.1.0", @@ -8380,10 +9118,31 @@ } } }, + "node_modules/fumadocs-mdx/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/fumadocs-mdx/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/fumadocs-openapi": { - "version": "10.9.0", - "resolved": "https://registry.npmjs.org/fumadocs-openapi/-/fumadocs-openapi-10.9.0.tgz", - "integrity": "sha512-3N/tcdY3Ji1s11//twhHKhTHv8g16ltptOpYJ7t0ldqyfMpnAO1A6X71J5Zvo/ljU08z9h9X/0JLi1jbcNb9iA==", + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/fumadocs-openapi/-/fumadocs-openapi-10.9.1.tgz", + "integrity": "sha512-9b+dDEaSmgwV6dSLoWgvdz5pnkXul4PN4UBrneztGRkll399Hb+uP+wBZPUkuiQF4vyI+8VrZo2coKgDCKoyog==", "license": "MIT", "dependencies": { "@fumari/json-schema-ts": "^0.0.2", @@ -8400,7 +9159,7 @@ "lucide-react": "^1.16.0", "remark": "^15.0.1", "remark-rehype": "^11.1.2", - "shiki": "^4.0.2", + "shiki": "^4.1.0", "tailwind-merge": "^3.6.0" }, "peerDependencies": { @@ -8424,10 +9183,28 @@ } } }, + "node_modules/fumadocs-openapi/node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/fumadocs-ui": { - "version": "16.9.1", - "resolved": "https://registry.npmjs.org/fumadocs-ui/-/fumadocs-ui-16.9.1.tgz", - "integrity": "sha512-2A8wO/RuoV0eOgabKMlcjBEPQKynbE4wkxoTfAj/E6bYCid/zY3DG+hhSI1Ssen5ws/73mFPacIEBsWhdAAsnw==", + "version": "16.9.3", + "resolved": "https://registry.npmjs.org/fumadocs-ui/-/fumadocs-ui-16.9.3.tgz", + "integrity": "sha512-eoVKj1H+ATut0su+WIoPWBLRqzPMGD0hekIBr4GopWvUg1lS997HL4kP+Leyf+3CYlZtFgyXb6ylbvRLFtEj6Q==", "license": "MIT", "dependencies": { "@fumadocs/tailwind": "0.0.5", @@ -8442,13 +9219,13 @@ "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", "class-variance-authority": "^0.7.1", - "lucide-react": "^1.16.0", - "motion": "^12.38.0", + "lucide-react": "^1.17.0", + "motion": "^12.40.0", "next-themes": "^0.4.6", "react-remove-scroll": "^2.7.2", "rehype-raw": "^7.0.0", "scroll-into-view-if-needed": "^3.1.0", - "shiki": "^4.0.2", + "shiki": "^4.1.0", "tailwind-merge": "^3.6.0", "unist-util-visit": "^5.1.0" }, @@ -8456,7 +9233,7 @@ "@takumi-rs/image-response": "*", "@types/mdx": "*", "@types/react": "*", - "fumadocs-core": "16.9.1", + "fumadocs-core": "16.9.3", "next": "16.x.x", "react": "^19.2.0", "react-dom": "^19.2.0" @@ -8476,6 +9253,24 @@ } } }, + "node_modules/fumadocs-ui/node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -8833,9 +9628,9 @@ } }, "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -9056,9 +9851,9 @@ } }, "node_modules/hono": { - "version": "4.12.22", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.22.tgz", - "integrity": "sha512-7fvVPbB92zNRsQke+uiRGwtTuef0tB2Dg4hWxYfFNvkQhIltWoyi0ONReM5LWA+jJWS3nfT5lTq+qbsIpX0IQw==", + "version": "4.12.23", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", + "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", "devOptional": true, "license": "MIT", "engines": { @@ -9205,39 +10000,6 @@ "node": ">= 0.4" } }, - "node_modules/ioredis": { - "version": "5.10.1", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", - "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", - "license": "MIT", - "dependencies": { - "@ioredis/commands": "1.5.1", - "cluster-key-slot": "^1.1.0", - "debug": "^4.3.4", - "denque": "^2.1.0", - "lodash.defaults": "^4.2.0", - "lodash.isarguments": "^3.1.0", - "redis-errors": "^1.2.0", - "redis-parser": "^3.0.0", - "standard-as-callback": "^2.1.0" - }, - "engines": { - "node": ">=12.22.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ioredis" - } - }, - "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -9912,12 +10674,12 @@ } }, "node_modules/kysely": { - "version": "0.28.17", - "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.17.tgz", - "integrity": "sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==", + "version": "0.29.2", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.29.2.tgz", + "integrity": "sha512-s6WVJyEZrbm6jhBpiKHsGHyePMrVQKJ85wZCFCr9W4QHv6WTjWIrdvTmO9hDEA3bNK0xkrE2DqrHsXMLWuZpQg==", "license": "MIT", "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } }, "node_modules/language-subtag-registry": { @@ -9967,6 +10729,7 @@ "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "devOptional": true, "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -10259,18 +11022,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash.defaults": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", - "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", - "license": "MIT" - }, - "node_modules/lodash.isarguments": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", - "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", - "license": "MIT" - }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -10334,9 +11085,9 @@ } }, "node_modules/lucide-react": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.16.0.tgz", - "integrity": "sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==", + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.17.0.tgz", + "integrity": "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -10346,6 +11097,7 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -11447,25 +12199,12 @@ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, "engines": { "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/mime": { @@ -11643,9 +12382,9 @@ } }, "node_modules/nanoid": { - "version": "5.1.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz", - "integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "funding": [ { "type": "github", @@ -11654,10 +12393,10 @@ ], "license": "MIT", "bin": { - "nanoid": "bin/nanoid.js" + "nanoid": "bin/nanoid.cjs" }, "engines": { - "node": "^18 || >=20" + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, "node_modules/nanostores": { @@ -11779,6 +12518,34 @@ "tslib": "^2.8.0" } }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/node-cron": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", @@ -11818,9 +12585,9 @@ } }, "node_modules/nodemailer": { - "version": "8.0.8", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.8.tgz", - "integrity": "sha512-p+XsnzXGdtIHXUu2ugxdfG+eX2nehsGhMjW9h0CWj1BhE30hrFz0kh0yIM0/VjUgVsRrDj+80ZO+I1nSkGE4tA==", + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.10.tgz", + "integrity": "sha512-BLFuSth7QtHOkBzyqTehWWyub0NTRDuK2Q2SQfnGLsrJnzyU+Yeh4WpV1eZGuARFj1xQJHIdnTuJZLP+b9R1GQ==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -11970,6 +12737,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "devOptional": true, "funding": [ "https://github.com/sponsors/sxzz", "https://opencollective.com/debug" @@ -12160,6 +12928,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -12310,12 +13093,13 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">=8.6" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -12364,9 +13148,10 @@ } }, "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "devOptional": true, "funding": [ { "type": "opencollective", @@ -12383,7 +13168,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -12397,24 +13182,6 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, - "node_modules/postcss/node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, "node_modules/postgres": { "version": "3.4.7", "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", @@ -12756,18 +13523,6 @@ "node": ">= 0.6" } }, - "node_modules/rate-limit-redis": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/rate-limit-redis/-/rate-limit-redis-4.3.1.tgz", - "integrity": "sha512-+a1zU8+D7L8siDK9jb14refQXz60vq427VuiplgnaLk9B2LnvGe/APLTfhwb4uNIL7eWVknh8GnRp/unCj+lMA==", - "license": "MIT", - "engines": { - "node": ">= 16" - }, - "peerDependencies": { - "express-rate-limit": ">= 6" - } - }, "node_modules/raw-body": { "version": "2.5.3", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", @@ -12814,12 +13569,6 @@ "react": "^19.2.6" } }, - "node_modules/react-dom/node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" - }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -13012,27 +13761,6 @@ "node": ">= 18.19.0" } }, - "node_modules/redis-errors": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", - "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/redis-parser": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", - "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", - "license": "MIT", - "dependencies": { - "redis-errors": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -13308,36 +14036,6 @@ "typescript": ">=3.0.3" } }, - "node_modules/resolve-tspaths/node_modules/fast-glob": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", - "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/resolve-tspaths/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/restructure": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", @@ -13369,6 +14067,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", + "devOptional": true, "license": "MIT", "dependencies": { "@oxc-project/types": "=0.132.0", @@ -13533,9 +14232,9 @@ } }, "node_modules/scheduler": { - "version": "0.25.0-rc-603e6108-20241029", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0-rc-603e6108-20241029.tgz", - "integrity": "sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, "node_modules/scroll-into-view-if-needed": { @@ -13860,6 +14559,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "devOptional": true, "license": "ISC" }, "node_modules/signal-exit": { @@ -13953,13 +14653,18 @@ "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "devOptional": true, "license": "MIT" }, - "node_modules/standard-as-callback": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", - "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", - "license": "MIT" + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } }, "node_modules/statuses": { "version": "2.0.2", @@ -14156,6 +14861,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -14270,12 +14987,13 @@ "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "devOptional": true, "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz", - "integrity": "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.3.tgz", + "integrity": "sha512-g62dB+w1/OEFnPvmX0yd/HnetYITOL+1nJW7kitOycOeAvmbWC/nu0fwmmQ/kupNojqExzyC/T++pST/jRJ2mQ==", "license": "MIT", "engines": { "node": ">=18" @@ -14297,10 +15015,40 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/tinyrainbow": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=14.0.0" @@ -14431,19 +15179,6 @@ "node": ">= 6" } }, - "node_modules/tsc-alias/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tsc-alias/node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -14592,18 +15327,18 @@ } }, "node_modules/typed-array-length": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", - "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0", - "reflect.getprototypeof": "^1.0.6" + "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" }, "engines": { "node": ">= 0.4" @@ -14627,16 +15362,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.4.tgz", - "integrity": "sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.0.tgz", + "integrity": "sha512-9f65qWLZdAW9m1JaxBDUHcqRUfL8bkxxXL7XxEfI+F09q56PkBvIfCjLF3yInsDM/BBmwkqmCQdCZe/RYlIWEw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.59.4", - "@typescript-eslint/parser": "8.59.4", - "@typescript-eslint/typescript-estree": "8.59.4", - "@typescript-eslint/utils": "8.59.4" + "@typescript-eslint/eslint-plugin": "8.60.0", + "@typescript-eslint/parser": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -15045,6 +15780,7 @@ "version": "8.0.14", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", + "devOptional": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", @@ -15132,10 +15868,24 @@ "node": ">= 6" } }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/vitest": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", + "devOptional": true, "license": "MIT", "dependencies": { "@vitest/expect": "4.1.7", @@ -15221,10 +15971,24 @@ } } }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/vitest/node_modules/std-env": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "devOptional": true, "license": "MIT" }, "node_modules/web-namespaces": { @@ -15328,14 +16092,14 @@ } }, "node_modules/which-typed-array": { - "version": "1.1.20", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", - "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.21.tgz", + "integrity": "sha512-zbRA8cVm6io/d5W8uIe2hblzN76/Wm3v/yiythQvr+dpBWeqhPSWIDNj4zOyHi4zKbMK6DN34Xsr9jPHJERAEw==", "dev": true, "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", + "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", @@ -15353,6 +16117,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "devOptional": true, "license": "MIT", "dependencies": { "siginfo": "^2.0.0", @@ -15393,6 +16158,21 @@ "xml-js": "bin/cli.js" } }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -15440,9 +16220,9 @@ } }, "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -15462,9 +16242,9 @@ } }, "node_modules/zustand": { - "version": "5.0.13", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz", - "integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==", + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", "license": "MIT", "engines": { "node": ">=12.20.0" diff --git a/package.json b/package.json index fa0e63c..720b1c6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "veriworkly", - "version": "3.10.2", + "version": "3.11.0", "private": true, "repository": { "type": "git", @@ -9,6 +9,7 @@ "workspaces": [ "apps/site", "apps/studio", + "apps/portfolio", "apps/blog-platform", "apps/docs-platform", "apps/server", @@ -18,19 +19,26 @@ "dev": "npm run dev -w @veriworkly/site", "dev:site": "npm run dev -w @veriworkly/site -- -p 3000", "dev:studio": "npm run dev -w @veriworkly/studio -- -p 3001", - "dev:server": "npm run dev -w @veriworkly/server", "dev:docs": "npm run dev -w @veriworkly/docs-platform -- -p 3002", "dev:blog": "npm run dev -w @veriworkly/blog-platform -- -p 3003", + "dev:portfolio": "npm run dev -w @veriworkly/portfolio -- -p 3004", + "dev:server": "npm run dev -w @veriworkly/server", "dev:all": "npm run dev --workspaces --if-present", "build": "npm run build --workspaces --if-present", "build:site": "npm run build -w @veriworkly/site", "build:studio": "npm run build -w @veriworkly/studio", "build:blog": "npm run build -w @veriworkly/blog-platform", "build:docs": "npm run build -w @veriworkly/docs-platform", + "build:portfolio": "npm run build -w @veriworkly/portfolio", + "build:server": "npm run build -w @veriworkly/server", "lint": "eslint .", "format": "prettier --check .", "format:write": "prettier --write .", - "generate:api": "npx @redocly/cli bundle apps/docs-platform/specs/openapi.yaml -o apps/docs-platform/openapi.yaml && node scripts/generate-api.mjs" + "generate:api": "npx @redocly/cli bundle apps/docs-platform/specs/openapi.yaml -o apps/docs-platform/openapi.yaml && node scripts/generate-api.mjs", + "db:push": "npm run db:push -w @veriworkly/server", + "db:migrate": "npm run db:migrate -w @veriworkly/server", + "db:generate": "npm run db:generate -w @veriworkly/server", + "db:studio": "npm run db:studio -w @veriworkly/server" }, "devDependencies": { "@hono/node-server": "2.0.2",