Skip to content

Commit 8ace930

Browse files
committed
add auth
1 parent 028dcd1 commit 8ace930

File tree

11 files changed

+321
-24
lines changed

11 files changed

+321
-24
lines changed

.github/workflows/deploy-fdr-lambda-dev.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ env:
1717
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
1818
GITHUB_TOKEN: ${{ secrets.FERN_GITHUB_TOKEN }}
1919
DATABASE_URL: ${{ secrets.DEV2_RDS_PROXY_URL }}
20+
VENUS_URL: ${{ secrets.DEV2_VENUS_URL }}
21+
PUBLIC_DOCS_CDN_URL: ${{ secrets.DEV2_PUBLIC_DOCS_CDN_URL }}
22+
PUBLIC_DOCS_S3_BUCKET_NAME: ${{ secrets.DEV2_PUBLIC_DOCS_S3_BUCKET_NAME }}
23+
PUBLIC_DOCS_S3_BUCKET_REGION: ${{ secrets.DEV2_PUBLIC_DOCS_S3_BUCKET_REGION }}
24+
PRIVATE_DOCS_S3_BUCKET_NAME: ${{ secrets.DEV2_PRIVATE_DOCS_S3_BUCKET_NAME }}
25+
PRIVATE_DOCS_S3_BUCKET_REGION: ${{ secrets.DEV2_PRIVATE_DOCS_S3_BUCKET_REGION }}
2026

2127
jobs:
2228
deploy_dev:

.github/workflows/deploy-fdr-lambda-prod.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ env:
1212
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
1313
GITHUB_TOKEN: ${{ secrets.FERN_GITHUB_TOKEN }}
1414
DATABASE_URL: ${{ secrets.PROD_RDS_PROXY_URL }}
15+
VENUS_URL: ${{ secrets.VENUS_URL }}
16+
PUBLIC_DOCS_CDN_URL: ${{ secrets.PUBLIC_DOCS_CDN_URL }}
17+
PUBLIC_DOCS_S3_BUCKET_NAME: ${{ secrets.PUBLIC_DOCS_S3_BUCKET_NAME }}
18+
PUBLIC_DOCS_S3_BUCKET_REGION: ${{ secrets.PUBLIC_DOCS_S3_BUCKET_REGION }}
19+
PRIVATE_DOCS_S3_BUCKET_NAME: ${{ secrets.PRIVATE_DOCS_S3_BUCKET_NAME }}
20+
PRIVATE_DOCS_S3_BUCKET_REGION: ${{ secrets.PRIVATE_DOCS_S3_BUCKET_REGION }}
1521

1622
jobs:
1723
deploy_prod:

.github/workflows/preview-fdr-lambda.yml

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ env:
1515
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
1616
GITHUB_TOKEN: ${{ secrets.FERN_GITHUB_TOKEN }}
1717
DATABASE_URL: ${{ secrets.DEV2_RDS_PROXY_URL }}
18+
VENUS_URL: ${{ secrets.DEV2_VENUS_URL }}
19+
PUBLIC_DOCS_CDN_URL: ${{ secrets.DEV2_PUBLIC_DOCS_CDN_URL }}
20+
PUBLIC_DOCS_S3_BUCKET_NAME: ${{ secrets.DEV2_PUBLIC_DOCS_S3_BUCKET_NAME }}
21+
PUBLIC_DOCS_S3_BUCKET_REGION: ${{ secrets.DEV2_PUBLIC_DOCS_S3_BUCKET_REGION }}
22+
PRIVATE_DOCS_S3_BUCKET_NAME: ${{ secrets.DEV2_PRIVATE_DOCS_S3_BUCKET_NAME }}
23+
PRIVATE_DOCS_S3_BUCKET_REGION: ${{ secrets.DEV2_PRIVATE_DOCS_S3_BUCKET_REGION }}
1824

