Skip to content

Commit 02f5cd3

Browse files
committed
Fill out the organization namespace: workspaces, permission groups, credential groups
Three more read-only files, all lazily loaded — the paths appear in the key view so glob discovers them, but no query runs until a read — and all gated by registration, so an unpermitted viewer's file simply does not exist: - workspaces.json (org members): the org's full workspace map with the viewer's access flag and fork parentage — account/workspaces.json only ever showed what the viewer can reach. Inaccessible workspaces stay nameable, not readable. - permission-groups.json (org admins): every group with member count, targeted workspaces, and the restrictions its config activates. access-control.json remains the per-viewer binding. The queries are lifted into lib/permission-groups/queries.ts because their only prior home was inline drizzle in the route handlers, which the VFS cannot import. - credential-groups.json (entitlement-gated): per-option configuration readiness and enrollment progress — the two facts that decide whether a credential_group workflow will do anything at runtime. The note teaches the contract that bit the audit: an active group with zero completed enrollments yields an empty loop, not an error. Enrollee emails are workspace-admin-only, matching the settings page; counts come from the first enrollment page and say so when truncated. The README documents each file only when mounted for this viewer.
1 parent 93d4349 commit 02f5cd3

4 files changed

Lines changed: 423 additions & 2 deletions

File tree

apps/sim/lib/copilot/vfs/serializers.test.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,17 @@ import {
2020
serializeApiKeyIntegrations,
2121
serializeBlockSchema,
2222
serializeConnectors,
23+
serializeCredentialGroups,
2324
serializeCredentials,
2425
serializeDeployments,
2526
serializeFileMeta,
2627
serializeIntegrationSchema,
2728
serializeKBMeta,
2829
serializeOrganization,
2930
serializeOrganizationCustomBlocks,
31+
serializeOrganizationWorkspaces,
3032
serializeOrgCustomBlockDetail,
33+
serializePermissionGroupRoster,
3134
serializeSandbox,
3235
serializeSandboxCatalog,
3336
serializeTableMeta,
@@ -778,14 +781,79 @@ describe('account and organization namespace serializers', () => {
778781
{ type: 'acme_retired', name: 'Retired', enabled: false },
779782
],
780783
forksMounted: false,
784+
permissionGroupsMounted: false,
785+
credentialGroupsMounted: true,
781786
})
782787

783788
expect(readme).toContain('# Organization')
784789
expect(readme).toContain('custom-blocks/{type}.json')
785790
expect(readme).toContain('**Acme Scorer** (`acme_scorer`) — published from Scorer in Platform')
786791
expect(readme).toContain('**Retired** (`acme_retired`) — disabled')
787-
// Forks are admin-gated; an unmounted file must not be advertised.
792+
// Gated files must not be advertised when unmounted for this viewer.
788793
expect(readme).not.toContain('forks.json')
794+
expect(readme).not.toContain('permission-groups.json')
795+
expect(readme).toContain('credential-groups.json')
796+
})
797+
798+
it('scopes credential-group people to admins and flags truncated counts', () => {
799+
const base = {
800+
id: 'cg-1',
801+
name: 'Clients',
802+
description: null,
803+
status: 'active' as const,
804+
options: [
805+
{ provider: 'gmail', label: 'Work email', required: true, configurationStatus: 'ready' },
806+
{ provider: 'slack', configurationStatus: 'not_configured' },
807+
],
808+
enrollmentCounts: { completed: 2, invited: 1 },
809+
enrollmentsTruncated: true,
810+
people: [{ email: 'a@x.com', status: 'completed' }],
811+
}
812+
813+
const admin = JSON.parse(serializeCredentialGroups([base], { includeEmails: true }))
814+
expect(admin.credentialGroups[0].people).toHaveLength(1)
815+
expect(admin.credentialGroups[0].enrollments.countsFromFirstPageOnly).toBe(true)
816+
expect(admin.credentialGroups[0].options[1].configurationStatus).toBe('not_configured')
817+
818+
const member = JSON.parse(serializeCredentialGroups([base], { includeEmails: false }))
819+
expect(member.credentialGroups[0].people).toBeUndefined()
820+
// The runtime contract the model most needs: empty loop, not an error.
821+
expect(member.note).toContain('empty loop, not an error')
822+
})
823+
824+
it('maps the org workspace directory with access flags and fork parentage', () => {
825+
const dir = JSON.parse(
826+
serializeOrganizationWorkspaces([
827+
{ id: 'ws-1', name: 'Platform', hasAccess: true, forkedFromWorkspaceId: null },
828+
{ id: 'ws-2', name: 'Client Fork', hasAccess: false, forkedFromWorkspaceId: 'ws-1' },
829+
])
830+
)
831+
expect(dir.workspaces[1]).toEqual({
832+
id: 'ws-2',
833+
name: 'Client Fork',
834+
hasAccess: false,
835+
forkedFromWorkspaceId: 'ws-1',
836+
})
837+
expect(dir.note).toContain('nameable, not readable')
838+
})
839+
840+
it('gives the admin roster restrictions per group, not per viewer', () => {
841+
const roster = JSON.parse(
842+
serializePermissionGroupRoster([
843+
{
844+
id: 'pg-1',
845+
name: 'Contractors',
846+
description: null,
847+
isDefault: false,
848+
memberCount: 4,
849+
workspaces: [{ id: 'ws-1', name: 'Platform' }],
850+
activeRestrictions: [{ key: 'hideDeployApi', description: 'Cannot deploy as API' }],
851+
},
852+
])
853+
)
854+
expect(roster.permissionGroups[0].memberCount).toBe(4)
855+
expect(roster.permissionGroups[0].activeRestrictions[0].key).toBe('hideDeployApi')
856+
expect(roster.note).toContain('access-control.json')
789857
})
790858

