Skip to content

Commit b523f91

Browse files
committed
Add GET /api/organizations/surface
An authorized read of the viewer's own organization, resolved from membership: the existing surface context plus navigation flags, the legacy layout's runtime flags, and the settings sections the legacy sidebar rule allows, so the Platform never re-derives those gates.
1 parent 77406dd commit b523f91

4 files changed

Lines changed: 185 additions & 0 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { getOrganizationSurfaceContract } from '@/lib/api/contracts/organization'
2+
import {
3+
defineInternalJsonRoute,
4+
internalRateLimits,
5+
internalSessionAuth,
6+
} from '@/lib/api/server/routes'
7+
import { internalOrganizationErrorPolicy } from '@/lib/api/server/routes/organizations'
8+
import { organizationOperations } from '@/lib/organizations/application/operations'
9+
import { readOrganizationSurface } from '@/lib/organizations/application/surface'
10+
11+
export const GET = defineInternalJsonRoute({
12+
contract: getOrganizationSurfaceContract,
13+
auth: internalSessionAuth,
14+
operation: organizationOperations.readSurface,
15+
rateLimit: internalRateLimits.none({
16+
reason:
17+
'Read once per Platform document, as the legacy organization layout reads it per render',
18+
}),
19+
errorPolicy: internalOrganizationErrorPolicy,
20+
mapInput: () => ({}),
21+
useCase: readOrganizationSurface,
22+
})

