Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
echo "🔍 Running linting..."
if [ -f .eslintrc.js ] || [ -f .eslintrc.json ] || [ -f .eslintrc.yml ] || [ -f .eslintrc.yaml ] || grep -q 'eslintConfig' package.json 2>/dev/null; then
if [ -f biome.json ] || [ -f biome.jsonc ]; then
npm run lint
if [ $? -ne 0 ]; then
echo "❌ Linting failed. Commit aborted."
exit 1
fi
else
echo "⚠️ No ESLint config found, skipping lint."
echo "⚠️ No Biome config found, skipping lint."
fi

echo "🔎 Running TypeScript type checks..."
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"dev": "ts-node-dev --respawn --transpile-only src/server.ts",
"build": "tsc",
"start": "node dist/src/server.js",
"test": "NODE_ENV=test jest --detectOpenHandles --forceExit",
"test": "NODE_ENV=test jest --detectOpenHandles --forceExit --silent",
"test:watch": "NODE_ENV=test npm test -- --watch",
"test:coverage": "NODE_ENV=test jest --coverage --forceExit",
"test:e2e": "newman run postman/safe-skies-api.postman_collection.json",
Expand Down
2 changes: 1 addition & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ app.use(morgan('dev'));
app.use(express.json());
app.use(cookieParser());

