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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ npm install
```env
PORT=3005
DATA_STORE=S3 # or LOCAL
SA_DOMAIN=superannotate.com # base SuperAnnotate domain, defaults to superannotate.com
```

### S3 backend (`DATA_STORE=S3`)
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import cors from "cors";
import chalk from "chalk";
import storageRouter from "./routes/storageRouter";
import annotationRouter from "./routes/annotationRouter";
import checkRouter from "./routes/checkRouter";
import { sendError, errorMiddleware } from "./utils/errorHandler";

const app = express();
Expand All @@ -21,6 +22,7 @@ if (process.env.NODE_ENV !== "production") {
// Routes
app.use("/storage", storageRouter);
app.use("/annotation", annotationRouter);
app.use("/check", checkRouter);

/**
* Health check endpoint
Expand Down
60 changes: 49 additions & 11 deletions src/middleware/__tests__/localSignValidator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jest.mock("../../utils/config", () => ({

jest.mock("../../utils/saApi", () => ({
SuperAnnotateApi: {
getItem: jest.fn(),
getAnnotationPermissions: jest.fn(),
},
}));

Expand All @@ -37,6 +37,7 @@ describe("PathValidatorMiddleware", () => {
});
req = {
headers: {},
method: "GET",
saAccessToken: "Bearer token",
};
res = {
Expand Down Expand Up @@ -84,33 +85,70 @@ describe("PathValidatorMiddleware", () => {

expect((req as any).saFilePath).toBe("1/2/folder/file.json");
expect(next).toHaveBeenCalledTimes(1);
expect(SaApi.SuperAnnotateApi.getItem).not.toHaveBeenCalled();
expect(SaApi.SuperAnnotateApi.getAnnotationPermissions).not.toHaveBeenCalled();
});

it("should set annotation path when folder and item headers are valid", async () => {
it("should set annotation path on GET when read permission is granted", async () => {
req.method = "GET";
req.headers = {
"sa-team-id": "1",
"sa-project-id": "2",
"sa-folder-id": "3",
"sa-item-id": "4",
};
(SaApi.SuperAnnotateApi.getItem as jest.Mock).mockResolvedValue({ id: 4, name: "annotation item" });
(SaApi.SuperAnnotateApi.getAnnotationPermissions as jest.Mock).mockResolvedValue({ read: true, write: false });

await PathValidatorMiddleware(req as Request, res as Response, next);

expect(SaApi.SuperAnnotateApi.getItem).toHaveBeenCalledWith(1, 2, 3, 4, "Bearer token");
expect(SaApi.SuperAnnotateApi.getAnnotationPermissions).toHaveBeenCalledWith(1, 2, 3, "Bearer token");
expect((req as any).saFilePath).toBe("1/2/3/4/annotation.json");
expect(next).toHaveBeenCalledTimes(1);
});

it("should return 401 when item lookup fails", async () => {
it("should set annotation path on POST when write permission is granted", async () => {
req.method = "POST";
req.headers = {
"sa-team-id": "1",
"sa-project-id": "2",
"sa-folder-id": "3",
"sa-item-id": "4",
};
(SaApi.SuperAnnotateApi.getItem as jest.Mock).mockResolvedValue(null);
(SaApi.SuperAnnotateApi.getAnnotationPermissions as jest.Mock).mockResolvedValue({ read: false, write: true });

await PathValidatorMiddleware(req as Request, res as Response, next);

expect((req as any).saFilePath).toBe("1/2/3/4/annotation.json");
expect(next).toHaveBeenCalledTimes(1);
});

it("should return 401 on GET when read permission is denied", async () => {
req.method = "GET";
req.headers = {
"sa-team-id": "1",
"sa-project-id": "2",
"sa-folder-id": "3",
"sa-item-id": "4",
};
(SaApi.SuperAnnotateApi.getAnnotationPermissions as jest.Mock).mockResolvedValue({ read: false, write: true });

await PathValidatorMiddleware(req as Request, res as Response, next);

expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ message: "Access to item is denied" })
);
expect(next).not.toHaveBeenCalled();
});

it("should return 401 on POST when write permission is denied", async () => {
req.method = "POST";
req.headers = {
"sa-team-id": "1",
"sa-project-id": "2",
"sa-folder-id": "3",
"sa-item-id": "4",
};
(SaApi.SuperAnnotateApi.getAnnotationPermissions as jest.Mock).mockResolvedValue({ read: true, write: false });

await PathValidatorMiddleware(req as Request, res as Response, next);

Expand All @@ -121,14 +159,14 @@ describe("PathValidatorMiddleware", () => {
expect(next).not.toHaveBeenCalled();
});

it("should propagate AuthException from item API", async () => {
it("should propagate AuthException from permissions API", async () => {
req.headers = {
"sa-team-id": "1",
"sa-project-id": "2",
"sa-folder-id": "3",
"sa-item-id": "4",
};
(SaApi.SuperAnnotateApi.getItem as jest.Mock).mockRejectedValue({
(SaApi.SuperAnnotateApi.getAnnotationPermissions as jest.Mock).mockRejectedValue({
error: { name: "AuthException" },
});

Expand All @@ -139,15 +177,15 @@ describe("PathValidatorMiddleware", () => {
expect(next).not.toHaveBeenCalled();
});

it("should propagate unexpected item API errors", async () => {
it("should propagate unexpected permissions API errors", async () => {
req.headers = {
"sa-team-id": "1",
"sa-project-id": "2",
"sa-folder-id": "3",
"sa-item-id": "4",
};
const error = new Error("network");
(SaApi.SuperAnnotateApi.getItem as jest.Mock).mockRejectedValue(error);
(SaApi.SuperAnnotateApi.getAnnotationPermissions as jest.Mock).mockRejectedValue(error);

await expect(
PathValidatorMiddleware(req as Request, res as Response, next)
Expand Down
8 changes: 5 additions & 3 deletions src/middleware/pathValidatorMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,12 @@ export const PathValidatorMiddleware = async (

let relativeFilePath;

// If folder ID and item ID are provided, validate user has access to the item
// If folder ID and item ID are provided, validate user has annotation access for the folder
if (saFolderId && saItemId) {
const item = await SaApi.SuperAnnotateApi.getItem(saTeamId, saProjectId, saFolderId, saItemId, saAccessToken);
if (!item || !item.id || !item.name) {
const perms = await SaApi.SuperAnnotateApi.getAnnotationPermissions(saTeamId, saProjectId, saFolderId, saAccessToken);
const isWriteRequest = req.method !== "GET" && req.method !== "HEAD";
const allowed = isWriteRequest ? perms.write : perms.read;
if (!allowed) {
sendError(res, 401, "Access to item is denied", "AUTH_ACCESS_DENIED");
return;
}
Expand Down
11 changes: 11 additions & 0 deletions src/repository/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,17 @@ class Repository {
public async validateSignature(path: string, expires: string, signature: string): Promise<boolean> {
return this.repository?.validateSignature(path, expires, signature) ?? false;
}

/**
* Verifies the storage backend is reachable and accessible
* @throws Error if the storage backend is not reachable or repository is not initialized
*/
public async checkConnection(): Promise<void> {
if (!this.repository) {
throw new Error("Repository is not initialized");
}
await this.repository.checkConnection();
}
}