‎apps/sim/lib/api/contracts/organization.ts‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from '@/lib/api/contracts/primitives'
99
import { organizationBillingDataSchema } from '@/lib/api/contracts/subscription'
1010
import { defineRouteContract } from '@/lib/api/contracts/types'
11+
import { deploymentShapeSchema } from '@/lib/api/contracts/workspaces'
1112
import { memberCreditLimitUpdateSchema } from '@/lib/billing/application/usage-limit-validation'
1213
import { HEX_COLOR_REGEX } from '@/lib/branding'
1314
import {
@@ -522,6 +523,66 @@ export const getOrganizationContract = defineRouteContract({
522523
response: { mode: 'json', schema: getOrganizationResponseSchema },
523524
})
524525

526+
const organizationSurfaceResponseSchema = z.object({
527+
organization: z.object({
528+
id: z.string().min(1),
529+
name: z.string(),
530+
slug: z.string(),
531+
logo: z.string().nullable(),
532+
memberCount: z.number().int().nonnegative(),
533+
}),
534+
viewer: z.object({
535+
role: organizationRoleSchema,
536+
isAdmin: z.boolean(),
537+
canInviteMembers: z.boolean(),
538+
canConnectSearchIntegrations: z.boolean(),
539+
canUsePersonalApiKeys: z.boolean(),
540+
canUseSearchMcp: z.boolean(),
541+
}),
542+
mothershipAvailable: z.boolean(),
543+
canBuild: z.boolean(),
544+
searchAccess: z.object({
545+
sourceMirrored: z.boolean(),
546+
memberScoped: z.boolean(),
547+
}),
548+
connectedAccountsAvailable: z.boolean(),
549+
settingsFeatures: z.object({
550+
billingEnabled: z.boolean(),
551+
hasEnterprisePlan: z.boolean(),
552+
governanceActive: z.boolean(),
553+
hosted: z.boolean(),
554+
selfHosted: z.record(z.string(), z.boolean().optional()),
555+
}),
556+
deployment: deploymentShapeSchema,
557+
/** Which destinations the viewer's navigation lists; none of these authorizes the page behind it. */
558+
navFlags: z.object({
559+
people: z.boolean(),
560+
simLinks: z.boolean(),
561+
benchmark: z.boolean(),
562+
platformAdmin: z.boolean(),
563+
anonymous: z.boolean(),
564+
}),
565+
featureFlags: z.object({
566+
dashboards: z.boolean(),
567+
'workflow-tests': z.boolean(),
568+
changelog: z.boolean(),
569+
'mothership-model-selector': z.boolean(),
570+
'mothership-computer-use': z.boolean(),
571+
'mothership-plan-mode': z.boolean(),
572+
'mothership-memory-spaces': z.boolean(),
573+
projects: z.boolean(),
574+
}),
575+
/** Settings sections the viewer may reach, from the same rule the legacy settings sidebar uses. */
576+
settingsSections: z.array(z.string().min(1)),
577+
})
578+
export type OrganizationSurfaceResponse = z.output<typeof organizationSurfaceResponseSchema>
579+
/** The viewer's own organization is resolved from their membership, so the route names none. */
580+
export const getOrganizationSurfaceContract = defineRouteContract({
581+
method: 'GET',
582+
path: '/api/organizations/surface',
583+
response: { mode: 'json', schema: organizationSurfaceResponseSchema },
584+
})
585+
525586
export const updateOrganizationContract = defineRouteContract({
526587
method: 'PUT',
527588
path: '/api/organizations/[id]',

‎apps/sim/lib/organizations/application/operations.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,15 @@ export const organizationOperations = {
2121
principalKinds: ['session', 'personal_api_key', 'oauth_access_token'],
2222
oauthScope: 'api:read',
2323
}),
24+
/**
25+
* permission-group-exempt: this is a member's own view of their organization; each capability it reports is resolved from the same permission config rather than gating the read.
26+
*/
27+
readSurface: defineOrganizationOperation({
28+
id: 'organizations.surface.read',
29+
minimumRole: 'member',
30+
capability: 'none',
31+
principalKinds: ['session'],
32+
}),
2433
/**
2534
* permission-group-exempt: administrators must be able to discover workspaces they govern.
2635
*/
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { ANONYMOUS_USER_ID } from '@sim/auth/principal'
2+
import { createLogger } from '@sim/logger'
3+
import { getErrorMessage } from '@sim/utils/errors'
4+
import type { OrganizationSurfaceResponse } from '@/lib/api/contracts/organization'
5+
import type { OperationUseCase } from '@/lib/core/application/operation'
6+
import {
7+
authorizeOrganizationOperation,
8+
OrganizationMembershipNotFoundError,
9+
} from '@/lib/core/application/organization-authorization'
10+
import { PrincipalKindAuthorizationError } from '@/lib/core/application/workspace-authorization'
11+
import { organizationOperations } from '@/lib/organizations/application/operations'
12+
import {
13+
getOrganizationSurfaceContext,
14+
resolveOrganizationLanding,
15+
} from '@/lib/organizations/surface'
16+
import { resolveOrganizationSurfaceFlags } from '@/lib/organizations/surface-flags'
17+
import { canBrowsePeople } from '@/lib/people/feature-flag'
18+
import { isPlatformAdmin } from '@/lib/permissions/super-user'
19+
import { isSimLinksEnabled } from '@/lib/sim-links/feature-flag'
20+
import { organizationSurfaceSettingsNavigation } from '@/app/o/[organizationId]/settings/navigation'
21+
22+
const logger = createLogger('OrganizationSurfaceRead')
23+
24+
/**
25+
* The viewer's organization surface, resolved from their membership. A member belongs to one
26+
* organization, so the caller names none and session active-organization state is not consulted.
27+
* Having no membership reads the same as not being a member of a named organization.
28+
*/
29+
export const readOrganizationSurface: OperationUseCase<
30+
typeof organizationOperations.readSurface,
31+
Record<string, never>,
32+
OrganizationSurfaceResponse
33+
> = {
34+
operation: organizationOperations.readSurface,
35+
async execute({ principal }) {
36+
if (principal.kind !== 'session') {
37+
throw new PrincipalKindAuthorizationError(
38+
principal.kind,
39+
organizationOperations.readSurface.id
40+
)
41+
}
42+
const { userId } = principal
43+
const organizationId = await resolveOrganizationLanding(userId, null)
44+
if (!organizationId) throw new OrganizationMembershipNotFoundError()
45+
await authorizeOrganizationOperation(principal, organizationOperations.readSurface, {
46+
organizationId,
47+
})
48+
const context = await getOrganizationSurfaceContext(organizationId, userId)
49+
if (!context) throw new OrganizationMembershipNotFoundError()
50+
51+
const [{ flags, benchmarkEnabled }, people, simLinks, platformAdmin] = await Promise.all([
52+
resolveOrganizationSurfaceFlags(organizationId, userId),
53+
canBrowsePeople(organizationId, context.viewer.role),
54+
isSimLinksEnabled(organizationId),
55+
/** Decides which links are drawn; the pages behind them authorize for themselves. */
56+
isPlatformAdmin(userId).catch((error: unknown) => {
57+
logger.warn('Could not resolve platform administration navigation', {
58+
error: getErrorMessage(error),
59+
})
60+
return false
61+
}),
62+
])
63+
64+
return {
65+
organization: context.organization,
66+
viewer: context.viewer,
67+
mothershipAvailable: context.mothershipAvailable,
68+
canBuild: context.canBuild,
69+
searchAccess: context.searchAccess,
70+
connectedAccountsAvailable: context.connectedAccountsAvailable,
71+
settingsFeatures: context.settingsFeatures,
72+
deployment: context.deployment,
73+
navFlags: {
74+
people,
75+
simLinks,
76+
benchmark: benchmarkEnabled,
77+
platformAdmin,
78+
anonymous: userId === ANONYMOUS_USER_ID,
79+
},
80+
featureFlags: flags,
81+
settingsSections: organizationSurfaceSettingsNavigation(
82+
context.viewer.isAdmin,
83+
context.settingsFeatures,
84+
{
85+
connectedAccounts: context.connectedAccountsAvailable,
86+
search: context.searchAccess.memberScoped,
87+
knowledgeGraphs: flags['mothership-memory-spaces'],
88+
},
89+
platformAdmin
90+
).map((item) => item.id),
91+
}
92+
},
93+
}

0 commit comments

Comments
 (0)