app.use((req, res, next) => {
app.use((req, _, next) => {
if (process.env.NODE_ENV === 'development') {
console.log(`Incoming request: ${req.method} ${req.url}`);
}
Expand Down
48 changes: 39 additions & 9 deletions src/controllers/atproto.controller.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,53 @@
import { Request, Response } from 'express';
import { AtprotoAgent } from '../repos/atproto';

interface SearchParams {
q: string;
sort: string;
since: string;
until: string;
author: string;
mentions: string;
hashtags: string;
limit: number;
cursor: string;
}

/**
* Search for posts using ATProto's searchPosts endpoint
* Supports pagination via cursor parameter
*/
export const searchPosts = async (req: Request, res: Response): Promise<void> => {
try {
const { q, sort, since, until, author, mentions, hashtags, limit, cursor } = req.query;

// Debug logging for cursor value
if (cursor) {
console.log('Received cursor:', cursor, 'Type:', typeof cursor);

// Log if we detect a numeric cursor (might be ATProto search API behavior)
if (/^\d+$/.test(cursor as string)) {
console.warn('Numeric cursor detected (ATProto search API behavior):', cursor);
// Don't reject - let's see if ATProto accepts it
}
}

if (!q || typeof q !== 'string') {
res.status(400).json({ error: 'Query parameter "q" is required' });
return;
}

// Build search parameters
const searchParams: any = {
q: q as string,
const searchParams: SearchParams = {
q,
sort: "",
since: "",
until: "",
author: "",
mentions: "",
hashtags: "",
limit: 25,
cursor: ""
};

// Add optional parameters if provided
Expand All @@ -45,7 +65,10 @@ export const searchPosts = async (req: Request, res: Response): Promise<void> =>
// Debug logging for response cursor
console.log('ATProto response cursor:', response.data.cursor, 'Type:', typeof response.data.cursor);

const responseData: any = {

// not sure what the correct type is for "response" but we can infer it
type ResponseData = typeof response.data
const responseData: ResponseData = {
posts: response.data.posts,
};

Expand All @@ -71,15 +94,22 @@ export const searchPosts = async (req: Request, res: Response): Promise<void> =>
export const searchUsers = async (req: Request, res: Response): Promise<void> => {
try {
const { q, limit, cursor } = req.query;

if (!q || typeof q !== 'string') {
res.status(400).json({ error: 'Query parameter "q" is required' });
return;
}

const searchParams: any = {
const searchParams: SearchParams = {
q: q as string,
limit: limit ? parseInt(limit as string) : 25,
sort: "",
since: "",
until: "",
author: "",
mentions: "",
hashtags: "",
cursor: ""
};

// Add cursor for pagination if provided
Expand All @@ -103,7 +133,7 @@ export const searchUsers = async (req: Request, res: Response): Promise<void> =>
export const getPosts = async (req: Request, res: Response): Promise<void> => {
try {
const { uris } = req.query;

if (!uris) {
res.status(400).json({ error: 'Query parameter "uris" is required' });
return;
Expand Down
214 changes: 107 additions & 107 deletions src/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,130 +10,130 @@ import { SessionPayload } from "../lib/types/session";
* Helper: Retrieve the user's Bluesky profile data by exchanging the OAuth callback parameters.
*/
const getUsersBlueskyProfileData = async (
oAuthCallbackParams: URLSearchParams,
oAuthCallbackParams: URLSearchParams,
) => {
const { session } = await BlueskyOAuthClient.callback(oAuthCallbackParams);

if (!session?.sub) {
throw new Error("Invalid session: No DID found.");
}

try {
const response = await AtprotoAgent.getProfile({
actor: session.sub,
});

if (!response.success || !response.data) {
throw new Error("Failed to fetch profile data");
}
return response.data;
} catch (error) {
console.error("Error fetching profile data:", error);
throw new Error("Failed to fetch profile data");
}
const { session } = await BlueskyOAuthClient.callback(oAuthCallbackParams);

if (!session?.sub) {
throw new Error("Invalid session: No DID found.");
}

try {
const response = await AtprotoAgent.getProfile({
actor: session.sub,
});

if (!response.success || !response.data) {
throw new Error("Failed to fetch profile data");
}
return response.data;
} catch (error) {
console.error("Error fetching profile data:", error);
throw new Error("Failed to fetch profile data");
}
};

/**
* Initiates the Bluesky OAuth flow.
* Expects a 'handle' query parameter and returns a JSON object containing the authorization URL.
*/
export const signin = async (req: Request, res: Response): Promise<void> => {
try {
const { handle } = req.query;
if (!handle) {
res.status(400).json({ error: "Handle is required" });
return;
}

const url = await BlueskyOAuthClient.authorize(handle as string);

res.json({ url: url.toString() });
} catch (err) {
console.error("Error initiating Bluesky auth:", err);
res.status(500).json({ error: "Failed to initiate authentication" });
}
try {
const { handle } = req.query;
if (!handle) {
res.status(400).json({ error: "Handle is required" });
return;
}

const url = await BlueskyOAuthClient.authorize(handle as string);

res.json({ url: url.toString() });
} catch (err) {
console.error("Error initiating Bluesky auth:", err);
res.status(500).json({ error: "Failed to initiate authentication" });
}
};

/**
* Logs the user out by clearing the custom JWT session cookie.
*/
export const logout = async (req: Request, res: Response): Promise<void> => {
try {
res.clearCookie("session_token", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
});

res.json({ success: true, message: "Logged out successfully" });
} catch (err) {
console.error("Error in logout:", err);
res.status(500).json({ error: "Failed to log out" });
}
export const logout = async (_: Request, res: Response): Promise<void> => {
try {
res.clearCookie("session_token", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
});

res.json({ success: true, message: "Logged out successfully" });
} catch (err) {
console.error("Error in logout:", err);
res.status(500).json({ error: "Failed to log out" });
}
};

/**
* Handles the OAuth callback from Bluesky.
*/
export const callback = async (req: Request, res: Response): Promise<void> => {
try {
// 1. Obtain initial profile data from Bluesky using OAuth callback parameters.
const profileData = await getUsersBlueskyProfileData(
new URLSearchParams(req.query as Record<string, string>),
);

// 2. Retrieve local feed permissions for the user.
const feedsResponse = await getActorFeeds(profileData.did);
const createdFeeds = feedsResponse?.feeds || [];

// 3. Build the initial user object merging local feed roles.
const initialUser = {
...profileData,
rolesByFeed: createdFeeds.map((feed) => ({
role: "admin" as UserRole,
uri: feed.uri,
displayName: feed.displayName,
feed_name: feed.displayName,
})),
};

// 4. Upsert (save) the user profile along with feed permissions.
const upsertSuccess = await saveProfile(initialUser, createdFeeds);
if (!upsertSuccess) {
throw new Error("Failed to save profile data");
}

// 5. Retrieve the complete profile (including any feed role updates).
const completeProfile = await getProfile(profileData.did);

if (!completeProfile) {
throw new Error("Failed to retrieve complete profile");
}

// 6. Create a session payload and sign a JWT.
const sessionPayload: SessionPayload = {
did: completeProfile.did,
handle: completeProfile.handle,
displayName: completeProfile.displayName,
rolesByFeed: initialUser.rolesByFeed || [],
};
const secret = process.env.JWT_SECRET;
if (!secret) {
throw new Error("Missing JWT_SECRET environment variable");
}
const token = jwt.sign(sessionPayload, process.env.JWT_SECRET!, {
expiresIn: "7d",
});
// 7. Redirect the user back to the client.
res.redirect(`${process.env.CLIENT_URL}/oauth/callback?token=${token}`);
} catch (err) {
console.error("OAuth callback error:", err);
const errorMessage =
err instanceof Error ? err.message : "An unknown error occurred.";
res.redirect(
`${process.env.CLIENT_URL}/oauth/login?error=${encodeURIComponent(
errorMessage,
)}`,
);
}
try {
// 1. Obtain initial profile data from Bluesky using OAuth callback parameters.
const profileData = await getUsersBlueskyProfileData(
new URLSearchParams(req.query as Record<string, string>),
);

// 2. Retrieve local feed permissions for the user.
const feedsResponse = await getActorFeeds(profileData.did);
const createdFeeds = feedsResponse?.feeds || [];

// 3. Build the initial user object merging local feed roles.
const initialUser = {
...profileData,
rolesByFeed: createdFeeds.map((feed) => ({
role: "admin" as UserRole,
uri: feed.uri,
displayName: feed.displayName,
feed_name: feed.displayName,
})),
};

// 4. Upsert (save) the user profile along with feed permissions.
const upsertSuccess = await saveProfile(initialUser, createdFeeds);
if (!upsertSuccess) {
throw new Error("Failed to save profile data");
}

// 5. Retrieve the complete profile (including any feed role updates).
const completeProfile = await getProfile(profileData.did);

if (!completeProfile) {
throw new Error("Failed to retrieve complete profile");
}

// 6. Create a session payload and sign a JWT.
const sessionPayload: SessionPayload = {
did: completeProfile.did,
handle: completeProfile.handle,
displayName: completeProfile.displayName,
rolesByFeed: initialUser.rolesByFeed || [],
};
const secret = process.env.JWT_SECRET;
if (!secret) {
throw new Error("Missing JWT_SECRET environment variable");
}
const token = jwt.sign(sessionPayload, process.env.JWT_SECRET!, {
expiresIn: "7d",
});
// 7. Redirect the user back to the client.
res.redirect(`${process.env.CLIENT_URL}/oauth/callback?token=${token}`);
} catch (err) {
console.error("OAuth callback error:", err);
const errorMessage =
err instanceof Error ? err.message : "An unknown error occurred.";
res.redirect(
`${process.env.CLIENT_URL}/oauth/login?error=${encodeURIComponent(
errorMessage,
)}`,
);
}
};
Loading