Skip to content

Commit dcdbd30

Browse files
authored
Merge branch 'main' into jacek/gate-api-changes-turbo-cache
2 parents ab067b9 + c2ba134 commit dcdbd30

26 files changed

Lines changed: 1073 additions & 120 deletions
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/expo': patch
3+
---
4+
5+
Bump the bundled `clerk-android` SDK (`clerk-android-api` and `clerk-android-ui`) from `1.0.16` to `1.0.18`. This pulls in the fix from clerk-android [#671](https://github.com/clerk/clerk-android/pull/671), which sets the correct IME actions on the prebuilt auth input fields so pressing Enter/Return submits the form (e.g. "Continue") instead of inserting a newline.

.changeset/proud-lions-allow.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/backend': patch
3+
---
4+
5+
Strip `private_metadata` from the backend resource `_raw` payload in `stripPrivateDataFromObject`, preventing it from leaking into `__clerk_ssr_state` when a `User`/`Organization` resource is passed to `buildClerkProps`.

.changeset/wide-items-flow.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@clerk/clerk-js': minor
3+
'@clerk/shared': minor
4+
'@clerk/ui': minor
5+
---
6+
7+
Internal `<ConfigureSSO />` refactor to call new org-scoped enterprise connections FAPI endpoints, replacing the `/me/` deprecated scope.

.github/workflows/api-changes.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ env:
4646
# The tool was renamed snapi -> break-check (package @clerk/break-check, binary
4747
# break-check, repo clerk/break-check). Pinned to a pkg.pr.new build of a
4848
# specific commit on main.
49-
BREAK_CHECK_PACKAGE: https://pkg.pr.new/clerk/break-check/@clerk/break-check@a4db93a68e43e45a71cc958cc4a722d91c5f62b2
49+
BREAK_CHECK_PACKAGE: https://pkg.pr.new/clerk/break-check/@clerk/break-check@98cb22591751a1628e4e260064ddffa5647d6de5
5050
BREAK_CHECK_FILTERS: >-
5151
--filter=@clerk/astro
5252
--filter=@clerk/backend
@@ -258,6 +258,7 @@ jobs:
258258
pnpm dlx --package "$BREAK_CHECK_PACKAGE" break-check detect \
259259
--baseline .api-snapshots-baseline \
260260
--output api-changes-report.md \
261+
--ai-apply-downgrades \
261262
--fail-on-breaking
262263
263264
# Note: on the cache-hit skip path we intentionally post nothing. A turbo HIT is
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { Organization } from '../../api/resources/Organization';
4+
import { User } from '../../api/resources/User';
5+
import { stripPrivateDataFromObject } from '../decorateObjectWithResources';
6+
7+
describe('stripPrivateDataFromObject', () => {
8+
it('removes top-level private metadata from user and organization', () => {
9+
const result = stripPrivateDataFromObject({
10+
user: { id: 'user_1', privateMetadata: { secret: 'a' } } as any,
11+
organization: { id: 'org_1', privateMetadata: { secret: 'b' } } as any,
12+
});
13+
14+
expect(result.user).not.toHaveProperty('privateMetadata');
15+
expect(result.organization).not.toHaveProperty('privateMetadata');
16+
});
17+
18+
it('strips private_metadata nested under the backend resource `_raw` payload', () => {
19+
const user = User.fromJSON({
20+
id: 'user_1',
21+
object: 'user',
22+
private_metadata: { ssn: '000-00-0000' },
23+
public_metadata: { plan: 'pro' },
24+
email_addresses: [],
25+
phone_numbers: [],
26+
web3_wallets: [],
27+
external_accounts: [],
28+
enterprise_accounts: [],
29+
} as any);
30+
31+
const organization = Organization.fromJSON({
32+
id: 'org_1',
33+
object: 'organization',
34+
name: 'Acme',
35+
slug: 'acme',
36+
private_metadata: { billingCustomerId: 'cus_secret' },
37+
public_metadata: { tier: 'enterprise' },
38+
} as any);
39+
40+
const result = stripPrivateDataFromObject({ user, organization });
41+
42+
// Serialize the way `buildClerkProps` embeds the state into the HTML response.
43+
const serialized = JSON.stringify(result);
44+
expect(serialized).not.toContain('000-00-0000');
45+
expect(serialized).not.toContain('cus_secret');
46+
47+
expect((result.user as any)._raw).not.toHaveProperty('private_metadata');
48+
expect((result.organization as any)._raw).not.toHaveProperty('private_metadata');
49+
50+
// Public metadata under `_raw` is intentionally preserved.
51+
expect((result.user as any)._raw.public_metadata).toEqual({ plan: 'pro' });
52+
expect((result.organization as any)._raw.public_metadata).toEqual({ tier: 'enterprise' });
53+
});
54+
55+
it('recursively strips private_metadata nested under `_raw.organization_memberships`', () => {
56+
const user = User.fromJSON({
57+
id: 'user_1',
58+
object: 'user',
59+
private_metadata: { ssn: '000-00-0000' },
60+
public_metadata: { plan: 'pro' },
61+
email_addresses: [],
62+
phone_numbers: [],
63+
web3_wallets: [],
64+
external_accounts: [],
65+
enterprise_accounts: [],
66+
organization_memberships: [
67+
{
68+
id: 'orgmem_1',
69+
object: 'organization_membership',
70+
role: 'admin',
71+
permissions: [],
72+
private_metadata: { membershipSecret: 'mem_secret' },
73+
public_metadata: { seat: 'a' },
74+
created_at: 1,
75+
updated_at: 1,
76+
organization: {
77+
id: 'org_1',
78+
object: 'organization',
79+
name: 'Acme',
80+
slug: 'acme',
81+
private_metadata: { billingCustomerId: 'cus_secret' },
82+
public_metadata: { tier: 'enterprise' },
83+
},
84+
},
85+
],
86+
} as any);
87+
88+
const result = stripPrivateDataFromObject({ user });
89+
90+
const serialized = JSON.stringify(result);
91+
expect(serialized).not.toContain('000-00-0000');
92+
expect(serialized).not.toContain('mem_secret');
93+
expect(serialized).not.toContain('cus_secret');
94+
95+
const membership = (result.user as any)._raw.organization_memberships[0];
96+
expect(membership).not.toHaveProperty('private_metadata');
97+
expect(membership.organization).not.toHaveProperty('private_metadata');
98+
99+
// Public metadata throughout the nested payload is intentionally preserved.
100+
expect(membership.public_metadata).toEqual({ seat: 'a' });
101+
expect(membership.organization.public_metadata).toEqual({ tier: 'enterprise' });
102+
});
103+
104+
it('does not mutate the original resource `raw` payload', () => {
105+
const user = User.fromJSON({
106+
id: 'user_1',
107+
object: 'user',
108+
private_metadata: { ssn: '000-00-0000' },
109+
email_addresses: [],
110+
phone_numbers: [],
111+
web3_wallets: [],
112+
external_accounts: [],
113+
enterprise_accounts: [],
114+
} as any);
115+
116+
stripPrivateDataFromObject({ user });
117+
118+
// The server-side `raw` getter must still expose the full payload.
119+
expect(user.raw?.private_metadata).toEqual({ ssn: '000-00-0000' });
120+
});
121+
});

packages/backend/src/util/decorateObjectWithResources.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export function stripPrivateDataFromObject<T extends WithResources<object>>(auth
5252
return { ...authObject, user, organization };
5353
}
5454

55-
function prunePrivateMetadata(resource?: { private_metadata: any } | { privateMetadata: any } | null) {
55+
function prunePrivateMetadata(resource?: { private_metadata?: any; privateMetadata?: any; _raw?: any } | null) {
5656
// Delete sensitive private metadata from resource before rendering in SSR
5757
if (resource) {
5858
if ('privateMetadata' in resource) {
@@ -61,7 +61,38 @@ function prunePrivateMetadata(resource?: { private_metadata: any } | { privateMe
6161
if ('private_metadata' in resource) {
6262
delete resource['private_metadata'];
6363
}
64+
// Backend resources (`User`, `Organization`) retain the full Backend API
65+
// payload on the enumerable `_raw` property, which still contains
66+
// `private_metadata`. The payload is also nested (e.g. a `User`'s
67+
// `organization_memberships[*]` each carry their own `private_metadata`
68+
// and a nested `organization.private_metadata`), so redact recursively on
69+
// a deep clone — leaving the original resource (and its `raw` getter)
70+
// untouched.
71+
if ('_raw' in resource && resource['_raw']) {
72+
resource['_raw'] = redactPrivateMetadataDeep(resource['_raw']);
73+
}
6474
}
6575

6676
return resource;
6777
}
78+
79+
/**
80+
* Returns a deep clone of `value` with every `private_metadata` / `privateMetadata`
81+
* property removed at any depth.
82+
*/
83+
function redactPrivateMetadataDeep(value: any): any {
84+
if (Array.isArray(value)) {
85+
return value.map(redactPrivateMetadataDeep);
86+
}
87+
if (value && typeof value === 'object') {
88+
const clone: Record<string, any> = {};
89+
for (const key of Object.keys(value)) {
90+
if (key === 'private_metadata' || key === 'privateMetadata') {
91+
continue;
92+
}
93+
clone[key] = redactPrivateMetadataDeep(value[key]);
94+
}
95+
return clone;
96+
}
97+
return value;
98+
}

packages/clerk-js/src/core/resources/Organization.ts

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,20 @@ import type {
22
AddMemberParams,
33
ClerkPaginatedResponse,
44
ClerkResourceReloadParams,
5+
CreateOrganizationEnterpriseConnectionParams,
56
CreateOrganizationParams,
7+
DeletedObjectJSON,
8+
DeletedObjectResource,
9+
EnterpriseConnectionJSON,
10+
EnterpriseConnectionResource,
11+
EnterpriseConnectionTestRunInitJSON,
12+
EnterpriseConnectionTestRunInitResource,
13+
EnterpriseConnectionTestRunJSON,
14+
EnterpriseConnectionTestRunResource,
15+
EnterpriseConnectionTestRunsPaginatedJSON,
616
GetDomainsParams,
17+
GetEnterpriseConnectionsParams,
18+
GetEnterpriseConnectionTestRunsParams,
719
GetInvitationsParams,
820
GetMembershipRequestParams,
921
GetMemberships,
@@ -23,13 +35,22 @@ import type {
2335
RoleJSON,
2436
SetOrganizationLogoParams,
2537
UpdateMembershipParams,
38+
UpdateOrganizationEnterpriseConnectionParams,
2639
UpdateOrganizationParams,
2740
} from '@clerk/shared/types';
2841

2942
import { convertPageToOffsetSearchParams } from '../../utils/convertPageToOffsetSearchParams';
3043
import { unixEpochToDate } from '../../utils/date';
44+
import { toEnterpriseConnectionBody } from '../../utils/enterpriseConnection';
3145
import { addPaymentMethod, getPaymentMethods, initializePaymentMethod } from '../modules/billing';
32-
import { BaseResource, OrganizationInvitation, OrganizationMembership } from './internal';
46+
import {
47+
BaseResource,
48+
DeletedObject,
49+
EnterpriseConnection,
50+
EnterpriseConnectionTestRun,
51+
OrganizationInvitation,
52+
OrganizationMembership,
53+
} from './internal';
3354
import { OrganizationDomain } from './OrganizationDomain';
3455
import { OrganizationMembershipRequest } from './OrganizationMembershipRequest';
3556
import { Role } from './Role';
@@ -142,6 +163,107 @@ export class Organization extends BaseResource implements OrganizationResource {
142163
return new OrganizationDomain(json);
143164
};
144165

166+
getEnterpriseConnections = async (
167+
params?: GetEnterpriseConnectionsParams,
168+
): Promise<EnterpriseConnectionResource[]> => {
169+
const { withOrganizationAccountLinking } = params || {};
170+
171+
const json = (
172+
await BaseResource._fetch({
173+
path: `/organizations/${this.id}/enterprise_connections`,
174+
method: 'GET',
175+
...(withOrganizationAccountLinking !== undefined
176+
? {
177+
search: {
178+
with_organization_account_linking: String(withOrganizationAccountLinking),
179+
},
180+
}
181+
: {}),
182+
})
183+
)?.response as unknown as EnterpriseConnectionJSON[];
184+
185+
return (json || []).map(connection => new EnterpriseConnection(connection));
186+
};
187+
188+
createEnterpriseConnection = async (
189+
params: CreateOrganizationEnterpriseConnectionParams,
190+
): Promise<EnterpriseConnectionResource> => {
191+
const json = (
192+
await BaseResource._fetch<EnterpriseConnectionJSON>({
193+
path: `/organizations/${this.id}/enterprise_connections`,
194+
method: 'POST',
195+
body: toEnterpriseConnectionBody(params, { omitOrganizationId: true }) as any,
196+
})
197+
)?.response as unknown as EnterpriseConnectionJSON;
198+
199+
return new EnterpriseConnection(json);
200+
};
201+
202+
updateEnterpriseConnection = async (
203+
enterpriseConnectionId: string,
204+
params: UpdateOrganizationEnterpriseConnectionParams,
205+
): Promise<EnterpriseConnectionResource> => {
206+
const json = (
207+
await BaseResource._fetch<EnterpriseConnectionJSON>({
208+
path: `/organizations/${this.id}/enterprise_connections/${enterpriseConnectionId}`,
209+
method: 'PATCH',
210+
body: toEnterpriseConnectionBody(params, { omitOrganizationId: true }) as any,
211+
})
212+
)?.response as unknown as EnterpriseConnectionJSON;
213+
214+
return new EnterpriseConnection(json);
215+
};
216+
217+
deleteEnterpriseConnection = async (enterpriseConnectionId: string): Promise<DeletedObjectResource> => {
218+
const json = (
219+
await BaseResource._fetch<DeletedObjectJSON>({
220+
path: `/organizations/${this.id}/enterprise_connections/${enterpriseConnectionId}`,
221+
method: 'DELETE',
222+
})
223+
)?.response as unknown as DeletedObjectJSON;
224+
225+
return new DeletedObject(json);
226+
};
227+
228+
createEnterpriseConnectionTestRun = async (
229+
enterpriseConnectionId: string,
230+
): Promise<EnterpriseConnectionTestRunInitResource> => {
231+
const json = (
232+
await BaseResource._fetch({
233+
path: `/organizations/${this.id}/enterprise_connections/${enterpriseConnectionId}/test_runs`,
234+
method: 'POST',
235+
})
236+
)?.response as unknown as EnterpriseConnectionTestRunInitJSON;
237+
238+
return { url: json.url };
239+
};
240+
241+
getEnterpriseConnectionTestRuns = async (
242+
enterpriseConnectionId: string,
243+
params?: GetEnterpriseConnectionTestRunsParams,
244+
): Promise<ClerkPaginatedResponse<EnterpriseConnectionTestRunResource>> => {
245+
const { status, ...rest } = params || {};
246+
const search = convertPageToOffsetSearchParams(rest);
247+
if (status?.length) {
248+
for (const s of status) {
249+
search.append('status', s);
250+
}
251+
}
252+
253+
const res = await BaseResource._fetch({
254+
path: `/organizations/${this.id}/enterprise_connections/${enterpriseConnectionId}/test_runs`,
255+
method: 'GET',
256+
search,
257+
});
258+
259+
const payload = res?.response as unknown as EnterpriseConnectionTestRunsPaginatedJSON | undefined;
260+
261+
return {
262+
total_count: payload?.total_count ?? 0,
263+
data: (payload?.data ?? []).map((row: EnterpriseConnectionTestRunJSON) => new EnterpriseConnectionTestRun(row)),
264+
};
265+
};
266+
145267
getMembershipRequests = async (
146268
getRequestParam?: GetMembershipRequestParams,
147269
): Promise<ClerkPaginatedResponse<OrganizationMembershipRequestResource>> => {

0 commit comments

Comments
 (0)