import { type Context, Hono } from 'hono' import * as z from 'zod/mini' import * as ApiKeys from '../../../ApiKeys.js' import type * as App from '../../../App.js' import * as Auth from '../../../internal/Auth.js' import * as Db from '../../../db/Db.js' import * as EarlyAccess from '../../../db/tables/earlyAccess.js' import * as Memberships from '../../../db/tables/memberships.js' import * as OpenApi from '../../../internal/OpenApi.js' import * as Organizations from '../../../db/tables/organizations.js' import * as Response from '../../../internal/Response.js' import * as Schema from '../../../internal/Schema.js' import * as ManagementError from '../Error.js' /** Zod schemas owned by the organizations resource. */ export namespace schema { const name = z .string() .check( z.minLength(1), z.maxLength(100), z.describe('Human-readable name.'), z.meta({ examples: ['Acme, Inc.'] }), ) /** One organization: the top-level tenant that owns API keys. */ export const Organization = OpenApi.component( Schema.describe( z.object({ createdAt: z.iso .datetime() .check( z.describe('When the organization was created (ISO 8601).'), z.meta({ examples: ['2026-01-01T00:00:00.000Z'] }), ), id: z .string() .check( z.describe('Opaque organization id (`org_…`).'), z.meta({ examples: ['org_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), name: z .string() .check( z.describe('Human-readable organization name.'), z.meta({ examples: ['Acme, Inc.'] }), ), role: z .optional(z.enum(Memberships.roles)) .check( z.describe("The caller's role; absent for API keys and the super admin."), z.meta({ examples: ['owner'] }), ), updatedAt: z.iso .datetime() .check( z.describe('When the organization was last updated (ISO 8601).'), z.meta({ examples: ['2026-01-01T00:00:00.000Z'] }), ), }), 'An organization: the top-level tenant that owns API keys.', ), 'Organization', ) /** Request body creating an organization. */ export const CreateOrganizationRequest = OpenApi.component( z.object({ name }).check(z.describe('Fields for creating an organization.')), 'CreateOrganizationRequest', ) /** Request body updating an organization. */ export const UpdateOrganizationRequest = OpenApi.component( z.object({ name }).check(z.describe('Fields for updating an organization.')), 'UpdateOrganizationRequest', ) /** Path parameters addressing one organization. */ 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 one organization.')) /** Schemas for the deleteOrganization operation. */ export namespace deleteOrganization { /** Confirmation that the organization was deleted. */ export const Response = OpenApi.component( z .object({ id: z .string() .check( z.describe('ID of the organization that was deleted.'), z.meta({ examples: ['org_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), }) .check(z.describe('Confirmation that the organization was deleted.')), 'DeleteOrganizationResponse', ) } /** Schemas for the listOrganizations operation. */ export namespace listOrganizations { /** Non-paginated list of organizations. */ export const Response = OpenApi.component( Schema.describe( z.object({ data: z.array(Organization).check(z.describe('The organizations, newest first.')), }), 'A non-paginated list of organizations.', ), 'OrganizationList', ) } } /** * Mounts organizations. Sessions access memberships, API keys access their owning organization, and the super admin bypasses ownership. */ export function orgs() { return new Hono() .get( '/v1/orgs', Auth.policy({ apiKey: { scopes: ['management:read'] }, session: true }), OpenApi.describeRoute({ operationId: 'listOrganizations', responses: OpenApi.responses({ errors: { 400: { codes: [], description: 'Malformed API key.' } }, success: { description: 'The organizations, newest first.', schema: schema.listOrganizations.Response, }, }), summary: 'List organizations', tags: ['Organizations'], }), async (c) => { if (Auth.narrowAccess) return Auth.accessError(c) const db = Db.get(c.get('db')) try { const principal = Auth.getPrincipal(c) if (!principal || principal.type === 'public') throw new Error('organization auth policy admitted an unsupported principal') const records = principal.type === 'super_admin' ? await Organizations.list(db) : principal.type === 'api_key' ? [await Organizations.get(db, principal.orgId)].filter( (record): record is Organizations.Record => record !== undefined, ) : await Organizations.listByMember(db, principal.id) return c.json( Response.validated(schema.listOrganizations.Response, { data: records.map(serializeOrganization), }), 200, ) } catch (cause) { return Response.upstream(c, cause) } }, ) .post( '/v1/orgs', Auth.policy({ session: true }), OpenApi.validate('json', schema.CreateOrganizationRequest, { code: 'body_invalid', message: 'Check the request body and try again.', }), OpenApi.describeRoute({ operationId: 'createOrganization', responses: OpenApi.responses({ errors: { 400: { codes: ['body_invalid'], description: 'Malformed API key or invalid body.' }, }, success: { description: 'The created organization.', schema: schema.Organization, }, }), summary: 'Create organization', tags: ['Organizations'], }), async (c) => { if (Auth.narrowAccess) return Auth.superAdminAccessError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'body_invalid', message: 'Check the request body and try again.', }) const principal = Auth.getPrincipal(c) if (principal?.type !== 'session' && principal?.type !== 'super_admin') return ManagementError.sessionRequired(c) const body = c.req.valid('json') const db = Db.get(c.get('db')) try { // Sessions own what they create; the super admin has no user row. // Organizations provisioned by the super admin start memberless. const record = principal.type === 'super_admin' ? await Organizations.create(db, { createdBy: 'super_admin', name: body.name }) : { ...(await Organizations.createOwned(db, { enabledBillingSources: principal.email && (await EarlyAccess.matches(db, principal.email.trim().toLowerCase())) ? ['stripe'] : [], name: body.name, userId: principal.id, })), role: 'owner' as const, } return c.json(Response.validated(schema.Organization, serializeOrganization(record)), 200) } catch (cause) { return Response.upstream(c, cause) } }, ) .get( '/v1/orgs/:orgId{org_[A-Za-z0-9_-]+}', 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: 'getOrganization', 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: 'One organization.', schema: schema.Organization }, }), summary: 'Get organization', tags: ['Organizations'], }), 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.', }) return c.json( Response.validated( schema.Organization, serializeOrganization({ ...Auth.org(c), role: Auth.membership(c)?.role }), ), 200, ) }, ) .patch( '/v1/orgs/:orgId{org_[A-Za-z0-9_-]+}', Auth.policy({ apiKey: { scopes: ['management:write'] }, session: true }), Auth.ensureOrg({ role: 'owner' }), OpenApi.validate('param', schema.Params, { code: 'param_invalid', message: 'Check the path parameters and try again.', }), OpenApi.validate('json', schema.UpdateOrganizationRequest, { code: 'body_invalid', message: 'Check the request body and try again.', }), OpenApi.describeRoute({ operationId: 'updateOrganization', responses: OpenApi.responses({ errors: { 400: { codes: ['body_invalid', 'param_invalid'], description: 'Malformed API key, invalid path, or invalid body.', }, 404: { codes: ['organization_not_found'], description: 'No accessible organization was found for this id.', }, }, success: { description: 'The updated organization.', schema: schema.Organization }, }), summary: 'Update organization', tags: ['Organizations'], }), 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 body = c.req.valid('json') const db = Db.get(c.get('db')) try { const updated = await Organizations.update(db, Auth.org(c).id, { name: body.name }) if (!updated) return organizationNotFound(c) return c.json( Response.validated( schema.Organization, serializeOrganization({ ...updated, role: Auth.membership(c)?.role }), ), 200, ) } catch (cause) { return Response.upstream(c, cause) } }, ) .delete( '/v1/orgs/:orgId{org_[A-Za-z0-9_-]+}', Auth.policy({ apiKey: { scopes: ['management:write'] }, session: true }), Auth.ensureOrg({ role: 'owner' }), OpenApi.validate('param', schema.Params, { code: 'param_invalid', message: 'Check the path parameters and try again.', }), OpenApi.describeRoute({ description: 'Delete an organization and all of its projects.', operationId: 'deleteOrganization', 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.', }, 409: { codes: ['sponsorships_unreported'], description: 'Billable sponsorships are still awaiting settlement.', }, }, success: { description: 'Confirmation that the organization was deleted.', schema: schema.deleteOrganization.Response, }, }), summary: 'Delete organization', tags: ['Organizations'], }), 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.', }) const db = Db.get(c.get('db')) try { if (await Organizations.hasUnreportedSponsorships(db, Auth.org(c).id)) return sponsorshipsUnreported(c) const kv = c.get('kv') if (kv) for (const record of await ApiKeys.listByOrg(kv.store, Auth.org(c).id, { scopeCatalog: c.get('scopeCatalog'), })) await ApiKeys.revoke(kv.store, record.id) const deleted = await Organizations.deleteOrganization(db, Auth.org(c).id) if (!deleted) return organizationNotFound(c) return c.json( Response.validated(schema.deleteOrganization.Response, { id: Auth.org(c).id }), 200, ) } catch (cause) { if (cause instanceof Organizations.UnreportedSponsorshipsError) return sponsorshipsUnreported(c) return Response.upstream(c, cause) } }, ) } function sponsorshipsUnreported(c: Context) { return Response.error(c, { code: 'sponsorships_unreported', message: 'Billable sponsorships are still awaiting settlement', status: 409, }) } function serializeOrganization( record: Organizations.Record & { role?: Memberships.Role | undefined }, ) { return { createdAt: record.createdAt, id: record.id, name: record.name, ...(record.role ? { role: record.role } : {}), updatedAt: record.updatedAt, } } function organizationNotFound(c: Context) { return Response.error(c, { code: 'organization_not_found', message: 'Organization not found', status: 404, }) }