>(
+ ({ className, onClick, ...props }, ref) => {
+ return (
+ {
+ onClick?.(e);
+ }}
+ >
+
+ {config.label}
+ {config.Icon}
+
+
+ );
+ },
+ );
+
+ return (
+
+
+
+
+
+
+
+ {config.popoverTitle}
+
+ {config.popoverDescription && (
+ {config.popoverDescription}
+ )}
+ {lastRefreshed && (
+
+ Last checked:
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx
new file mode 100644
index 0000000..aae8f8b
--- /dev/null
+++ b/src/components/ui/badge.tsx
@@ -0,0 +1,48 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+import { Slot } from "radix-ui"
+
+import { cn } from "@/src/lib/utils"
+
+const badgeVariants = cva(
+ "inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
+ secondary:
+ "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
+ destructive:
+ "bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline:
+ "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+ ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+ link: "text-primary underline-offset-4 [a&]:hover:underline",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function Badge({
+ className,
+ variant = "default",
+ asChild = false,
+ ...props
+}: React.ComponentProps<"span"> &
+ VariantProps & { asChild?: boolean }) {
+ const Comp = asChild ? Slot.Root : "span"
+
+ return (
+
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx
new file mode 100644
index 0000000..c9cb4a0
--- /dev/null
+++ b/src/components/ui/popover.tsx
@@ -0,0 +1,89 @@
+"use client"
+
+import * as React from "react"
+import { Popover as PopoverPrimitive } from "radix-ui"
+
+import { cn } from "@/src/lib/utils"
+
+function Popover({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function PopoverTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function PopoverContent({
+ className,
+ align = "center",
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function PopoverAnchor({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
+ return (
+
+ )
+}
+
+function PopoverDescription({
+ className,
+ ...props
+}: React.ComponentProps<"p">) {
+ return (
+
+ )
+}
+
+export {
+ Popover,
+ PopoverTrigger,
+ PopoverContent,
+ PopoverAnchor,
+ PopoverHeader,
+ PopoverTitle,
+ PopoverDescription,
+}
diff --git a/src/controllers/ExternalDatabaseController.ts b/src/controllers/ExternalDatabaseController.ts
index 1e36eed..6aa54ac 100755
--- a/src/controllers/ExternalDatabaseController.ts
+++ b/src/controllers/ExternalDatabaseController.ts
@@ -1,67 +1,41 @@
import { Database, DatabaseInfo } from "../lib/types/database-types";
-import { ResponseToError } from "../lib/utils/errors";
-
-export async function LinkDatabase(
- teamId: string,
- data: DatabaseInfo,
-): Promise {
- const response = await fetch(`/api/teams/${teamId}/databases`, {
- method: "POST",
- body: JSON.stringify(data),
- });
-
- const responseData = await response.json();
- if (!response.ok) {
- console.log(responseData);
- throw ResponseToError(responseData);
- }
-
- return responseData as Database;
-}
-
-export async function ListDatabases(teamId: string): Promise {
- const response = await fetch(`/api/teams/${teamId}/databases`, {
- method: "GET",
- });
-
- const responseData = await response.json();
- if (!response.ok) {
- throw ResponseToError(responseData);
- }
-
- return responseData as Database[];
-}
-
-export async function DeleteDatabase(
- teamId: string,
- databaseId: string,
-): Promise {
- const response = await fetch(`/api/teams/${teamId}/databases/${databaseId}`, {
- method: "DELETE",
- });
-
- const responseData = await response.json();
- if (!response.ok) {
- throw ResponseToError(responseData);
- }
-
- return responseData as Database;
-}
-
-export async function RenameDatabase(
- teamId: string,
- databaseId: string,
- newName: string,
-): Promise {
- const response = await fetch(`/api/teams/${teamId}/databases/${databaseId}`, {
- method: "PATCH",
- body: JSON.stringify({ name: newName }),
- });
-
- const responseData = await response.json();
- if (!response.ok) {
- throw ResponseToError(responseData);
- }
-
- return responseData as Database;
-}
+import { fetcher } from "../lib/utils/api-utils";
+
+export const ExternalDatabaseController = {
+ link: (teamId: string, data: DatabaseInfo) =>
+ fetcher(`/api/teams/${teamId}/databases`, {
+ method: "POST",
+ body: JSON.stringify(data),
+ }),
+
+ list: (teamId: string) =>
+ fetcher(`/api/teams/${teamId}/databases`),
+
+ delete: (teamId: string, databaseId: string) =>
+ fetcher(`/api/teams/${teamId}/databases/${databaseId}`, {
+ method: "DELETE",
+ }),
+
+ rename: (teamId: string, databaseId: string, newName: string) =>
+ fetcher(`/api/teams/${teamId}/databases/${databaseId}`, {
+ method: "PATCH",
+ body: JSON.stringify({ name: newName }),
+ }),
+
+ rotate: (
+ teamId: string,
+ databaseId: string,
+ accessKey: string,
+ secretKey: string,
+ ) =>
+ fetcher(`/api/teams/${teamId}/databases/${databaseId}`, {
+ method: "POST",
+ body: JSON.stringify({ accessKey, secretKey }),
+ }),
+
+ refresh: (teamId: string, databaseId: string) =>
+ fetcher(
+ `/api/teams/${teamId}/databases/${databaseId}/refresh`,
+ { method: "POST" },
+ ),
+};
diff --git a/src/controllers/RobloxCredentialController.ts b/src/controllers/RobloxCredentialController.ts
new file mode 100644
index 0000000..bf012d4
--- /dev/null
+++ b/src/controllers/RobloxCredentialController.ts
@@ -0,0 +1,43 @@
+import {
+ RobloxCredential,
+ RobloxCredentialInfo,
+} from "../lib/types/roblox-credentials-types";
+import { fetcher } from "../lib/utils/api-utils";
+
+export const RobloxCredentialController = {
+ link: (teamId: string, data: RobloxCredentialInfo) =>
+ fetcher(`/api/teams/${teamId}/roblox-credentials`, {
+ method: "POST",
+ body: JSON.stringify(data),
+ }),
+
+ delete: (teamId: string, credId: string) =>
+ fetcher(
+ `/api/teams/${teamId}/roblox-credentials/${credId}`,
+ { method: "DELETE" },
+ ),
+
+ rename: (teamId: string, credId: string, newName: string) =>
+ fetcher(
+ `/api/teams/${teamId}/roblox-credentials/${credId}`,
+ { method: "PATCH", body: JSON.stringify({ name: newName }) },
+ ),
+
+ rotate: (teamId: string, credId: string, newKey: string) =>
+ fetcher(
+ `/api/teams/${teamId}/roblox-credentials/${credId}`,
+ {
+ method: "POST",
+ body: JSON.stringify({ key: newKey }),
+ },
+ ),
+
+ refresh: (teamId: string, credId: string) =>
+ fetcher(
+ `/api/teams/${teamId}/roblox-credentials/${credId}/refresh`,
+ { method: "POST" },
+ ),
+
+ list: (teamId: string) =>
+ fetcher(`/api/teams/${teamId}/roblox-credentials`),
+};
diff --git a/src/controllers/TeamController.ts b/src/controllers/TeamController.ts
index 9817370..68a2c09 100644
--- a/src/controllers/TeamController.ts
+++ b/src/controllers/TeamController.ts
@@ -1,97 +1,34 @@
import { Team, TeamMember, UserTeam } from "../lib/types/team-types";
-import { ResponseToError } from "../lib/utils/errors";
-
-export async function CreateTeam(name: string): Promise {
- const response = await fetch("/api/teams", {
- method: "POST",
- body: JSON.stringify({ name }),
- });
-
- const responseData = await response.json();
- if (!response.ok) {
- throw ResponseToError(responseData);
- }
-
- return responseData as UserTeam;
-}
-
-export async function DeleteTeam(teamId: string): Promise {
- const response = await fetch(`/api/teams/${teamId}`, { method: "DELETE" });
-
- const responseData = await response.json();
- if (!response.ok) {
- const error = ResponseToError(responseData);
- console.log(error);
- throw error;
- }
-
- return responseData as Team;
-}
-
-export async function ListTeams(): Promise {
- const response = await fetch(`/api/teams`, { method: "GET" });
-
- const responseData = await response.json();
- if (!response.ok) {
- throw ResponseToError(responseData);
- }
-
- return responseData as UserTeam[];
-}
-
-export async function RemoveTeamMember({
- memberId,
- teamId,
-}: {
- memberId: string;
- teamId: string;
-}): Promise {
- const response = await fetch(`/api/teams/${teamId}/members/${memberId}`, {
- method: "DELETE",
- });
-
- const responseData = await response.json();
- if (!response.ok) {
- throw ResponseToError(responseData);
- }
-
- return responseData as TeamMember;
-}
-
-export async function ResolveTeamBySlug(slug: string): Promise {
- const response = await fetch(`/api/teams/resolve-slug/${slug}`);
-
- const responseData = await response.json();
- if (!response.ok) {
- throw ResponseToError(responseData);
- }
- return responseData as UserTeam;
-}
-
-export async function ChangeTeamName(
- teamId: string,
- newName: { name?: string; displayName?: string },
-): Promise {
- const response = await fetch(`/api/teams/${teamId}`, {
- method: "PATCH",
- body: JSON.stringify(newName),
- });
-
- const responseData = await response.json();
- if (!response.ok) {
- throw ResponseToError(responseData);
- }
-
- return responseData as Team;
-}
-
-export async function ListTeamMembers(teamId: string): Promise {
- const response = await fetch(`/api/teams/${teamId}/members`);
-
- const responseData = await response.json();
- if (!response.ok) {
- throw ResponseToError(responseData);
- }
-
- return responseData as TeamMember[];
-}
+import { fetcher } from "../lib/utils/api-utils";
+
+export const TeamController = {
+ create: (name: string) =>
+ fetcher("/api/teams", {
+ method: "POST",
+ body: JSON.stringify({ name }),
+ }),
+
+ delete: (teamId: string) =>
+ fetcher(`/api/teams/${teamId}`, { method: "DELETE" }),
+
+ list: () => fetcher("/api/teams"),
+
+ resolve: (slug: string) =>
+ fetcher(`/api/teams/resolve-slug/${slug}`),
+
+ changeName: (
+ teamId: string,
+ newName: { name?: string; displayName?: string },
+ ) =>
+ fetcher(`/api/teams/${teamId}`, {
+ method: "PATCH",
+ body: JSON.stringify(newName),
+ }),
+
+ removeMember: (teamId: string, memberId: string) =>
+ fetcher(`/api/teams/${teamId}/members/${memberId}`, {
+ method: "DELETE",
+ }),
+ listMembers: (teamId: string) =>
+ fetcher(`/api/teams/${teamId}/members`),
+};
diff --git a/src/db/schema/database.ts b/src/db/schema/database.ts
index f23ba7f..6d275a2 100755
--- a/src/db/schema/database.ts
+++ b/src/db/schema/database.ts
@@ -8,6 +8,12 @@ export const database = sqliteTable("database", {
teamId: text("team_id")
.notNull()
.references(() => team.id, { onDelete: "cascade" }),
+
+ status: text("status", { enum: ["healthy", "warning", "error"] })
+ .notNull()
+ .default("healthy"),
+ errorMessage: text("error_message"),
+
type: text("type", { enum: ["S3"] })
.notNull()
.default("S3"),
@@ -24,6 +30,15 @@ export const database = sqliteTable("database", {
skIv: text("sk_iv").notNull(),
skTag: text("sk_tag").notNull(),
+ lastUsed: integer("last_used", { mode: "timestamp_ms" })
+ .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
+ .notNull(),
+ lastRefreshedAt: integer("last_refreshed_at", {
+ mode: "timestamp_ms",
+ })
+ .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
+ .notNull(),
+
createdAt: integer("createdAt", { mode: "timestamp_ms" })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts
index ce40bfa..2b31b5f 100644
--- a/src/db/schema/index.ts
+++ b/src/db/schema/index.ts
@@ -6,3 +6,4 @@ export * from "./two_factor";
export * from "./database";
export * from "./team";
export * from "./team_member";
+export * from "./roblox_credentials";
diff --git a/src/db/schema/roblox_credentials.ts b/src/db/schema/roblox_credentials.ts
new file mode 100644
index 0000000..22d9b0c
--- /dev/null
+++ b/src/db/schema/roblox_credentials.ts
@@ -0,0 +1,41 @@
+import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
+import { team } from "./team";
+import { sql } from "drizzle-orm";
+import { user } from "./user";
+
+export const roblox_credentials = sqliteTable("roblox_credentials", {
+ id: text("id").primaryKey(),
+ teamId: text("team_id")
+ .notNull()
+ .references(() => team.id, { onDelete: "cascade" }),
+ name: text("name").notNull(),
+
+ status: text("status", { enum: ["healthy", "warning", "error"] })
+ .notNull()
+ .default("healthy"),
+ errorMessage: text("error_message"),
+
+ keyCiphertext: text("key_ciphertext").notNull(),
+ keyIv: text("key_iv").notNull(),
+ keyTag: text("key_tag").notNull(),
+
+ keyOwnerRobloxId: integer("key_owner_roblox_id").notNull(),
+
+ expirationDate: integer("expiration_date", {
+ mode: "timestamp_ms",
+ }),
+ lastUsed: integer("last_used", { mode: "timestamp_ms" })
+ .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
+ .notNull(),
+ lastRefreshedAt: integer("last_refreshed_at", {
+ mode: "timestamp_ms",
+ })
+ .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
+ .notNull(),
+ createdAt: integer("created_at", { mode: "timestamp_ms" })
+ .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
+ .notNull(),
+ createdBy: text("created_by").references(() => user.id, {
+ onDelete: "set null",
+ }),
+});
diff --git a/src/hooks/useDatabase.ts b/src/hooks/useDatabase.ts
index ad53d53..6cf82b2 100644
--- a/src/hooks/useDatabase.ts
+++ b/src/hooks/useDatabase.ts
@@ -1,15 +1,10 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTeam } from "./useTeam";
-import {
- DeleteDatabase,
- LinkDatabase,
- ListDatabases,
- RenameDatabase,
-} from "../controllers/ExternalDatabaseController";
import { Database, DatabaseInfo } from "../lib/types/database-types";
import { useEffect } from "react";
import { hasPermission } from "../lib/utils/team-utils";
import { useRouter } from "next/navigation";
+import { ExternalDatabaseController } from "../controllers/ExternalDatabaseController";
export function useDatabases() {
const {
@@ -21,16 +16,11 @@ export function useDatabases() {
const query = useQuery({
queryKey: ["databases", team?.id],
- queryFn: () => ListDatabases(team!.id),
+ queryFn: () => ExternalDatabaseController.list(team!.id),
enabled: !!team?.id,
+ staleTime: 5 * 60 * 1000,
});
- useEffect(() => {
- if (team && !hasPermission(team.role, "ListDatabases")) {
- router.replace(`/dashboard/${team.slug}/`);
- }
- }, [team]);
-
const isLoading = isLoadingTeam || query.isLoading;
const isError = isErrorTeam || query.isError;
@@ -46,7 +36,7 @@ export function useDatabaseMutations() {
const createDatabase = useMutation({
mutationFn: ({ teamId, data }: { teamId: string; data: DatabaseInfo }) =>
- LinkDatabase(teamId, data),
+ ExternalDatabaseController.link(teamId, data),
onSuccess: (database, variables) => {
queryClient.setQueryData(
["databases", variables.teamId],
@@ -65,7 +55,7 @@ export function useDatabaseMutations() {
}: {
teamId: string;
databaseId: string;
- }) => DeleteDatabase(teamId, databaseId),
+ }) => ExternalDatabaseController.delete(teamId, databaseId),
onSuccess: (database, variables) => {
queryClient.setQueryData(
["databases", variables.teamId],
@@ -86,7 +76,49 @@ export function useDatabaseMutations() {
teamId: string;
databaseId: string;
newName: string;
- }) => RenameDatabase(teamId, databaseId, newName),
+ }) => ExternalDatabaseController.rename(teamId, databaseId, newName),
+ onSuccess: (database, variables) => {
+ queryClient.setQueryData(
+ ["databases", variables.teamId],
+ (prevData) => {
+ if (!prevData) return [database];
+ return prevData.map((db) => (db.id === database.id ? database : db));
+ },
+ );
+ },
+ });
+
+ const refreshDatabase = useMutation({
+ mutationFn: ({
+ teamId,
+ databaseId,
+ }: {
+ teamId: string;
+ databaseId: string;
+ }) => ExternalDatabaseController.refresh(teamId, databaseId),
+ onSuccess: (database, variables) => {
+ queryClient.setQueryData(
+ ["databases", variables.teamId],
+ (prevData) => {
+ if (!prevData) return [database];
+ return prevData.map((db) => (db.id === database.id ? database : db));
+ },
+ );
+ },
+ });
+
+ const rotateDatabase = useMutation({
+ mutationFn: ({
+ teamId,
+ databaseId,
+ accessKey,
+ secretKey,
+ }: {
+ teamId: string;
+ databaseId: string;
+ accessKey: string;
+ secretKey: string;
+ }) => ExternalDatabaseController.rotate(teamId, databaseId, accessKey, secretKey),
onSuccess: (database, variables) => {
queryClient.setQueryData(
["databases", variables.teamId],
@@ -102,5 +134,7 @@ export function useDatabaseMutations() {
createDatabase,
deleteDatabase,
renameDatabase,
+ refreshDatabase,
+ rotateDatabase,
};
}
diff --git a/src/hooks/useMember.ts b/src/hooks/useMember.ts
index feeab3e..c4d9835 100644
--- a/src/hooks/useMember.ts
+++ b/src/hooks/useMember.ts
@@ -1,7 +1,7 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useTeam } from "./useTeam";
-import { ListTeamMembers } from "../controllers/TeamController";
+import { TeamController } from "../controllers/TeamController";
export function useMembers() {
const {
@@ -12,8 +12,9 @@ export function useMembers() {
const query = useQuery({
queryKey: ["members", team?.id],
- queryFn: () => ListTeamMembers(team!.id),
+ queryFn: () => TeamController.listMembers(team!.id),
enabled: !!team?.id,
+ staleTime: 5 * 60 * 1000,
});
const isLoading = isLoadingTeam || query.isLoading;
diff --git a/src/hooks/useRobloxCredential.ts b/src/hooks/useRobloxCredential.ts
new file mode 100644
index 0000000..6897ae9
--- /dev/null
+++ b/src/hooks/useRobloxCredential.ts
@@ -0,0 +1,142 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useTeam } from "./useTeam";
+import { RobloxCredentialController } from "../controllers/RobloxCredentialController";
+import { useEffect } from "react";
+import { hasPermission } from "../lib/utils/team-utils";
+import { useRouter } from "next/navigation";
+import {
+ RobloxCredential,
+ RobloxCredentialInfo,
+} from "../lib/types/roblox-credentials-types";
+
+export function useRobloxCredentials() {
+ const router = useRouter();
+ const {
+ data: team,
+ isLoading: isTeamLoading,
+ isError: isTeamError,
+ } = useTeam();
+
+ const query = useQuery({
+ queryKey: ["robloxCredentials", team?.id],
+ queryFn: () => RobloxCredentialController.list(team!.id),
+ enabled: !!team?.id,
+ staleTime: 5 * 60 * 1000,
+ });
+
+ const isLoading = isTeamLoading || query.isLoading;
+ const isError = isTeamError || query.isError;
+
+ return {
+ ...query,
+ isLoading,
+ isError,
+ };
+}
+
+export function useRobloxCredentialMutations() {
+ const queryClient = useQueryClient();
+
+ const linkRobloxCredential = useMutation({
+ mutationFn: ({
+ teamId,
+ data,
+ }: {
+ teamId: string;
+ data: RobloxCredentialInfo;
+ }) => RobloxCredentialController.link(teamId, data),
+ onSuccess: (newCred, variables) => {
+ queryClient.setQueryData(
+ ["robloxCredentials", variables.teamId],
+ (prevData) => {
+ if (!prevData) return [newCred];
+ return [...prevData, newCred];
+ },
+ );
+ },
+ });
+
+ const deleteRobloxCredential = useMutation({
+ mutationFn: ({ teamId, credId }: { teamId: string; credId: string }) =>
+ RobloxCredentialController.delete(teamId, credId),
+ onSuccess: (oldCred, variables) => {
+ queryClient.setQueryData(
+ ["robloxCredentials", variables.teamId],
+ (prevData) => {
+ if (!prevData) return prevData;
+ return prevData.filter((cred) => cred.id !== oldCred.id);
+ },
+ );
+ },
+ });
+
+ const renameRobloxCredential = useMutation({
+ mutationFn: ({
+ teamId,
+ credId,
+ newName,
+ }: {
+ teamId: string;
+ credId: string;
+ newName: string;
+ }) => RobloxCredentialController.rename(teamId, credId, newName),
+ onSuccess: (renamedCred, variables) => {
+ queryClient.setQueryData(
+ ["robloxCredentials", variables.teamId],
+ (prevData) => {
+ if (!prevData) return [renamedCred];
+ return prevData.map((cred) =>
+ cred.id == renamedCred.id ? renamedCred : cred,
+ );
+ },
+ );
+ },
+ });
+
+ const rotateRobloxCredential = useMutation({
+ mutationFn: ({
+ teamId,
+ credId,
+ newKey,
+ }: {
+ teamId: string;
+ credId: string;
+ newKey: string;
+ }) => RobloxCredentialController.rotate(teamId, credId, newKey),
+ onSuccess: (rotatedCred, variables) => {
+ queryClient.setQueryData(
+ ["robloxCredentials", variables.teamId],
+ (prevData) => {
+ if (!prevData) return [rotatedCred];
+ return prevData.map((cred) =>
+ cred.id == rotatedCred.id ? rotatedCred : cred,
+ );
+ },
+ );
+ },
+ });
+
+ const refreshRobloxCredential = useMutation({
+ mutationFn: ({ teamId, credId }: { teamId: string; credId: string }) =>
+ RobloxCredentialController.refresh(teamId, credId),
+ onSuccess: (refreshedCred, variables) => {
+ queryClient.setQueryData(
+ ["robloxCredentials", variables.teamId],
+ (prevData) => {
+ if (!prevData) return [refreshedCred];
+ return prevData.map((cred) =>
+ cred.id === refreshedCred.id ? refreshedCred : cred,
+ );
+ },
+ );
+ },
+ });
+
+ return {
+ linkRobloxCredential,
+ deleteRobloxCredential,
+ renameRobloxCredential,
+ rotateRobloxCredential,
+ refreshRobloxCredential,
+ };
+}
diff --git a/src/hooks/useTeam.ts b/src/hooks/useTeam.ts
index d400bc8..c6796e7 100644
--- a/src/hooks/useTeam.ts
+++ b/src/hooks/useTeam.ts
@@ -1,15 +1,9 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useParams, useRouter } from "next/navigation";
-import {
- ChangeTeamName,
- CreateTeam,
- DeleteTeam,
- ListTeams,
- ResolveTeamBySlug,
-} from "../controllers/TeamController";
import { useEffect } from "react";
import { UserTeam } from "../lib/types/team-types";
+import { TeamController } from "../controllers/TeamController";
export function useTeam() {
const { teamSlug } = useParams();
@@ -17,8 +11,9 @@ export function useTeam() {
const query = useQuery({
queryKey: ["team", teamSlug],
- queryFn: () => ResolveTeamBySlug(teamSlug as string),
+ queryFn: () => TeamController.resolve(teamSlug as string),
enabled: !!teamSlug,
+ staleTime: 5 * 60 * 1000,
retry: false,
});
@@ -34,7 +29,8 @@ export function useTeam() {
export function useTeams() {
return useQuery({
queryKey: ["teams"],
- queryFn: ListTeams,
+ queryFn: () => TeamController.list(),
+ staleTime: 5 * 60 * 1000,
});
}
@@ -43,7 +39,7 @@ export function useTeamMutations() {
const router = useRouter();
const createTeam = useMutation({
- mutationFn: (name: string) => CreateTeam(name),
+ mutationFn: (name: string) => TeamController.create(name),
onSuccess: (team) => {
// Add the newly created team to the teams list
queryClient.setQueryData(["teams"], (prevData) => {
@@ -54,7 +50,7 @@ export function useTeamMutations() {
});
const deleteTeam = useMutation({
- mutationFn: (teamId: string) => DeleteTeam(teamId),
+ mutationFn: (teamId: string) => TeamController.delete(teamId),
onSuccess: (oldTeam) => {
// Remove old team from the teams list
queryClient.setQueryData(["teams"], (prevData) => {
@@ -74,7 +70,7 @@ export function useTeamMutations() {
}: {
teamId: string;
payload: { name?: string; displayName?: string };
- }) => ChangeTeamName(teamId, payload),
+ }) => TeamController.changeName(teamId, payload),
onSuccess: (newTeam, variables) => {
const cachedTeam = queryClient.getQueryData(["teams"]);
const oldTeam = cachedTeam?.find((team) => team.id === variables.teamId);
diff --git a/src/lib/config.ts b/src/lib/config.ts
new file mode 100644
index 0000000..8e1e2d3
--- /dev/null
+++ b/src/lib/config.ts
@@ -0,0 +1,11 @@
+export const TEAM_LIMITS = {
+ free: {
+ maxMembers: 5,
+ },
+};
+
+export const USER_LIMITS = {
+ free: {
+ maxOwnedTeams: 3,
+ },
+};
diff --git a/src/lib/types/database-types.ts b/src/lib/types/database-types.ts
index 1d76943..8e5d890 100644
--- a/src/lib/types/database-types.ts
+++ b/src/lib/types/database-types.ts
@@ -1,18 +1,42 @@
import { database } from "@/src/db/schema";
import { InferDrizzleSelect } from "../utils";
+import z from "zod";
+
+export type DatabaseStatus = typeof database.$inferSelect.status;
+
+export const database_status = {
+ connection_failed: "Could not connect to the S3 bucket",
+ invalid_credentials: "Invalid credentials or insufficient bucket permissions",
+ bucket_not_found: "The specified bucket does not exist or is not accessible",
+ endpoint_unreachable: "The endpoint URL is unreachable",
+ rate_limited: "The S3 endpoint is rate limiting requests",
+ s3_down: "The S3 service or endpoint is currently unavailable",
+};
export const DatabaseSelect = {
id: database.id,
teamId: database.teamId,
- createdBy: database.createdBy,
+
+ status: database.status,
+ errorMessage: database.errorMessage,
+
name: database.name,
endpoint: database.endpoint,
region: database.region,
type: database.type,
+
+ createdBy: database.createdBy,
createdAt: database.createdAt,
+ lastUsed: database.lastUsed,
+ lastRefreshedAt: database.lastRefreshedAt,
};
export type Database = InferDrizzleSelect;
+export const DatabaseRotateSchema = z.object({
+ accessKey: z.string().min(1).max(256),
+ secretKey: z.string().min(1).max(256),
+});
+
export const DatabaseInfo = {
type: database.type,
name: database.name,
diff --git a/src/lib/types/roblox-credentials-types.ts b/src/lib/types/roblox-credentials-types.ts
new file mode 100644
index 0000000..7589547
--- /dev/null
+++ b/src/lib/types/roblox-credentials-types.ts
@@ -0,0 +1,62 @@
+import { roblox_credentials } from "@/src/db/schema/roblox_credentials";
+import { InferDrizzleSelect } from "../utils";
+import z from "zod";
+
+export type RobloxCredentialStatus =
+ typeof roblox_credentials.$inferSelect.status;
+
+export const RobloxCredentialSelect = {
+ id: roblox_credentials.id,
+ teamId: roblox_credentials.teamId,
+ status: roblox_credentials.status,
+ errorMessage: roblox_credentials.errorMessage,
+ name: roblox_credentials.name,
+ expirationDate: roblox_credentials.expirationDate,
+ keyOwnerRobloxId: roblox_credentials.keyOwnerRobloxId,
+ createdAt: roblox_credentials.createdAt,
+ lastUsed: roblox_credentials.lastUsed,
+ lastRefreshedAt: roblox_credentials.lastRefreshedAt,
+};
+export type RobloxCredential = InferDrizzleSelect<
+ typeof RobloxCredentialSelect
+>;
+
+export const RobloxCredentialInfo = {
+ name: roblox_credentials.name,
+ key: roblox_credentials.keyCiphertext,
+};
+export type RobloxCredentialInfo = InferDrizzleSelect<
+ typeof RobloxCredentialInfo
+>;
+export const RobloxCredentialInfoSchema = z.object({
+ name: z
+ .string()
+ .min(3, { error: "Name must be at least 3 characters" })
+ .max(32, { error: "Name must be at most 32 characters" }),
+ key: z
+ .string()
+ .min(1, { error: "Key must contain at least 1 character" })
+ .max(2048, { error: "Key must be at most 2048 characters" }),
+});
+
+export const RobloxCredentialRenameSchema = z.object({
+ name: z
+ .string()
+ .min(3, { error: "Name must be at least 3 characters" })
+ .max(32, { error: "Name must be at most 32 characters" }),
+});
+
+export const RobloxCredentialRotateSchema = z.object({
+ key: z
+ .string()
+ .min(1, { error: "Key must contain at least 1 character" })
+ .max(2048, { error: "Key must be at most 2048 characters" }),
+});
+
+export const roblox_credential_status = {
+ expires_soon: "This key expires in less than a week",
+ expired: "This key has reached its expiration date",
+ disabled: "This key is disabled in the Roblox Creator Dashboard",
+ rate_limit: "Roblox is rate-limiting this key",
+ roblox_down: "Roblox servers are currently unreachable",
+};
diff --git a/src/lib/utils/errors.ts b/src/lib/utils/api-utils.ts
similarity index 78%
rename from src/lib/utils/errors.ts
rename to src/lib/utils/api-utils.ts
index 1f00ca3..0c37ad2 100644
--- a/src/lib/utils/errors.ts
+++ b/src/lib/utils/api-utils.ts
@@ -1,4 +1,7 @@
import { NextResponse } from "next/server";
+import { createLogger } from "./logger";
+
+const logger = createLogger("api-utils");
export type ErrorResponse = {
code: string;
@@ -28,7 +31,7 @@ export function ErrorToNextResponse(error: unknown): NextResponse {
);
}
- console.error("Unhandled server error:", (error as any).message ?? error);
+ logger.error("Unhandled server error", error);
return NextResponse.json(
{ error: "Unknown server error. Please try again later." },
{ status: 500 },
@@ -56,3 +59,17 @@ export const AuthenticationRequired = new ApiError(
"AuthenticationRequired",
"You must reauthenticate to perform this action",
);
+
+export async function fetcher(
+ url: string,
+ options?: RequestInit,
+): Promise {
+ const response = await fetch(url, options);
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw ResponseToError(data);
+ }
+
+ return data as T;
+}
diff --git a/src/lib/utils/logger.ts b/src/lib/utils/logger.ts
new file mode 100644
index 0000000..78c3b3a
--- /dev/null
+++ b/src/lib/utils/logger.ts
@@ -0,0 +1,54 @@
+type LogLevel = "info" | "warn" | "error" | "debug";
+
+interface LogContext {
+ teamId?: string;
+ actorId?: string;
+ resourceId?: string;
+ operation?: string;
+ [key: string]: string | undefined;
+}
+
+function formatContext(context?: LogContext): string {
+ if (!context) return "";
+ const parts = Object.entries(context)
+ .filter(([, v]) => v !== undefined)
+ .map(([k, v]) => `${k}=${v}`);
+ return parts.length > 0 ? " | " + parts.join(" ") : "";
+}
+
+function log(
+ level: LogLevel,
+ service: string,
+ message: string,
+ error?: unknown,
+ context?: LogContext,
+): void {
+ const timestamp = new Date().toISOString();
+ const tag = `[${level.toUpperCase()}] [${service}]`;
+ const ctx = formatContext(context);
+
+ if (error !== undefined) {
+ const name = error instanceof Error ? error.name : "UnknownError";
+ const msg = error instanceof Error ? error.message : String(error);
+ console.log(`${timestamp} ${tag} ${message} | errorName=${name} errorMessage=${msg}${ctx}`);
+ } else {
+ console.log(`${timestamp} ${tag} ${message}${ctx}`);
+ }
+}
+
+export function createLogger(service: string) {
+ return {
+ info(message: string, context?: LogContext) {
+ log("info", service, message, undefined, context);
+ },
+ warn(message: string, context?: LogContext) {
+ log("warn", service, message, undefined, context);
+ },
+ error(message: string, error?: unknown, context?: LogContext) {
+ log("error", service, message, error, context);
+ },
+ debug(message: string, context?: LogContext) {
+ log("debug", service, message, undefined, context);
+ },
+ };
+}
diff --git a/src/lib/utils/team-utils.ts b/src/lib/utils/team-utils.ts
index 08f7153..10ea171 100644
--- a/src/lib/utils/team-utils.ts
+++ b/src/lib/utils/team-utils.ts
@@ -20,6 +20,15 @@ export const TEAM_PERMISSIONS = {
LinkDatabase: ROLES_RANK.admin,
DeleteDatabase: ROLES_RANK.admin,
RenameDatabase: ROLES_RANK.admin,
+ RefreshDatabase: ROLES_RANK.admin,
+ RotateDatabaseCredentials: ROLES_RANK.admin,
+
+ ListRobloxCredentials: ROLES_RANK.viewer,
+ LinkRobloxCredential: ROLES_RANK.admin,
+ DeleteRobloxCredential: ROLES_RANK.admin,
+ RenameRobloxCredential: ROLES_RANK.admin,
+ RotateRobloxCredential: ROLES_RANK.admin,
+ RefreshRobloxCredential: ROLES_RANK.admin,
} as const satisfies Record;
export type TeamAction = keyof typeof TEAM_PERMISSIONS;
diff --git a/src/lib/utils/url-utils.ts b/src/lib/utils/url-utils.ts
new file mode 100644
index 0000000..c755ad8
--- /dev/null
+++ b/src/lib/utils/url-utils.ts
@@ -0,0 +1,23 @@
+const PRIVATE_IP_RE = [
+ /^localhost$/i,
+ /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/,
+ /^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/,
+ /^172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}$/,
+ /^192\.168\.\d{1,3}\.\d{1,3}$/,
+ /^169\.254\.\d{1,3}\.\d{1,3}$/,
+ /^0\.\d{1,3}\.\d{1,3}\.\d{1,3}$/,
+];
+
+export function isSafeEndpointUrl(raw: string): boolean {
+ let parsed: URL;
+ try {
+ parsed = new URL(raw);
+ } catch {
+ return false;
+ }
+
+ if (parsed.protocol !== "https:") return false;
+
+ const host = parsed.hostname;
+ return !PRIVATE_IP_RE.some((re) => re.test(host));
+}
diff --git a/src/services/ExternalDatabaseService.ts b/src/services/ExternalDatabaseService.ts
index 4214a04..979083d 100755
--- a/src/services/ExternalDatabaseService.ts
+++ b/src/services/ExternalDatabaseService.ts
@@ -2,12 +2,20 @@ import { and, eq } from "drizzle-orm";
import { db } from "../db";
import { database } from "../db/schema";
import { HeadBucketCommand, S3Client } from "@aws-sdk/client-s3";
-import { EncryptString256 } from "../lib/crypto/aes";
+import { DecryptString256, EncryptString256 } from "../lib/crypto/aes";
import { randomUUID } from "crypto";
-import { AccessDenied, ApiError, DatabaseError } from "../lib/utils/errors";
-import { Database, DatabaseSelect } from "../lib/types/database-types";
+import { AccessDenied, ApiError, DatabaseError } from "../lib/utils/api-utils";
+import {
+ Database,
+ DatabaseSelect,
+ DatabaseStatus,
+ database_status,
+} from "../lib/types/database-types";
import { TeamService } from "./TeamService";
import { hasPermission } from "../lib/utils/team-utils";
+import { createLogger } from "../lib/utils/logger";
+
+const logger = createLogger("ExternalDatabaseService");
export interface DatabaseCredentials {
AccessKeyID: string;
@@ -24,6 +32,175 @@ const InvalidS3Credentials = new ApiError(
);
export const ExternalDatabaseService = {
+ // Internal Methods
+ async _setDbStatus(
+ databaseId: string,
+ info: { kind: DatabaseStatus; message?: string },
+ tx?: any,
+ ) {
+ const client = tx ?? db;
+ return await client
+ .update(database)
+ .set({
+ status: info.kind,
+ errorMessage: info.kind == "healthy" ? null : (info.message ?? null),
+ })
+ .where(eq(database.id, databaseId));
+ },
+
+ async _useDatabase(
+ databaseId: string,
+ teamId: string,
+ callback: (
+ creds: DatabaseCredentials,
+ setStatus: (kind: DatabaseStatus, message?: string) => Promise,
+ tx: Parameters[0]>[0],
+ ) => T | Promise,
+ ): Promise {
+ return await db.transaction(async (tx) => {
+ let dbInfo: typeof database.$inferSelect;
+ try {
+ [dbInfo] = await tx
+ .select()
+ .from(database)
+ .where(and(eq(database.id, databaseId), eq(database.teamId, teamId)));
+ } catch {
+ throw DatabaseError;
+ }
+
+ if (!dbInfo) {
+ throw AccessDenied;
+ }
+
+ let decryptedAk: string;
+ let decryptedSk: string;
+ try {
+ decryptedAk = DecryptString256({
+ encryptedData: dbInfo.akCiphertext,
+ initializationVector: dbInfo.akIv,
+ authTag: dbInfo.akTag,
+ });
+ decryptedSk = DecryptString256({
+ encryptedData: dbInfo.skCiphertext,
+ initializationVector: dbInfo.skIv,
+ authTag: dbInfo.skTag,
+ });
+ } catch {
+ throw new ApiError(
+ 500,
+ "DATABASE_CRED_DECRYPTION_ERROR",
+ "There was an error while accessing your database credentials. If the problem persists, please contact support.",
+ );
+ }
+
+ const setStatus = async (kind: DatabaseStatus, message?: string) => {
+ try {
+ await this._setDbStatus(databaseId, { kind, message }, tx);
+ } catch {
+ throw DatabaseError;
+ }
+ };
+
+ try {
+ await tx
+ .update(database)
+ .set({ lastUsed: new Date() })
+ .where(eq(database.id, databaseId));
+ } catch {
+ throw DatabaseError;
+ }
+
+ return await callback(
+ {
+ AccessKeyID: decryptedAk,
+ SecretAccessKey: decryptedSk,
+ EndpointURL: dbInfo.endpoint,
+ Region: dbInfo.region,
+ BucketName: dbInfo.bucketName,
+ },
+ setStatus,
+ tx,
+ );
+ });
+ },
+
+ async _introspectDatabase(
+ creds: DatabaseCredentials,
+ ): Promise<{ status: DatabaseStatus; message?: string }> {
+ const s3Client = new S3Client({
+ region: creds.Region,
+ endpoint: creds.EndpointURL,
+ credentials: {
+ accessKeyId: creds.AccessKeyID,
+ secretAccessKey: creds.SecretAccessKey,
+ },
+ forcePathStyle: true,
+ });
+
+ try {
+ await s3Client.send(new HeadBucketCommand({ Bucket: creds.BucketName }));
+ return { status: "healthy" };
+ } catch (error: any) {
+ const httpStatus = error.$metadata?.httpStatusCode;
+ const errorName = error.name;
+
+ if (
+ errorName === "SlowDown" ||
+ errorName === "ThrottlingException" ||
+ httpStatus === 429
+ ) {
+ return { status: "warning", message: database_status.rate_limited };
+ } else if (httpStatus === 503) {
+ return { status: "warning", message: database_status.s3_down };
+ } else if (
+ httpStatus === 403 ||
+ errorName === "AccessDenied" ||
+ errorName === "Forbidden"
+ ) {
+ return {
+ status: "error",
+ message: database_status.invalid_credentials,
+ };
+ } else if (httpStatus === 404 || errorName === "NoSuchBucket") {
+ return { status: "error", message: database_status.bucket_not_found };
+ } else if (
+ error.code === "ENOTFOUND" ||
+ error.code === "ECONNREFUSED" ||
+ error.code === "ETIMEDOUT"
+ ) {
+ return {
+ status: "error",
+ message: database_status.endpoint_unreachable,
+ };
+ } else {
+ return { status: "error", message: database_status.connection_failed };
+ }
+ }
+ },
+
+ async _applyIntrospectResults(
+ databaseId: string,
+ result: { status: DatabaseStatus; message?: string },
+ tx?: any,
+ ): Promise {
+ const client = tx ?? db;
+ try {
+ const [updated] = await client
+ .update(database)
+ .set({
+ status: result.status,
+ errorMessage:
+ result.status === "healthy" ? null : (result.message ?? null),
+ lastRefreshedAt: new Date(),
+ })
+ .where(eq(database.id, databaseId))
+ .returning(DatabaseSelect);
+ return updated;
+ } catch {
+ throw DatabaseError;
+ }
+ },
+
async CreateS3Client(Creds: DatabaseCredentials): Promise {
const s3Client = new S3Client({
region: Creds.Region,
@@ -56,7 +233,14 @@ export const ExternalDatabaseService = {
}
// Check if credentials are valid
- await this.CreateS3Client(creds); // Will throw an error if credentials are invalid
+ const introspectResult = await this._introspectDatabase(creds);
+ if (introspectResult.status === "error") {
+ throw new ApiError(
+ 400,
+ "DATABASE_UNREACHABLE",
+ introspectResult.message ?? database_status.connection_failed,
+ );
+ }
// Encrypt sensitive data
const encodedAkData = EncryptString256(creds.AccessKeyID);
@@ -76,6 +260,9 @@ export const ExternalDatabaseService = {
endpoint: creds.EndpointURL,
region: creds.Region,
+ status: introspectResult.status,
+ errorMessage: introspectResult.message ?? null,
+
akCiphertext: encodedAkData.encryptedData,
akIv: encodedAkData.initializationVector,
akTag: encodedAkData.authTag,
@@ -88,7 +275,7 @@ export const ExternalDatabaseService = {
return newRecord;
} catch (error) {
- console.error("Database insertion failed: ", error);
+ logger.error("Database insertion failed", error, { teamId, operation: "LinkDatabase" });
throw DatabaseError;
}
},
@@ -107,7 +294,7 @@ export const ExternalDatabaseService = {
return results;
} catch (error) {
- console.error("Error while fetching user's databases: ", error);
+ logger.error("Failed to fetch databases", error, { teamId, operation: "ListDatabase" });
throw DatabaseError;
}
},
@@ -130,7 +317,8 @@ export const ExternalDatabaseService = {
if (!result) throw AccessDenied;
return result;
} catch (error) {
- console.error(`Failed to delete database ${databaseId}: `, error);
+ if (error instanceof ApiError) throw error;
+ logger.error("Failed to delete database", error, { resourceId: databaseId, operation: "DeleteDatabase" });
throw DatabaseError;
}
},
@@ -155,8 +343,86 @@ export const ExternalDatabaseService = {
if (!result) throw AccessDenied;
return result;
} catch (error) {
- console.error(`Failed to rename database ${databaseId}: `, error);
+ if (error instanceof ApiError) throw error;
+ logger.error("Failed to rename database", error, { resourceId: databaseId, operation: "RenameDatabase" });
throw DatabaseError;
}
},
+
+ async RefreshDatabase(
+ actorId: string,
+ teamId: string,
+ databaseId: string,
+ ): Promise {
+ const actorRole = await TeamService.GetTeamUserRole(actorId, teamId);
+ if (!hasPermission(actorRole, "RefreshDatabase")) {
+ throw AccessDenied;
+ }
+
+ return await this._useDatabase(
+ databaseId,
+ teamId,
+ async (creds, _setStatus, tx) => {
+ const updated = await this._applyIntrospectResults(
+ databaseId,
+ await this._introspectDatabase(creds),
+ tx,
+ );
+ if (!updated) throw DatabaseError;
+ return updated;
+ },
+ );
+ },
+
+ async RotateDatabaseCredentials(
+ actorId: string,
+ teamId: string,
+ databaseId: string,
+ newCreds: Pick,
+ ): Promise {
+ const actorRole = await TeamService.GetTeamUserRole(actorId, teamId);
+ if (!hasPermission(actorRole, "RotateDatabaseCredentials")) {
+ throw AccessDenied;
+ }
+
+ return await this._useDatabase(databaseId, teamId, async (creds, _setStatus, tx) => {
+ const fullCreds: DatabaseCredentials = {
+ ...creds,
+ AccessKeyID: newCreds.AccessKeyID,
+ SecretAccessKey: newCreds.SecretAccessKey,
+ };
+
+ const introspectResult = await this._introspectDatabase(fullCreds);
+ if (introspectResult.status === "error") {
+ throw new ApiError(
+ 400,
+ "DATABASE_UNREACHABLE",
+ introspectResult.message ?? database_status.connection_failed,
+ );
+ }
+
+ const encodedAkData = EncryptString256(newCreds.AccessKeyID);
+ const encodedSkData = EncryptString256(newCreds.SecretAccessKey);
+
+ try {
+ const [updated] = await tx
+ .update(database)
+ .set({
+ akCiphertext: encodedAkData.encryptedData,
+ akIv: encodedAkData.initializationVector,
+ akTag: encodedAkData.authTag,
+ skCiphertext: encodedSkData.encryptedData,
+ skIv: encodedSkData.initializationVector,
+ skTag: encodedSkData.authTag,
+ })
+ .where(eq(database.id, databaseId))
+ .returning(DatabaseSelect);
+ if (!updated) throw AccessDenied;
+ return await this._applyIntrospectResults(databaseId, introspectResult, tx);
+ } catch (error) {
+ if (error instanceof ApiError) throw error;
+ throw DatabaseError;
+ }
+ });
+ },
};
diff --git a/src/services/RobloxCredentialsService.ts b/src/services/RobloxCredentialsService.ts
new file mode 100644
index 0000000..6854244
--- /dev/null
+++ b/src/services/RobloxCredentialsService.ts
@@ -0,0 +1,400 @@
+import { randomUUID } from "crypto";
+import { db } from "../db";
+import { roblox_credentials } from "../db/schema/roblox_credentials";
+import { DecryptString256, EncryptString256 } from "../lib/crypto/aes";
+import {
+ roblox_credential_status,
+ RobloxCredential,
+ RobloxCredentialInfo,
+ RobloxCredentialSelect,
+ RobloxCredentialStatus,
+} from "../lib/types/roblox-credentials-types";
+import { AccessDenied, ApiError, DatabaseError } from "../lib/utils/api-utils";
+import { hasPermission } from "../lib/utils/team-utils";
+import { createLogger } from "../lib/utils/logger";
+import { TeamService } from "./TeamService";
+import { and, eq } from "drizzle-orm";
+
+const logger = createLogger("RobloxCredentialsService");
+
+export const RobloxCredentialsService = {
+ // Internal Methods
+ async _introspectKey(key: string) {
+ const response = await fetch(
+ "https://apis.roblox.com/api-keys/v1/introspect",
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ apiKey: key }),
+ },
+ );
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new ApiError(
+ response.status,
+ "ROBLOX_INTROSPECT_ERROR",
+ data.message ??
+ "There was an error while verifying your API key. Please try again later",
+ );
+ }
+
+ return data;
+ },
+
+ async _setKeyStatus(
+ credId: string,
+ info: { kind: RobloxCredentialStatus; message?: string },
+ tx?: any,
+ ) {
+ const client = tx ?? db;
+ return await client
+ .update(roblox_credentials)
+ .set({
+ status: info.kind,
+ errorMessage: info.kind == "healthy" ? null : (info.message ?? null),
+ })
+ .where(eq(roblox_credentials.id, credId));
+ },
+
+ async _useCredential(
+ credId: string,
+ teamId: string,
+ callback: (
+ key: string,
+ setStatus: (
+ kind: RobloxCredentialStatus,
+ message?: string,
+ ) => Promise,
+ tx: Parameters[0]>[0],
+ ) => T | Promise,
+ ): Promise {
+ return await db.transaction(async (tx) => {
+ let keyInfo: typeof roblox_credentials.$inferSelect;
+ try {
+ [keyInfo] = await tx
+ .select()
+ .from(roblox_credentials)
+ .where(and(eq(roblox_credentials.id, credId), eq(roblox_credentials.teamId, teamId)));
+ } catch {
+ throw DatabaseError;
+ }
+
+ if (!keyInfo) {
+ throw AccessDenied;
+ }
+
+ let decryptedKey;
+ try {
+ decryptedKey = DecryptString256({
+ encryptedData: keyInfo.keyCiphertext,
+ initializationVector: keyInfo.keyIv,
+ authTag: keyInfo.keyTag,
+ });
+ } catch {
+ throw new ApiError(
+ 500,
+ "ROBLOX_CRED_DECRYPTION_ERROR",
+ "There was an error while accessing your Roblox API Key. If the problem persists, please contact support.",
+ );
+ }
+
+ const setStatus = async (
+ kind: RobloxCredentialStatus,
+ message?: string,
+ ) => {
+ try {
+ await this._setKeyStatus(credId, { kind, message: message }, tx);
+ } catch {
+ throw DatabaseError;
+ }
+ };
+
+ try {
+ await tx
+ .update(roblox_credentials)
+ .set({ lastUsed: new Date() })
+ .where(eq(roblox_credentials.id, credId));
+ } catch {
+ throw DatabaseError;
+ }
+
+ return await callback(decryptedKey, setStatus, tx);
+ });
+ },
+
+ async _applyIntrospectResults(
+ credId: string,
+ keyInfo: any,
+ tx?: Parameters[0]>[0],
+ ) {
+ let status: RobloxCredentialStatus = "healthy";
+ let message: string | undefined = undefined;
+
+ const client = tx ?? db;
+
+ // Check if the credential is expiring soon (in less than a week)
+ const expDate = new Date(keyInfo.expirationTimeUtc);
+ const now = Date.now();
+ const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000;
+ const isExpiringSoon = expDate.getTime() - now < ONE_WEEK_MS;
+
+ if (!keyInfo.enabled) {
+ status = "error";
+ message = roblox_credential_status.disabled;
+ } else if (keyInfo.expired) {
+ status = "error";
+ message = roblox_credential_status.expired;
+ } else if (isExpiringSoon) {
+ status = "warning";
+ message = roblox_credential_status.expires_soon;
+ }
+
+ // Update the credential row with the new introspect data
+ try {
+ const [updated] = await client
+ .update(roblox_credentials)
+ .set({
+ ...(keyInfo.expirationTimeUtc !== undefined
+ ? { expirationDate: new Date(keyInfo.expirationTimeUtc) }
+ : {}),
+ lastRefreshedAt: new Date(),
+ status,
+ errorMessage: message ?? null,
+ keyOwnerRobloxId: keyInfo.authorizedUserId,
+ })
+ .where(eq(roblox_credentials.id, credId))
+ .returning(RobloxCredentialSelect);
+
+ // Return immediately the updated credential
+ return updated;
+ } catch {
+ throw DatabaseError;
+ }
+ },
+
+ // API Methods
+ async LinkRobloxCredential(
+ actorId: string,
+ teamId: string,
+ creds: RobloxCredentialInfo,
+ ): Promise {
+ const actorRole = await TeamService.GetTeamUserRole(actorId, teamId);
+
+ if (!hasPermission(actorRole, "LinkRobloxCredential")) {
+ throw AccessDenied;
+ }
+
+ let finalStatus: RobloxCredentialStatus = "healthy";
+ let message;
+ const keyInfo = await this._introspectKey(creds.key);
+
+ if (!keyInfo.enabled) {
+ finalStatus = "error";
+ message = roblox_credential_status.disabled;
+ } else if (keyInfo.expired) {
+ finalStatus = "error";
+ message = roblox_credential_status.expired;
+ }
+
+ const encodedKey = EncryptString256(creds.key);
+ try {
+ const [newRecord] = await db
+ .insert(roblox_credentials)
+ .values({
+ id: randomUUID(),
+ teamId,
+ name: creds.name,
+ status: finalStatus,
+ errorMessage: message,
+ keyCiphertext: encodedKey.encryptedData,
+ keyIv: encodedKey.initializationVector,
+ keyTag: encodedKey.authTag,
+ expirationDate: keyInfo.expirationTimeUtc
+ ? new Date(keyInfo.expirationTimeUtc)
+ : null,
+ keyOwnerRobloxId: keyInfo.authorizedUserId,
+ createdBy: actorId,
+ })
+ .returning(RobloxCredentialSelect);
+
+ return newRecord;
+ } catch {
+ throw DatabaseError;
+ }
+ },
+
+ async DeleteRobloxCredential(
+ actorId: string,
+ teamId: string,
+ credId: string,
+ ): Promise {
+ const actorRole = await TeamService.GetTeamUserRole(actorId, teamId);
+ if (!hasPermission(actorRole, "DeleteRobloxCredential")) {
+ throw AccessDenied;
+ }
+
+ try {
+ const [result] = await db
+ .delete(roblox_credentials)
+ .where(
+ and(
+ eq(roblox_credentials.id, credId),
+ eq(roblox_credentials.teamId, teamId),
+ ),
+ )
+ .returning(RobloxCredentialSelect);
+ if (!result) throw AccessDenied;
+ return result;
+ } catch {
+ throw DatabaseError;
+ }
+ },
+
+ async RenameRobloxCredential(
+ actorId: string,
+ teamId: string,
+ credId: string,
+ newName: string,
+ ): Promise {
+ const actorRole = await TeamService.GetTeamUserRole(actorId, teamId);
+ if (!hasPermission(actorRole, "RenameRobloxCredential")) {
+ throw AccessDenied;
+ }
+
+ try {
+ const [result] = await db
+ .update(roblox_credentials)
+ .set({ name: newName })
+ .where(
+ and(
+ eq(roblox_credentials.id, credId),
+ eq(roblox_credentials.teamId, teamId),
+ ),
+ )
+ .returning(RobloxCredentialSelect);
+ if (!result) throw AccessDenied;
+ return result;
+ } catch {
+ throw DatabaseError;
+ }
+ },
+
+ async RotateRobloxCredential(
+ actorId: string,
+ teamId: string,
+ credId: string,
+ newKey: string,
+ ): Promise {
+ const actorRole = await TeamService.GetTeamUserRole(actorId, teamId);
+ if (!hasPermission(actorRole, "RotateRobloxCredential")) {
+ throw AccessDenied;
+ }
+
+ const keyInfo = await this._introspectKey(newKey);
+ if (!keyInfo.enabled) {
+ throw new ApiError(
+ 400,
+ "ROTATION_FAILED",
+ "The new key is disabled. Please enable the key in the Roblox Creator Dashboard before rotating",
+ );
+ } else if (keyInfo.expired) {
+ throw new ApiError(
+ 400,
+ "ROTATION_FAILED",
+ "The new key is expired. Please refresh it in the Roblox Creator Dashboard before rotating",
+ );
+ }
+
+ const newKeyEncrypted = EncryptString256(newKey);
+ return await db.transaction(async (tx) => {
+ await tx
+ .update(roblox_credentials)
+ .set({
+ keyCiphertext: newKeyEncrypted.encryptedData,
+ keyIv: newKeyEncrypted.initializationVector,
+ keyTag: newKeyEncrypted.authTag,
+ })
+ .where(eq(roblox_credentials.id, credId));
+
+ return this._applyIntrospectResults(credId, keyInfo, tx);
+ });
+ },
+
+ async ListTeamRobloxCredentials(
+ actorId: string,
+ teamId: string,
+ ): Promise {
+ const actorRole = await TeamService.GetTeamUserRole(actorId, teamId);
+ if (!hasPermission(actorRole, "ListRobloxCredentials")) {
+ throw AccessDenied;
+ }
+
+ try {
+ const results = await db
+ .select(RobloxCredentialSelect)
+ .from(roblox_credentials)
+ .where(eq(roblox_credentials.teamId, teamId));
+ return results;
+ } catch {
+ throw DatabaseError;
+ }
+ },
+
+ async RefreshRobloxCredential(
+ actorId: string,
+ teamId: string,
+ credId: string,
+ ): Promise {
+ const actorRole = await TeamService.GetTeamUserRole(actorId, teamId);
+ if (!hasPermission(actorRole, "RefreshRobloxCredential")) {
+ throw AccessDenied;
+ }
+
+ return await this._useCredential(credId, teamId, async (key, setStatus, tx) => {
+ // Try to fetch the credential info using _introspectKey
+ let keyInfo;
+ try {
+ keyInfo = await this._introspectKey(key);
+ } catch (error) {
+ if (error instanceof ApiError) {
+ if (error.status === 429) {
+ await setStatus("warning", roblox_credential_status.rate_limit);
+ } else if (error.status >= 500) {
+ await setStatus("warning", roblox_credential_status.roblox_down);
+ } else {
+ await setStatus("error", error.clientMessage);
+ }
+ } else {
+ throw error;
+ }
+ }
+
+ // Update credential row
+ // Skip if _introspectKey rejected
+ if (keyInfo) {
+ try {
+ const updatedCred = await this._applyIntrospectResults(
+ credId,
+ keyInfo,
+ tx,
+ );
+ if (updatedCred) return updatedCred;
+ } catch (error) {
+ logger.error("Failed to apply introspect results", error, { resourceId: credId });
+ }
+ }
+
+ // Return the final credential info (fallback for when _introspectKey rejected)
+ const result = await tx
+ .select(RobloxCredentialSelect)
+ .from(roblox_credentials)
+ .where(eq(roblox_credentials.id, credId))
+ .get();
+
+ if (!result) throw DatabaseError;
+ return result;
+ });
+ },
+};
diff --git a/src/services/TeamService.test.ts b/src/services/TeamService.test.ts
index 7083d8f..5846098 100644
--- a/src/services/TeamService.test.ts
+++ b/src/services/TeamService.test.ts
@@ -3,7 +3,7 @@ import { db } from "@/src/db";
import { TeamService } from "./TeamService";
import { team_member, user } from "../db/schema";
import { and, eq } from "drizzle-orm";
-import { UserNotFound } from "../lib/utils/errors";
+import { UserNotFound } from "../lib/utils/api-utils";
import { TeamRole } from "../lib/types/team-types";
async function createUser(name: string) {
diff --git a/src/services/TeamService.ts b/src/services/TeamService.ts
index 5531f89..cfd0812 100644
--- a/src/services/TeamService.ts
+++ b/src/services/TeamService.ts
@@ -7,7 +7,7 @@ import {
ApiError,
DatabaseError,
UserNotFound,
-} from "../lib/utils/errors";
+} from "../lib/utils/api-utils";
import { randomUUID } from "crypto";
import { team_member } from "../db/schema/team_member";
import { CheckUserExist } from "../lib/utils/auth-utils";