diff --git a/apps/api/package.json b/apps/api/package.json index 34a9311a..64e57f11 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -35,12 +35,13 @@ "zod": "^3.25.62" }, "dependencies": { + "@crosspost/scheduler-sdk": "^0.1.1", "@crosspost/sdk": "^0.3.0", "@crosspost/types": "^0.3.0", - "@crosspost/scheduler-sdk": "^0.1.1", "@curatedotfun/shared-db": "workspace:*", "@curatedotfun/types": "workspace:*", "@curatedotfun/utils": "workspace:*", + "@fastnear/utils": "^0.9.7", "@hono/node-server": "^1.8.2", "@hono/zod-openapi": "^0.9.5", "@hono/zod-validator": "^0.5.0", @@ -60,7 +61,7 @@ "lodash": "^4.17.21", "mustache": "^4.2.0", "near-api-js": "^5.1.1", - "near-sign-verify": "^0.3.6", + "near-sign-verify": "^0.4.1", "ora": "^8.1.1", "pg": "^8.15.6", "pinata-web3": "^0.5.4", diff --git a/apps/api/src/middlewares/auth.middleware.ts b/apps/api/src/middlewares/auth.middleware.ts index 831f4efc..394b8a36 100644 --- a/apps/api/src/middlewares/auth.middleware.ts +++ b/apps/api/src/middlewares/auth.middleware.ts @@ -1,54 +1,31 @@ import { Context, MiddlewareHandler, Next } from "hono"; -import { verify } from "near-sign-verify"; +import { verify } from "hono/jwt"; +import { getCookie } from "hono/cookie"; export function createAuthMiddleware(): MiddlewareHandler { return async (c: Context, next: Next) => { - const method = c.req.method; + const token = getCookie(c, "token"); let accountId: string | null = null; - if (method === "GET") { - const nearAccountHeader = c.req.header("X-Near-Account"); - if ( - nearAccountHeader && - nearAccountHeader.toLowerCase() !== "anonymous" - ) { - accountId = nearAccountHeader; + if (token) { + const secret = process.env.JWT_SECRET; + if (!secret) { + console.error("JWT_SECRET is not set."); + c.status(500); + return c.json({ error: "Internal Server Error" }); + } + try { + const decodedPayload = await verify(token, secret); + if (decodedPayload && typeof decodedPayload.sub === "string") { + accountId = decodedPayload.sub; + } + } catch (error) { + // Invalid token, proceed as anonymous + console.warn("JWT verification failed:", error); } - // If header is missing or "anonymous", accountId remains null - c.set("accountId", accountId); - await next(); - return; - } - - // For non-GET requests (POST, PUT, DELETE, PATCH, etc.) - const authHeader = c.req.header("Authorization"); - if (!authHeader || !authHeader.startsWith("Bearer ")) { - c.status(401); - return c.json({ - error: "Unauthorized", - details: "Missing or malformed Authorization header.", - }); } - const token = authHeader.substring(7); // Remove "Bearer " - - try { - const verificationResult = await verify(token, { - expectedRecipient: "curatefun.near", - requireFullAccessKey: false, - nonceMaxAge: 300000, // 5 mins - }); - - accountId = verificationResult.accountId; - c.set("accountId", accountId); - await next(); - } catch (error) { - console.error("Token verification error:", error); - c.status(401); - return c.json({ - error: "Unauthorized", - details: "Invalid token signature or recipient.", - }); - } + c.set("accountId", accountId); + await next(); }; } diff --git a/apps/api/src/routes/api/auth.ts b/apps/api/src/routes/api/auth.ts new file mode 100644 index 00000000..6e714e08 --- /dev/null +++ b/apps/api/src/routes/api/auth.ts @@ -0,0 +1,67 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod"; +import { AuthService } from "../../services/auth.service"; +import { Env } from "../../types/app"; +import { setCookie } from "hono/cookie"; + +export const authRoutes = new Hono(); + +const CreateAuthRequestSchema = z.object({ + accountId: z.string(), +}); + +const VerifyAuthRequestSchema = z.object({ + token: z.string(), + accountId: z.string(), +}); + +authRoutes.post( + "/initiate-login", + zValidator("json", CreateAuthRequestSchema), + async (c) => { + const payload = c.req.valid("json"); + const sp = c.var.sp; + const authService = sp.getService("authService"); + const result = await authService.createAuthRequest(payload); + return c.json(result); + }, +); + +authRoutes.post( + "/verify-login", + zValidator("json", VerifyAuthRequestSchema), + async (c) => { + const payload = c.req.valid("json"); + const sp = c.var.sp; + const authService = sp.getService("authService"); + try { + const { jwt } = await authService.verifyAuthRequest(payload); + setCookie(c, "token", jwt, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "Strict", + path: "/", + maxAge: 60 * 60 * 24 * 7, // 7 days + }); + return c.json({ success: true }); + } catch (error: unknown) { + c.status(401); + return c.json({ + success: false, + error: error instanceof Error ? error.message : "Authentication failed", + }); + } + }, +); + +authRoutes.post("/logout", async (c) => { + setCookie(c, "token", "", { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "Strict", + path: "/", + maxAge: 0, + }); + return c.json({ success: true }); +}); diff --git a/apps/api/src/routes/api/index.ts b/apps/api/src/routes/api/index.ts index 6fde926d..436b81d4 100644 --- a/apps/api/src/routes/api/index.ts +++ b/apps/api/src/routes/api/index.ts @@ -14,6 +14,7 @@ import { activityRoutes } from "./activity"; import { uploadRoutes } from "./upload"; import { pluginsRoutes } from "./plugins"; import { moderationRoutes } from "./moderation"; +import { authRoutes } from "./auth"; // Create main API router export const apiRoutes = new Hono(); @@ -36,3 +37,4 @@ apiRoutes.route("/users", usersRoutes); apiRoutes.route("/activity", activityRoutes); apiRoutes.route("/upload", uploadRoutes); apiRoutes.route("/moderate", moderationRoutes); +apiRoutes.route("/auth", authRoutes); diff --git a/apps/api/src/services/auth.service.ts b/apps/api/src/services/auth.service.ts new file mode 100644 index 00000000..568da260 --- /dev/null +++ b/apps/api/src/services/auth.service.ts @@ -0,0 +1,103 @@ +import { + AuthRequestRepository, + InsertAuthRequest, +} from "@curatedotfun/shared-db"; +import { toHex } from "@fastnear/utils"; +import { randomBytes } from "crypto"; +import { sign } from "hono/jwt"; +import { verify } from "near-sign-verify"; +import { z } from "zod"; +import { UserService } from "./users.service"; + +const AUTH_REQUEST_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes +const JWT_EXPIRY_SECONDS = 60 * 60 * 24 * 7; // 7 days + +const CreateAuthRequestSchema = z.object({ + accountId: z.string(), +}); + +const VerifyAuthRequestSchema = z.object({ + token: z.string(), + accountId: z.string(), +}); + +export class AuthService { + private userService: UserService; + private authRequestRepository: AuthRequestRepository; + + constructor( + authRequestRepository: AuthRequestRepository, + userService: UserService, + ) { + this.authRequestRepository = authRequestRepository; + this.userService = userService; + } + + async createAuthRequest(payload: z.infer) { + const { accountId } = payload; + await this.userService.ensureUserProfile(accountId); + + const nonce = randomBytes(32).toString("hex"); + + const expiresAt = new Date(Date.now() + AUTH_REQUEST_EXPIRY_MS); + + const newAuthRequest: InsertAuthRequest = { + nonce, + accountId, + expiresAt, + }; + + await this.authRequestRepository.create(newAuthRequest); + + return { + nonce, + recipient: "curatefun.near", + }; + } + + async verifyAuthRequest(payload: z.infer) { + const { token, accountId } = payload; + + const latestRequest = + await this.authRequestRepository.findLatestByAccountId(accountId); + + if (!latestRequest) { + throw new Error("No recent auth request found for this account."); + } + + if (latestRequest.expiresAt < new Date()) { + await this.authRequestRepository.deleteById(latestRequest.id); + throw new Error("Auth request has expired."); + } + + const message = `Authorize Curate.fun`; + + const verificationResult = await verify(token, { + expectedRecipient: "curatefun.near", + expectedMessage: message, + validateNonce: (nonceFromToken) => { + const receivedNonceHex = toHex(nonceFromToken); + return receivedNonceHex === latestRequest.nonce; + }, + }); + + if (verificationResult.accountId !== accountId) { + throw new Error("Account ID mismatch."); + } + + await this.authRequestRepository.deleteById(latestRequest.id); + + const jwtPayload = { + sub: accountId, + exp: Math.floor(Date.now() / 1000) + JWT_EXPIRY_SECONDS, + }; + + const secret = process.env.JWT_SECRET; + if (!secret) { + throw new Error("JWT_SECRET is not set."); + } + + const jwt = await sign(jwtPayload, secret); + return { jwt }; + } +} diff --git a/apps/api/src/services/users.service.ts b/apps/api/src/services/users.service.ts index 4cfeebe3..af54e869 100644 --- a/apps/api/src/services/users.service.ts +++ b/apps/api/src/services/users.service.ts @@ -46,6 +46,16 @@ export class UserService implements IBaseService { return UserProfileSchema.parse(parsedUser); } + async ensureUserProfile(nearAccountId: string): Promise { + const existingUser = await this.findUserByNearAccountId(nearAccountId); + if (existingUser) { + return existingUser; + } + + const newUser = await this.createUser({ nearAccountId }); + return newUser; + } + /** * Find a user by NEAR account ID and return as API UserProfile */ diff --git a/apps/api/src/utils/service-provider.ts b/apps/api/src/utils/service-provider.ts index 1606d8bd..1587c91c 100644 --- a/apps/api/src/utils/service-provider.ts +++ b/apps/api/src/utils/service-provider.ts @@ -1,5 +1,6 @@ import { ActivityRepository, + AuthRequestRepository, FeedRepository, LeaderboardRepository, ModerationRepository, @@ -11,6 +12,7 @@ import { SubmissionService } from "services/submission.service"; import { MockTwitterService } from "../__test__/mocks/twitter-service.mock"; import { db } from "../db"; import { ActivityService } from "../services/activity.service"; +import { AuthService } from "../services/auth.service"; import { ConfigService, isProduction } from "../services/config.service"; import { DistributionService } from "../services/distribution.service"; import { FeedService } from "../services/feed.service"; @@ -86,6 +88,10 @@ export class ServiceProvider { ); this.services.set("userService", userService); + const authRequestRepository = new AuthRequestRepository(db); + const authService = new AuthService(authRequestRepository, userService); + this.services.set("authService", authService); + const feedService = new FeedService( feedRepository, processorService, @@ -207,6 +213,14 @@ export class ServiceProvider { return this.getService("userService"); } + /** + * Get the auth service + * @returns The auth service + */ + public getAuthService(): AuthService { + return this.getService("authService"); + } + /** * Get the activity service * @returns The activity service diff --git a/apps/app/package.json b/apps/app/package.json index b02413a5..f19ae52f 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@crosspost/sdk": "^0.3.0", + "@fastnear/utils": "^0.9.7", "@hookform/resolvers": "^5.0.1", "@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-checkbox": "^1.1.5", @@ -43,7 +44,7 @@ "immer": "^10.1.1", "lodash": "^4.17.21", "lucide-react": "^0.483.0", - "near-sign-verify": "^0.3.6", + "near-sign-verify": "^0.4.1", "pinata-web3": "^0.5.4", "postcss": "^8.4.49", "react": "^18.3.1", diff --git a/apps/app/src/components/UserMenu.tsx b/apps/app/src/components/UserMenu.tsx index 91a71c47..73141082 100644 --- a/apps/app/src/components/UserMenu.tsx +++ b/apps/app/src/components/UserMenu.tsx @@ -21,8 +21,14 @@ interface UserMenuProps { export default function UserMenu({ className }: UserMenuProps) { const [dropdownOpen, setDropdownOpen] = useState(false); const navigate = useNavigate(); - const { currentAccountId, handleSignIn, isSignedIn, handleSignOut } = - useAuth(); + const { + currentAccountId, + handleSignIn, + isSignedIn, + handleSignOut, + isAuthorized, + handleAuthorize, + } = useAuth(); const { data: userProfile } = useNearSocialProfile(currentAccountId || ""); const ProfileImage = ({ size = "small" }: { size?: "small" | "medium" }) => { @@ -41,71 +47,79 @@ export default function UserMenu({ className }: UserMenuProps) { return "User"; }; + if (!isSignedIn) { + return ( + + ); + } + + if (!isAuthorized) { + return ( + + ); + } + return ( - <> - {isSignedIn ? ( - - - - - - -
- {currentAccountId ? ( - - ) : ( - - )} -
-

- {currentAccountId} -

-
-
-
- - { - navigate({ to: "/profile" }); - }} - > - - Profile - - - - Disconnect - -
-
- ) : ( - - )} - + + + +
+ {currentAccountId ? ( + + ) : ( + + )} +
+

+ {currentAccountId} +

+
+
+
+ + { + navigate({ to: "/profile" }); + }} + > + + Profile + + + + Disconnect + +
+ ); } diff --git a/apps/app/src/contexts/auth-context.tsx b/apps/app/src/contexts/auth-context.tsx index 53282b43..0209673b 100644 --- a/apps/app/src/contexts/auth-context.tsx +++ b/apps/app/src/contexts/auth-context.tsx @@ -1,24 +1,25 @@ -import { ensureUserProfile } from "../lib/api/users"; -import { toast } from "../hooks/use-toast"; -import { near } from "../lib/near"; +import { sign } from "near-sign-verify"; import React, { createContext, + useCallback, useContext, useEffect, - useRef, useState, - type Dispatch, type ReactNode, - type SetStateAction, } from "react"; +import { toast } from "../hooks/use-toast"; +import { apiClient } from "../lib/api-client"; +import { near } from "../lib/near"; +import { fromHex } from "@fastnear/utils"; interface IAuthContext { currentAccountId: string | null; isSignedIn: boolean; - setCurrentAccountId: Dispatch>; - setIsSignedIn: Dispatch>; + isAuthorized: boolean; handleSignIn: () => Promise; - handleSignOut: () => void; + handleSignOut: () => Promise; + handleAuthorize: () => Promise; + checkAuthorization: () => Promise; } const AuthContext = createContext(undefined); @@ -38,84 +39,113 @@ interface AuthProviderProps { export function AuthProvider({ children, }: AuthProviderProps): React.ReactElement { - const [currentAccountId, setCurrentAccountId] = useState( - near.accountId() ?? null, - ); - const [isSignedIn, setIsSignedIn] = useState( - near.authStatus() === "SignedIn", - ); - const previousAccountIdRef = useRef(currentAccountId); + const [currentAccountId, setCurrentAccountId] = useState(null); + const [isSignedIn, setIsSignedIn] = useState(false); + const [isAuthorized, setIsAuthorized] = useState(false); - useEffect(() => { - const accountListener = near.event.onAccount((newAccountId) => { - setCurrentAccountId(newAccountId); - setIsSignedIn(!!newAccountId); - - if (newAccountId) { - // any init? - // client.setAccountHeader(newAccountId); - } else { - // any clean up? - } - }); - - return () => { - near.event.offAccount(accountListener); - }; + const checkAuthorization = useCallback(async () => { + try { + await apiClient.makeRequest("GET", "/users/me"); + setIsAuthorized(true); + } catch (error) { + console.error("Authorization check failed:", error); + setIsAuthorized(false); + } }, []); useEffect(() => { - console.log("AuthProvider main useEffect triggered:", { - currentAccountId, - prev: previousAccountIdRef.current, - }); + const accountId = near.accountId(); + if (accountId) { + setCurrentAccountId(accountId); + setIsSignedIn(true); + checkAuthorization(); + } + }, [checkAuthorization]); - if (currentAccountId && currentAccountId !== previousAccountIdRef.current) { + const handleSignIn = async (): Promise => { + try { + await near.requestSignIn(); + } catch (e: unknown) { toast({ - title: "Success!", - description: `Connected as: ${currentAccountId}`, - variant: "success", + title: "Sign-in failed", + description: e instanceof Error ? e.message : String(e), + variant: "destructive", }); - } else if (!currentAccountId && previousAccountIdRef.current !== null) { + } + }; + + const handleAuthorize = async (): Promise => { + if (!currentAccountId) { toast({ - title: "Signed out", - description: "You have been signed out successfully.", - variant: "success", + title: "Not Signed In", + description: "Please sign in before authorizing.", + variant: "destructive", }); + return; } - previousAccountIdRef.current = currentAccountId; - }, [currentAccountId]); - - const handleSignIn = async (): Promise => { try { - await near.requestSignIn(); + const { nonce, recipient } = await apiClient.makeRequest<{ + nonce: string; + recipient: string; + }>("POST", "/auth/initiate-login", { accountId: currentAccountId }); + + const message = "Authorize Curate.fun"; + + const authToken = await sign(message, { + signer: near, + recipient, + nonce: fromHex(nonce), + }); + + await apiClient.makeRequest("POST", "/auth/verify-login", { + token: authToken, + accountId: currentAccountId, + }); - // Get the account ID after sign-in - const accountId = near.accountId(); - if (accountId) { - // Ensure user profile exists - await ensureUserProfile(accountId); - } + setIsAuthorized(true); + toast({ + title: "Authorization Successful!", + description: "You have successfully authorized the application.", + variant: "success", + }); } catch (e: unknown) { + setIsAuthorized(false); toast({ - title: "Sign-in failed", + title: "Authorization failed", description: e instanceof Error ? e.message : String(e), variant: "destructive", }); } }; - const handleSignOut = (): void => { + const handleSignOut = async (): Promise => { + try { + await apiClient.makeRequest("POST", "/auth/logout"); + } catch (error) { + console.error( + "Logout failed on backend, signing out on client anyway.", + error, + ); + } near.signOut(); + setCurrentAccountId(null); + setIsSignedIn(false); + setIsAuthorized(false); + toast({ + title: "Signed out", + description: "You have been signed out successfully.", + variant: "success", + }); }; const contextValue: IAuthContext = { currentAccountId, isSignedIn, - setCurrentAccountId, - setIsSignedIn, + isAuthorized, handleSignIn, handleSignOut, + handleAuthorize, + checkAuthorization, }; return ( diff --git a/apps/app/src/lib/api-client.ts b/apps/app/src/lib/api-client.ts index 74af0277..f83bf9ed 100644 --- a/apps/app/src/lib/api-client.ts +++ b/apps/app/src/lib/api-client.ts @@ -1,6 +1,3 @@ -import { near } from "./near"; -import { sign } from "near-sign-verify"; - export class ApiError extends Error { constructor( message: string, @@ -22,9 +19,7 @@ class ApiClient { async makeRequest( method: string, path: string, - auth: { currentAccountId: string | null; isSignedIn: boolean }, requestData?: TRequest, - message?: string, // Descriptive message for signing non-GET requests ): Promise { const fullUrl = new URL(this.baseUrl + path, window.location.origin); const bodyString = @@ -39,30 +34,6 @@ class ApiClient { headers["Content-Type"] = "application/json"; } - if (method === "GET") { - if (auth.currentAccountId) { - headers["X-Near-Account"] = auth.currentAccountId || "anonymous"; - } - } else { - // Non-GET requests (POST, PUT, DELETE, PATCH) - if (!auth.currentAccountId) { - throw new ApiError( - "Account ID missing for authenticated request.", - 400, - ); - } - - const messageForSigning = message || `${method} request to ${path}`; - - const authToken = await sign({ - signer: near, - recipient: "curatefun.near", - message: messageForSigning, - }); - - headers["Authorization"] = `Bearer ${authToken}`; - } - const requestOptions: RequestInit = { method, headers, diff --git a/docker-compose.yml b/docker-compose.yml index ff4a7c0b..73be8ced 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,22 +36,19 @@ services: # handles data migration and database seeding db-migrate-dev: - image: oven/bun:latest - working_dir: /app/packages/shared-db - volumes: - - ./:/app - - node_modules_cache:/app/node_modules + build: + context: . + dockerfile: packages/shared-db/Dockerfile.dev environment: DATABASE_URL: postgresql://postgres:postgres@postgres_dev:5432/curatedotfun depends_on: postgres_dev: condition: service_healthy - command: sh -c "bunx drizzle-kit migrate" profiles: ["dev"] db-seed-dev: extends: db-migrate-dev - command: sh -c "bun ./scripts/seed-dev.ts" + command: ["seed:dev"] profiles: ["dev"] # Application service diff --git a/packages/shared-db/Dockerfile.dev b/packages/shared-db/Dockerfile.dev new file mode 100644 index 00000000..1f820cf9 --- /dev/null +++ b/packages/shared-db/Dockerfile.dev @@ -0,0 +1,40 @@ +FROM node:18-alpine AS base + +RUN npm install -g pnpm turbo + +# Pruner stage +FROM base AS pruner +WORKDIR /app +COPY . . +# Prune for the shared-db package and its dependencies +RUN turbo prune --scope=@curatedotfun/shared-db --docker + +# Builder stage +FROM base AS builder +WORKDIR /app +COPY --from=pruner /app/out/full/ . +COPY --from=pruner /app/out/json/ . +COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml +COPY --from=pruner /app/pnpm-workspace.yaml ./pnpm-workspace.yaml + +RUN pnpm install --frozen-lockfile + +# Production stage: Minimal image to run migrations +FROM node:18-alpine AS production +WORKDIR /app + +RUN npm install -g pnpm + +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/package.json ./package.json +COPY --from=builder /app/pnpm-workspace.yaml ./pnpm-workspace.yaml +COPY --from=builder /app/packages/shared-db ./packages/shared-db + +# Set the working directory for commands +WORKDIR /app/packages/shared-db + +# The entrypoint is just 'pnpm', commands will be appended +ENTRYPOINT ["pnpm"] + +# The default command to run when nothing is specified +CMD ["db:migrate"] diff --git a/packages/shared-db/migrations/0017_warm_cerebro.sql b/packages/shared-db/migrations/0017_warm_cerebro.sql new file mode 100644 index 00000000..46d4caf7 --- /dev/null +++ b/packages/shared-db/migrations/0017_warm_cerebro.sql @@ -0,0 +1,13 @@ +CREATE TABLE "auth_requests" ( + "id" serial PRIMARY KEY NOT NULL, + "nonce" text NOT NULL, + "state" text, + "account_id" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now(), + CONSTRAINT "auth_requests_nonce_unique" UNIQUE("nonce"), + CONSTRAINT "auth_requests_state_unique" UNIQUE("state") +); +--> statement-breakpoint +CREATE INDEX "auth_requests_account_id_created_at_idx" ON "auth_requests" USING btree ("account_id","created_at"); \ No newline at end of file diff --git a/packages/shared-db/migrations/meta/0017_snapshot.json b/packages/shared-db/migrations/meta/0017_snapshot.json new file mode 100644 index 00000000..a142e05f --- /dev/null +++ b/packages/shared-db/migrations/meta/0017_snapshot.json @@ -0,0 +1,1530 @@ +{ + "id": "b69c918c-1d07-4586-a203-c127f2e4c4e0", + "prevId": "44639cd2-5a48-4df4-a04f-1b99d8cc672b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.activities": { + "name": "activities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "activity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "feed_id": { + "name": "feed_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "submission_id": { + "name": "submission_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "activities_user_id_idx": { + "name": "activities_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activities_type_idx": { + "name": "activities_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activities_timestamp_idx": { + "name": "activities_timestamp_idx", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activities_feed_id_idx": { + "name": "activities_feed_id_idx", + "columns": [ + { + "expression": "feed_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activities_submission_id_idx": { + "name": "activities_submission_id_idx", + "columns": [ + { + "expression": "submission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activities_metadata_type_idx": { + "name": "activities_metadata_type_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'type')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "activities_user_id_users_id_fk": { + "name": "activities_user_id_users_id_fk", + "tableFrom": "activities", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "activities_feed_id_feeds_id_fk": { + "name": "activities_feed_id_feeds_id_fk", + "tableFrom": "activities", + "tableTo": "feeds", + "columnsFrom": ["feed_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "activities_submission_id_submissions_tweet_id_fk": { + "name": "activities_submission_id_submissions_tweet_id_fk", + "tableFrom": "activities", + "tableTo": "submissions", + "columnsFrom": ["submission_id"], + "columnsTo": ["tweet_id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feed_user_stats": { + "name": "feed_user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "feed_id": { + "name": "feed_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "submissions_count": { + "name": "submissions_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "approvals_count": { + "name": "approvals_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "points": { + "name": "points", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "curator_rank": { + "name": "curator_rank", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "approver_rank": { + "name": "approver_rank", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "feed_user_stats_user_feed_idx": { + "name": "feed_user_stats_user_feed_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "feed_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feed_user_stats_curator_rank_idx": { + "name": "feed_user_stats_curator_rank_idx", + "columns": [ + { + "expression": "feed_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "curator_rank", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feed_user_stats_approver_rank_idx": { + "name": "feed_user_stats_approver_rank_idx", + "columns": [ + { + "expression": "feed_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "approver_rank", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "feed_user_stats_user_id_users_id_fk": { + "name": "feed_user_stats_user_id_users_id_fk", + "tableFrom": "feed_user_stats", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "feed_user_stats_feed_id_feeds_id_fk": { + "name": "feed_user_stats_feed_id_feeds_id_fk", + "tableFrom": "feed_user_stats", + "tableTo": "feeds", + "columnsFrom": ["feed_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "total_submissions": { + "name": "total_submissions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_approvals": { + "name": "total_approvals", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_points": { + "name": "total_points", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_users_id_fk": { + "name": "user_stats_user_id_users_id_fk", + "tableFrom": "user_stats", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feed_plugins": { + "name": "feed_plugins", + "schema": "", + "columns": { + "feed_id": { + "name": "feed_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "feed_plugins_feed_idx": { + "name": "feed_plugins_feed_idx", + "columns": [ + { + "expression": "feed_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feed_plugins_plugin_idx": { + "name": "feed_plugins_plugin_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "feed_plugins_feed_id_feeds_id_fk": { + "name": "feed_plugins_feed_id_feeds_id_fk", + "tableFrom": "feed_plugins", + "tableTo": "feeds", + "columnsFrom": ["feed_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "feed_plugins_feed_id_plugin_id_pk": { + "name": "feed_plugins_feed_id_plugin_id_pk", + "columns": ["feed_id", "plugin_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feed_recaps_state": { + "name": "feed_recaps_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "feed_id": { + "name": "feed_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recap_id": { + "name": "recap_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_job_id": { + "name": "external_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_successful_completion": { + "name": "last_successful_completion", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_run_error": { + "name": "last_run_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "feed_recap_id_idx": { + "name": "feed_recap_id_idx", + "columns": [ + { + "expression": "feed_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recap_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "feed_recaps_state_feed_id_feeds_id_fk": { + "name": "feed_recaps_state_feed_id_feeds_id_fk", + "tableFrom": "feed_recaps_state", + "tableTo": "feeds", + "columnsFrom": ["feed_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "feed_recaps_state_external_job_id_unique": { + "name": "feed_recaps_state_external_job_id_unique", + "nullsNotDistinct": false, + "columns": ["external_job_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feeds": { + "name": "feeds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "admins": { + "name": "admins", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "feeds_created_by_users_near_account_id_fk": { + "name": "feeds_created_by_users_near_account_id_fk", + "tableFrom": "feeds", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["near_account_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.twitter_cache": { + "name": "twitter_cache", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.twitter_cookies": { + "name": "twitter_cookies", + "schema": "", + "columns": { + "username": { + "name": "username", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cookies": { + "name": "cookies", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.submission_counts": { + "name": "submission_counts", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_reset_date": { + "name": "last_reset_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "submission_counts_date_idx": { + "name": "submission_counts_date_idx", + "columns": [ + { + "expression": "last_reset_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.submission_feeds": { + "name": "submission_feeds", + "schema": "", + "columns": { + "submission_id": { + "name": "submission_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feed_id": { + "name": "feed_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "submission_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "submission_feeds_feed_idx": { + "name": "submission_feeds_feed_idx", + "columns": [ + { + "expression": "feed_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "submission_feeds_submission_id_submissions_tweet_id_fk": { + "name": "submission_feeds_submission_id_submissions_tweet_id_fk", + "tableFrom": "submission_feeds", + "tableTo": "submissions", + "columnsFrom": ["submission_id"], + "columnsTo": ["tweet_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "submission_feeds_feed_id_feeds_id_fk": { + "name": "submission_feeds_feed_id_feeds_id_fk", + "tableFrom": "submission_feeds", + "tableTo": "feeds", + "columnsFrom": ["feed_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "submission_feeds_submission_id_feed_id_pk": { + "name": "submission_feeds_submission_id_feed_id_pk", + "columns": ["submission_id", "feed_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.submissions": { + "name": "submissions", + "schema": "", + "columns": { + "tweet_id": { + "name": "tweet_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "curator_id": { + "name": "curator_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "curator_username": { + "name": "curator_username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "curator_tweet_id": { + "name": "curator_tweet_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "curator_notes": { + "name": "curator_notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "submitted_at": { + "name": "submitted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "submissions_user_id_idx": { + "name": "submissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "submissions_submitted_at_idx": { + "name": "submissions_submitted_at_idx", + "columns": [ + { + "expression": "submitted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "auth_provider_id": { + "name": "auth_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "near_account_id": { + "name": "near_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "near_public_key": { + "name": "near_public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "profile_image": { + "name": "profile_image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "users_auth_provider_id_idx": { + "name": "users_auth_provider_id_idx", + "columns": [ + { + "expression": "auth_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_near_account_id_idx": { + "name": "users_near_account_id_idx", + "columns": [ + { + "expression": "near_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_near_public_key_idx": { + "name": "users_near_public_key_idx", + "columns": [ + { + "expression": "near_public_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_auth_provider_id_unique": { + "name": "users_auth_provider_id_unique", + "nullsNotDistinct": false, + "columns": ["auth_provider_id"] + }, + "users_near_account_id_unique": { + "name": "users_near_account_id_unique", + "nullsNotDistinct": false, + "columns": ["near_account_id"] + }, + "users_near_public_key_unique": { + "name": "users_near_public_key_unique", + "nullsNotDistinct": false, + "columns": ["near_public_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugins": { + "name": "plugins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entry_point": { + "name": "entry_point", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "plugin_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "schema_definition": { + "name": "schema_definition", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "plugins_name_unique": { + "name": "plugins_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + }, + "plugins_repo_url_unique": { + "name": "plugins_repo_url_unique", + "nullsNotDistinct": false, + "columns": ["repo_url"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.moderation_history": { + "name": "moderation_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "submission_id": { + "name": "submission_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feed_id": { + "name": "feed_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "moderator_account_id": { + "name": "moderator_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "moderator_account_id_type": { + "name": "moderator_account_id_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "moderation_history_submission_idx": { + "name": "moderation_history_submission_idx", + "columns": [ + { + "expression": "submission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "moderation_history_moderator_account_idx": { + "name": "moderation_history_moderator_account_idx", + "columns": [ + { + "expression": "moderator_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "moderation_history_feed_idx": { + "name": "moderation_history_feed_idx", + "columns": [ + { + "expression": "feed_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "moderation_history_submission_id_submissions_tweet_id_fk": { + "name": "moderation_history_submission_id_submissions_tweet_id_fk", + "tableFrom": "moderation_history", + "tableTo": "submissions", + "columnsFrom": ["submission_id"], + "columnsTo": ["tweet_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "moderation_history_feed_id_feeds_id_fk": { + "name": "moderation_history_feed_id_feeds_id_fk", + "tableFrom": "moderation_history", + "tableTo": "feeds", + "columnsFrom": ["feed_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_requests": { + "name": "auth_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "auth_requests_account_id_created_at_idx": { + "name": "auth_requests_account_id_created_at_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "auth_requests_nonce_unique": { + "name": "auth_requests_nonce_unique", + "nullsNotDistinct": false, + "columns": ["nonce"] + }, + "auth_requests_state_unique": { + "name": "auth_requests_state_unique", + "nullsNotDistinct": false, + "columns": ["state"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.activity_type": { + "name": "activity_type", + "schema": "public", + "values": [ + "CONTENT_SUBMISSION", + "CONTENT_APPROVAL", + "CONTENT_REJECTION", + "TOKEN_BUY", + "TOKEN_SELL", + "POINTS_REDEMPTION", + "POINTS_AWARDED" + ] + }, + "public.submission_status": { + "name": "submission_status", + "schema": "public", + "values": ["pending", "approved", "rejected"] + }, + "public.plugin_type": { + "name": "plugin_type", + "schema": "public", + "values": ["transformer", "distributor", "source", "rule", "outcome"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/shared-db/migrations/meta/_journal.json b/packages/shared-db/migrations/meta/_journal.json index d1a2170b..284b3546 100644 --- a/packages/shared-db/migrations/meta/_journal.json +++ b/packages/shared-db/migrations/meta/_journal.json @@ -120,6 +120,13 @@ "when": 1750451880375, "tag": "0016_ambitious_blazing_skull", "breakpoints": true + }, + { + "idx": 17, + "version": "7", + "when": 1750522673585, + "tag": "0017_warm_cerebro", + "breakpoints": true } ] } diff --git a/packages/shared-db/src/index.ts b/packages/shared-db/src/index.ts index 0729c2b2..cc217aa8 100644 --- a/packages/shared-db/src/index.ts +++ b/packages/shared-db/src/index.ts @@ -15,3 +15,4 @@ export * from "./repositories/submission.repository"; export * from "./repositories/moderation.repository"; export * from "./repositories/user.repository"; export * from "./repositories/plugin.repository"; +export * from "./repositories/authRequest.repository"; diff --git a/packages/shared-db/src/repositories/authRequest.repository.ts b/packages/shared-db/src/repositories/authRequest.repository.ts new file mode 100644 index 00000000..7542db71 --- /dev/null +++ b/packages/shared-db/src/repositories/authRequest.repository.ts @@ -0,0 +1,95 @@ +import { and, eq, desc, gte, lt } from "drizzle-orm"; +import { InsertAuthRequest, authRequests } from "../schema"; +import { executeWithRetry, withErrorHandling } from "../utils"; +import { DB } from "../validators"; + +export class AuthRequestRepository { + private readonly db: DB; + + constructor(db: DB) { + this.db = db; + } + + async create(data: InsertAuthRequest) { + return withErrorHandling( + async () => { + return executeWithRetry(async (dbInstance) => { + const result = await dbInstance + .insert(authRequests) + .values(data) + .returning(); + return result[0] ?? null; + }, this.db); + }, + { + operationName: "create auth request", + additionalContext: { data }, + }, + ); + } + + async findLatestByAccountId(accountId: string) { + return withErrorHandling( + async () => { + return executeWithRetry(async (dbInstance) => { + const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000); + const result = await dbInstance + .select() + .from(authRequests) + .where( + and( + eq(authRequests.accountId, accountId), + gte(authRequests.createdAt, fiveMinutesAgo), + ), + ) + .orderBy(desc(authRequests.createdAt)) + .limit(1); + return result[0] ?? null; + }, this.db); + }, + { + operationName: "find latest auth request by account id", + additionalContext: { accountId }, + }, + null, + ); + } + + async deleteById(id: number): Promise { + return withErrorHandling( + async () => { + return executeWithRetry(async (dbInstance) => { + const result = await dbInstance + .delete(authRequests) + .where(eq(authRequests.id, id)) + .returning(); + return result.length > 0; + }, this.db); + }, + { + operationName: "delete auth request by id", + additionalContext: { id }, + }, + false, + ); + } + + async deleteExpired(): Promise { + return withErrorHandling( + async () => { + return executeWithRetry(async (dbInstance) => { + const now = new Date(); + const result = await dbInstance + .delete(authRequests) + .where(lt(authRequests.expiresAt, now)) + .returning(); + return result.length; + }, this.db); + }, + { + operationName: "delete expired auth requests", + }, + 0, + ); + } +} diff --git a/packages/shared-db/src/schema/auth.ts b/packages/shared-db/src/schema/auth.ts new file mode 100644 index 00000000..c2d27799 --- /dev/null +++ b/packages/shared-db/src/schema/auth.ts @@ -0,0 +1,45 @@ +import { + serial, + pgTable as table, + text, + timestamp, + index, +} from "drizzle-orm/pg-core"; +import { relations } from "drizzle-orm"; +import { users } from "./users"; +import { timestamps } from "./common"; +import { createInsertSchema } from "drizzle-zod"; +import { z } from "zod"; + +export const authRequests = table( + "auth_requests", + { + id: serial("id").primaryKey(), + nonce: text("nonce").notNull().unique(), + state: text("state").unique(), + accountId: text("account_id").notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + ...timestamps, + }, + (authRequests) => [ + index("auth_requests_account_id_created_at_idx").on( + authRequests.accountId, + authRequests.createdAt, + ), + ], +); + +export const authRequestsRelations = relations(authRequests, ({ one }) => ({ + user: one(users, { + fields: [authRequests.accountId], + references: [users.nearAccountId], + }), +})); + +export const insertAuthRequestSchema = createInsertSchema(authRequests, { + id: z.undefined(), + createdAt: z.undefined(), + updatedAt: z.undefined(), +}); + +export type InsertAuthRequest = z.infer; diff --git a/packages/shared-db/src/schema/index.ts b/packages/shared-db/src/schema/index.ts index 0663f5ad..1fe41ab6 100644 --- a/packages/shared-db/src/schema/index.ts +++ b/packages/shared-db/src/schema/index.ts @@ -6,3 +6,4 @@ export * from "./submissions"; export * from "./users"; export * from "./plugins"; export * from "./moderation"; +export * from "./auth"; diff --git a/packages/utils/src/errors.ts b/packages/utils/src/errors.ts index e29f2bed..b16af61f 100644 --- a/packages/utils/src/errors.ts +++ b/packages/utils/src/errors.ts @@ -1,4 +1,7 @@ -import { PluginErrorInterface, PluginErrorContext } from "@curatedotfun/types"; +import type { + PluginErrorInterface, + PluginErrorContext, +} from "@curatedotfun/types"; export enum PluginErrorCode { // General Plugin Errors diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index dad0db0e..49bbc161 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,2 +1 @@ -export * from "./plugins/index"; export * from "./errors"; diff --git a/packages/utils/src/plugins/index.ts b/packages/utils/src/plugins/index.ts deleted file mode 100644 index 644bcb69..00000000 --- a/packages/utils/src/plugins/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Converts a package name to a normalized remote name by removing the @ symbol - * and converting / to underscore. This is used for consistent remote package naming - * in build configurations and package management. - * - * @example - * ```ts - * getNormalizedRemoteName('@curatedotfun/telegram') // 'curatedotfun_telegram' - * getNormalizedRemoteName('@org/pkg-name') // 'org_pkg-name' - * getNormalizedRemoteName('simple-package') // 'simple-package' - * ``` - * - * @param packageName - The original package name (e.g. '@scope/package') - * @returns The normalized remote name (e.g. 'scope_package') - */ -export function getNormalizedRemoteName(packageName: string): string { - return packageName.toLowerCase().replace("@", "").replace("/", "_"); -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d87bcb86..bf13f791 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@curatedotfun/utils': specifier: workspace:* version: link:../../packages/utils + '@fastnear/utils': + specifier: ^0.9.7 + version: 0.9.7 '@hono/node-server': specifier: ^1.8.2 version: 1.14.4(hono@4.7.11) @@ -99,8 +102,8 @@ importers: specifier: ^5.1.1 version: 5.1.1(encoding@0.1.13) near-sign-verify: - specifier: ^0.3.6 - version: 0.3.8 + specifier: ^0.4.1 + version: 0.4.1 ora: specifier: ^8.1.1 version: 8.2.0 @@ -168,6 +171,9 @@ importers: '@crosspost/sdk': specifier: ^0.3.0 version: 0.3.0 + '@fastnear/utils': + specifier: ^0.9.7 + version: 0.9.7 '@hookform/resolvers': specifier: ^5.0.1 version: 5.1.1(react-hook-form@7.57.0(react@18.3.1)) @@ -245,7 +251,7 @@ importers: version: 4.1.0 fastintear: specifier: latest - version: 0.1.13 + version: 0.1.14 immer: specifier: ^10.1.1 version: 10.1.1 @@ -256,8 +262,8 @@ importers: specifier: ^0.483.0 version: 0.483.0(react@18.3.1) near-sign-verify: - specifier: ^0.3.6 - version: 0.3.8 + specifier: ^0.4.1 + version: 0.4.1 pinata-web3: specifier: ^0.5.4 version: 0.5.4 @@ -3298,8 +3304,8 @@ packages: fast-uri@3.0.6: resolution: {integrity: sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==} - fastintear@0.1.13: - resolution: {integrity: sha512-enWIBztIaGz814HTHtOSr0jHQOAWwY9VIvkE51Ai+M4JMMXiozRoI9K+N70XGoQFch8ntTgehopzXXTpm4X9vA==} + fastintear@0.1.14: + resolution: {integrity: sha512-fwZsvD8TLlBaMm1yFrltIrhzp1EH2cutiR+80+SozD5ugzmeI1/sEI5qghvBdOi+YPnPiNO90tAVm73rOTAJjg==} fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} @@ -4030,8 +4036,8 @@ packages: near-api-js@5.1.1: resolution: {integrity: sha512-h23BGSKxNv8ph+zU6snicstsVK1/CTXsQz4LuGGwoRE24Hj424nSe4+/1tzoiC285Ljf60kPAqRCmsfv9etF2g==} - near-sign-verify@0.3.8: - resolution: {integrity: sha512-GyA5ytiqEWTm3hX9iJRMbI9nHgh5GooYHi36d+EZh5L1WjQ6SKazx50b1ukrOh4NjilywSo2Ao3o4D8HaTGUzg==} + near-sign-verify@0.4.1: + resolution: {integrity: sha512-yQzYUetcv/KjOf7/GIqAfLsy6bQktJyRJQp6i9DCTgVL9jjMHtCD/e5EBqPYSI5iSdPGQ7a/GAcFDvvtV4X7mw==} negotiator@0.6.3: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} @@ -8329,7 +8335,7 @@ snapshots: fast-uri@3.0.6: {} - fastintear@0.1.13: + fastintear@0.1.14: dependencies: '@fastnear/utils': 0.9.7 '@noble/curves': 1.9.2 @@ -9057,7 +9063,7 @@ snapshots: transitivePeerDependencies: - encoding - near-sign-verify@0.3.8: + near-sign-verify@0.4.1: dependencies: '@noble/curves': 1.9.2 '@noble/hashes': 1.8.0 diff --git a/scripts/dev.sh b/scripts/dev.sh index 71725822..406868fd 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -39,7 +39,7 @@ trap cleanup SIGINT SIGTERM EXIT # Set DATABASE_URL environment variable for the dev process export DATABASE_URL="postgresql://postgres:postgres@localhost:5432/curatedotfun" -echo "Running pnpm install for monorepo dependencies (if not already done)..." +echo "Installing packages..." pnpm install # Ensure all host dependencies are installed for turbo to work, and for db scripts when volume is mounted. if [ "$SKIP_DB" = true ]; then