fix(organization): filter returned: false fields from API responses (#7531)

Co-authored-by: Alex Yang <himself65@outlook.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
Paola Estefanía de Campos
2026-01-28 17:06:03 -08:00
committed by Alex Yang
co-authored by Alex Yang cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
parent 27af866f26
commit 57f1e212eb
10 changed files with 401 additions and 68 deletions
@@ -147,7 +147,7 @@ class ConnectionMutex {
#resolve?: () => void;
async lock(): Promise<void> {
while (this.#promise) {
while (await this.#promise) {
await this.#promise;
}
@@ -149,7 +149,7 @@ class ConnectionMutex {
#resolve?: () => void;
async lock(): Promise<void> {
while (this.#promise) {
while (await this.#promise) {
await this.#promise;
}
+9
View File
@@ -147,6 +147,9 @@ type AddOptionalFields<
* ```ts
* type AdditionalFields = InferAdditionalFieldsFromPluginOptions<"organization", OrganizationOptions>
* ```
*
* @param isClientSide - When `true` (default), filters out `input: false` fields (clients can't send these).
* When `false`, includes all fields (for internal/server-side use).
*/
export type InferAdditionalFieldsFromPluginOptions<
SchemaName extends string,
@@ -174,6 +177,12 @@ type RemoveFieldsWithInputFalse<T extends Record<string, DBFieldAttribute>> = {
[K in keyof T as T[K]["input"] extends false ? never : K]: T[K];
};
export type RemoveFieldsWithReturnedFalse<
T extends Record<string, DBFieldAttribute>,
> = {
[K in keyof T as T[K]["returned"] extends false ? never : K]: T[K];
};
type InferFieldInput<T extends DBFieldAttribute> = InferValueType<T["type"]>;
export type PluginFieldAttribute = Omit<
@@ -1,5 +1,6 @@
import type { AuthContext, GenericEndpointContext } from "@better-auth/core";
import { getCurrentAdapter } from "@better-auth/core/context";
import type { DBFieldAttribute } from "@better-auth/core/db";
import { BetterAuthError } from "@better-auth/core/error";
import { parseJSON } from "../../client/parser";
import type { InferAdditionalFieldsFromPluginOptions } from "../../db";
@@ -20,13 +21,39 @@ import type {
} from "./schema";
import type { OrganizationOptions } from "./types";
/**
* Filters output data by removing fields with `returned: false` attribute.
* This ensures sensitive fields are not exposed in API responses.
*/
function filterOutputFields<T extends Record<string, unknown> | null>(
data: T,
additionalFields: Record<string, DBFieldAttribute> | undefined,
): T {
if (!data || !additionalFields) {
return data;
}
const returnFiltered = Object.entries(additionalFields)
.filter(([, { returned }]) => returned === false)
.map(([key]) => key);
return Object.entries(structuredClone(data))
.filter(([key]) => !returnFiltered.includes(key))
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {} as T);
}
export const getOrgAdapter = <O extends OrganizationOptions>(
context: AuthContext,
options?: O | undefined,
) => {
const baseAdapter = context.adapter;
const orgAdditionalFields = options?.schema?.organization?.additionalFields;
const memberAdditionalFields = options?.schema?.member?.additionalFields;
const invitationAdditionalFields =
options?.schema?.invitation?.additionalFields;
const teamAdditionalFields = options?.schema?.team?.additionalFields;
return {
findOrganizationBySlug: async (slug: string) => {
findOrganizationBySlug: async (
slug: string,
): Promise<InferOrganization<O> | null> => {
const adapter = await getCurrentAdapter(baseAdapter);
const organization = await adapter.findOne<InferOrganization<O, false>>({
model: "organization",
@@ -37,13 +64,16 @@ export const getOrgAdapter = <O extends OrganizationOptions>(
},
],
});
return organization;
return filterOutputFields(
organization,
orgAdditionalFields,
) as InferOrganization<O> | null;
},
createOrganization: async (data: {
organization: OrganizationInput &
// This represents the additional fields from the plugin options
Record<string, any>;
}) => {
}): Promise<InferOrganization<O>> => {
const adapter = await getCurrentAdapter(baseAdapter);
const organization = await adapter.create<
OrganizationInput,
@@ -59,13 +89,17 @@ export const getOrgAdapter = <O extends OrganizationOptions>(
forceAllowId: true,
});
return {
const result = {
...organization,
metadata:
organization.metadata && typeof organization.metadata === "string"
? JSON.parse(organization.metadata)
: undefined,
} as typeof organization;
};
return filterOutputFields(
result,
orgAdditionalFields,
) as InferOrganization<O>;
},
findMemberByEmail: async (data: {
email: string;
@@ -353,7 +387,7 @@ export const getOrgAdapter = <O extends OrganizationOptions>(
updateOrganization: async (
organizationId: string,
data: Partial<OrganizationInput>,
) => {
): Promise<InferOrganization<O> | null> => {
const adapter = await getCurrentAdapter(baseAdapter);
const organization = await adapter.update<InferOrganization<O, false>>({
model: "organization",
@@ -374,12 +408,16 @@ export const getOrgAdapter = <O extends OrganizationOptions>(
if (!organization) {
return null;
}
return {
const result = {
...organization,
metadata: organization.metadata
? parseJSON<Record<string, any>>(organization.metadata)
: undefined,
};
return filterOutputFields(
result,
orgAdditionalFields,
) as InferOrganization<O>;
},
deleteOrganization: async (organizationId: string) => {
const adapter = await getCurrentAdapter(baseAdapter);
@@ -425,7 +463,9 @@ export const getOrgAdapter = <O extends OrganizationOptions>(
);
return session as Session;
},
findOrganizationById: async (organizationId: string) => {
findOrganizationById: async (
organizationId: string,
): Promise<InferOrganization<O> | null> => {
const adapter = await getCurrentAdapter(baseAdapter);
const organization = await adapter.findOne<InferOrganization<O, false>>({
model: "organization",
@@ -436,7 +476,10 @@ export const getOrgAdapter = <O extends OrganizationOptions>(
},
],
});
return organization;
return filterOutputFields(
organization,
orgAdditionalFields,
) as InferOrganization<O> | null;
},
checkMembership: async ({
userId,
@@ -519,8 +562,12 @@ export const getOrgAdapter = <O extends OrganizationOptions>(
"Unexpected error: User not found for member",
);
}
const filteredMember = filterOutputFields(
member,
memberAdditionalFields,
);
return {
...member,
...filteredMember,
user: {
id: user.id,
name: user.name,
@@ -530,14 +577,24 @@ export const getOrgAdapter = <O extends OrganizationOptions>(
};
});
const filteredOrg = filterOutputFields(org, orgAdditionalFields);
const filteredInvitations = invitations.map((inv) =>
filterOutputFields(inv, invitationAdditionalFields),
);
const filteredTeams = teams?.map((team) =>
filterOutputFields(team, teamAdditionalFields),
);
return {
...org,
invitations,
...filteredOrg,
invitations: filteredInvitations,
members: membersWithUsers,
teams,
teams: filteredTeams,
};
},
listOrganizations: async (userId: string) => {
listOrganizations: async (
userId: string,
): Promise<InferOrganization<O>[]> => {
const adapter = await getCurrentAdapter(baseAdapter);
const result = await adapter.findMany<
InferMember<O, false> & { organization: InferOrganization<O, false> }
@@ -558,7 +615,13 @@ export const getOrgAdapter = <O extends OrganizationOptions>(
return [];
}
const organizations = result.map((member) => member.organization);
const organizations = result.map(
(member) =>
filterOutputFields(
member.organization,
orgAdditionalFields,
) as InferOrganization<O>,
);
return organizations;
},
@@ -118,14 +118,14 @@ export const organizationClient = <CO extends OrganizationClientOptions>(
type OrganizationReturn = CO["teams"] extends { enabled: true }
? {
members: InferMember<CO, false>[];
members: InferMember<CO>[];
invitations: InferInvitation<CO>[];
teams: InferTeam<CO, false>[];
} & InferOrganization<CO, false>
teams: InferTeam<CO>[];
} & InferOrganization<CO>
: {
members: InferMember<CO, false>[];
invitations: InferInvitation<CO, false>[];
} & InferOrganization<CO, false>;
members: InferMember<CO>[];
invitations: InferInvitation<CO>[];
} & InferOrganization<CO>;
type Schema = CO["schema"];
return {
@@ -154,10 +154,10 @@ export const organizationClient = <CO extends OrganizationClientOptions>(
getActions: ($fetch, _$store, co) => ({
$Infer: {
ActiveOrganization: {} as OrganizationReturn,
Organization: {} as InferOrganization<CO, false>,
Invitation: {} as InferInvitation<CO, false>,
Member: {} as InferMember<CO, false>,
Team: {} as InferTeam<CO, false>,
Organization: {} as InferOrganization<CO>,
Invitation: {} as InferInvitation<CO>,
Member: {} as InferMember<CO>,
Team: {} as InferTeam<CO>,
},
organization: {
checkRolePermission: <
@@ -182,7 +182,7 @@ export const organizationClient = <CO extends OrganizationClientOptions>(
},
}),
getAtoms: ($fetch) => {
const listOrganizations = useAuthQuery<InferOrganization<CO, false>[]>(
const listOrganizations = useAuthQuery<InferOrganization<CO>[]>(
$listOrg,
"/organization/list",
$fetch,
@@ -192,9 +192,9 @@ export const organizationClient = <CO extends OrganizationClientOptions>(
);
const activeOrganization = useAuthQuery<
Prettify<
InferOrganization<CO, false> & {
members: InferMember<CO, false>[];
invitations: InferInvitation<CO, false>[];
InferOrganization<CO> & {
members: InferMember<CO>[];
invitations: InferInvitation<CO>[];
}
>
>(
@@ -2181,21 +2181,9 @@ describe("Additional Fields", async () => {
},
headers,
});
type Result = PrettifyDeep<typeof updatedOrg>;
expect(updatedOrg?.someRequiredField).toBe("hey2");
//@ts-expect-error
expect(db.organization[0]?.someRequiredField).toBe("hey2");
expectTypeOf<Result>().toEqualTypeOf<{
id: string;
name: string;
slug: string;
createdAt: Date;
logo?: string | null | undefined;
someRequiredField: string;
someOptionalField?: string | undefined;
someHiddenField?: string | undefined;
metadata: any;
} | null>();
});
it("list user organizations", async () => {
@@ -2902,3 +2890,260 @@ describe("organization hooks", async (it) => {
expect(hooksCalled).toContain("afterCreateInvitation");
});
});
describe("organization additionalFields with returned: false", async () => {
const db = {
user: [] as { id: string }[],
session: [] as { id: string }[],
account: [] as { id: string }[],
verification: [] as { id: string }[],
organization: [] as {
id: string;
publicField: string;
secretField: string;
}[],
member: [] as {
id: string;
memberPublicField: string;
memberSecretField: string;
}[],
invitation: [] as {
id: string;
invitationPublicField: string;
invitationSecretField: string;
}[],
};
const orgOptions = {
schema: {
organization: {
additionalFields: {
publicField: {
type: "string",
required: true,
},
secretField: {
type: "string",
required: false,
returned: false,
input: true,
},
},
},
member: {
additionalFields: {
memberPublicField: {
type: "string",
required: true,
},
memberSecretField: {
type: "string",
required: false,
returned: false,
input: true,
},
},
},
invitation: {
additionalFields: {
invitationPublicField: {
type: "string",
required: true,
},
invitationSecretField: {
type: "string",
required: false,
returned: false,
input: true,
},
},
},
},
invitationLimit: 3,
} satisfies OrganizationOptions;
const { auth, signInWithTestUser } = await getTestInstance({
database: memoryAdapter(db, {
debugLogs: false,
}),
plugins: [organization(orgOptions)],
logger: {
level: "error",
},
});
const { headers } = await signInWithTestUser();
const client = createAuthClient({
plugins: [
organizationClient({
schema: inferOrgAdditionalFields(orgOptions.schema),
}),
],
baseURL: "http://localhost:3000/api/auth",
fetchOptions: {
customFetchImpl: async (url, init) => {
return auth.handler(new Request(url, init));
},
headers,
},
});
it("should save field with returned: false but not return it in response", async () => {
const org = await client.organization.create({
name: "Test Org",
slug: "test-org-secret",
publicField: "public-value",
secretField: "secret-value",
});
expect(org.data).toBeDefined();
// Note: publicField and secretField use `as any` because endpoint response types
// are inferred from adapter return values which include all fields.
// The runtime correctly filters returned: false fields.
expect((org.data as any).publicField).toBe("public-value");
expect((org.data as any).secretField).toBeUndefined();
const dbOrg = db.organization.find((o) => o.id === org.data?.id);
expect(dbOrg).toBeDefined();
expect(dbOrg?.secretField).toBe("secret-value");
});
it("should not return secret field when getting organization by slug", async () => {
await client.organization.create({
name: "Test Org 2",
slug: "test-org-secret-2",
publicField: "public-value-2",
secretField: "secret-value-2",
});
// Get organization - secret field should not be returned
const fullOrg = await client.organization.getFullOrganization({
query: { organizationSlug: "test-org-secret-2" },
});
expect(fullOrg.data).toBeDefined();
expect(fullOrg.data?.publicField).toBe("public-value-2");
// @ts-expect-error - secretField has returned: false
expect(fullOrg.data?.secretField).toBeUndefined();
});
it("should not return secret field when listing organizations", async () => {
const orgs = await client.organization.list();
expect(orgs.data).toBeDefined();
expect(orgs.data!.length).toBeGreaterThan(0);
for (const org of orgs.data!) {
expect((org as any).secretField).toBeUndefined();
expect((org as any).publicField).toBeDefined();
}
});
it("should not return member secret field in full organization response", async () => {
// Create organization first
const org = await client.organization.create({
name: "Member Test Org",
slug: "member-test-org",
publicField: "public",
});
// Create a new user using auth API directly
const signUpRes = await auth.api.signUpEmail({
body: {
email: "member-test@example.com",
password: "password123",
name: "Member Test",
},
});
// Add member with secret field via server API
await auth.api.addMember({
body: {
userId: signUpRes.user.id,
role: "member",
organizationId: org.data!.id,
memberPublicField: "member-public",
memberSecretField: "member-secret",
},
headers,
});
// Get full organization - member secret field should not be returned
const fullOrg = await client.organization.getFullOrganization({
query: { organizationId: org.data!.id },
});
expect(fullOrg.data?.members).toBeDefined();
const addedMember = fullOrg.data?.members?.find(
(m) => m.userId === signUpRes.user.id,
);
expect(addedMember).toBeDefined();
expect(addedMember?.memberPublicField).toBe("member-public");
// @ts-expect-error - memberSecretField has returned: false
expect(addedMember?.memberSecretField).toBeUndefined();
const dbMember = db.member.find((m) => m.id === addedMember?.id);
expect(dbMember?.memberSecretField).toBe("member-secret");
});
it("should not return invitation secret field", async () => {
const org = await client.organization.create({
name: "Invitation Test Org",
slug: "invitation-test-org",
publicField: "public",
});
// Invite member with secret field
await client.organization.inviteMember({
email: "invite-test@example.com",
role: "member",
organizationId: org.data!.id,
invitationPublicField: "invite-public",
invitationSecretField: "invite-secret",
});
// Get full organization - invitation secret field should not be returned
const fullOrg = await client.organization.getFullOrganization({
query: { organizationId: org.data!.id },
});
expect(fullOrg.data?.invitations).toBeDefined();
const invitation = fullOrg.data?.invitations?.find(
(i) => i.email === "invite-test@example.com",
);
expect(invitation).toBeDefined();
expect(invitation?.invitationPublicField).toBe("invite-public");
// @ts-expect-error - invitationSecretField has returned: false
expect(invitation?.invitationSecretField).toBeUndefined();
const dbInvitation = db.invitation.find((i) => i.id === invitation?.id);
expect(dbInvitation?.invitationSecretField).toBe("invite-secret");
});
it("should not return secret field after updating organization", async () => {
const org = await client.organization.create({
name: "Update Test Org",
slug: "update-test-org",
publicField: "original-public",
secretField: "original-secret",
});
// Update organization
const updated = await client.organization.update({
organizationId: org.data!.id,
data: {
name: "Updated Test Org",
publicField: "updated-public",
secretField: "updated-secret",
},
});
expect(updated.data).toBeDefined();
expect((updated.data as any).publicField).toBe("updated-public");
expect((updated.data as any).secretField).toBeUndefined();
const dbOrg = db.organization.find((o) => o.id === org.data?.id);
expect(dbOrg?.secretField).toBe("updated-secret");
});
});
@@ -349,7 +349,7 @@ export const createInvitation = <O extends OrganizationOptions>(option: O) => {
);
}
return ctx.json(updatedInvitation as InferInvitation<O, false>);
return ctx.json(updatedInvitation as unknown as InferInvitation<O>);
}
if (
@@ -298,7 +298,7 @@ export const removeMember = <O extends OrganizationOptions>(options: O) =>
message: ORGANIZATION_ERROR_CODES.MEMBER_NOT_FOUND,
});
}
let toBeRemovedMember: InferMember<O, false> | null = null;
let toBeRemovedMember: InferMember<O> | null = null;
if (ctx.body.memberIdOrEmail.includes("@")) {
toBeRemovedMember = await adapter.findMemberByEmail({
email: ctx.body.memberIdOrEmail,
@@ -309,7 +309,7 @@ export const removeMember = <O extends OrganizationOptions>(options: O) =>
if (!result) toBeRemovedMember = null;
else {
const { user: _user, ...member } = result;
toBeRemovedMember = member as unknown as InferMember<O, false>;
toBeRemovedMember = member as unknown as InferMember<O>;
}
}
if (!toBeRemovedMember) {
@@ -751,14 +751,14 @@ export const getFullOrganization = <O extends OrganizationOptions>(
type OrganizationReturn = O["teams"] extends { enabled: true }
? {
members: InferMember<O, false>[];
invitations: InferInvitation<O, false>[];
teams: InferTeam<O, false>[];
} & InferOrganization<O, false>
members: InferMember<O>[];
invitations: InferInvitation<O>[];
teams: InferTeam<O>[];
} & InferOrganization<O>
: {
members: InferMember<O, false>[];
invitations: InferInvitation<O, false>[];
} & InferOrganization<O, false>;
members: InferMember<O>[];
invitations: InferInvitation<O>[];
} & InferOrganization<O>;
return ctx.json(organization as unknown as OrganizationReturn);
},
);
@@ -889,14 +889,14 @@ export const setActiveOrganization = <O extends OrganizationOptions>(
});
type OrganizationReturn = O["teams"] extends { enabled: true }
? {
members: InferMember<O, false>[];
invitations: InferInvitation<O, false>[];
teams: InferTeam<O, false>[];
} & InferOrganization<O, false>
members: InferMember<O>[];
invitations: InferInvitation<O>[];
teams: InferTeam<O>[];
} & InferOrganization<O>
: {
members: InferMember<O, false>[];
invitations: InferInvitation<O, false>[];
} & InferOrganization<O, false>;
members: InferMember<O>[];
invitations: InferInvitation<O>[];
} & InferOrganization<O>;
return ctx.json(organization as unknown as OrganizationReturn);
},
);
@@ -1,7 +1,10 @@
import type { BetterAuthPluginDBSchema } from "@better-auth/core/db";
import type { Prettify } from "better-call";
import * as z from "zod";
import type { InferAdditionalFieldsFromPluginOptions } from "../../db";
import type {
FieldAttributeToObject,
RemoveFieldsWithReturnedFalse,
} from "../../db";
import { generateId } from "../../utils";
import type { OrganizationOptions } from "./types";
@@ -378,6 +381,22 @@ export type InferOrganizationRolesFromOption<
export type InvitationStatus = "pending" | "accepted" | "rejected" | "canceled";
import type { DBFieldAttribute } from "@better-auth/core/db";
type InferAdditionalFieldsOutput<
SchemaName extends string,
Options extends OrganizationOptions,
isClientSide extends boolean,
> = Options["schema"] extends {
[key in SchemaName]?: {
additionalFields: infer Field extends Record<string, DBFieldAttribute>;
};
}
? isClientSide extends true
? FieldAttributeToObject<RemoveFieldsWithReturnedFalse<Field>>
: FieldAttributeToObject<Field>
: {};
export type InferMember<
O extends OrganizationOptions,
isClientSide extends boolean = true,
@@ -412,23 +431,20 @@ export type InferMember<
image?: string | undefined;
};
}) &
InferAdditionalFieldsFromPluginOptions<"member", O, isClientSide>
InferAdditionalFieldsOutput<"member", O, isClientSide>
>;
export type InferOrganization<
O extends OrganizationOptions,
isClientSide extends boolean = true,
> = Prettify<
Organization &
InferAdditionalFieldsFromPluginOptions<"organization", O, isClientSide>
Organization & InferAdditionalFieldsOutput<"organization", O, isClientSide>
>;
export type InferTeam<
O extends OrganizationOptions,
isClientSide extends boolean = true,
> = Prettify<
Team & InferAdditionalFieldsFromPluginOptions<"team", O, isClientSide>
>;
> = Prettify<Team & InferAdditionalFieldsOutput<"team", O, isClientSide>>;
export type InferInvitation<
O extends OrganizationOptions,
@@ -458,5 +474,5 @@ export type InferInvitation<
expiresAt: Date;
createdAt: Date;
}) &
InferAdditionalFieldsFromPluginOptions<"invitation", O, isClientSide>
InferAdditionalFieldsOutput<"invitation", O, isClientSide>
>;