import { type Context, Hono } from 'hono' import * as z from 'zod/mini' import type * as App from '../../../App.js' import * as Auth from '../../../internal/Auth.js' import * as Db from '../../../db/Db.js' import * as Memberships from '../../../db/tables/memberships.js' import * as OpenApi from '../../../internal/OpenApi.js' import * as Response from '../../../internal/Response.js' import * as Schema from '../../../internal/Schema.js' import * as Users from '../../../db/tables/users.js' /** Zod schemas owned by the members resource. */ export namespace schema { /** A membership role. */ export const Role = z .enum(Memberships.roles) .check(z.describe('Membership role.'), z.meta({ examples: ['admin'] })) /** One organization member: the membership plus the member's identity. */ export const Member = OpenApi.component( Schema.describe( z.object({ address: z .optional(z.templateLiteral(['0x', z.string().check(z.regex(/^[0-9a-fA-F]{40}$/))])) .check( z.describe("The member's wallet address, when wallet sign-in established it."), z.meta({ examples: ['0x20f414b3cbcf1fd8e8f5e1c1c2e3d4a5b6c7d8e9'] }), ), createdAt: z.iso .datetime() .check( z.describe('When the member joined (ISO 8601).'), z.meta({ examples: ['2026-01-01T00:00:00.000Z'] }), ), email: z .optional(z.email()) .check( z.describe("The member's verified email, when known."), z.meta({ examples: ['dev@example.com'] }), ), role: Role, userId: z .string() .check( z.describe('Member user id (`usr_…`).'), z.meta({ examples: ['usr_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), }), "One organization member: the membership plus the member's identity.", ), 'Member', ) /** Path parameters addressing an organization's member collection. */ export const Params = z .object({ orgId: z .string() .check( z.describe('The organization id (`org_…`).'), z.meta({ examples: ['org_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), }) .check(z.describe("Path parameters for an organization's members.")) /** Path parameters addressing one member. */ export const MemberParams = z .object({ orgId: z .string() .check( z.describe('The organization id (`org_…`).'), z.meta({ examples: ['org_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), userId: z .string() .check( z.describe('The member user id (`usr_…`).'), z.meta({ examples: ['usr_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), }) .check(z.describe('Path parameters for one member.')) /** Request body changing a member's role. */ export const UpdateMemberRequest = OpenApi.component( z.object({ role: Role }).check(z.describe("Fields for changing a member's role.")), 'UpdateMemberRequest', ) /** Schemas for the listMembers operation. */ export namespace listMembers { /** Non-paginated list of an organization's members. */ export const Response = OpenApi.component( Schema.describe( z.object({ data: z .array(Member) .check(z.describe('The members, oldest first.'), z.meta({ examples: [[]] })), }), "A non-paginated list of an organization's members.", ), 'MemberList', ) } /** Schemas for the removeMember operation. */ export namespace removeMember { /** Confirmation that the member was removed. */ export const Response = OpenApi.component( z .object({ userId: z .string() .check( z.describe('User id (`usr_…`) of the removed member.'), z.meta({ examples: ['usr_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), }) .check(z.describe('Confirmation that the member was removed.')), 'RemoveMemberResponse', ) } } /** * Mounts the `/orgs/:orgId/members` resource: the organization's team. * Members and read-scoped keys list. Owners and write-scoped keys manage; * members may remove themselves. The last owner remains protected. */ export function members() { return new Hono() .get( '/v1/orgs/:orgId{org_[A-Za-z0-9_-]+}/members', Auth.policy({ apiKey: { scopes: ['management:read'] }, session: true }), Auth.ensureOrg(), OpenApi.validate('param', schema.Params, { code: 'param_invalid', message: 'Check the path parameters and try again.', }), OpenApi.describeRoute({ operationId: 'listMembers', responses: OpenApi.responses({ errors: { 400: { codes: ['param_invalid'], description: 'Malformed API key or invalid path.' }, 404: { codes: ['organization_not_found'], description: 'No accessible organization was found for this id.', }, }, success: { description: 'The members, oldest first.', schema: schema.listMembers.Response, }, }), summary: 'List members', tags: ['Members'], }), async (c) => { if (Auth.narrowAccess) return Auth.accessError(c) if (Auth.narrowScope) return Auth.ensureOrgError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'param_invalid', message: 'Check the path parameters and try again.', }) const db = Db.get(c.get('db')) try { const records = await Memberships.listByOrgDetailed(db, Auth.org(c).id) return c.json( Response.validated(schema.listMembers.Response, { data: records.map(serializeMember), }), 200, ) } catch (cause) { return Response.upstream(c, cause) } }, ) .patch( '/v1/orgs/:orgId{org_[A-Za-z0-9_-]+}/members/:userId{usr_[A-Za-z0-9_-]+}', Auth.policy({ apiKey: { scopes: ['management:write'] }, session: true }), Auth.ensureOrg({ role: 'owner' }), OpenApi.validate('param', schema.MemberParams, { code: 'param_invalid', message: 'Check the path parameters and try again.', }), OpenApi.validate('json', schema.UpdateMemberRequest, { code: 'body_invalid', message: 'Check the request body and try again.', }), OpenApi.describeRoute({ description: "Change a member's role. The last owner cannot be demoted.", operationId: 'updateMember', responses: OpenApi.responses({ errors: { 400: { codes: ['body_invalid', 'param_invalid'], description: 'Malformed API key, invalid path, or invalid body.', }, 404: { codes: ['member_not_found', 'organization_not_found'], description: 'No accessible organization or member was found for this id.', }, 409: { codes: ['last_owner'], description: 'The organization would be left without an owner.', }, }, success: { description: 'The updated member.', schema: schema.Member }, }), summary: 'Update member', tags: ['Members'], }), async (c) => { if (Auth.narrowAccess) return Auth.accessError(c) if (Auth.narrowScope) return Auth.ensureOrgError(c) if (Auth.narrowOrgRole) return Auth.ensureOrgRoleError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'param_invalid', message: 'Check the path parameters and try again.', }) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'body_invalid', message: 'Check the request body and try again.', }) const { userId } = c.req.valid('param') const body = c.req.valid('json') const db = Db.get(c.get('db')) try { const result = await Memberships.update(db, Auth.org(c).id, userId, { role: body.role }) if (result.error === 'last_owner') return lastOwner(c) if (result.error) return memberNotFound(c) const user = await Users.get(db, userId) if (!user) return memberNotFound(c) return c.json( Response.validated( schema.Member, serializeMember({ ...result.record, address: user.address, email: user.email }), ), 200, ) } catch (cause) { return Response.upstream(c, cause) } }, ) .delete( '/v1/orgs/:orgId{org_[A-Za-z0-9_-]+}/members/:userId{usr_[A-Za-z0-9_-]+}', Auth.policy({ apiKey: { scopes: ['management:write'] }, session: true }), Auth.ensureOrg(), OpenApi.validate('param', schema.MemberParams, { code: 'param_invalid', message: 'Check the path parameters and try again.', }), OpenApi.describeRoute({ description: 'Remove a member. Owners and management-write keys remove anyone; members remove themselves. The last owner stays.', // prettier-ignore operationId: 'removeMember', responses: OpenApi.responses({ errors: { 400: { codes: ['param_invalid'], description: 'Malformed API key or invalid path.' }, 404: { codes: ['member_not_found', 'organization_not_found'], description: 'No accessible organization or member was found for this id.', }, 409: { codes: ['last_owner'], description: 'The organization would be left without an owner.', }, }, success: { description: 'Confirmation that the member was removed.', schema: schema.removeMember.Response, }, }), summary: 'Remove member', tags: ['Members'], }), async (c) => { if (Auth.narrowAccess) return Auth.accessError(c) if (Auth.narrowScope) return Auth.ensureOrgError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'param_invalid', message: 'Check the path parameters and try again.', }) const { userId } = c.req.valid('param') // Owners and write-scoped keys remove anyone; other members only themselves. const caller = Auth.membership(c) if (caller && caller.role !== 'owner' && caller.userId !== userId) return Response.error(c, { code: 'forbidden', message: 'Requires the owner role', status: 403, }) const db = Db.get(c.get('db')) try { const result = await Memberships.remove(db, Auth.org(c).id, userId) if (result === 'last_owner') return lastOwner(c) if (result === 'not_found') return memberNotFound(c) return c.json(Response.validated(schema.removeMember.Response, { userId }), 200) } catch (cause) { return Response.upstream(c, cause) } }, ) } /** Projects a detailed membership row onto the member shape. */ function serializeMember( record: Memberships.Record & { address: string | null; email: string | null }, ) { return { ...(record.address === null ? {} : { address: record.address }), createdAt: record.createdAt, ...(record.email === null ? {} : { email: record.email }), role: record.role, userId: record.userId, } } function lastOwner(c: Context) { return Response.error(c, { code: 'last_owner', message: 'The organization would be left without an owner', status: 409, }) } function memberNotFound(c: Context) { return Response.error(c, { code: 'member_not_found', message: 'Member not found', status: 404, }) }