From 8ee7e21f292bab178a0d426711bc173686ee08cb Mon Sep 17 00:00:00 2001 From: Karen Yazbaryan Date: Wed, 20 May 2026 18:24:56 +0400 Subject: [PATCH 1/4] Removed getItem, added and switched to getAnnotationPermissions, added User-Agent --- .../__tests__/localSignValidator.test.ts | 60 ++++++++++--- src/middleware/pathValidatorMiddleware.ts | 8 +- src/types/saApi.ts | 18 ++-- src/utils/saApi.ts | 87 ++++++++++++------- 4 files changed, 122 insertions(+), 51 deletions(-) diff --git a/src/middleware/__tests__/localSignValidator.test.ts b/src/middleware/__tests__/localSignValidator.test.ts index 4da1ef7..d345ff5 100644 --- a/src/middleware/__tests__/localSignValidator.test.ts +++ b/src/middleware/__tests__/localSignValidator.test.ts @@ -17,7 +17,7 @@ jest.mock("../../utils/config", () => ({ jest.mock("../../utils/saApi", () => ({ SuperAnnotateApi: { - getItem: jest.fn(), + getAnnotationPermissions: jest.fn(), }, })); @@ -37,6 +37,7 @@ describe("PathValidatorMiddleware", () => { }); req = { headers: {}, + method: "GET", saAccessToken: "Bearer token", }; res = { @@ -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); @@ -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" }, }); @@ -139,7 +177,7 @@ 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", @@ -147,7 +185,7 @@ describe("PathValidatorMiddleware", () => { "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) diff --git a/src/middleware/pathValidatorMiddleware.ts b/src/middleware/pathValidatorMiddleware.ts index 5bbbce9..6a8fdc5 100644 --- a/src/middleware/pathValidatorMiddleware.ts +++ b/src/middleware/pathValidatorMiddleware.ts @@ -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; } diff --git a/src/types/saApi.ts b/src/types/saApi.ts index a105497..d0c6ef3 100644 --- a/src/types/saApi.ts +++ b/src/types/saApi.ts @@ -4,6 +4,13 @@ export type RequestOptions = { headers?: Record; queryParams?: Record; + body?: unknown; +}; + +/** Annotation permissions resolved from SuperAnnotate aggregate accesses */ +export type AnnotationPermissions = { + read: boolean; + write: boolean; }; /** @@ -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[]; + }; }; diff --git a/src/utils/saApi.ts b/src/utils/saApi.ts index 6f6a6e7..22ecd9a 100644 --- a/src/utils/saApi.ts +++ b/src/utils/saApi.ts @@ -1,12 +1,13 @@ import https from "https"; import path from "path"; import SafeJSON from "./functions"; -import { RequestOptions, SaAuthError, SaItem, SaUser } from "../types"; +import { AnnotationPermissions, RequestOptions, SaAggregateAccessesResponse, SaAuthError, SaUser } from "../types"; -export type { SaAuthError, SaItem, SaUser } from "../types"; +export type { AnnotationPermissions, SaAuthError, SaUser } from "../types"; -const SA_ITEM_API_HOST = "item.superannotate.com"; -const SA_USER_API_HOST = "api.superannotate.com"; +const SA_ITEM_API_HOST = "item.devsuperannotate.com"; +const SA_USER_API_HOST = "api.devsuperannotate.com"; +const SA_USER_AGENT = "SA External Data Store"; /** * Client for interacting with the SuperAnnotate API @@ -37,9 +38,21 @@ export class SuperAnnotateApi { }); } + // User-Agent is enforced for all SuperAnnotate API calls and cannot be overridden by callers + const headers: Record = { ...(options.headers || {}), "User-Agent": SA_USER_AGENT }; + let bodyBuffer: Buffer | undefined; + if (options.body !== undefined) { + const serialized = SafeJSON.stringify(options.body) ?? ""; + bodyBuffer = Buffer.from(serialized, "utf8"); + if (!Object.keys(headers).some((h) => h.toLowerCase() === "content-type")) { + headers["Content-Type"] = "application/json"; + } + headers["Content-Length"] = String(bodyBuffer.length); + } + const requestOptions: https.RequestOptions = { method, - headers: options.headers || {}, + headers, }; return new Promise((resolve, reject) => { @@ -75,23 +88,47 @@ export class SuperAnnotateApi { reject(e); }); + if (bodyBuffer) { + req.write(bodyBuffer); + } + req.end(); }); } /** - * Retrieves an item from SuperAnnotate API + * Retrieves the current user from SuperAnnotate API + * @param authToken - Authorization token (Bearer token) + * @returns Promise resolving to the current user data + * @throws SaAuthError if authentication fails + */ + public static async getMySAUser(authToken: string): Promise { + const userResponse = await SuperAnnotateApi.request(SA_USER_API_HOST, `/api/v1/user/ME`, "GET", { + headers: { + Authorization: authToken, + }, + }); + + return userResponse as SaUser; + } + + /** + * Resolves annotation read/write permissions for the current user + * Calls POST item.devsuperannotate.com/api/v1/items/aggregateAccesses and + * inspects data.actions for GetItemAnnotation (read) and EditItemAnnotation (write) * @param teamId - Team ID * @param projectId - Project ID * @param folderId - Folder ID - * @param itemId - Item ID * @param authToken - Authorization token (Bearer token) - * @returns Promise resolving to the item data - * @throws SaAuthError if authentication fails or item is not found + * @returns Annotation permissions ({ read, write }) + * @throws SaAuthError if authentication fails */ - public static async getItem(teamId: number, projectId: number, folderId: number, itemId: number, authToken: string): Promise { - // Encode entity context as base64 for the API header - // Using Buffer instead of btoa (browser API) for Node.js compatibility + public static async getAnnotationPermissions( + teamId: number, + projectId: number, + folderId: number, + authToken: string + ): Promise { const entityContext = SafeJSON.stringify({ team_id: teamId, project_id: projectId, @@ -99,29 +136,17 @@ export class SuperAnnotateApi { }) ?? "{}"; const encodedContext = Buffer.from(entityContext).toString("base64"); - const itemResponse = await SuperAnnotateApi.request(SA_ITEM_API_HOST, `/api/v1/items/${itemId.toString()}`, "GET", { + const response = await SuperAnnotateApi.request(SA_ITEM_API_HOST, `/api/v1/items/aggregateAccesses`, "POST", { headers: { Authorization: authToken, "x-sa-entity-context": encodedContext, }, - }) as SaItem; - - return itemResponse; - } - - /** - * Retrieves the current user from SuperAnnotate API - * @param authToken - Authorization token (Bearer token) - * @returns Promise resolving to the current user data - * @throws SaAuthError if authentication fails - */ - public static async getMySAUser(authToken: string): Promise { - const userResponse = await SuperAnnotateApi.request(SA_USER_API_HOST, `/api/v1/user/ME`, "GET", { - headers: { - Authorization: authToken, - }, - }); + }) as SaAggregateAccessesResponse; - return userResponse as SaUser; + const actions = response?.data?.actions ?? []; + return { + read: actions.includes("GetItemAnnotation"), + write: actions.includes("EditItemAnnotation"), + }; } } \ No newline at end of file From f744bec9ce5f7c91fc22dbeef5421a50fc0a006e Mon Sep 17 00:00:00 2001 From: Karen Yazbaryan Date: Wed, 20 May 2026 18:39:34 +0400 Subject: [PATCH 2/4] Added /check endpoint --- src/index.ts | 2 ++ src/repository/index.ts | 11 +++++++++ src/repository/localRepository.ts | 8 +++++++ src/repository/s3Repository.ts | 8 +++++++ src/routes/checkRouter.ts | 38 +++++++++++++++++++++++++++++++ src/utils/s3Sdk.ts | 11 ++++++++- 6 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 src/routes/checkRouter.ts diff --git a/src/index.ts b/src/index.ts index 1a15d03..30955bc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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(); @@ -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 diff --git a/src/repository/index.ts b/src/repository/index.ts index 5504738..aa2292c 100644 --- a/src/repository/index.ts +++ b/src/repository/index.ts @@ -84,6 +84,17 @@ class Repository { public async validateSignature(path: string, expires: string, signature: string): Promise { 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 { + if (!this.repository) { + throw new Error("Repository is not initialized"); + } + await this.repository.checkConnection(); + } } // Export singleton instance diff --git a/src/repository/localRepository.ts b/src/repository/localRepository.ts index 7c48c2b..74716a9 100644 --- a/src/repository/localRepository.ts +++ b/src/repository/localRepository.ts @@ -153,6 +153,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 { + await fs.access(this.basePath, fsSync.constants.R_OK | fsSync.constants.W_OK); + } + public async validateSignature(path: string, expires: string, signature: string): Promise { const expiresTimestamp = Number(expires); if (!Number.isFinite(expiresTimestamp) || expiresTimestamp < Date.now()) { diff --git a/src/repository/s3Repository.ts b/src/repository/s3Repository.ts index 0b9deb4..bcacc31 100644 --- a/src/repository/s3Repository.ts +++ b/src/repository/s3Repository.ts @@ -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 { + await this.s3Sdk.checkConnection(); + } } \ No newline at end of file diff --git a/src/routes/checkRouter.ts b/src/routes/checkRouter.ts new file mode 100644 index 0000000..63ba6b8 --- /dev/null +++ b/src/routes/checkRouter.ts @@ -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; diff --git a/src/utils/s3Sdk.ts b/src/utils/s3Sdk.ts index 3358af2..d03197f 100644 --- a/src/utils/s3Sdk.ts +++ b/src/utils/s3Sdk.ts @@ -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"; @@ -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 { + 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 From aa2ab211452ad9bc796097b632fb7463f5906fbe Mon Sep 17 00:00:00 2001 From: Karen Yazbaryan Date: Tue, 2 Jun 2026 09:37:19 +0400 Subject: [PATCH 3/4] Fixed local store directory creation --- src/repository/localRepository.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/repository/localRepository.ts b/src/repository/localRepository.ts index 74716a9..3677bfd 100644 --- a/src/repository/localRepository.ts +++ b/src/repository/localRepository.ts @@ -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 { - const filePath = this.getFilePath(path); + public async saveDataStream(relativePath: string, stream: Readable): Promise { + 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); From 2b2e416a4ef5f90c593b64faf3f454eeb351f475 Mon Sep 17 00:00:00 2001 From: Karen Yazbaryan Date: Mon, 8 Jun 2026 11:58:03 +0400 Subject: [PATCH 4/4] Added saDomain environment --- README.md | 1 + src/utils/config.ts | 8 ++++++++ src/utils/saApi.ts | 7 ++++--- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 09fe17f..c039360 100644 --- a/README.md +++ b/README.md @@ -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`) diff --git a/src/utils/config.ts b/src/utils/config.ts index 52acc54..8a5eff0 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -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 diff --git a/src/utils/saApi.ts b/src/utils/saApi.ts index 22ecd9a..39844e1 100644 --- a/src/utils/saApi.ts +++ b/src/utils/saApi.ts @@ -1,12 +1,13 @@ import https from "https"; import path from "path"; import SafeJSON from "./functions"; +import { Config } from "./config"; import { AnnotationPermissions, RequestOptions, SaAggregateAccessesResponse, SaAuthError, SaUser } from "../types"; export type { AnnotationPermissions, SaAuthError, SaUser } from "../types"; -const SA_ITEM_API_HOST = "item.devsuperannotate.com"; -const SA_USER_API_HOST = "api.devsuperannotate.com"; +const SA_ITEM_API_HOST = `item.${Config.saDomain()}`; +const SA_USER_API_HOST = `api.${Config.saDomain()}`; const SA_USER_AGENT = "SA External Data Store"; /** @@ -114,7 +115,7 @@ export class SuperAnnotateApi { /** * Resolves annotation read/write permissions for the current user - * Calls POST item.devsuperannotate.com/api/v1/items/aggregateAccesses and + * Calls POST item.{SA_DOMAIN}/api/v1/items/aggregateAccesses and * inspects data.actions for GetItemAnnotation (read) and EditItemAnnotation (write) * @param teamId - Team ID * @param projectId - Project ID