1925
jobs:
2026
preview_lambda:
@@ -108,17 +114,24 @@ jobs:
108114
**📝 Available Endpoints:**
109115
- Base: \`GET ${previewUrl}\`
110116
- Health: \`GET ${previewUrl}/health\`
111-
- Metadata: \`POST ${previewUrl}/metadata-for-url\`
117+
- Metadata (public): \`POST ${previewUrl}/metadata-for-url\`
118+
- Load Docs (requires auth): \`POST ${previewUrl}/load-docs-for-url\`
112119
113120
**📋 Example Usage:**
114121
\`\`\`bash
115122
# Test default endpoint
116123
curl "${previewUrl}"
117124
118-
# Test metadata endpoint
125+
# Test metadata endpoint (public - no auth required)
119126
curl -X POST "${previewUrl}/metadata-for-url" \\
120127
-H "Content-Type: application/json" \\
121128
-d '{"url":"https://docs.buildwithfern.com"}'
129+
130+
# Test load docs endpoint (requires Fern token)
131+
curl -X POST "${previewUrl}/load-docs-for-url" \\
132+
-H "Content-Type: application/json" \\
133+
-H "Authorization: Bearer \$FERN_TOKEN" \\
134+
-d '{"url":"https://docs.buildwithfern.com"}'
122135
\`\`\`
123136
124137
**🏷️ Stack Name:** \`fdr-lambda-preview-${prNumber}\`

servers/fdr-lambda-deploy/scripts/fdr-lambda-deploy-stack.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,14 @@ export class FdrLambdaDeployStack extends Stack {
9696
NODE_ENV: "production",
9797
ENVIRONMENT_TYPE: environmentType,
9898
DATABASE_URL: getEnvironmentVariableOrThrow("DATABASE_URL"),
99+
VENUS_URL: getEnvironmentVariableOrThrow("VENUS_URL"),
100+
PUBLIC_DOCS_CDN_URL: getEnvironmentVariableOrThrow("PUBLIC_DOCS_CDN_URL"),
101+
PUBLIC_DOCS_S3_BUCKET_NAME: getEnvironmentVariableOrThrow("PUBLIC_DOCS_S3_BUCKET_NAME"),
102+
PUBLIC_DOCS_S3_BUCKET_REGION: getEnvironmentVariableOrDefault("PUBLIC_DOCS_S3_BUCKET_REGION", "us-east-1"),
103+
PRIVATE_DOCS_S3_BUCKET_NAME: getEnvironmentVariableOrThrow("PRIVATE_DOCS_S3_BUCKET_NAME"),
104+
PRIVATE_DOCS_S3_BUCKET_REGION: getEnvironmentVariableOrDefault("PRIVATE_DOCS_S3_BUCKET_REGION", "us-east-1"),
105+
AWS_ACCESS_KEY_ID: getEnvironmentVariableOrThrow("AWS_ACCESS_KEY_ID"),
106+
AWS_SECRET_ACCESS_KEY: getEnvironmentVariableOrThrow("AWS_SECRET_ACCESS_KEY"),
99107
...(isPreview && { IS_PREVIEW: "true", PR_NUMBER: prNumber! })
100108
}
101109
});
@@ -192,3 +200,7 @@ function getEnvironmentVariableOrThrow(environmentVariable: string): string {
192200
}
193201
return value;
194202
}
203+
204+
function getEnvironmentVariableOrDefault(environmentVariable: string, defaultValue: string): string {
205+
return process.env[environmentVariable] ?? defaultValue;
206+
}

servers/fdr-lambda/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"@aws-sdk/client-s3": "^3.744.0",
1717
"@aws-sdk/s3-request-presigner": "^3.744.0",
1818
"@fern-api/fdr-sdk": "workspace:*",
19+
"@fern-api/venus-api-sdk": "^0.19.5",
1920
"@types/aws-lambda": "^8.10.143"
2021
},
2122
"devDependencies": {

servers/fdr-lambda/src/__test__/handler.test.ts

Lines changed: 111 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
44
// Use vi.hoisted to ensure mocks are set up before module imports
55
const mockQuery = vi.hoisted(() => vi.fn());
66
const mockGetPresignedUrl = vi.hoisted(() => vi.fn());
7+
const mockIsMember = vi.hoisted(() => vi.fn());
78

89
vi.mock("pg", () => {
910
return {
@@ -18,6 +19,17 @@ vi.mock("../utils/s3", () => ({
1819
getPresignedDocsAssetsDownloadUrl: mockGetPresignedUrl
1920
}));
2021

22+
vi.mock("@fern-api/venus-api-sdk", () => ({
23+
FernVenusApiClient: vi.fn(() => ({
24+
organization: {
25+
isMember: mockIsMember
26+
}
27+
})),
28+
FernVenusApi: {
29+
OrganizationId: (id: string) => id
30+
}
31+
}));
32+
2133
// Import handler after mocks are configured
2234
import { handler } from "../index";
2335

@@ -27,16 +39,21 @@ describe("Lambda Handler", () => {
2739
vi.clearAllMocks();
2840
mockQuery.mockReset();
2941
mockGetPresignedUrl.mockReset();
42+
mockIsMember.mockReset();
3043
// Default S3 mock to return a URL
3144
mockGetPresignedUrl.mockResolvedValue("https://s3.example.com/file.png");
45+
// Default Venus mock to allow access (member of fern org)
46+
mockIsMember.mockResolvedValue({ ok: true, body: true });
47+
// Set VENUS_URL for tests
48+
process.env.VENUS_URL = "https://venus.buildwithfern.com";
3249
});
3350

34-
const createMockEvent = (path: string, method: string, body?: any): APIGatewayProxyEvent => {
51+
const createMockEvent = (path: string, method: string, body?: any, headers?: Record<string, string>): APIGatewayProxyEvent => {
3552
return {
3653
path,
3754
httpMethod: method,
3855
body: body ? JSON.stringify(body) : null,
39-
headers: {},
56+
headers: headers || {},
4057
multiValueHeaders: {},
4158
isBase64Encoded: false,
4259
pathParameters: null,
@@ -305,7 +322,7 @@ describe("Lambda Handler", () => {
305322
});
306323

307324
describe("POST /load-docs-for-url", () => {
308-
it("should return docs for a valid URL", async () => {
325+
it("should return docs for a valid URL with auth", async () => {
309326
const mockDocsDefinition = Buffer.from(
310327
JSON.stringify({
311328
type: "v3",
@@ -345,6 +362,8 @@ describe("Lambda Handler", () => {
345362

346363
const event = createMockEvent("/load-docs-for-url", "POST", {
347364
url: "https://docs.example.com"
365+
}, {
366+
Authorization: "Bearer test-token"
348367
});
349368
const context = createMockContext();
350369

@@ -390,6 +409,8 @@ describe("Lambda Handler", () => {
390409

391410
const event = createMockEvent("/v2/registry/docs/load-docs-for-url", "POST", {
392411
url: "docs.test.com"
412+
}, {
413+
Authorization: "Bearer test-token"
393414
});
394415
const context = createMockContext();
395416

@@ -429,6 +450,89 @@ describe("Lambda Handler", () => {
429450
expect(body.error).toBe("InvalidUrlError");
430451
});
431452

453+
it("should return 401 when authorization header is missing", async () => {
454+
const mockDocsDefinition = Buffer.from(
455+
JSON.stringify({
456+
type: "v3",
457+
pages: {},
458+
config: {
459+
navigation: { items: [] },
460+
colorsV3: { type: "light" }
461+
},
462+
files: {},
463+
referencedApis: []
464+
})
465+
);
466+
467+
mockQuery.mockResolvedValueOnce({
468+
rows: [
469+
{
470+
orgID: "test-org",
471+
domain: "docs.example.com",
472+
path: "",
473+
docsDefinition: mockDocsDefinition,
474+
docsConfigInstanceId: "config-123",
475+
authType: "PUBLIC",
476+
hasPublicS3Assets: true
477+
}
478+
]
479+
});
480+
481+
const event = createMockEvent("/load-docs-for-url", "POST", {
482+
url: "https://docs.example.com"
483+
}); // No auth header
484+
const context = createMockContext();
485+
486+
const result = await handler(event, context);
487+
488+
expect(result.statusCode).toBe(401);
489+
expect(JSON.parse(result.body).error).toBe("UnauthorizedError");
490+
});
491+
492+
it("should return 403 when user is not in the org", async () => {
493+
const mockDocsDefinition = Buffer.from(
494+
JSON.stringify({
495+
type: "v3",
496+
pages: {},
497+
config: {
498+
navigation: { items: [] },
499+
colorsV3: { type: "light" }
500+
},
501+
files: {},
502+
referencedApis: []
503+
})
504+
);
505+
506+
mockQuery.mockResolvedValueOnce({
507+
rows: [
508+
{
509+
orgID: "test-org",
510+
domain: "docs.example.com",
511+
path: "",
512+
docsDefinition: mockDocsDefinition,
513+
docsConfigInstanceId: "config-123",
514+
authType: "PUBLIC",
515+
hasPublicS3Assets: true
516+
}
517+
]
518+
});
519+
520+
// Mock Venus to deny access (not in fern org, not in specific org)
521+
mockIsMember.mockResolvedValue({ ok: true, body: false });
522+
523+
const event = createMockEvent("/load-docs-for-url", "POST", {
524+
url: "https://docs.example.com"
525+
}, {
526+
Authorization: "Bearer test-token"
527+
});
528+
const context = createMockContext();
529+
530+
const result = await handler(event, context);
531+
532+
expect(result.statusCode).toBe(403);
533+
expect(JSON.parse(result.body).error).toBe("UserNotInOrgError");
534+
});
535+
432536
it("should return 404 when domain is not registered", async () => {
433537
// Mock empty DocsV2 result and empty V1 Docs result (fallback)
434538
mockQuery
@@ -437,6 +541,8 @@ describe("Lambda Handler", () => {
437541

438542
const event = createMockEvent("/load-docs-for-url", "POST", {
439543
url: "https://unknown.example.com"
544+
}, {
545+
Authorization: "Bearer test-token"
440546
});
441547
const context = createMockContext();
442548

@@ -516,6 +622,8 @@ describe("Lambda Handler", () => {
516622

517623
const event = createMockEvent("/load-docs-for-url", "POST", {
518624
url: "https://docs.example.com"
625+
}, {
626+
Authorization: "Bearer test-token"
519627
});
520628
const context = createMockContext();
521629

servers/fdr-lambda/src/errors.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
export class DomainNotRegisteredError extends Error {
2+
constructor() {
3+
super("Domain not registered");
4+
this.name = "DomainNotRegisteredError";
5+
}
6+
}
7+
8+
export class InvalidUrlError extends Error {
9+
constructor(url: string, originalError: Error) {
10+
super(`Invalid URL: ${url}`);
11+
this.name = "InvalidUrlError";
12+
this.cause = originalError;
13+
}
14+
}
15+
16+
export class UnauthorizedError extends Error {
17+
constructor(message: string) {
18+
super(message);
19+
this.name = "UnauthorizedError";
20+
}
21+
}
22+
23+
export class UserNotInOrgError extends Error {
24+
constructor(message: string) {
25+
super(message);
26+
this.name = "UserNotInOrgError";
27+
}
28+
}
29+
30+
export class UnavailableError extends Error {
31+
constructor(message: string) {
32+
super(message);
33+
this.name = "UnavailableError";
34+
}
35+
}

servers/fdr-lambda/src/index.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import type { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from "aws-lambda";
22
import { Pool } from "pg";
3+
import { DomainNotRegisteredError, InvalidUrlError, UnauthorizedError, UserNotInOrgError } from "./errors";
34
import { getDocsForUrl } from "./services/getDocsForUrl";
4-
import { DomainNotRegisteredError, getMetadataForUrl, InvalidUrlError } from "./services/getMetadataForUrl";
5+
import { getMetadataForUrl } from "./services/getMetadataForUrl";
56
import { initializeS3 } from "./utils/s3";
67

78
// Create connection pool outside handler for connection reuse
@@ -103,7 +104,14 @@ export const handler = async (event: APIGatewayProxyEvent, context: Context): Pr
103104
throw new InvalidUrlError(body.url, error as Error);
104105
}
105106

106-
const docsResponse = await getDocsForUrl(parsedUrl, pool);
107+
// Extract Authorization header (case-insensitive)
108+
const authHeader =
109+
event.headers?.Authorization ||
110+
event.headers?.authorization ||
111+
event.headers?.["x-fern-token"] ||
112+
event.headers?.["X-Fern-Token"];
113+
114+
const docsResponse = await getDocsForUrl(parsedUrl, pool, authHeader);
107115

108116
return {
109117
statusCode: 200,
@@ -173,6 +181,38 @@ export const handler = async (event: APIGatewayProxyEvent, context: Context): Pr
173181
};
174182
}
175183

184+
// Handle UnauthorizedError
185+
if (error instanceof UnauthorizedError) {
186+
return {
187+
statusCode: 401,
188+
headers: {
189+
"Content-Type": "application/json",
190+
"Access-Control-Allow-Origin": "*"
191+
},
192+
body: JSON.stringify({
193+
error: "UnauthorizedError",
194+
message: error.message,
195+
requestId: context.awsRequestId
196+
})
197+
};
198+
}
199+
200+
// Handle UserNotInOrgError
201+
if (error instanceof UserNotInOrgError) {
202+
return {
203+
statusCode: 403,
204+
headers: {
205+
"Content-Type": "application/json",
206+
"Access-Control-Allow-Origin": "*"
207+
},
208+
body: JSON.stringify({
209+
error: "UserNotInOrgError",
210+
message: error.message,
211+
requestId: context.awsRequestId
212+
})
213+
};
214+
}
215+
176216
return {
177217
statusCode: 500,
178218
headers: {

0 commit comments

Comments
 (0)