feat(platform-service): add org workspace foundation
This commit is contained in:
parent
841d2f5129
commit
33c5a5a5ce
@ -41,6 +41,8 @@ const CONTAINER_DEFS: Record<string, ContainerConfig> = {
|
|||||||
email_verifications: { partitionKeyPath: '/productId', defaultTtl: 7 * 86400 },
|
email_verifications: { partitionKeyPath: '/productId', defaultTtl: 7 * 86400 },
|
||||||
// SmartAuth — OAuth provider linking
|
// SmartAuth — OAuth provider linking
|
||||||
auth_providers: { partitionKeyPath: '/userId' },
|
auth_providers: { partitionKeyPath: '/userId' },
|
||||||
|
// SmartAuth — Enterprise IdP configs
|
||||||
|
auth_enterprise_idps: { partitionKeyPath: '/orgId' },
|
||||||
// SmartAuth — TOTP MFA secrets + recovery codes
|
// SmartAuth — TOTP MFA secrets + recovery codes
|
||||||
auth_mfa: { partitionKeyPath: '/userId' },
|
auth_mfa: { partitionKeyPath: '/userId' },
|
||||||
// SmartAuth — MFA enforcement policies (per product)
|
// SmartAuth — MFA enforcement policies (per product)
|
||||||
@ -63,6 +65,10 @@ const CONTAINER_DEFS: Record<string, ContainerConfig> = {
|
|||||||
// Generic orchestration runs
|
// Generic orchestration runs
|
||||||
agent_runs: { partitionKeyPath: '/productId', defaultTtl: 30 * 86400 },
|
agent_runs: { partitionKeyPath: '/productId', defaultTtl: 30 * 86400 },
|
||||||
agent_run_steps: { partitionKeyPath: '/pk', defaultTtl: 30 * 86400 },
|
agent_run_steps: { partitionKeyPath: '/pk', defaultTtl: 30 * 86400 },
|
||||||
|
// Canonical tenant model
|
||||||
|
organizations: { partitionKeyPath: '/productId' },
|
||||||
|
workspaces: { partitionKeyPath: '/orgId' },
|
||||||
|
org_memberships: { partitionKeyPath: '/orgId' },
|
||||||
// Telemetry (client diagnostics — see docs/WINDSURF/CLIENT_TELEMETRY_DESIGN.md)
|
// Telemetry (client diagnostics — see docs/WINDSURF/CLIENT_TELEMETRY_DESIGN.md)
|
||||||
telemetry_events: { partitionKeyPath: '/pk', defaultTtl: 30 * 86400 },
|
telemetry_events: { partitionKeyPath: '/pk', defaultTtl: 30 * 86400 },
|
||||||
telemetry_error_clusters: { partitionKeyPath: '/pk', defaultTtl: 90 * 86400 },
|
telemetry_error_clusters: { partitionKeyPath: '/pk', defaultTtl: 90 * 86400 },
|
||||||
|
|||||||
@ -0,0 +1,59 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { MemoryDatastoreProvider } from '@bytelyst/datastore';
|
||||||
|
import { _resetDatastoreProvider, setProvider } from '../../lib/datastore.js';
|
||||||
|
import * as repo from './repository.js';
|
||||||
|
|
||||||
|
describe('orgs repository', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setProvider(new MemoryDatastoreProvider());
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
_resetDatastoreProvider();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates orgs, workspaces, and memberships', async () => {
|
||||||
|
await repo.createOrganization({
|
||||||
|
id: 'org_1',
|
||||||
|
productId: 'lysnrai',
|
||||||
|
name: 'Acme',
|
||||||
|
slug: 'acme',
|
||||||
|
status: 'active',
|
||||||
|
ownerUserId: 'user_1',
|
||||||
|
createdAt: '2026-03-15T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-03-15T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
await repo.createWorkspace({
|
||||||
|
id: 'ws_1',
|
||||||
|
orgId: 'org_1',
|
||||||
|
productId: 'lysnrai',
|
||||||
|
name: 'Operations',
|
||||||
|
slug: 'operations',
|
||||||
|
status: 'active',
|
||||||
|
createdAt: '2026-03-15T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-03-15T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
await repo.createMembership({
|
||||||
|
id: 'mbr_1',
|
||||||
|
orgId: 'org_1',
|
||||||
|
productId: 'lysnrai',
|
||||||
|
scope: 'workspace',
|
||||||
|
workspaceId: 'ws_1',
|
||||||
|
userId: 'user_2',
|
||||||
|
role: 'member',
|
||||||
|
status: 'active',
|
||||||
|
createdAt: '2026-03-15T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-03-15T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const orgs = await repo.listOrganizations('lysnrai', { limit: 20 });
|
||||||
|
const workspaces = await repo.listWorkspaces('org_1');
|
||||||
|
const memberships = await repo.listMemberships('org_1', { limit: 100 });
|
||||||
|
|
||||||
|
expect(orgs).toHaveLength(1);
|
||||||
|
expect(workspaces[0].name).toBe('Operations');
|
||||||
|
expect(memberships[0].workspaceId).toBe('ws_1');
|
||||||
|
});
|
||||||
|
});
|
||||||
128
services/platform-service/src/modules/orgs/repository.ts
Normal file
128
services/platform-service/src/modules/orgs/repository.ts
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import { NotFoundError } from '../../lib/errors.js';
|
||||||
|
import { getCollection } from '../../lib/datastore.js';
|
||||||
|
import type {
|
||||||
|
ListMembershipsQuery,
|
||||||
|
ListOrganizationsQuery,
|
||||||
|
MembershipDoc,
|
||||||
|
OrganizationDoc,
|
||||||
|
WorkspaceDoc,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
function orgCollection() {
|
||||||
|
return getCollection<OrganizationDoc>('organizations', '/productId');
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspaceCollection() {
|
||||||
|
return getCollection<WorkspaceDoc>('workspaces', '/orgId');
|
||||||
|
}
|
||||||
|
|
||||||
|
function membershipCollection() {
|
||||||
|
return getCollection<MembershipDoc>('org_memberships', '/orgId');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createOrganization(doc: OrganizationDoc): Promise<OrganizationDoc> {
|
||||||
|
return orgCollection().create(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listOrganizations(
|
||||||
|
productId: string,
|
||||||
|
query: ListOrganizationsQuery
|
||||||
|
): Promise<OrganizationDoc[]> {
|
||||||
|
return orgCollection().findMany({
|
||||||
|
filter: {
|
||||||
|
productId,
|
||||||
|
...(query.status ? { status: query.status } : {}),
|
||||||
|
},
|
||||||
|
sort: { createdAt: -1 },
|
||||||
|
limit: query.limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrganization(id: string, productId: string): Promise<OrganizationDoc> {
|
||||||
|
const org = await orgCollection().findById(id, productId);
|
||||||
|
if (!org) throw new NotFoundError(`Organization '${id}' not found`);
|
||||||
|
return org;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateOrganization(
|
||||||
|
id: string,
|
||||||
|
productId: string,
|
||||||
|
updates: Partial<OrganizationDoc>
|
||||||
|
): Promise<OrganizationDoc> {
|
||||||
|
const updated = await orgCollection().update(id, productId, {
|
||||||
|
...updates,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
if (!updated) throw new NotFoundError(`Organization '${id}' not found`);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWorkspace(doc: WorkspaceDoc): Promise<WorkspaceDoc> {
|
||||||
|
return workspaceCollection().create(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listWorkspaces(orgId: string): Promise<WorkspaceDoc[]> {
|
||||||
|
return workspaceCollection().findMany({
|
||||||
|
filter: { orgId },
|
||||||
|
sort: { createdAt: -1 },
|
||||||
|
limit: 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWorkspace(id: string, orgId: string): Promise<WorkspaceDoc> {
|
||||||
|
const workspace = await workspaceCollection().findById(id, orgId);
|
||||||
|
if (!workspace) throw new NotFoundError(`Workspace '${id}' not found`);
|
||||||
|
return workspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateWorkspace(
|
||||||
|
id: string,
|
||||||
|
orgId: string,
|
||||||
|
updates: Partial<WorkspaceDoc>
|
||||||
|
): Promise<WorkspaceDoc> {
|
||||||
|
const updated = await workspaceCollection().update(id, orgId, {
|
||||||
|
...updates,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
if (!updated) throw new NotFoundError(`Workspace '${id}' not found`);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMembership(doc: MembershipDoc): Promise<MembershipDoc> {
|
||||||
|
return membershipCollection().create(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listMemberships(
|
||||||
|
orgId: string,
|
||||||
|
query: ListMembershipsQuery
|
||||||
|
): Promise<MembershipDoc[]> {
|
||||||
|
const memberships = await membershipCollection().findMany({
|
||||||
|
filter: {
|
||||||
|
orgId,
|
||||||
|
...(query.scope ? { scope: query.scope } : {}),
|
||||||
|
...(query.workspaceId ? { workspaceId: query.workspaceId } : {}),
|
||||||
|
},
|
||||||
|
sort: { createdAt: -1 },
|
||||||
|
limit: query.limit,
|
||||||
|
});
|
||||||
|
return memberships;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMembership(id: string, orgId: string): Promise<MembershipDoc> {
|
||||||
|
const membership = await membershipCollection().findById(id, orgId);
|
||||||
|
if (!membership) throw new NotFoundError(`Membership '${id}' not found`);
|
||||||
|
return membership;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateMembership(
|
||||||
|
id: string,
|
||||||
|
orgId: string,
|
||||||
|
updates: Partial<MembershipDoc>
|
||||||
|
): Promise<MembershipDoc> {
|
||||||
|
const updated = await membershipCollection().update(id, orgId, {
|
||||||
|
...updates,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
if (!updated) throw new NotFoundError(`Membership '${id}' not found`);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
80
services/platform-service/src/modules/orgs/routes.test.ts
Normal file
80
services/platform-service/src/modules/orgs/routes.test.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import Fastify from 'fastify';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const repoMock = {
|
||||||
|
createOrganization: vi.fn(),
|
||||||
|
listOrganizations: vi.fn(),
|
||||||
|
getOrganization: vi.fn(),
|
||||||
|
updateOrganization: vi.fn(),
|
||||||
|
createWorkspace: vi.fn(),
|
||||||
|
listWorkspaces: vi.fn(),
|
||||||
|
getWorkspace: vi.fn(),
|
||||||
|
updateWorkspace: vi.fn(),
|
||||||
|
createMembership: vi.fn(),
|
||||||
|
listMemberships: vi.fn(),
|
||||||
|
updateMembership: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('./repository.js', () => repoMock);
|
||||||
|
|
||||||
|
async function buildApp(payload?: { sub: string; productId: string; role?: string }) {
|
||||||
|
const { orgRoutes } = await import('./routes.js');
|
||||||
|
const app = Fastify({ logger: false });
|
||||||
|
if (payload) {
|
||||||
|
app.addHook('onRequest', async req => {
|
||||||
|
req.jwtPayload = payload;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await app.register(orgRoutes, { prefix: '/api' });
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('orgRoutes', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /orgs creates an organization and owner membership', async () => {
|
||||||
|
repoMock.createOrganization.mockResolvedValue({
|
||||||
|
id: 'org_1',
|
||||||
|
name: 'Acme',
|
||||||
|
ownerUserId: 'admin_1',
|
||||||
|
});
|
||||||
|
repoMock.createMembership.mockResolvedValue({ id: 'mbr_1' });
|
||||||
|
|
||||||
|
const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' });
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/orgs',
|
||||||
|
payload: { name: 'Acme', slug: 'acme' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(repoMock.createOrganization).toHaveBeenCalled();
|
||||||
|
expect(repoMock.createMembership).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
scope: 'org',
|
||||||
|
role: 'owner',
|
||||||
|
userId: 'admin_1',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /orgs/:id/memberships validates workspace scope', async () => {
|
||||||
|
repoMock.getOrganization.mockResolvedValue({ id: 'org_1' });
|
||||||
|
const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' });
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/orgs/org_1/memberships',
|
||||||
|
payload: { userId: 'user_2', role: 'member', scope: 'workspace' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(repoMock.createMembership).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
194
services/platform-service/src/modules/orgs/routes.ts
Normal file
194
services/platform-service/src/modules/orgs/routes.ts
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { BadRequestError, ForbiddenError } from '../../lib/errors.js';
|
||||||
|
import * as repo from './repository.js';
|
||||||
|
import {
|
||||||
|
CreateMembershipSchema,
|
||||||
|
CreateOrganizationSchema,
|
||||||
|
CreateWorkspaceSchema,
|
||||||
|
ListMembershipsQuerySchema,
|
||||||
|
ListOrganizationsQuerySchema,
|
||||||
|
MembershipDoc,
|
||||||
|
OrganizationDoc,
|
||||||
|
UpdateMembershipSchema,
|
||||||
|
UpdateOrganizationSchema,
|
||||||
|
UpdateWorkspaceSchema,
|
||||||
|
WorkspaceDoc,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
function requireAdmin(req: { jwtPayload?: { sub?: string; role?: string; productId?: string } }): {
|
||||||
|
userId: string;
|
||||||
|
productId: string;
|
||||||
|
} {
|
||||||
|
const payload = req.jwtPayload;
|
||||||
|
if (!payload?.sub) {
|
||||||
|
throw new ForbiddenError('Authentication required');
|
||||||
|
}
|
||||||
|
if (!payload.role || !['super_admin', 'admin'].includes(payload.role)) {
|
||||||
|
throw new ForbiddenError('Admin access required');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
userId: payload.sub,
|
||||||
|
productId: payload.productId ?? process.env.DEFAULT_PRODUCT_ID ?? 'lysnrai',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function orgRoutes(app: FastifyInstance) {
|
||||||
|
app.get('/orgs', async req => {
|
||||||
|
const access = requireAdmin(req);
|
||||||
|
const parsed = ListOrganizationsQuerySchema.safeParse(req.query);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new BadRequestError(parsed.error.issues.map(issue => issue.message).join('; '));
|
||||||
|
}
|
||||||
|
return repo.listOrganizations(access.productId, parsed.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/orgs', async req => {
|
||||||
|
const access = requireAdmin(req);
|
||||||
|
const parsed = CreateOrganizationSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new BadRequestError(parsed.error.issues.map(issue => issue.message).join('; '));
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const org: OrganizationDoc = {
|
||||||
|
id: `org_${randomUUID()}`,
|
||||||
|
productId: access.productId,
|
||||||
|
name: parsed.data.name,
|
||||||
|
slug: parsed.data.slug,
|
||||||
|
status: 'active',
|
||||||
|
ownerUserId: parsed.data.ownerUserId ?? access.userId,
|
||||||
|
metadata: parsed.data.metadata,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
const created = await repo.createOrganization(org);
|
||||||
|
const ownerMembership: MembershipDoc = {
|
||||||
|
id: `mbr_${created.id}_${created.ownerUserId}_org`,
|
||||||
|
orgId: created.id,
|
||||||
|
productId: access.productId,
|
||||||
|
scope: 'org',
|
||||||
|
userId: created.ownerUserId,
|
||||||
|
role: 'owner',
|
||||||
|
status: 'active',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
await repo.createMembership(ownerMembership);
|
||||||
|
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/orgs/:id', async req => {
|
||||||
|
const access = requireAdmin(req);
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
return repo.getOrganization(id, access.productId);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/orgs/:id', async req => {
|
||||||
|
const access = requireAdmin(req);
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const parsed = UpdateOrganizationSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new BadRequestError(parsed.error.issues.map(issue => issue.message).join('; '));
|
||||||
|
}
|
||||||
|
return repo.updateOrganization(id, access.productId, parsed.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/orgs/:id/workspaces', async req => {
|
||||||
|
requireAdmin(req);
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
return repo.listWorkspaces(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/orgs/:id/workspaces', async req => {
|
||||||
|
const access = requireAdmin(req);
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const org = await repo.getOrganization(id, access.productId);
|
||||||
|
const parsed = CreateWorkspaceSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new BadRequestError(parsed.error.issues.map(issue => issue.message).join('; '));
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const workspace: WorkspaceDoc = {
|
||||||
|
id: `ws_${randomUUID()}`,
|
||||||
|
orgId: org.id,
|
||||||
|
productId: access.productId,
|
||||||
|
name: parsed.data.name,
|
||||||
|
slug: parsed.data.slug,
|
||||||
|
status: 'active',
|
||||||
|
description: parsed.data.description,
|
||||||
|
metadata: parsed.data.metadata,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
return repo.createWorkspace(workspace);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/orgs/:id/workspaces/:workspaceId', async req => {
|
||||||
|
requireAdmin(req);
|
||||||
|
const { id, workspaceId } = req.params as { id: string; workspaceId: string };
|
||||||
|
const parsed = UpdateWorkspaceSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new BadRequestError(parsed.error.issues.map(issue => issue.message).join('; '));
|
||||||
|
}
|
||||||
|
return repo.updateWorkspace(workspaceId, id, parsed.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/orgs/:id/memberships', async req => {
|
||||||
|
requireAdmin(req);
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const parsed = ListMembershipsQuerySchema.safeParse(req.query);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new BadRequestError(parsed.error.issues.map(issue => issue.message).join('; '));
|
||||||
|
}
|
||||||
|
return repo.listMemberships(id, parsed.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/orgs/:id/memberships', async req => {
|
||||||
|
const access = requireAdmin(req);
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const parsed = CreateMembershipSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new BadRequestError(parsed.error.issues.map(issue => issue.message).join('; '));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.data.scope === 'workspace' && !parsed.data.workspaceId) {
|
||||||
|
throw new BadRequestError('workspaceId is required for workspace-scoped memberships');
|
||||||
|
}
|
||||||
|
|
||||||
|
await repo.getOrganization(id, access.productId);
|
||||||
|
if (parsed.data.workspaceId) {
|
||||||
|
await repo.getWorkspace(parsed.data.workspaceId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const membership: MembershipDoc = {
|
||||||
|
id: `mbr_${id}_${parsed.data.userId}_${parsed.data.workspaceId ?? 'org'}`,
|
||||||
|
orgId: id,
|
||||||
|
productId: access.productId,
|
||||||
|
scope: parsed.data.scope,
|
||||||
|
workspaceId: parsed.data.workspaceId,
|
||||||
|
userId: parsed.data.userId,
|
||||||
|
role: parsed.data.role,
|
||||||
|
status: 'active',
|
||||||
|
invitedBy: parsed.data.invitedBy ?? access.userId,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
return repo.createMembership(membership);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/orgs/:id/memberships/:membershipId', async req => {
|
||||||
|
requireAdmin(req);
|
||||||
|
const { id, membershipId } = req.params as { id: string; membershipId: string };
|
||||||
|
const parsed = UpdateMembershipSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new BadRequestError(parsed.error.issues.map(issue => issue.message).join('; '));
|
||||||
|
}
|
||||||
|
return repo.updateMembership(membershipId, id, parsed.data);
|
||||||
|
});
|
||||||
|
}
|
||||||
116
services/platform-service/src/modules/orgs/types.ts
Normal file
116
services/platform-service/src/modules/orgs/types.ts
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const OrganizationStatusSchema = z.enum(['active', 'disabled']);
|
||||||
|
export const WorkspaceStatusSchema = z.enum(['active', 'archived']);
|
||||||
|
export const MembershipRoleSchema = z.enum(['owner', 'admin', 'member', 'viewer']);
|
||||||
|
export const MembershipScopeSchema = z.enum(['org', 'workspace']);
|
||||||
|
|
||||||
|
export const OrganizationSchema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
productId: z.string().min(1),
|
||||||
|
name: z.string().min(1),
|
||||||
|
slug: z.string().min(1),
|
||||||
|
status: OrganizationStatusSchema,
|
||||||
|
ownerUserId: z.string().min(1),
|
||||||
|
metadata: z.record(z.unknown()).optional(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
updatedAt: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type OrganizationDoc = z.infer<typeof OrganizationSchema> & {
|
||||||
|
_ts?: number;
|
||||||
|
_etag?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WorkspaceSchema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
orgId: z.string().min(1),
|
||||||
|
productId: z.string().min(1),
|
||||||
|
name: z.string().min(1),
|
||||||
|
slug: z.string().min(1),
|
||||||
|
status: WorkspaceStatusSchema,
|
||||||
|
description: z.string().optional(),
|
||||||
|
metadata: z.record(z.unknown()).optional(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
updatedAt: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type WorkspaceDoc = z.infer<typeof WorkspaceSchema> & {
|
||||||
|
_ts?: number;
|
||||||
|
_etag?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MembershipSchema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
orgId: z.string().min(1),
|
||||||
|
productId: z.string().min(1),
|
||||||
|
scope: MembershipScopeSchema,
|
||||||
|
workspaceId: z.string().optional(),
|
||||||
|
userId: z.string().min(1),
|
||||||
|
role: MembershipRoleSchema,
|
||||||
|
status: z.enum(['active', 'invited', 'disabled']).default('active'),
|
||||||
|
invitedBy: z.string().optional(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
updatedAt: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type MembershipDoc = z.infer<typeof MembershipSchema> & {
|
||||||
|
_ts?: number;
|
||||||
|
_etag?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CreateOrganizationSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
slug: z.string().min(1),
|
||||||
|
ownerUserId: z.string().min(1).optional(),
|
||||||
|
metadata: z.record(z.unknown()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UpdateOrganizationSchema = z.object({
|
||||||
|
name: z.string().min(1).optional(),
|
||||||
|
slug: z.string().min(1).optional(),
|
||||||
|
status: OrganizationStatusSchema.optional(),
|
||||||
|
metadata: z.record(z.unknown()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CreateWorkspaceSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
slug: z.string().min(1),
|
||||||
|
description: z.string().optional(),
|
||||||
|
metadata: z.record(z.unknown()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UpdateWorkspaceSchema = z.object({
|
||||||
|
name: z.string().min(1).optional(),
|
||||||
|
slug: z.string().min(1).optional(),
|
||||||
|
status: WorkspaceStatusSchema.optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
metadata: z.record(z.unknown()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CreateMembershipSchema = z.object({
|
||||||
|
userId: z.string().min(1),
|
||||||
|
role: MembershipRoleSchema.default('member'),
|
||||||
|
scope: MembershipScopeSchema.default('org'),
|
||||||
|
workspaceId: z.string().optional(),
|
||||||
|
invitedBy: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UpdateMembershipSchema = z.object({
|
||||||
|
role: MembershipRoleSchema.optional(),
|
||||||
|
status: z.enum(['active', 'invited', 'disabled']).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ListOrganizationsQuerySchema = z.object({
|
||||||
|
status: OrganizationStatusSchema.optional(),
|
||||||
|
limit: z.coerce.number().min(1).max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ListMembershipsQuerySchema = z.object({
|
||||||
|
scope: MembershipScopeSchema.optional(),
|
||||||
|
workspaceId: z.string().optional(),
|
||||||
|
limit: z.coerce.number().min(1).max(100).default(100),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ListOrganizationsQuery = z.infer<typeof ListOrganizationsQuerySchema>;
|
||||||
|
export type ListMembershipsQuery = z.infer<typeof ListMembershipsQuerySchema>;
|
||||||
@ -24,6 +24,7 @@ import { createServiceApp, registerOptionalJwtContext, startService } from '@byt
|
|||||||
import { productRoutes } from './modules/products/routes.js';
|
import { productRoutes } from './modules/products/routes.js';
|
||||||
import { loadProductCache } from './modules/products/cache.js';
|
import { loadProductCache } from './modules/products/cache.js';
|
||||||
import { authRoutes } from './modules/auth/routes.js';
|
import { authRoutes } from './modules/auth/routes.js';
|
||||||
|
import { orgRoutes } from './modules/orgs/routes.js';
|
||||||
import { oauthRoutes } from './modules/auth/oauth/routes.js';
|
import { oauthRoutes } from './modules/auth/oauth/routes.js';
|
||||||
import { mfaRoutes } from './modules/auth/mfa/routes.js';
|
import { mfaRoutes } from './modules/auth/mfa/routes.js';
|
||||||
import { passkeyRoutes } from './modules/auth/passkeys/routes.js';
|
import { passkeyRoutes } from './modules/auth/passkeys/routes.js';
|
||||||
@ -126,6 +127,7 @@ await registerOptionalApiKeyContext(app);
|
|||||||
// Register route modules
|
// Register route modules
|
||||||
await app.register(productRoutes, { prefix: '/api' });
|
await app.register(productRoutes, { prefix: '/api' });
|
||||||
await app.register(authRoutes, { prefix: '/api' });
|
await app.register(authRoutes, { prefix: '/api' });
|
||||||
|
await app.register(orgRoutes, { prefix: '/api' });
|
||||||
await app.register(oauthRoutes, { prefix: '/api' });
|
await app.register(oauthRoutes, { prefix: '/api' });
|
||||||
await app.register(mfaRoutes, { prefix: '/api' });
|
await app.register(mfaRoutes, { prefix: '/api' });
|
||||||
await app.register(passkeyRoutes, { prefix: '/api' });
|
await app.register(passkeyRoutes, { prefix: '/api' });
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user