791859
it('summarizes fork mappings by resource type and omits them at the root', () => {

apps/sim/lib/copilot/vfs/serializers.ts

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1652,6 +1652,8 @@ export function buildOrganizationReadme(input: {
16521652
workspaceName?: string | null
16531653
}>
16541654
forksMounted: boolean
1655+
permissionGroupsMounted: boolean
1656+
credentialGroupsMounted: boolean
16551657
}): string {
16561658
const lines: string[] = [
16571659
'# Organization',
@@ -1663,8 +1665,19 @@ export function buildOrganizationReadme(input: {
16631665
'- `organization.json` — org identity, your relationship (internal/external) and role, who can manage it. Plan usage and credits live in `account/billing.json`, not here.',
16641666
'- `access-control.json` — the permission group governing YOU and the restrictions it enforces. Restrictions are enforced server-side on every action, so consult this before promising an action is possible. It describes this user only.',
16651667
'- `custom-blocks.json` — names-only index of org-published blocks.',
1666-
'- `custom-blocks/{type}.json` — one block in depth: provenance and a read-only view of the DEPLOYED workflow graph backing it. To add the block to a workflow, use its callable schema at `components/blocks/{type}.json`; the deployed graph is for understanding what the block does, not for editing.',
1668+
'- `custom-blocks/{type}.json` — one block in depth: provenance and a read-only view of the DEPLOYED workflow graph backing it (org members only). To add the block to a workflow, use its callable schema at `components/blocks/{type}.json`; the deployed graph is for understanding what the block does, not for editing.',
1669+
'- `workspaces.json` — every workspace in the organization with your access flag and fork parentage (org members only).',
16671670
]
1671+
if (input.permissionGroupsMounted) {
1672+
lines.push(
1673+
'- `permission-groups.json` — the admin roster: every group with member count, targeted workspaces, and active restrictions.'
1674+
)
1675+
}
1676+
if (input.credentialGroupsMounted) {
1677+
lines.push(
1678+
'- `credential-groups.json` — managed credential groups: per-provider configuration readiness and enrollment progress. Consumed in workflows via the credential_group block.'
1679+
)
1680+
}
16681681
if (input.forksMounted) {
16691682
lines.push(
16701683
"- `forks.json` — this workspace's place in the fork tree and what was mapped from the parent. Forking, promoting, and rolling back are admin actions in the UI."
@@ -1685,6 +1698,123 @@ export function buildOrganizationReadme(input: {
16851698
return lines.join('\n')
16861699
}
16871700

1701+
/**
1702+
* `organization/workspaces.json` — the org's workspace map: every workspace in
1703+
* the organization, with whether the viewer can open it and its fork
1704+
* parentage. Broader than `account/workspaces.json`, which lists only what the
1705+
* viewer can reach.
1706+
*/
1707+
export function serializeOrganizationWorkspaces(
1708+
workspaces: Array<{
1709+
id: string
1710+
name: string
1711+
hasAccess: boolean
1712+
forkedFromWorkspaceId?: string | null
1713+
}>
1714+
): string {
1715+
return JSON.stringify(
1716+
{
1717+
workspaces: workspaces.map((entry) => ({
1718+
id: entry.id,
1719+
name: entry.name,
1720+
hasAccess: entry.hasAccess,
1721+
...(entry.forkedFromWorkspaceId
1722+
? { forkedFromWorkspaceId: entry.forkedFromWorkspaceId }
1723+
: {}),
1724+
})),
1725+
note: 'Every workspace in the organization. hasAccess is YOUR access; workspaces without it are nameable, not readable, and only the current workspace is mounted in this VFS.',
1726+
},
1727+
null,
1728+
2
1729+
)
1730+
}
1731+
1732+
/**
1733+
* `organization/permission-groups.json` — the org-admin roster: every group
1734+
* with member count, targeted workspaces, and the restrictions its config
1735+
* activates. `access-control.json` stays the per-viewer view; this is the
1736+
* management matrix.
1737+
*/
1738+
export function serializePermissionGroupRoster(
1739+
groups: Array<{
1740+
id: string
1741+
name: string
1742+
description: string | null
1743+
isDefault: boolean
1744+
memberCount: number
1745+
workspaces: Array<{ id: string; name: string }>
1746+
activeRestrictions: Array<{ key: string; description: string }>
1747+
}>
1748+
): string {
1749+
return JSON.stringify(
1750+
{
1751+
permissionGroups: groups.map((group) => ({
1752+
id: group.id,
1753+
name: group.name,
1754+
...(group.description ? { description: group.description } : {}),
1755+
isDefault: group.isDefault,
1756+
memberCount: group.memberCount,
1757+
workspaces: group.workspaces,
1758+
activeRestrictions: group.activeRestrictions,
1759+
})),
1760+
note: 'Management view (org admins). The group governing THIS user, with resolution reason, is in access-control.json. Group membership and scopes are edited in the Sim UI.',
1761+
},
1762+
null,
1763+
2
1764+
)
1765+
}
1766+
1767+
/**
1768+
* `organization/credential-groups.json` — managed credential groups with the
1769+
* two facts that decide whether a workflow using them will actually run:
1770+
* per-option configuration readiness and enrollment progress. Enrollee emails
1771+
* are the same privilege as the settings page, so they appear for workspace
1772+
* admins only.
1773+
*/
1774+
export function serializeCredentialGroups(
1775+
groups: Array<{
1776+
id: string
1777+
name: string
1778+
description: string | null
1779+
status: 'active' | 'disabled'
1780+
options: Array<{
1781+
provider: string
1782+
label?: string | null
1783+
required?: boolean
1784+
configurationStatus: string
1785+
}>
1786+
enrollmentCounts: Record<string, number>
1787+
enrollmentsTruncated: boolean
1788+
people?: Array<{ email: string; status: string }>
1789+
}>,
1790+
options: { includeEmails: boolean }
1791+
): string {
1792+
return JSON.stringify(
1793+
{
1794+
credentialGroups: groups.map((group) => ({
1795+
id: group.id,
1796+
name: group.name,
1797+
...(group.description ? { description: group.description } : {}),
1798+
status: group.status,
1799+
options: group.options.map((option) => ({
1800+
provider: option.provider,
1801+
...(option.label ? { label: option.label } : {}),
1802+
...(option.required !== undefined ? { required: option.required } : {}),
1803+
configurationStatus: option.configurationStatus,
1804+
})),
1805+
enrollments: {
1806+
...group.enrollmentCounts,
1807+
...(group.enrollmentsTruncated ? { countsFromFirstPageOnly: true } : {}),
1808+
},
1809+
...(options.includeEmails && group.people ? { people: group.people } : {}),
1810+
})),
1811+
note: 'A workflow consumes a group through a credential_group block (operation list_credentials -> ForEach over the returned credentialId page). list_credentials returns only ACTIVE credentials of in_progress/completed people — an active group with zero completed enrollments yields an empty loop, not an error. An option at not_configured makes the whole group unusable. Enrollment is admin-driven from the settings UI; invite links cannot be created or read from here.',
1812+
},
1813+
null,
1814+
2
1815+
)
1816+
}
1817+
16881818
/**
16891819
* `organization/forks.json` — this workspace's place in the fork tree plus the
16901820
* parent/child resource and block mappings.

0 commit comments

Comments
 (0)