// Export singleton instance
Expand Down
15 changes: 13 additions & 2 deletions src/repository/localRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,11 @@ export class LocalRepository {
* @param stream - Readable stream containing the data to save
* @throws Error if write fails
*/
public async saveDataStream(path: string, stream: Readable): Promise<void> {
const filePath = this.getFilePath(path);
public async saveDataStream(relativePath: string, stream: Readable): Promise<void> {
const filePath = this.getFilePath(relativePath);

// Ensure parent directory exists before writing
await fs.mkdir(path.dirname(filePath), { recursive: true });

// Create write stream and pipe the input stream to it
const writeStream = fsSync.createWriteStream(filePath);
Expand Down Expand Up @@ -153,6 +156,14 @@ export class LocalRepository {
return `${host}/storage/fileSigned?path=${encodeURIComponent(path)}&expires=${expires}&signature=${signature}`;
}

/**
* Verifies the local storage base directory exists and is readable/writable
* @throws Error if the directory cannot be accessed
*/
public async checkConnection(): Promise<void> {
await fs.access(this.basePath, fsSync.constants.R_OK | fsSync.constants.W_OK);
}

public async validateSignature(path: string, expires: string, signature: string): Promise<boolean> {
const expiresTimestamp = Number(expires);
if (!Number.isFinite(expiresTimestamp) || expiresTimestamp < Date.now()) {
Expand Down
8 changes: 8 additions & 0 deletions src/repository/s3Repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,12 @@ export class S3Repository {
// S3 signed URLs are validated by AWS at request time.
return true;
}

/**
* Verifies the S3 bucket is reachable with the configured credentials
* @throws Error if the bucket cannot be reached
*/
public async checkConnection(): Promise<void> {
await this.s3Sdk.checkConnection();
}
}
38 changes: 38 additions & 0 deletions src/routes/checkRouter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Router, Request, Response } from "express";
import repository from "../repository";
import { AuthSaMiddleware } from "../middleware/authSaMiddleware";
import { sendError } from "../utils/errorHandler";
import { ErrorCode } from "../types";

const router = Router();

/**
* GET /check
* Verifies that the service can authenticate against SuperAnnotate (via AuthSaMiddleware)
* and reach the configured storage backend
*
* Headers:
* - header named by Config.saAuthHeader(): SuperAnnotate access token (required)
*
* Returns:
* - 200 OK with { auth: "ok", storage: "ok" } when both dependencies are healthy
* - 401 Unauthorized when the SuperAnnotate token is missing or invalid (from AuthSaMiddleware)
* - 500 Internal Server Error when the storage backend is unreachable
*/
router.get("/", AuthSaMiddleware, async (_req: Request, res: Response) => {
try {
await repository.checkConnection();
} catch (error) {
console.error("Storage connection check failed:", error);
sendError(res, 500, "Failed to validate storage connection", ErrorCode.INTERNAL_SERVER_ERROR);
return;
}

res.status(200).json({
auth: "ok",
storage: "ok",
timestamp: new Date().toISOString(),
});
});

export default router;
18 changes: 12 additions & 6 deletions src/types/saApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@
export type RequestOptions = {
headers?: Record<string, string>;
queryParams?: Record<string, string | number | boolean>;
body?: unknown;
};

/** Annotation permissions resolved from SuperAnnotate aggregate accesses */
export type AnnotationPermissions = {
read: boolean;
write: boolean;
};

/**
Expand All @@ -27,10 +34,9 @@ export type SaUser = {
updatedAt: string;
};

/** SuperAnnotate item (from items API) */
export type SaItem = {
id: string;
name: string;
createdAt: string;
updatedAt: string;
/** Response shape from POST /api/v1/items/aggregateAccesses */
export type SaAggregateAccessesResponse = {
data: {
actions: string[];
};
};
8 changes: 8 additions & 0 deletions src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,14 @@ export class Config {
return process.env.LOCAL_SIGN_SECRET_KEY;
}

/**
* Gets the SuperAnnotate base domain from environment variables
* @returns SuperAnnotate base domain (defaults to "superannotate.com")
*/
static saDomain(): string {
return process.env.SA_DOMAIN || "superannotate.com";
}

/**
* Gets the SuperAnnotate access token header name
* @returns SuperAnnotate access token header name
Expand Down
11 changes: 10 additions & 1 deletion src/utils/s3Sdk.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, ListObjectsV2Command, HeadObjectCommand, GetObjectCommandOutput } from "@aws-sdk/client-s3";
import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, ListObjectsV2Command, HeadObjectCommand, HeadBucketCommand, GetObjectCommandOutput } from "@aws-sdk/client-s3";
import { Upload } from "@aws-sdk/lib-storage";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { Readable } from "stream";
Expand Down Expand Up @@ -199,6 +199,15 @@ export class S3Sdk {
return await getSignedUrl(this.client, command, { expiresIn });
}

/**
* Verifies the S3 bucket is reachable and accessible with the configured credentials
* @throws Error if the bucket cannot be reached (network, auth, or NotFound)
*/
public async checkConnection(): Promise<void> {
const command = new HeadBucketCommand({ Bucket: this.bucketName });
await this.client.send(command);
}

/**
* Generate a presigned URL for putting an object
* Presigned URLs allow temporary upload access to S3 without AWS credentials
Expand Down
Loading