diff --git a/fern/apis/fdr/definition/commons.yml b/fern/apis/fdr/definition/commons.yml index 6bb8431c46..8be958bad3 100644 --- a/fern/apis/fdr/definition/commons.yml +++ b/fern/apis/fdr/definition/commons.yml @@ -43,6 +43,8 @@ types: PropertyKey: string + AudienceId: string + EndpointIdentifier: properties: path: EndpointPathLiteral diff --git a/fern/apis/fdr/definition/navigation/latest/__package__.yml b/fern/apis/fdr/definition/navigation/latest/__package__.yml index 63c422187c..1b39192128 100644 --- a/fern/apis/fdr/definition/navigation/latest/__package__.yml +++ b/fern/apis/fdr/definition/navigation/latest/__package__.yml @@ -334,7 +334,21 @@ types: type: optional docs: The slug that should be used in the canonical URL rel. If not provided, the `slug` will be used. icon: optional - hidden: optional + hidden: + type: optional + docs: If true, this node will not be displayed in the sidebar, and noindex will be considered true. + authed: + type: optional + docs: | + If true, this node is only visible to authenticated users. + If false, this node is only visible to all users (including anonymous). + audience: + type: optional> + availability: in-development + docs: | + The audience(s) that this node is intended for. If not provided, the node is intended for all audiences. + If provided, the node is only intended for the specified audience(s). OR logic is used for multiple audiences on a single node. + AND logic is used when evaluating audiences up the tree. WithPage: properties: diff --git a/packages/commons/fdr-utils/src/types.ts b/packages/commons/fdr-utils/src/types.ts index 2dd58c3d7e..6fd309517b 100644 --- a/packages/commons/fdr-utils/src/types.ts +++ b/packages/commons/fdr-utils/src/types.ts @@ -13,6 +13,8 @@ export interface VersionSwitcherInfo { index: number; availability: FernNavigation.Availability | undefined; pointsTo: FernNavigation.Slug | undefined; + hidden: boolean | undefined; + authed: boolean | undefined; } interface SidebarTabGroup { @@ -22,6 +24,8 @@ interface SidebarTabGroup { index: number; slug: FernNavigation.Slug; pointsTo: FernNavigation.Slug | undefined; + hidden: boolean | undefined; + authed: boolean | undefined; } interface SidebarTabLink { @@ -38,6 +42,8 @@ interface SidebarTabChangelog { icon: string | undefined; index: number; slug: FernNavigation.Slug; + hidden: boolean | undefined; + authed: boolean | undefined; } export type SidebarTab = SidebarTabGroup | SidebarTabLink | SidebarTabChangelog; diff --git a/packages/fdr-sdk/src/__test__/__snapshots__/hume.test.ts.snap b/packages/fdr-sdk/src/__test__/__snapshots__/hume.test.ts.snap index 672224d879..e1694aca1e 100644 --- a/packages/fdr-sdk/src/__test__/__snapshots__/hume.test.ts.snap +++ b/packages/fdr-sdk/src/__test__/__snapshots__/hume.test.ts.snap @@ -117,6 +117,8 @@ exports[`hume > gets navigation root for /reference/expression-measurement-api/s exports[`hume > gets navigation root for /support 1`] = ` { + "audience": undefined, + "authed": undefined, "canonicalSlug": undefined, "hidden": false, "icon": undefined, diff --git a/packages/fdr-sdk/src/__test__/__snapshots__/no-version-no-tabs.test.ts.snap b/packages/fdr-sdk/src/__test__/__snapshots__/no-version-no-tabs.test.ts.snap index 13817c5564..bc048a47ac 100644 --- a/packages/fdr-sdk/src/__test__/__snapshots__/no-version-no-tabs.test.ts.snap +++ b/packages/fdr-sdk/src/__test__/__snapshots__/no-version-no-tabs.test.ts.snap @@ -20,6 +20,8 @@ exports[`no-version-no-tabs > gets navigation root for /docs 1`] = ` exports[`no-version-no-tabs > gets navigation root for /docs/api/page-6 1`] = ` { + "audience": undefined, + "authed": undefined, "canonicalSlug": undefined, "hidden": undefined, "icon": undefined, @@ -45,6 +47,8 @@ exports[`no-version-no-tabs > gets navigation root for /docs/api/section-1 1`] = exports[`no-version-no-tabs > gets navigation root for /docs/api/section-2 1`] = ` { + "audience": undefined, + "authed": undefined, "canonicalSlug": undefined, "hidden": undefined, "icon": undefined, diff --git a/packages/fdr-sdk/src/__test__/__snapshots__/no-version-yes-tabs.test.ts.snap b/packages/fdr-sdk/src/__test__/__snapshots__/no-version-yes-tabs.test.ts.snap index 12e8c6d438..b66d401faf 100644 --- a/packages/fdr-sdk/src/__test__/__snapshots__/no-version-yes-tabs.test.ts.snap +++ b/packages/fdr-sdk/src/__test__/__snapshots__/no-version-yes-tabs.test.ts.snap @@ -23,6 +23,8 @@ exports[`no-version-yes-tabs > gets navigation root for /docs/api 1`] = ` exports[`no-version-yes-tabs > gets navigation root for /docs/api/page-2 1`] = ` { + "audience": undefined, + "authed": undefined, "canonicalSlug": undefined, "hidden": undefined, "icon": undefined, @@ -48,6 +50,8 @@ exports[`no-version-yes-tabs > gets navigation root for /docs/api/section-1 1`] exports[`no-version-yes-tabs > gets navigation root for /docs/api/tab-1 1`] = ` { + "audience": undefined, + "authed": undefined, "canonicalSlug": undefined, "hidden": undefined, "icon": undefined, @@ -66,6 +70,8 @@ exports[`no-version-yes-tabs > gets navigation root for /docs/api/tab-1 3`] = `" exports[`no-version-yes-tabs > gets navigation root for /docs/api/tab-2 1`] = ` { + "audience": undefined, + "authed": undefined, "canonicalSlug": undefined, "hidden": undefined, "icon": undefined, diff --git a/packages/fdr-sdk/src/__test__/__snapshots__/polytomic.test.ts.snap b/packages/fdr-sdk/src/__test__/__snapshots__/polytomic.test.ts.snap index 06b6c5951d..b60bdf8364 100644 --- a/packages/fdr-sdk/src/__test__/__snapshots__/polytomic.test.ts.snap +++ b/packages/fdr-sdk/src/__test__/__snapshots__/polytomic.test.ts.snap @@ -861,6 +861,8 @@ exports[`polytomic > gets navigation root for /2023-04-25/not-found 1`] = ` exports[`polytomic > gets navigation root for /2024-02-08/guides/introduction 1`] = ` { + "audience": undefined, + "authed": undefined, "canonicalSlug": undefined, "hidden": false, "icon": undefined, diff --git a/packages/fdr-sdk/src/__test__/__snapshots__/primer.test.ts.snap b/packages/fdr-sdk/src/__test__/__snapshots__/primer.test.ts.snap index 5fa1ae07f8..b4c078d49d 100644 --- a/packages/fdr-sdk/src/__test__/__snapshots__/primer.test.ts.snap +++ b/packages/fdr-sdk/src/__test__/__snapshots__/primer.test.ts.snap @@ -157,6 +157,8 @@ exports[`primer > gets navigation root for /docs/api/introduction/getting-starte exports[`primer > gets navigation root for /docs/api/v2.1/api-reference/client-session-api/retrieve-client-side-token 1`] = ` { "apiDefinitionId": "7ed504c0-fc2e-4a52-b3fd-b277869eda14", + "audience": undefined, + "authed": undefined, "availability": undefined, "canonicalSlug": "docs/api/v2.3/api-reference/client-session-api/retrieve-client-side-token", "endpointId": "subpackage_clientSessionApi.retrieveClientSideToken", diff --git a/packages/fdr-sdk/src/__test__/__snapshots__/uploadcare.test.ts.snap b/packages/fdr-sdk/src/__test__/__snapshots__/uploadcare.test.ts.snap index 13f87f383f..5e4b745b75 100644 --- a/packages/fdr-sdk/src/__test__/__snapshots__/uploadcare.test.ts.snap +++ b/packages/fdr-sdk/src/__test__/__snapshots__/uploadcare.test.ts.snap @@ -86,6 +86,8 @@ exports[`uploadcare > gets navigation root for /docs/file-management 1`] = ` exports[`uploadcare > gets navigation root for /docs/file-management/overview 1`] = ` { + "audience": undefined, + "authed": undefined, "canonicalSlug": undefined, "hidden": undefined, "icon": undefined, @@ -118,6 +120,8 @@ exports[`uploadcare > gets navigation root for /docs/introduction 1`] = ` exports[`uploadcare > gets navigation root for /docs/introduction/intro 1`] = ` { + "audience": undefined, + "authed": undefined, "canonicalSlug": undefined, "hidden": undefined, "icon": undefined, diff --git a/packages/fdr-sdk/src/__test__/__snapshots__/yes-version-no-tabs.test.ts.snap b/packages/fdr-sdk/src/__test__/__snapshots__/yes-version-no-tabs.test.ts.snap index 5cb6556268..d4174b3ffc 100644 --- a/packages/fdr-sdk/src/__test__/__snapshots__/yes-version-no-tabs.test.ts.snap +++ b/packages/fdr-sdk/src/__test__/__snapshots__/yes-version-no-tabs.test.ts.snap @@ -67,6 +67,8 @@ exports[`yes-version-no-tabs > gets navigation root for /docs/api/version-1 3`] exports[`yes-version-no-tabs > gets navigation root for /docs/api/version-1/page-5 1`] = ` { + "audience": undefined, + "authed": undefined, "canonicalSlug": undefined, "hidden": undefined, "icon": undefined, @@ -92,6 +94,8 @@ exports[`yes-version-no-tabs > gets navigation root for /docs/api/version-1/sect exports[`yes-version-no-tabs > gets navigation root for /docs/api/version-1/version-1 1`] = ` { + "audience": undefined, + "authed": undefined, "canonicalSlug": undefined, "hidden": undefined, "icon": undefined, @@ -117,6 +121,8 @@ exports[`yes-version-no-tabs > gets navigation root for /docs/api/version-2 1`] exports[`yes-version-no-tabs > gets navigation root for /docs/api/version-2/version-1 1`] = ` { + "audience": undefined, + "authed": undefined, "canonicalSlug": undefined, "hidden": undefined, "icon": undefined, diff --git a/packages/fdr-sdk/src/__test__/__snapshots__/yes-version-yes-tabs.test.ts.snap b/packages/fdr-sdk/src/__test__/__snapshots__/yes-version-yes-tabs.test.ts.snap index fa30ca83c2..089d119e7e 100644 --- a/packages/fdr-sdk/src/__test__/__snapshots__/yes-version-yes-tabs.test.ts.snap +++ b/packages/fdr-sdk/src/__test__/__snapshots__/yes-version-yes-tabs.test.ts.snap @@ -70,6 +70,8 @@ exports[`yes-version-yes-tabs > gets navigation root for /docs/api/version-1 1`] exports[`yes-version-yes-tabs > gets navigation root for /docs/api/version-1/section-2 1`] = ` { + "audience": undefined, + "authed": undefined, "canonicalSlug": undefined, "hidden": undefined, "icon": undefined, diff --git a/packages/fdr-sdk/src/client/generated/api/resources/commons/types/AudienceId.ts b/packages/fdr-sdk/src/client/generated/api/resources/commons/types/AudienceId.ts new file mode 100644 index 0000000000..a48acc77b7 --- /dev/null +++ b/packages/fdr-sdk/src/client/generated/api/resources/commons/types/AudienceId.ts @@ -0,0 +1,13 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +import * as FernRegistry from "../../../index"; + +export type AudienceId = string & { + AudienceId: void; +}; + +export function AudienceId(value: string): FernRegistry.AudienceId { + return value as unknown as FernRegistry.AudienceId; +} diff --git a/packages/fdr-sdk/src/client/generated/api/resources/commons/types/index.ts b/packages/fdr-sdk/src/client/generated/api/resources/commons/types/index.ts index 11cb260750..401177ffa5 100644 --- a/packages/fdr-sdk/src/client/generated/api/resources/commons/types/index.ts +++ b/packages/fdr-sdk/src/client/generated/api/resources/commons/types/index.ts @@ -14,6 +14,7 @@ export * from "./FileId"; export * from "./Url"; export * from "./JqString"; export * from "./PropertyKey"; +export * from "./AudienceId"; export * from "./EndpointIdentifier"; export * from "./EndpointPathLiteral"; export * from "./HttpMethod"; diff --git a/packages/fdr-sdk/src/client/generated/api/resources/navigation/resources/latest/types/WithNodeMetadata.ts b/packages/fdr-sdk/src/client/generated/api/resources/navigation/resources/latest/types/WithNodeMetadata.ts index 3a68364651..8654b97802 100644 --- a/packages/fdr-sdk/src/client/generated/api/resources/navigation/resources/latest/types/WithNodeMetadata.ts +++ b/packages/fdr-sdk/src/client/generated/api/resources/navigation/resources/latest/types/WithNodeMetadata.ts @@ -22,5 +22,17 @@ export interface WithNodeMetadata extends FernRegistry.navigation.latest.WithNod /** The slug that should be used in the canonical URL rel. If not provided, the `slug` will be used. */ canonicalSlug: FernRegistry.navigation.latest.Slug | undefined; icon: string | undefined; + /** If true, this node will not be displayed in the sidebar, and noindex will be considered true. */ hidden: boolean | undefined; + /** + * If true, this node is only visible to authenticated users. + * If false, this node is only visible to all users (including anonymous). + */ + authed: boolean | undefined; + /** + * The audience(s) that this node is intended for. If not provided, the node is intended for all audiences. + * If provided, the node is only intended for the specified audience(s). OR logic is used for multiple audiences on a single node. + * AND logic is used when evaluating audiences up the tree. + */ + audience: FernRegistry.AudienceId[] | undefined; } diff --git a/packages/fdr-sdk/src/navigation/index.ts b/packages/fdr-sdk/src/navigation/index.ts index 9f69acc109..b7ade428d6 100644 --- a/packages/fdr-sdk/src/navigation/index.ts +++ b/packages/fdr-sdk/src/navigation/index.ts @@ -7,5 +7,6 @@ export * from "../client/generated/api/resources/commons"; export * from "./ApiDefinitionHolder"; export * as migrate from "./migrators"; export * as utils from "./utils"; +export * from "./utils/pruneNavigationTree"; export * from "./versions"; export { ApiDefinitionHolder, ApiDefinitionPruner, ApiTypeIdVisitor, NodeCollector }; diff --git a/packages/fdr-sdk/src/navigation/migrators/v1ToV2.ts b/packages/fdr-sdk/src/navigation/migrators/v1ToV2.ts index 5880ac3c1a..643e0ead2d 100644 --- a/packages/fdr-sdk/src/navigation/migrators/v1ToV2.ts +++ b/packages/fdr-sdk/src/navigation/migrators/v1ToV2.ts @@ -30,6 +30,8 @@ export class FernNavigationV1ToLatest { canonicalSlug: undefined, icon: node.icon, hidden: node.hidden, + authed: undefined, + audience: undefined, }; return latest; @@ -96,8 +98,10 @@ export class FernNavigationV1ToLatest { canonicalSlug: undefined, icon: node.icon, hidden: node.hidden, + authed: undefined, id: FernNavigation.NodeId(node.id), pointsTo: node.pointsTo ? FernNavigation.Slug(node.pointsTo) : undefined, + audience: undefined, }; return latest; }; @@ -118,9 +122,11 @@ export class FernNavigationV1ToLatest { canonicalSlug, icon: node.icon, hidden: node.hidden, + authed: undefined, id: FernNavigation.NodeId(node.id), pageId: FernNavigation.PageId(node.pageId), noindex: node.noindex, + audience: undefined, }; return latest; }; @@ -155,8 +161,10 @@ export class FernNavigationV1ToLatest { canonicalSlug: undefined, icon: node.icon, hidden: node.hidden, + authed: undefined, id: FernNavigation.NodeId(node.id), pointsTo: node.pointsTo ? FernNavigation.Slug(node.pointsTo) : undefined, + audience: undefined, }; return latest; }; @@ -248,6 +256,7 @@ export class FernNavigationV1ToLatest { canonicalSlug: undefined, icon: node.icon, hidden: node.hidden, + authed: undefined, pointsTo: node.pointsTo ? FernNavigation.Slug(node.pointsTo) : undefined, default: node.default, productId: FernNavigation.ProductId(node.productId), @@ -256,6 +265,7 @@ export class FernNavigationV1ToLatest { versioned: (value) => this.versioned(value, [...parents, node]), }), subtitle: node.subtitle, + audience: undefined, }; return latest; }; @@ -307,8 +317,10 @@ export class FernNavigationV1ToLatest { canonicalSlug, icon: node.icon, hidden: node.hidden, + authed: undefined, pageId: FernNavigation.PageId(node.pageId), noindex: node.noindex, + audience: undefined, }; return latest; }; @@ -335,10 +347,12 @@ export class FernNavigationV1ToLatest { canonicalSlug, icon: node.icon, hidden: node.hidden, + authed: undefined, pointsTo: node.pointsTo ? FernNavigation.Slug(node.pointsTo) : undefined, collapsed: node.collapsed, overviewPageId, noindex: node.noindex, + audience: undefined, }; return latest; }; @@ -369,12 +383,14 @@ export class FernNavigationV1ToLatest { canonicalSlug, icon: node.icon, hidden: node.hidden, + authed: undefined, id: FernNavigation.NodeId(node.id), overviewPageId, noindex: node.noindex, apiDefinitionId: node.apiDefinitionId, availability: this.#availability(node.availability), pointsTo: node.pointsTo ? FernNavigation.Slug(node.pointsTo) : undefined, + audience: undefined, }; return latest; }; @@ -401,8 +417,10 @@ export class FernNavigationV1ToLatest { canonicalSlug, icon: node.icon, hidden: node.hidden, + authed: undefined, overviewPageId, noindex: node.noindex, + audience: undefined, }; return latest; }; @@ -420,7 +438,9 @@ export class FernNavigationV1ToLatest { canonicalSlug: undefined, icon: node.icon, hidden: node.hidden, + authed: undefined, year: node.year, + audience: undefined, }; return latest; }; @@ -438,7 +458,9 @@ export class FernNavigationV1ToLatest { canonicalSlug: undefined, icon: node.icon, hidden: node.hidden, + authed: undefined, month: node.month, + audience: undefined, }; return latest; }; @@ -460,9 +482,11 @@ export class FernNavigationV1ToLatest { canonicalSlug, icon: node.icon, hidden: node.hidden, + authed: undefined, date: node.date, pageId: FernNavigation.PageId(node.pageId), noindex: node.noindex, + audience: undefined, }; return latest; }; @@ -489,12 +513,14 @@ export class FernNavigationV1ToLatest { canonicalSlug, icon: node.icon, hidden: node.hidden, + authed: undefined, pointsTo: node.pointsTo ? FernNavigation.Slug(node.pointsTo) : undefined, playground: node.playground, overviewPageId, noindex: node.noindex, apiDefinitionId: node.apiDefinitionId, availability: this.#availability(node.availability), + audience: undefined, }; return latest; }; @@ -519,12 +545,14 @@ export class FernNavigationV1ToLatest { canonicalSlug, icon: node.icon, hidden: node.hidden, + authed: undefined, playground: node.playground, apiDefinitionId: node.apiDefinitionId, availability: this.#availability(node.availability), method: node.method, endpointId: node.endpointId, isResponseStream: node.isResponseStream, + audience: undefined, }; return latest; }; @@ -562,10 +590,12 @@ export class FernNavigationV1ToLatest { canonicalSlug, icon: node.icon, hidden: node.hidden, + authed: undefined, playground: node.playground, apiDefinitionId: node.apiDefinitionId, availability: this.#availability(node.availability), webSocketId: node.webSocketId, + audience: undefined, }; return latest; }; @@ -590,10 +620,12 @@ export class FernNavigationV1ToLatest { canonicalSlug, icon: node.icon, hidden: node.hidden, + authed: undefined, apiDefinitionId: node.apiDefinitionId, availability: this.#availability(node.availability), method: node.method, webhookId: node.webhookId, + audience: undefined, }; return latest; }; diff --git a/packages/fdr-sdk/src/navigation/utils/__test__/pruneNavigationTree.test.ts b/packages/fdr-sdk/src/navigation/utils/__test__/pruneNavigationTree.test.ts index 2ec3a34327..c0e87786f7 100644 --- a/packages/fdr-sdk/src/navigation/utils/__test__/pruneNavigationTree.test.ts +++ b/packages/fdr-sdk/src/navigation/utils/__test__/pruneNavigationTree.test.ts @@ -1,5 +1,5 @@ import { FernNavigation } from "../../.."; -import { pruneNavigationTree } from "../pruneNavigationTree"; +import { Pruner } from "../pruneNavigationTree"; describe("pruneNavigationTree", () => { it("should not prune the tree if keep returns true for all nodes", () => { @@ -18,19 +18,25 @@ describe("pruneNavigationTree", () => { canonicalSlug: undefined, icon: undefined, hidden: undefined, + authed: undefined, noindex: undefined, + audience: undefined, }, ], collapsed: undefined, canonicalSlug: undefined, icon: undefined, hidden: undefined, + authed: undefined, overviewPageId: undefined, noindex: undefined, pointsTo: undefined, + audience: undefined, }; - const result = pruneNavigationTree(root, () => true); + const result = Pruner.from(root) + .keep(() => true) + .get(); // structuredClone should duplicate the object expect(result === root).toBe(false); @@ -50,16 +56,20 @@ describe("pruneNavigationTree", () => { canonicalSlug: undefined, icon: undefined, hidden: undefined, + authed: undefined, noindex: undefined, + audience: undefined, }, ], collapsed: undefined, canonicalSlug: undefined, icon: undefined, hidden: undefined, + authed: undefined, overviewPageId: undefined, noindex: undefined, pointsTo: FernNavigation.Slug("root/page"), + audience: undefined, }); }); @@ -79,19 +89,25 @@ describe("pruneNavigationTree", () => { canonicalSlug: undefined, icon: undefined, hidden: undefined, + authed: undefined, noindex: undefined, + audience: undefined, }, ], collapsed: undefined, canonicalSlug: undefined, icon: undefined, hidden: undefined, + authed: undefined, overviewPageId: undefined, noindex: undefined, pointsTo: FernNavigation.Slug("root/page"), + audience: undefined, }; - const result = pruneNavigationTree(root, (node) => node.id !== FernNavigation.NodeId("page")); + const result = Pruner.from(root) + .keep((node) => node.id !== FernNavigation.NodeId("page")) + .get(); expect(result).toBeUndefined(); }); @@ -113,18 +129,24 @@ describe("pruneNavigationTree", () => { canonicalSlug: undefined, icon: undefined, hidden: undefined, + authed: undefined, noindex: undefined, + audience: undefined, }, ], collapsed: undefined, canonicalSlug: undefined, icon: undefined, hidden: undefined, + authed: undefined, noindex: undefined, pointsTo: undefined, + audience: undefined, }; - const result = pruneNavigationTree(root, (node) => node.id !== "root"); + const result = Pruner.from(root) + .keep((node) => node.id !== "root") + .get(); // structuredClone should duplicate the object expect(result === root).toBe(false); @@ -145,15 +167,19 @@ describe("pruneNavigationTree", () => { canonicalSlug: undefined, icon: undefined, hidden: undefined, + authed: undefined, noindex: undefined, + audience: undefined, }, ], collapsed: undefined, canonicalSlug: undefined, icon: undefined, hidden: undefined, + authed: undefined, noindex: undefined, pointsTo: FernNavigation.Slug("root/page"), + audience: undefined, }); }); @@ -174,18 +200,24 @@ describe("pruneNavigationTree", () => { canonicalSlug: undefined, icon: undefined, hidden: undefined, + authed: undefined, noindex: undefined, + audience: undefined, }, ], collapsed: undefined, canonicalSlug: undefined, icon: undefined, hidden: undefined, + authed: undefined, noindex: undefined, pointsTo: undefined, + audience: undefined, }; - const result = pruneNavigationTree(root, (node) => node.id !== "page"); + const result = Pruner.from(root) + .keep((node) => node.id !== "page") + .get(); // structuredClone should duplicate the object expect(result === root).toBe(false); @@ -201,8 +233,10 @@ describe("pruneNavigationTree", () => { canonicalSlug: undefined, icon: undefined, hidden: undefined, + authed: undefined, noindex: undefined, pointsTo: undefined, + audience: undefined, }); }); @@ -223,18 +257,24 @@ describe("pruneNavigationTree", () => { canonicalSlug: undefined, icon: undefined, hidden: undefined, + authed: undefined, noindex: undefined, + audience: undefined, }, ], collapsed: undefined, canonicalSlug: undefined, icon: undefined, hidden: undefined, + authed: undefined, noindex: undefined, pointsTo: undefined, + audience: undefined, }; - const result = pruneNavigationTree(root, (node) => node.id !== "root"); + const result = Pruner.from(root) + .keep((node) => node.id !== "root") + .get(); // structuredClone should duplicate the object expect(result === root).toBe(false); @@ -255,15 +295,19 @@ describe("pruneNavigationTree", () => { canonicalSlug: undefined, icon: undefined, hidden: undefined, + authed: undefined, noindex: undefined, + audience: undefined, }, ], collapsed: undefined, canonicalSlug: undefined, icon: undefined, hidden: undefined, + authed: undefined, noindex: undefined, pointsTo: FernNavigation.Slug("root/page"), + audience: undefined, }); }); @@ -291,15 +335,19 @@ describe("pruneNavigationTree", () => { canonicalSlug: undefined, icon: undefined, hidden: undefined, + authed: undefined, noindex: undefined, + audience: undefined, }, ], collapsed: undefined, canonicalSlug: undefined, icon: undefined, hidden: undefined, + authed: undefined, noindex: undefined, pointsTo: undefined, + audience: undefined, }, { type: "page", @@ -310,18 +358,24 @@ describe("pruneNavigationTree", () => { canonicalSlug: undefined, icon: undefined, hidden: undefined, + authed: undefined, noindex: undefined, + audience: undefined, }, ], collapsed: undefined, canonicalSlug: undefined, icon: undefined, hidden: undefined, + authed: undefined, noindex: undefined, pointsTo: undefined, + audience: undefined, }; - const result = pruneNavigationTree(root, (node) => node.id !== "page1"); + const result = Pruner.from(root) + .keep((node) => node.id !== "page1") + .get(); // structuredClone should duplicate the object expect(result === root).toBe(false); @@ -342,14 +396,18 @@ describe("pruneNavigationTree", () => { canonicalSlug: undefined, icon: undefined, hidden: undefined, + authed: undefined, noindex: undefined, + audience: undefined, }, ], collapsed: undefined, canonicalSlug: undefined, icon: undefined, hidden: undefined, + authed: undefined, noindex: undefined, + audience: undefined, // NOTE: points to is updated! pointsTo: "root/page", diff --git a/packages/fdr-sdk/src/navigation/utils/index.ts b/packages/fdr-sdk/src/navigation/utils/index.ts index b1e2b704f8..44f3d27e67 100644 --- a/packages/fdr-sdk/src/navigation/utils/index.ts +++ b/packages/fdr-sdk/src/navigation/utils/index.ts @@ -4,6 +4,5 @@ export * from "./createBreadcrumbs"; export * from "./findNode"; export * from "./getApiReferenceId"; export * from "./getNoIndexFromFrontmatter"; -export * from "./pruneNavigationTree"; export * from "./toRootNode"; export * from "./toUnversionedSlug"; diff --git a/packages/fdr-sdk/src/navigation/utils/pruneNavigationTree.ts b/packages/fdr-sdk/src/navigation/utils/pruneNavigationTree.ts index 9bc3c8ac81..3fd99d9ab6 100644 --- a/packages/fdr-sdk/src/navigation/utils/pruneNavigationTree.ts +++ b/packages/fdr-sdk/src/navigation/utils/pruneNavigationTree.ts @@ -1,55 +1,71 @@ import structuredClone from "@ungap/structured-clone"; -import { DeepReadonly } from "ts-essentials"; import { FernNavigation } from "../.."; -import { bfs } from "../../utils/traversers/bfs"; import { prunetree } from "../../utils/traversers/prunetree"; import { mutableDeleteChild } from "./deleteChild"; import { mutableUpdatePointsTo } from "./updatePointsTo"; -/** - * @param root the root node of the navigation tree - * @param keep a function that returns true if the node should be kept - * @param hide a function that returns true if the node should be hidden - * @returns a new navigation tree with only the nodes that should be kept - */ -export function pruneNavigationTree( - root: DeepReadonly, - keep?: (node: FernNavigation.NavigationNode) => boolean, - hide?: (node: FernNavigation.NavigationNodeWithMetadata) => boolean, -): ROOT | undefined { - const clone = structuredClone(root) as ROOT; - return mutablePruneNavigationTree(clone, keep, hide); -} +type Predicate = ( + node: T, + parents: readonly FernNavigation.NavigationNodeParent[], +) => boolean; + +export class Pruner { + public static from(tree: ROOT): Pruner { + return new Pruner(tree); + } -function mutablePruneNavigationTree( - root: ROOT, - keep: (node: FernNavigation.NavigationNode) => boolean = () => true, - hide: (node: FernNavigation.NavigationNodeWithMetadata) => boolean = () => false, -): ROOT | undefined { - const [result] = prunetree(root, { - predicate: keep, - getChildren: FernNavigation.getChildren, - getPointer: (node) => node.id, - deleter: mutableDeleteChild, - }); - - if (result == null) { - return undefined; + private tree: ROOT | undefined; + private constructor(tree: ROOT) { + this.tree = structuredClone(tree) as ROOT; } - // since the tree has been pruned, we need to update the pointsTo property - mutableUpdatePointsTo(result); + public keep(predicate: Predicate): this { + if (this.tree == null) { + return this; + } + const [result] = prunetree(this.tree, { + predicate, + getChildren: FernNavigation.getChildren, + getPointer: (node) => node.id, + deleter: mutableDeleteChild, + }); + this.tree = result; + return this; + } + + public remove(predicate: Predicate): this { + return this.keep((node, parents) => !predicate(node, parents)); + } - // other operations - bfs( - result, - (node) => { - if (FernNavigation.hasMarkdown(node) && hide(node)) { - node.hidden = true; + public hide(predicate: Predicate): this { + if (this.tree == null) { + return this; + } + FernNavigation.traverseBF(this.tree, (node, parents) => { + if (FernNavigation.hasMetadata(node)) { + node.hidden = predicate(node, parents) ? true : undefined; } - }, - FernNavigation.getChildren, - ); + }); + return this; + } - return result; + public authed(predicate: Predicate): this { + if (this.tree == null) { + return this; + } + FernNavigation.traverseBF(this.tree, (node, parents) => { + if (FernNavigation.hasMetadata(node)) { + node.authed = predicate(node, parents) ? true : undefined; + } + }); + return this; + } + + public get(): ROOT | undefined { + if (this.tree == null) { + return undefined; + } + mutableUpdatePointsTo(this.tree); + return this.tree; + } } diff --git a/packages/fdr-sdk/src/utils/traversers/prunetree.ts b/packages/fdr-sdk/src/utils/traversers/prunetree.ts index d34aad46dc..bb6fbb80fd 100644 --- a/packages/fdr-sdk/src/utils/traversers/prunetree.ts +++ b/packages/fdr-sdk/src/utils/traversers/prunetree.ts @@ -6,7 +6,7 @@ interface PruneTreeOptions { * @param node the node to check * @returns **false** if the node SHOULD be deleted */ - predicate: (node: NODE) => boolean; + predicate: (node: NODE, parents: readonly PARENT[]) => boolean; getChildren: (node: PARENT) => readonly NODE[]; /** @@ -53,7 +53,7 @@ export function prunetree
{tab.icon && } {tab.title} + {tab.type !== "tabLink" && tab.authed && }
diff --git a/packages/ui/app/src/header/VersionDropdown.tsx b/packages/ui/app/src/header/VersionDropdown.tsx index 211d2ec9ed..3632f1c7d5 100644 --- a/packages/ui/app/src/header/VersionDropdown.tsx +++ b/packages/ui/app/src/header/VersionDropdown.tsx @@ -1,6 +1,6 @@ import { FernButton } from "@fern-ui/components"; import { getVersionAvailabilityLabel } from "@fern-ui/fdr-utils"; -import { NavArrowDown } from "iconoir-react"; +import { Lock, NavArrowDown } from "iconoir-react"; import { useAtomValue } from "jotai"; import { CURRENT_VERSION_ID_ATOM, VERSIONS_ATOM } from "../atoms"; import { FernLinkDropdown } from "../components/FernLinkDropdown"; @@ -25,13 +25,15 @@ export const VersionDropdown: React.FC = () => {
({ + options={versions.map(({ id, title, availability, slug, pointsTo, hidden, authed }) => ({ type: "value", label: title, helperText: availability != null ? getVersionAvailabilityLabel(availability) : undefined, value: id, disabled: availability == null, href: toHref(pointsTo ?? slug), + icon: authed ? : undefined, + className: hidden ? "opacity-50" : undefined, }))} contentProps={{ "data-testid": "version-dropdown-content", diff --git a/packages/ui/app/src/resolver/ApiDefinitionResolver.ts b/packages/ui/app/src/resolver/ApiDefinitionResolver.ts index dfbe13ff26..6d8e0af419 100644 --- a/packages/ui/app/src/resolver/ApiDefinitionResolver.ts +++ b/packages/ui/app/src/resolver/ApiDefinitionResolver.ts @@ -180,11 +180,13 @@ export class ApiDefinitionResolver { id: node.id, hidden: node.hidden, canonicalSlug: node.canonicalSlug, + authed: node.authed, icon: node.icon, noindex: node.noindex, pageId: node.overviewPageId, title: node.title, slug: node.slug, + audience: node.audience, }); if (resolvedOverviewPage != null) { items.unshift(resolvedOverviewPage); diff --git a/packages/ui/app/src/sidebar/SidebarLink.tsx b/packages/ui/app/src/sidebar/SidebarLink.tsx index 90aabbc203..a9f8a96393 100644 --- a/packages/ui/app/src/sidebar/SidebarLink.tsx +++ b/packages/ui/app/src/sidebar/SidebarLink.tsx @@ -1,7 +1,7 @@ import type * as FernNavigation from "@fern-api/fdr-sdk/navigation"; import { FernTooltip, RemoteFontAwesomeIcon } from "@fern-ui/components"; import cn, { clsx } from "clsx"; -import { NavArrowDown } from "iconoir-react"; +import { Lock, NavArrowDown } from "iconoir-react"; import { range } from "lodash-es"; import { Url } from "next/dist/shared/lib/router/router"; import { @@ -42,6 +42,7 @@ interface SidebarSlugLinkProps { rightElement?: ReactNode; tooltipContent?: ReactNode; hidden?: boolean; + authed?: boolean; as?: keyof JSX.IntrinsicElements | JSXElementConstructor; } @@ -76,6 +77,7 @@ const SidebarLinkInternal = forwardRef((props, target, rel, hidden, + authed, as = "span", } = props; @@ -119,6 +121,14 @@ const SidebarLinkInternal = forwardRef((props, }; const withTooltip = (content: ReactNode) => { + if (authed) { + return ( + + {content} + + ); + } + if (tooltipContent == null) { return content; } @@ -172,7 +182,7 @@ const SidebarLinkInternal = forwardRef((props, )} {createElement(as, { className: "fern-sidebar-link-text" }, title)} - {rightElement} + {authed ? : rightElement} {expandButton} , diff --git a/packages/ui/app/src/sidebar/SidebarTabButton.tsx b/packages/ui/app/src/sidebar/SidebarTabButton.tsx index 915b892399..6718d1b829 100644 --- a/packages/ui/app/src/sidebar/SidebarTabButton.tsx +++ b/packages/ui/app/src/sidebar/SidebarTabButton.tsx @@ -1,6 +1,6 @@ import { RemoteFontAwesomeIcon } from "@fern-ui/components"; import { SidebarTab } from "@fern-ui/fdr-utils"; -import cn from "clsx"; +import cn, { clsx } from "clsx"; import { memo } from "react"; import { FernLink } from "../components/FernLink"; import { useSidebarTabHref } from "../hooks/useSidebarTabHref"; @@ -24,13 +24,17 @@ const UnmemoizedSidebarTabButton: React.FC = ({ tab, sel href={useSidebarTabHref(tab)} data-state={selected ? "active" : "inactive"} > -
+
diff --git a/packages/ui/app/src/sidebar/nodes/SidebarApiLeafNode.tsx b/packages/ui/app/src/sidebar/nodes/SidebarApiLeafNode.tsx index 5592fda2b2..601634e4f5 100644 --- a/packages/ui/app/src/sidebar/nodes/SidebarApiLeafNode.tsx +++ b/packages/ui/app/src/sidebar/nodes/SidebarApiLeafNode.tsx @@ -35,6 +35,7 @@ export function SidebarApiLeafNode({ node, depth, shallow }: SidebarApiLeafNodeP title={node.title} depth={Math.max(0, depth - 1)} hidden={node.hidden} + authed={node.authed} icon={renderRightElement()} selected={selected} shallow={shallow} diff --git a/packages/ui/app/src/sidebar/nodes/SidebarApiPackageNode.tsx b/packages/ui/app/src/sidebar/nodes/SidebarApiPackageNode.tsx index b0ff8e79e4..ac04752f96 100644 --- a/packages/ui/app/src/sidebar/nodes/SidebarApiPackageNode.tsx +++ b/packages/ui/app/src/sidebar/nodes/SidebarApiPackageNode.tsx @@ -51,6 +51,7 @@ export function SidebarApiPackageNode({ slug={node.slug} depth={Math.max(depth - 1, 0)} title={node.title} + authed={node.authed} selected={selected} icon={node.icon} hidden={node.hidden} @@ -93,6 +94,7 @@ export function SidebarApiPackageNode({ toggleExpand={handleToggleExpand} showIndicator={showIndicator} hidden={node.hidden} + authed={node.authed} slug={node.overviewPageId != null ? node.slug : undefined} selected={selected} shallow={shallow} diff --git a/packages/ui/app/src/sidebar/nodes/SidebarChangelogNode.tsx b/packages/ui/app/src/sidebar/nodes/SidebarChangelogNode.tsx index ffe320d3eb..5ae38051c1 100644 --- a/packages/ui/app/src/sidebar/nodes/SidebarChangelogNode.tsx +++ b/packages/ui/app/src/sidebar/nodes/SidebarChangelogNode.tsx @@ -28,6 +28,7 @@ export function SidebarChangelogNode({ node, depth, className }: SidebarChangelo icon={node.icon ?? } tooltipContent={renderChangelogTooltip(node)} hidden={node.hidden} + authed={node.authed} /> ); } diff --git a/packages/ui/app/src/sidebar/nodes/SidebarLinkNode.tsx b/packages/ui/app/src/sidebar/nodes/SidebarLinkNode.tsx index fcfad1739e..6e1109a886 100644 --- a/packages/ui/app/src/sidebar/nodes/SidebarLinkNode.tsx +++ b/packages/ui/app/src/sidebar/nodes/SidebarLinkNode.tsx @@ -1,5 +1,6 @@ import type * as FernNavigation from "@fern-api/fdr-sdk/navigation"; import { OpenNewWindow } from "iconoir-react"; +import { useEffect, useState } from "react"; import { SidebarLink } from "../SidebarLink"; interface SidebarLinkNodeProps { @@ -9,6 +10,12 @@ interface SidebarLinkNodeProps { } export function SidebarLinkNode({ node, depth, className }: SidebarLinkNodeProps): React.ReactElement { + // TODO: handle this more gracefully, and make this SSG-friendly + const [origin, setOrigin] = useState("xxx"); + useEffect(() => { + setOrigin(window.location.origin); + }, []); + return ( } + rightElement={ + node.url.startsWith("http") && + !node.url.startsWith(origin) && + } href={node.url} /> ); diff --git a/packages/ui/app/src/sidebar/nodes/SidebarPageNode.tsx b/packages/ui/app/src/sidebar/nodes/SidebarPageNode.tsx index 288602b864..73cda0fd29 100644 --- a/packages/ui/app/src/sidebar/nodes/SidebarPageNode.tsx +++ b/packages/ui/app/src/sidebar/nodes/SidebarPageNode.tsx @@ -26,6 +26,7 @@ export function SidebarPageNode({ node, depth, className, shallow }: SidebarPage selected={selected} icon={node.icon} hidden={node.hidden} + authed={node.authed} shallow={shallow} /> ); diff --git a/packages/ui/app/src/sidebar/nodes/SidebarRootApiPackageNode.tsx b/packages/ui/app/src/sidebar/nodes/SidebarRootApiPackageNode.tsx index 5f19f66179..9f0f2f0861 100644 --- a/packages/ui/app/src/sidebar/nodes/SidebarRootApiPackageNode.tsx +++ b/packages/ui/app/src/sidebar/nodes/SidebarRootApiPackageNode.tsx @@ -37,6 +37,7 @@ export function SidebarRootApiPackageNode({ selected={selected} icon={node.icon} hidden={node.hidden} + authed={node.authed} shallow={shallow} /> ); diff --git a/packages/ui/app/src/sidebar/nodes/SidebarRootHeading.tsx b/packages/ui/app/src/sidebar/nodes/SidebarRootHeading.tsx index 126cf3959b..996e8d1d99 100644 --- a/packages/ui/app/src/sidebar/nodes/SidebarRootHeading.tsx +++ b/packages/ui/app/src/sidebar/nodes/SidebarRootHeading.tsx @@ -31,6 +31,7 @@ export function SidebarRootHeading({ node, className, shallow }: SidebarRootHead className={className} title={node.title} hidden={node.hidden} + authed={node.authed} slug={node.slug} selected={selected} shallow={shallow} diff --git a/packages/ui/app/src/sidebar/nodes/SidebarRootSectionNode.tsx b/packages/ui/app/src/sidebar/nodes/SidebarRootSectionNode.tsx index a01964d98e..636dd5d342 100644 --- a/packages/ui/app/src/sidebar/nodes/SidebarRootSectionNode.tsx +++ b/packages/ui/app/src/sidebar/nodes/SidebarRootSectionNode.tsx @@ -33,6 +33,7 @@ export function SidebarRootSectionNode({ node, className }: SidebarRootSectionNo selected={selected} icon={node.icon} hidden={node.hidden} + authed={node.authed} /> ); } diff --git a/packages/ui/app/src/sidebar/nodes/SidebarSectionNode.tsx b/packages/ui/app/src/sidebar/nodes/SidebarSectionNode.tsx index 9012f076f3..84f1329b91 100644 --- a/packages/ui/app/src/sidebar/nodes/SidebarSectionNode.tsx +++ b/packages/ui/app/src/sidebar/nodes/SidebarSectionNode.tsx @@ -62,6 +62,7 @@ export function SidebarSectionNode({ node, className, depth }: SidebarSectionNod toggleExpand={handleToggleExpand} showIndicator={showIndicator} hidden={node.hidden} + authed={node.authed} slug={node.overviewPageId != null ? node.slug : undefined} selected={selected} /> diff --git a/packages/ui/docs-bundle/src/middleware.ts b/packages/ui/docs-bundle/src/middleware.ts index 071684029d..d26edf488b 100644 --- a/packages/ui/docs-bundle/src/middleware.ts +++ b/packages/ui/docs-bundle/src/middleware.ts @@ -96,7 +96,7 @@ export const middleware: NextMiddleware = async (request) => { * redirect to the custom auth provider */ if (!isLoggedIn && authConfig?.type === "basic_token_verification") { - if (!withBasicTokenAnonymous(authConfig, pathname)) { + if (withBasicTokenAnonymous(authConfig, pathname)) { const destination = new URL(authConfig.redirect); destination.searchParams.set("state", urlJoin(withDefaultProtocol(xFernHost), pathname)); // TODO: validate allowlist of domains to prevent open redirects diff --git a/packages/ui/docs-bundle/src/server/DocsLoader.ts b/packages/ui/docs-bundle/src/server/DocsLoader.ts index 1d88881b53..703294c670 100644 --- a/packages/ui/docs-bundle/src/server/DocsLoader.ts +++ b/packages/ui/docs-bundle/src/server/DocsLoader.ts @@ -110,7 +110,7 @@ export class DocsLoader { // TODO: store this in cache node = !auth ? pruneWithBasicTokenAnonymous(authConfig, node) - : pruneWithBasicTokenAuthed(authConfig, node); + : pruneWithBasicTokenAuthed(authConfig, node, toAudience(auth.user.audience)); } } catch (e) { // TODO: sentry @@ -135,3 +135,10 @@ export class DocsLoader { return docs?.definition.filesV2 ?? {}; } } + +function toAudience(audience: string | string[] | undefined): string[] { + if (typeof audience === "string") { + return [audience]; + } + return audience ?? []; +} diff --git a/packages/ui/docs-bundle/src/server/__test__/withBasicTokenViewAllowed.test.ts b/packages/ui/docs-bundle/src/server/__test__/withBasicTokenViewAllowed.test.ts index 5d1a6df08e..b0b2594fbc 100644 --- a/packages/ui/docs-bundle/src/server/__test__/withBasicTokenViewAllowed.test.ts +++ b/packages/ui/docs-bundle/src/server/__test__/withBasicTokenViewAllowed.test.ts @@ -1,33 +1,35 @@ import { NodeId, PageId, Slug, Url } from "@fern-api/fdr-sdk/navigation"; -import { withBasicTokenAnonymous, withBasicTokenAnonymousCheck } from "../withBasicTokenAnonymous"; +import { matchAudience, withBasicTokenAnonymous, withBasicTokenAnonymousCheck } from "../withBasicTokenAnonymous"; describe("withBasicTokenAnonymous", () => { it("should deny the request if the allowlist is empty", () => { - expect(withBasicTokenAnonymous({}, "/public")).toBe(false); - expect(withBasicTokenAnonymous({ allowlist: [] }, "/public")).toBe(false); + expect(withBasicTokenAnonymous({}, "/public")).toBe(true); + expect(withBasicTokenAnonymous({ allowlist: [] }, "/public")).toBe(true); }); it("should allow the request to pass through if the path is in the allowlist", () => { - expect(withBasicTokenAnonymous({ allowlist: ["/public"] }, "/public")).toBe(true); + expect(withBasicTokenAnonymous({ allowlist: ["/public"] }, "/public")).toBe(false); }); it("should allow the request to pass through if the path matches a regex in the allowlist", () => { - expect(withBasicTokenAnonymous({ allowlist: ["/public/(.*)"] }, "/public/123")).toBe(true); + expect(withBasicTokenAnonymous({ allowlist: ["/public/(.*)"] }, "/public/123")).toBe(false); }); it("should allow the request to pass through if the path matches a path expression in the allowlist", () => { - expect(withBasicTokenAnonymous({ allowlist: ["/public/:id"] }, "/public/123")).toBe(true); + expect(withBasicTokenAnonymous({ allowlist: ["/public/:id"] }, "/public/123")).toBe(false); }); it("should not allow the request to pass through if the path is not in the allowlist", () => { - expect(withBasicTokenAnonymous({ allowlist: ["/public", "/public/:id"] }, "/private")).toBe(false); - expect(withBasicTokenAnonymous({ allowlist: ["/public", "/public/:id"] }, "/private/123")).toBe(false); + expect(withBasicTokenAnonymous({ allowlist: ["/public", "/public/:id"] }, "/private")).toBe(true); + expect(withBasicTokenAnonymous({ allowlist: ["/public", "/public/:id"] }, "/private/123")).toBe(true); }); it("shouuld respect denylist before allowlist", () => { - expect(withBasicTokenAnonymous({ allowlist: ["/public"], denylist: ["/public"] }, "/public")).toBe(false); + expect(withBasicTokenAnonymous({ allowlist: ["/public"], denylist: ["/public"] }, "/public")).toBe(true); }); +}); +describe("withBasicTokenAnonymousCheck", () => { it("should never deny external links", () => { expect( withBasicTokenAnonymousCheck({ denylist: ["/(.*)"] })({ @@ -37,10 +39,10 @@ describe("withBasicTokenAnonymous", () => { icon: undefined, id: NodeId("1"), }), - ).toBe(true); + ).toBe(false); }); - it("should prune childless non-leaf nodes", () => { + it("should ignore childless non-leaf nodes", () => { expect( withBasicTokenAnonymousCheck({ allowlist: ["/public"] })({ type: "section", @@ -52,9 +54,11 @@ describe("withBasicTokenAnonymous", () => { canonicalSlug: undefined, icon: undefined, hidden: undefined, + authed: undefined, overviewPageId: undefined, noindex: undefined, pointsTo: undefined, + audience: undefined, }), ).toBe(false); }); @@ -71,10 +75,53 @@ describe("withBasicTokenAnonymous", () => { canonicalSlug: undefined, icon: undefined, hidden: undefined, + authed: undefined, overviewPageId: PageId("1.mdx"), noindex: undefined, pointsTo: undefined, + audience: undefined, }), - ).toBe(true); + ).toBe(false); + }); +}); + +describe("matchAudience", () => { + it("should return true if the audience is empty", () => { + expect(matchAudience([], [])).toBe(true); + expect(matchAudience([], [[], []])).toBe(true); + }); + + it("should return false if an audience filter exists", () => { + expect(matchAudience([], [["a"]])).toBe(false); + }); + + it("should return true if the audience matches the filter", () => { + expect(matchAudience(["a"], [["a"]])).toBe(true); + }); + + it("should return true if the audience matches any of the filters", () => { + expect(matchAudience(["a"], [["b", "a"]])).toBe(true); + }); + + it("should return false if the audience does not match any of the filters", () => { + expect(matchAudience(["a"], [["b"]])).toBe(false); + }); + + it("should return false if the audience does not match all filters across all nodes", () => { + expect(matchAudience(["a"], [["a"], ["b"]])).toBe(false); + expect(matchAudience(["b"], [["a"], ["a", "b"]])).toBe(false); + }); + + it("should return true if the audience matches all filters across all nodes", () => { + expect(matchAudience(["a"], [["a"], ["a"]])).toBe(true); + expect(matchAudience(["a"], [["a"], ["a", "b"]])).toBe(true); + expect(matchAudience(["a", "b"], [["a"], ["a", "b"]])).toBe(true); + expect(matchAudience(["a", "b"], [["a"], ["b"]])).toBe(true); + }); + + it("should return true if the user has more audiences than the filter", () => { + expect(matchAudience(["a", "b"], [])).toBe(true); + expect(matchAudience(["a", "b"], [[]])).toBe(true); + expect(matchAudience(["a", "b"], [["a"]])).toBe(true); }); }); diff --git a/packages/ui/docs-bundle/src/server/withBasicTokenAnonymous.ts b/packages/ui/docs-bundle/src/server/withBasicTokenAnonymous.ts index 5e4d36af24..ea15b7fb78 100644 --- a/packages/ui/docs-bundle/src/server/withBasicTokenAnonymous.ts +++ b/packages/ui/docs-bundle/src/server/withBasicTokenAnonymous.ts @@ -1,19 +1,40 @@ -import { getChildren, isLeaf, isPage, utils, type NavigationNode, type RootNode } from "@fern-api/fdr-sdk/navigation"; +import { FernNavigation } from "@fern-api/fdr-sdk"; +import { + NavigationNodeParent, + Pruner, + hasMetadata, + isPage, + type NavigationNode, + type RootNode, +} from "@fern-api/fdr-sdk/navigation"; +import { EMPTY_ARRAY } from "@fern-api/ui-core-utils"; import type { AuthEdgeConfigBasicTokenVerification } from "@fern-ui/fern-docs-auth"; import { matchPath } from "@fern-ui/fern-docs-utils"; +interface AuthRulesPathName { + /** + * List of paths that should be allowed to pass through without authentication + */ + allowlist?: string[]; + + /** + * List of paths that should be denied access without authentication + */ + denylist?: string[]; + + /** + * List of paths that should be allowed to pass through without authentication, but should be hidden when the user is authenticated + */ + anonymous?: string[]; +} + /** - * @param auth Basic token verification configuration - * @param pathname pathname of the request to check - * @returns true if the request is allowed to pass through, false otherwise + * @returns true if the request should should be marked as authed */ -export function withBasicTokenAnonymous( - auth: Pick, - pathname: string, -): boolean { +export function withBasicTokenAnonymous(auth: AuthRulesPathName, pathname: string): boolean { // if the path is in the denylist, deny the request if (auth.denylist?.find((path) => matchPath(path, pathname))) { - return false; + return true; } // if the path is in the allowlist, allow the request to pass through @@ -21,32 +42,40 @@ export function withBasicTokenAnonymous( auth.allowlist?.find((path) => matchPath(path, pathname)) || auth.anonymous?.find((path) => matchPath(path, pathname)) ) { - return true; + return false; } // if the path is not in the allowlist, deny the request - return false; + return true; } /** * @internal visibleForTesting */ export function withBasicTokenAnonymousCheck( - auth: Pick, -): (node: NavigationNode) => boolean { - return (node: NavigationNode) => { + auth: AuthRulesPathName, +): (node: NavigationNode, parents?: readonly NavigationNodeParent[]) => boolean { + const hasAudience = (audiences: string[] | undefined) => { + return audiences != null && audiences.length > 0; + }; + return (node, parents = EMPTY_ARRAY) => { + if ([...parents, node].some((n) => hasMetadata(n) && (n.authed || hasAudience(n.audience)))) { + return true; + } + if (isPage(node)) { return withBasicTokenAnonymous(auth, `/${node.slug}`); - } else if (!isLeaf(node) && getChildren(node).length === 0) { - return false; } - return true; + return false; }; } export function pruneWithBasicTokenAnonymous(auth: AuthEdgeConfigBasicTokenVerification, node: RootNode): RootNode { - const result = utils.pruneNavigationTree(node, withBasicTokenAnonymousCheck(auth)); + const result = Pruner.from(node) + // mark nodes that are authed + .authed(withBasicTokenAnonymousCheck(auth)) + .get(); // TODO: handle this more gracefully if (result == null) { @@ -56,15 +85,35 @@ export function pruneWithBasicTokenAnonymous(auth: AuthEdgeConfigBasicTokenVerif return result; } -export function pruneWithBasicTokenAuthed(auth: AuthEdgeConfigBasicTokenVerification, node: RootNode): RootNode { - const result = utils.pruneNavigationTree( - node, - // do not delete any nodes - () => true, - // hide nodes that are in the anonymous list - (n) => auth.anonymous?.find((path) => matchPath(path, `/${n.slug}`)) != null, - ); +function getAudienceFilters(...node: FernNavigation.NavigationNode[]): string[][] { + return node.map((n) => (hasMetadata(n) ? n.audience ?? [] : [])).filter((audience) => audience.length > 0); +} +/** + * @internal + * @param audience current viewer's audience + * @param filters audience filters for the current node + * @returns true if the audience matches the filters (i.e. the viewer is allowed to view the node) + */ +export function matchAudience(audience: string[], filters: string[][]): boolean { + if (filters.length === 0 || filters.every((filter) => filter.length === 0)) { + return true; + } + + return filters.every((filter) => filter.some((aud) => audience.includes(aud))); +} + +export function pruneWithBasicTokenAuthed(auth: AuthRulesPathName, node: RootNode, audience: string[] = []): RootNode { + const result = Pruner.from(node) + // apply audience filters + .keep((n, parents) => !hasMetadata(n) || matchAudience(audience, getAudienceFilters(...parents, n))) + // hide nodes that are not authed + .hide((n) => node.hidden || auth.anonymous?.find((path) => matchPath(path, `/${n.slug}`)) != null) + // mark all nodes as unauthed since we are currently authenticated + .authed(() => false) + .get(); + + // TODO: handle this more gracefully if (result == null) { throw new Error("Failed to prune navigation tree"); } diff --git a/packages/ui/docs-bundle/src/server/withInitialProps.ts b/packages/ui/docs-bundle/src/server/withInitialProps.ts index c790783ea7..c89c7a028f 100644 --- a/packages/ui/docs-bundle/src/server/withInitialProps.ts +++ b/packages/ui/docs-bundle/src/server/withInitialProps.ts @@ -27,6 +27,7 @@ import type { AuthProps } from "./authProps"; import { handleLoadDocsError } from "./handleLoadDocsError"; import type { LoadWithUrlResponse } from "./loadWithUrl"; import { isTrailingSlashEnabled } from "./trailingSlash"; +import { pruneNavigationPredicate, withPrunedSidebar } from "./withPrunedSidebar"; import { withVersionSwitcherInfo } from "./withVersionSwitcherInfo"; interface WithInitialProps { @@ -163,13 +164,6 @@ export async function withInitialProps({ : undefined, }; - const versions = withVersionSwitcherInfo({ - node: node.node, - parents: node.parents, - versions: node.versions, - slugMap: node.collector.slugMap, - }); - const logoHref = docs.definition.config.logoHref ?? (node.landingPage?.slug != null && !node.landingPage.hidden ? `/${node.landingPage.slug}` : undefined); @@ -202,16 +196,58 @@ export async function withInitialProps({ } } - // prune the navigation tree to remove hidden nodes, unless it is the current node - const sidebar = - node.sidebar != null - ? FernNavigation.utils.pruneNavigationTree(node.sidebar, (n) => { - if (FernNavigation.hasMetadata(n) && n.hidden) { - return n.id === node.node.id; - } - return true; - }) - : undefined; + const pruneOpts = { + node: node.node, + isAuthenticated: auth != null, + isAuthenticatedPagesDiscoverable: featureFlags.isAuthenticatedPagesDiscoverable, + }; + + const currentVersionId = node.currentVersion?.versionId; + const versions = withVersionSwitcherInfo({ + node: node.node, + parents: node.parents, + versions: node.versions.filter( + (version) => pruneNavigationPredicate(version, pruneOpts) || version.versionId === currentVersionId, + ), + slugMap: node.collector.slugMap, + }); + + const sidebar = withPrunedSidebar(node.sidebar, pruneOpts); + + const filteredTabs = node.tabs.filter((tab) => pruneNavigationPredicate(tab, pruneOpts) || tab === node.currentTab); + + const tabs = filteredTabs.map((tab, index) => + visitDiscriminatedUnion(tab)._visit({ + tab: (tab) => ({ + type: "tabGroup", + title: tab.title, + icon: tab.icon, + index, + slug: tab.slug, + pointsTo: tab.pointsTo, + hidden: tab.hidden, + authed: tab.authed, + }), + link: (link) => ({ + type: "tabLink", + title: link.title, + icon: link.icon, + index, + url: link.url, + }), + changelog: (changelog) => ({ + type: "tabChangelog", + title: changelog.title, + icon: changelog.icon, + index, + slug: changelog.slug, + hidden: changelog.hidden, + authed: changelog.authed, + }), + }), + ); + + const currentTabIndex = node.currentTab == null ? undefined : filteredTabs.indexOf(node.currentTab); const props: ComponentProps = { baseUrl: docs.baseUrl, @@ -233,34 +269,9 @@ export async function withInitialProps({ } : undefined, navigation: { - currentTabIndex: node.currentTab == null ? undefined : node.tabs.indexOf(node.currentTab), - tabs: node.tabs.map((tab, index) => - visitDiscriminatedUnion(tab)._visit({ - tab: (tab) => ({ - type: "tabGroup", - title: tab.title, - icon: tab.icon, - index, - slug: tab.slug, - pointsTo: tab.pointsTo, - }), - link: (link) => ({ - type: "tabLink", - title: link.title, - icon: link.icon, - index, - url: link.url, - }), - changelog: (changelog) => ({ - type: "tabChangelog", - title: changelog.title, - icon: changelog.icon, - index, - slug: changelog.slug, - }), - }), - ), - currentVersionId: node.currentVersion?.versionId, + currentTabIndex, + tabs, + currentVersionId, versions, sidebar, trailingSlash: isTrailingSlashEnabled(), diff --git a/packages/ui/docs-bundle/src/server/withPrunedSidebar.ts b/packages/ui/docs-bundle/src/server/withPrunedSidebar.ts new file mode 100644 index 0000000000..463436004b --- /dev/null +++ b/packages/ui/docs-bundle/src/server/withPrunedSidebar.ts @@ -0,0 +1,45 @@ +import * as FernNavigation from "@fern-api/fdr-sdk/navigation"; + +interface WithPrunedSidebarOpts { + node: FernNavigation.NavigationNode; + isAuthenticated: boolean; + isAuthenticatedPagesDiscoverable: boolean; +} + +/** + * Note: at the stage of calling this function, the audiences should already been evaluated. + */ +export function pruneNavigationPredicate( + n: FernNavigation.NavigationNode, + { node, isAuthenticated, isAuthenticatedPagesDiscoverable }: WithPrunedSidebarOpts, +): boolean { + // prune hidden nodes, unless it is the current node + if (FernNavigation.hasMetadata(n) && n.hidden) { + return n.id === node.id; + } + + // prune authenticated pages (unless the isAuthenticatedPagesDiscoverable flag is turned on) + if (FernNavigation.hasMetadata(n) && n.authed && isAuthenticated && !isAuthenticatedPagesDiscoverable) { + return false; + } + + // prune nodes that are not pages and have no children (avoid pruning links) + if (!FernNavigation.isPage(n) && !FernNavigation.isLeaf(n)) { + return FernNavigation.getChildren(n).length > 0; + } + + return true; +} + +export function withPrunedSidebar( + sidebar: FernNavigation.SidebarRootNode | undefined, + opts: WithPrunedSidebarOpts, +): FernNavigation.SidebarRootNode | undefined { + if (!sidebar) { + return sidebar; + } + + return FernNavigation.Pruner.from(sidebar) + .keep((n) => pruneNavigationPredicate(n, opts)) + .get(); +} diff --git a/packages/ui/docs-bundle/src/server/withVersionSwitcherInfo.ts b/packages/ui/docs-bundle/src/server/withVersionSwitcherInfo.ts index 1510002fd3..8ffc7b7604 100644 --- a/packages/ui/docs-bundle/src/server/withVersionSwitcherInfo.ts +++ b/packages/ui/docs-bundle/src/server/withVersionSwitcherInfo.ts @@ -66,7 +66,9 @@ export function withVersionSwitcherInfo({ pointsTo: node.slug, index, availability: version.availability, - }; + hidden: version.hidden, + authed: version.authed, + } satisfies VersionSwitcherInfo; } const expectedSlugs = unversionedSlugs.map((slug) => FernNavigation.slugjoin(version.slug, slug)); @@ -105,7 +107,9 @@ export function withVersionSwitcherInfo({ pointsTo, index, availability: version.availability, - }; + hidden: version.hidden, + authed: version.authed, + } satisfies VersionSwitcherInfo; }); } diff --git a/packages/ui/fern-docs-auth/src/types.ts b/packages/ui/fern-docs-auth/src/types.ts index fbf568ec06..b89409b855 100644 --- a/packages/ui/fern-docs-auth/src/types.ts +++ b/packages/ui/fern-docs-auth/src/types.ts @@ -3,6 +3,12 @@ import { z } from "zod"; export const FernUserSchema = z.object({ name: z.string().optional(), email: z.string().optional(), + audience: z + .union([z.string(), z.array(z.string())], { + description: + "The audience of the token (can be a string or an array of strings) which limits what content users can access", + }) + .optional(), }); export type FernUser = z.infer; diff --git a/packages/ui/fern-docs-edge-config/src/getFeatureFlags.ts b/packages/ui/fern-docs-edge-config/src/getFeatureFlags.ts index c4c0886ffc..0a56bf7111 100644 --- a/packages/ui/fern-docs-edge-config/src/getFeatureFlags.ts +++ b/packages/ui/fern-docs-edge-config/src/getFeatureFlags.ts @@ -34,6 +34,7 @@ const FEATURE_FLAGS = [ "hide-404-page" as const, "new-search-experience" as const, "grpc-endpoints" as const, + "authenticated-pages-discoverable" as const, ]; type FeatureFlag = (typeof FEATURE_FLAGS)[number]; @@ -87,6 +88,10 @@ export async function getFeatureFlags(domain: string): Promise { const isFileForgeHackEnabled = checkDomainMatchesCustomers(domain, config["file-forge-hack-enabled"]); const is404PageHidden = checkDomainMatchesCustomers(domain, config["hide-404-page"]); const isNewSearchExperienceEnabled = checkDomainMatchesCustomers(domain, config["new-search-experience"]); + const isAuthenticatedPagesDiscoverable = checkDomainMatchesCustomers( + domain, + config["authenticated-pages-discoverable"], + ); const grpcEndpoints = config["grpc-endpoints"]; return { @@ -118,6 +123,7 @@ export async function getFeatureFlags(domain: string): Promise { isFileForgeHackEnabled, is404PageHidden, isNewSearchExperienceEnabled, + isAuthenticatedPagesDiscoverable, grpcEndpoints, }; } catch (e) { @@ -152,6 +158,7 @@ export async function getFeatureFlags(domain: string): Promise { isFileForgeHackEnabled: false, is404PageHidden: false, isNewSearchExperienceEnabled: false, + isAuthenticatedPagesDiscoverable: false, grpcEndpoints: [], }; } diff --git a/packages/ui/fern-docs-utils/src/flags.ts b/packages/ui/fern-docs-utils/src/flags.ts index 8cf47cde66..e0cb08c63b 100644 --- a/packages/ui/fern-docs-utils/src/flags.ts +++ b/packages/ui/fern-docs-utils/src/flags.ts @@ -27,6 +27,7 @@ export interface FeatureFlags { isFileForgeHackEnabled: boolean; is404PageHidden: boolean; isNewSearchExperienceEnabled: boolean; + isAuthenticatedPagesDiscoverable: boolean; // TODO: remove this after pinecone demo, this is a temporary flag grpcEndpoints: readonly string[]; } @@ -60,6 +61,7 @@ export const DEFAULT_FEATURE_FLAGS: FeatureFlags = { isFileForgeHackEnabled: false, is404PageHidden: false, isNewSearchExperienceEnabled: false, + isAuthenticatedPagesDiscoverable: false, // TODO: remove this after pinecone demo, this is a temporary flag grpcEndpoints: [], }; diff --git a/packages/ui/local-preview-bundle/src/utils/getDocsPageProps.ts b/packages/ui/local-preview-bundle/src/utils/getDocsPageProps.ts index 6759875a05..753b9a3e3c 100644 --- a/packages/ui/local-preview-bundle/src/utils/getDocsPageProps.ts +++ b/packages/ui/local-preview-bundle/src/utils/getDocsPageProps.ts @@ -105,6 +105,8 @@ export async function getDocsPageProps( pointsTo, index, availability: version.availability, + hidden: version.hidden, + authed: version.authed, }; }); @@ -138,6 +140,8 @@ export async function getDocsPageProps( index, slug: tab.slug, pointsTo: tab.pointsTo, + hidden: tab.hidden, + authed: tab.authed, }), link: (link) => ({ type: "tabLink", @@ -152,6 +156,8 @@ export async function getDocsPageProps( icon: changelog.icon, index, slug: changelog.slug, + hidden: changelog.hidden, + authed: changelog.authed, }), }), ), diff --git a/servers/fdr/src/api/generated/api/resources/commons/types/AudienceId.d.ts b/servers/fdr/src/api/generated/api/resources/commons/types/AudienceId.d.ts new file mode 100644 index 0000000000..a42585d353 --- /dev/null +++ b/servers/fdr/src/api/generated/api/resources/commons/types/AudienceId.d.ts @@ -0,0 +1,8 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +import * as FernRegistry from "../../../index"; +export declare type AudienceId = string & { + AudienceId: void; +}; +export declare function AudienceId(value: string): FernRegistry.AudienceId; diff --git a/servers/fdr/src/api/generated/api/resources/commons/types/AudienceId.js b/servers/fdr/src/api/generated/api/resources/commons/types/AudienceId.js new file mode 100644 index 0000000000..935cbae44b --- /dev/null +++ b/servers/fdr/src/api/generated/api/resources/commons/types/AudienceId.js @@ -0,0 +1,6 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ +export function AudienceId(value) { + return value; +} diff --git a/servers/fdr/src/api/generated/api/resources/commons/types/index.d.ts b/servers/fdr/src/api/generated/api/resources/commons/types/index.d.ts index 11cb260750..401177ffa5 100644 --- a/servers/fdr/src/api/generated/api/resources/commons/types/index.d.ts +++ b/servers/fdr/src/api/generated/api/resources/commons/types/index.d.ts @@ -14,6 +14,7 @@ export * from "./FileId"; export * from "./Url"; export * from "./JqString"; export * from "./PropertyKey"; +export * from "./AudienceId"; export * from "./EndpointIdentifier"; export * from "./EndpointPathLiteral"; export * from "./HttpMethod"; diff --git a/servers/fdr/src/api/generated/api/resources/commons/types/index.js b/servers/fdr/src/api/generated/api/resources/commons/types/index.js index 11cb260750..401177ffa5 100644 --- a/servers/fdr/src/api/generated/api/resources/commons/types/index.js +++ b/servers/fdr/src/api/generated/api/resources/commons/types/index.js @@ -14,6 +14,7 @@ export * from "./FileId"; export * from "./Url"; export * from "./JqString"; export * from "./PropertyKey"; +export * from "./AudienceId"; export * from "./EndpointIdentifier"; export * from "./EndpointPathLiteral"; export * from "./HttpMethod"; diff --git a/servers/fdr/src/api/generated/api/resources/navigation/resources/latest/types/WithNodeMetadata.d.ts b/servers/fdr/src/api/generated/api/resources/navigation/resources/latest/types/WithNodeMetadata.d.ts index 71381f1793..208ac6cadd 100644 --- a/servers/fdr/src/api/generated/api/resources/navigation/resources/latest/types/WithNodeMetadata.d.ts +++ b/servers/fdr/src/api/generated/api/resources/navigation/resources/latest/types/WithNodeMetadata.d.ts @@ -20,5 +20,17 @@ export interface WithNodeMetadata extends FernRegistry.navigation.latest.WithNod /** The slug that should be used in the canonical URL rel. If not provided, the `slug` will be used. */ canonicalSlug: FernRegistry.navigation.latest.Slug | undefined; icon: string | undefined; + /** If true, this node will not be displayed in the sidebar, and noindex will be considered true. */ hidden: boolean | undefined; + /** + * If true, this node is only visible to authenticated users. + * If false, this node is only visible to all users (including anonymous). + */ + authed: boolean | undefined; + /** + * The audience(s) that this node is intended for. If not provided, the node is intended for all audiences. + * If provided, the node is only intended for the specified audience(s). OR logic is used for multiple audiences on a single node. + * AND logic is used when evaluating audiences up the tree. + */ + audience: FernRegistry.AudienceId[] | undefined; }