From 20466fbab0c9e771010218630610a5ee0a4eeef8 Mon Sep 17 00:00:00 2001 From: Andrew Jiang Date: Thu, 10 Oct 2024 08:46:11 -0400 Subject: [PATCH] feat: audience filters via jwt (#1629) --- fern/apis/fdr/definition/commons.yml | 2 + .../navigation/latest/__package__.yml | 17 ++++- .../__test__/__snapshots__/hume.test.ts.snap | 1 + .../no-version-no-tabs.test.ts.snap | 2 + .../no-version-yes-tabs.test.ts.snap | 3 + .../__snapshots__/polytomic.test.ts.snap | 1 + .../__snapshots__/primer.test.ts.snap | 1 + .../__snapshots__/uploadcare.test.ts.snap | 2 + .../yes-version-no-tabs.test.ts.snap | 3 + .../yes-version-yes-tabs.test.ts.snap | 1 + .../api/resources/commons/types/AudienceId.ts | 13 ++++ .../api/resources/commons/types/index.ts | 1 + .../latest/types/WithNodeMetadata.ts | 11 +++ .../src/navigation/migrators/v1ToV2.ts | 16 ++++ .../__test__/pruneNavigationTree.test.ts | 23 ++++++ .../app/src/resolver/ApiDefinitionResolver.ts | 1 + .../ui/docs-bundle/src/server/DocsLoader.ts | 9 ++- .../withBasicTokenViewAllowed.test.ts | 47 +++++++++++- .../src/server/withBasicTokenAnonymous.ts | 75 ++++++++++++++++--- packages/ui/fern-docs-auth/src/types.ts | 6 ++ .../resources/commons/types/AudienceId.d.ts | 8 ++ .../api/resources/commons/types/AudienceId.js | 6 ++ .../api/resources/commons/types/index.d.ts | 1 + .../api/resources/commons/types/index.js | 1 + .../latest/types/WithNodeMetadata.d.ts | 11 +++ 25 files changed, 246 insertions(+), 16 deletions(-) create mode 100644 packages/fdr-sdk/src/client/generated/api/resources/commons/types/AudienceId.ts create mode 100644 servers/fdr/src/api/generated/api/resources/commons/types/AudienceId.d.ts create mode 100644 servers/fdr/src/api/generated/api/resources/commons/types/AudienceId.js 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 23bec19799..1b39192128 100644 --- a/fern/apis/fdr/definition/navigation/latest/__package__.yml +++ b/fern/apis/fdr/definition/navigation/latest/__package__.yml @@ -334,8 +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 - authed: 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/fdr-sdk/src/__test__/__snapshots__/hume.test.ts.snap b/packages/fdr-sdk/src/__test__/__snapshots__/hume.test.ts.snap index 08740207c2..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,7 @@ 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, 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 a4cf6ba4a6..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,7 @@ 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, @@ -46,6 +47,7 @@ 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, 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 18d563f725..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,7 @@ 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, @@ -49,6 +50,7 @@ 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, @@ -68,6 +70,7 @@ 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, 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 ae5b64fade..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,7 @@ 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, 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 73577f6131..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,7 @@ 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", 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 1f123a13f4..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,7 @@ 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, @@ -119,6 +120,7 @@ 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, 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 13ad021170..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,7 @@ 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, @@ -93,6 +94,7 @@ 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, @@ -119,6 +121,7 @@ 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, 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 8b30ce586f..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,7 @@ 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, 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 de4ca78219..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,6 +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/migrators/v1ToV2.ts b/packages/fdr-sdk/src/navigation/migrators/v1ToV2.ts index 2b40fa7dd5..643e0ead2d 100644 --- a/packages/fdr-sdk/src/navigation/migrators/v1ToV2.ts +++ b/packages/fdr-sdk/src/navigation/migrators/v1ToV2.ts @@ -31,6 +31,7 @@ export class FernNavigationV1ToLatest { icon: node.icon, hidden: node.hidden, authed: undefined, + audience: undefined, }; return latest; @@ -100,6 +101,7 @@ export class FernNavigationV1ToLatest { authed: undefined, id: FernNavigation.NodeId(node.id), pointsTo: node.pointsTo ? FernNavigation.Slug(node.pointsTo) : undefined, + audience: undefined, }; return latest; }; @@ -124,6 +126,7 @@ export class FernNavigationV1ToLatest { id: FernNavigation.NodeId(node.id), pageId: FernNavigation.PageId(node.pageId), noindex: node.noindex, + audience: undefined, }; return latest; }; @@ -161,6 +164,7 @@ export class FernNavigationV1ToLatest { authed: undefined, id: FernNavigation.NodeId(node.id), pointsTo: node.pointsTo ? FernNavigation.Slug(node.pointsTo) : undefined, + audience: undefined, }; return latest; }; @@ -261,6 +265,7 @@ export class FernNavigationV1ToLatest { versioned: (value) => this.versioned(value, [...parents, node]), }), subtitle: node.subtitle, + audience: undefined, }; return latest; }; @@ -315,6 +320,7 @@ export class FernNavigationV1ToLatest { authed: undefined, pageId: FernNavigation.PageId(node.pageId), noindex: node.noindex, + audience: undefined, }; return latest; }; @@ -346,6 +352,7 @@ export class FernNavigationV1ToLatest { collapsed: node.collapsed, overviewPageId, noindex: node.noindex, + audience: undefined, }; return latest; }; @@ -383,6 +390,7 @@ export class FernNavigationV1ToLatest { apiDefinitionId: node.apiDefinitionId, availability: this.#availability(node.availability), pointsTo: node.pointsTo ? FernNavigation.Slug(node.pointsTo) : undefined, + audience: undefined, }; return latest; }; @@ -412,6 +420,7 @@ export class FernNavigationV1ToLatest { authed: undefined, overviewPageId, noindex: node.noindex, + audience: undefined, }; return latest; }; @@ -431,6 +440,7 @@ export class FernNavigationV1ToLatest { hidden: node.hidden, authed: undefined, year: node.year, + audience: undefined, }; return latest; }; @@ -450,6 +460,7 @@ export class FernNavigationV1ToLatest { hidden: node.hidden, authed: undefined, month: node.month, + audience: undefined, }; return latest; }; @@ -475,6 +486,7 @@ export class FernNavigationV1ToLatest { date: node.date, pageId: FernNavigation.PageId(node.pageId), noindex: node.noindex, + audience: undefined, }; return latest; }; @@ -508,6 +520,7 @@ export class FernNavigationV1ToLatest { noindex: node.noindex, apiDefinitionId: node.apiDefinitionId, availability: this.#availability(node.availability), + audience: undefined, }; return latest; }; @@ -539,6 +552,7 @@ export class FernNavigationV1ToLatest { method: node.method, endpointId: node.endpointId, isResponseStream: node.isResponseStream, + audience: undefined, }; return latest; }; @@ -581,6 +595,7 @@ export class FernNavigationV1ToLatest { apiDefinitionId: node.apiDefinitionId, availability: this.#availability(node.availability), webSocketId: node.webSocketId, + audience: undefined, }; return latest; }; @@ -610,6 +625,7 @@ export class FernNavigationV1ToLatest { 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 11fd326b6c..c0e87786f7 100644 --- a/packages/fdr-sdk/src/navigation/utils/__test__/pruneNavigationTree.test.ts +++ b/packages/fdr-sdk/src/navigation/utils/__test__/pruneNavigationTree.test.ts @@ -20,6 +20,7 @@ describe("pruneNavigationTree", () => { hidden: undefined, authed: undefined, noindex: undefined, + audience: undefined, }, ], collapsed: undefined, @@ -30,6 +31,7 @@ describe("pruneNavigationTree", () => { overviewPageId: undefined, noindex: undefined, pointsTo: undefined, + audience: undefined, }; const result = Pruner.from(root) @@ -56,6 +58,7 @@ describe("pruneNavigationTree", () => { hidden: undefined, authed: undefined, noindex: undefined, + audience: undefined, }, ], collapsed: undefined, @@ -66,6 +69,7 @@ describe("pruneNavigationTree", () => { overviewPageId: undefined, noindex: undefined, pointsTo: FernNavigation.Slug("root/page"), + audience: undefined, }); }); @@ -87,6 +91,7 @@ describe("pruneNavigationTree", () => { hidden: undefined, authed: undefined, noindex: undefined, + audience: undefined, }, ], collapsed: undefined, @@ -97,6 +102,7 @@ describe("pruneNavigationTree", () => { overviewPageId: undefined, noindex: undefined, pointsTo: FernNavigation.Slug("root/page"), + audience: undefined, }; const result = Pruner.from(root) @@ -125,6 +131,7 @@ describe("pruneNavigationTree", () => { hidden: undefined, authed: undefined, noindex: undefined, + audience: undefined, }, ], collapsed: undefined, @@ -134,6 +141,7 @@ describe("pruneNavigationTree", () => { authed: undefined, noindex: undefined, pointsTo: undefined, + audience: undefined, }; const result = Pruner.from(root) @@ -161,6 +169,7 @@ describe("pruneNavigationTree", () => { hidden: undefined, authed: undefined, noindex: undefined, + audience: undefined, }, ], collapsed: undefined, @@ -170,6 +179,7 @@ describe("pruneNavigationTree", () => { authed: undefined, noindex: undefined, pointsTo: FernNavigation.Slug("root/page"), + audience: undefined, }); }); @@ -192,6 +202,7 @@ describe("pruneNavigationTree", () => { hidden: undefined, authed: undefined, noindex: undefined, + audience: undefined, }, ], collapsed: undefined, @@ -201,6 +212,7 @@ describe("pruneNavigationTree", () => { authed: undefined, noindex: undefined, pointsTo: undefined, + audience: undefined, }; const result = Pruner.from(root) @@ -224,6 +236,7 @@ describe("pruneNavigationTree", () => { authed: undefined, noindex: undefined, pointsTo: undefined, + audience: undefined, }); }); @@ -246,6 +259,7 @@ describe("pruneNavigationTree", () => { hidden: undefined, authed: undefined, noindex: undefined, + audience: undefined, }, ], collapsed: undefined, @@ -255,6 +269,7 @@ describe("pruneNavigationTree", () => { authed: undefined, noindex: undefined, pointsTo: undefined, + audience: undefined, }; const result = Pruner.from(root) @@ -282,6 +297,7 @@ describe("pruneNavigationTree", () => { hidden: undefined, authed: undefined, noindex: undefined, + audience: undefined, }, ], collapsed: undefined, @@ -291,6 +307,7 @@ describe("pruneNavigationTree", () => { authed: undefined, noindex: undefined, pointsTo: FernNavigation.Slug("root/page"), + audience: undefined, }); }); @@ -320,6 +337,7 @@ describe("pruneNavigationTree", () => { hidden: undefined, authed: undefined, noindex: undefined, + audience: undefined, }, ], collapsed: undefined, @@ -329,6 +347,7 @@ describe("pruneNavigationTree", () => { authed: undefined, noindex: undefined, pointsTo: undefined, + audience: undefined, }, { type: "page", @@ -341,6 +360,7 @@ describe("pruneNavigationTree", () => { hidden: undefined, authed: undefined, noindex: undefined, + audience: undefined, }, ], collapsed: undefined, @@ -350,6 +370,7 @@ describe("pruneNavigationTree", () => { authed: undefined, noindex: undefined, pointsTo: undefined, + audience: undefined, }; const result = Pruner.from(root) @@ -377,6 +398,7 @@ describe("pruneNavigationTree", () => { hidden: undefined, authed: undefined, noindex: undefined, + audience: undefined, }, ], collapsed: undefined, @@ -385,6 +407,7 @@ describe("pruneNavigationTree", () => { hidden: undefined, authed: undefined, noindex: undefined, + audience: undefined, // NOTE: points to is updated! pointsTo: "root/page", diff --git a/packages/ui/app/src/resolver/ApiDefinitionResolver.ts b/packages/ui/app/src/resolver/ApiDefinitionResolver.ts index 31a7353686..6d8e0af419 100644 --- a/packages/ui/app/src/resolver/ApiDefinitionResolver.ts +++ b/packages/ui/app/src/resolver/ApiDefinitionResolver.ts @@ -186,6 +186,7 @@ export class ApiDefinitionResolver { pageId: node.overviewPageId, title: node.title, slug: node.slug, + audience: node.audience, }); if (resolvedOverviewPage != null) { items.unshift(resolvedOverviewPage); 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 a71011f3f4..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,5 +1,5 @@ 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", () => { @@ -27,7 +27,9 @@ describe("withBasicTokenAnonymous", () => { it("shouuld respect denylist before allowlist", () => { expect(withBasicTokenAnonymous({ allowlist: ["/public"], denylist: ["/public"] }, "/public")).toBe(true); }); +}); +describe("withBasicTokenAnonymousCheck", () => { it("should never deny external links", () => { expect( withBasicTokenAnonymousCheck({ denylist: ["/(.*)"] })({ @@ -56,6 +58,7 @@ describe("withBasicTokenAnonymous", () => { overviewPageId: undefined, noindex: undefined, pointsTo: undefined, + audience: undefined, }), ).toBe(false); }); @@ -76,7 +79,49 @@ describe("withBasicTokenAnonymous", () => { overviewPageId: PageId("1.mdx"), noindex: undefined, pointsTo: undefined, + audience: undefined, }), ).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 ee7abd0c36..ea15b7fb78 100644 --- a/packages/ui/docs-bundle/src/server/withBasicTokenAnonymous.ts +++ b/packages/ui/docs-bundle/src/server/withBasicTokenAnonymous.ts @@ -1,16 +1,37 @@ -import { Pruner, isPage, 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 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 true; @@ -32,9 +53,16 @@ export function withBasicTokenAnonymous( * @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}`); } @@ -57,12 +85,35 @@ export function pruneWithBasicTokenAnonymous(auth: AuthEdgeConfigBasicTokenVerif return result; } -export function pruneWithBasicTokenAuthed(auth: AuthEdgeConfigBasicTokenVerification, node: RootNode): RootNode { +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) => auth.anonymous?.find((path) => matchPath(path, `/${n.slug}`)) != null) + .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/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/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 3a71ff6b8b..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,6 +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; }