import { type Context, Hono } from 'hono' import * as z from 'zod/mini' import * as Db from '../../../db/Db.js' import * as InviteLinks from '../../../db/tables/inviteLinks.js' import * as Organizations from '../../../db/tables/organizations.js' import * as Auth from '../../../internal/Auth.js' import * as OpenApi from '../../../internal/OpenApi.js' import * as Response from '../../../internal/Response.js' import * as Schema from '../../../internal/Schema.js' import type { Environment } from '../App.js' // A bare domain (`example.org`), normalized before validation. const domainPattern = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/ /** OpenAPI schemas owned by reusable invite links. */ export namespace schema { const allowedEmailDomains = z .nullable( z.readonly( z .array( z .string() .check( z.trim(), z.toLowerCase(), z.regex(domainPattern, 'Must be a bare domain (example.org).'), ), ) .check( z.minLength(1), z.overwrite((domains) => [...new Set(domains)]), ), ), ) .check( z.describe('Email domains allowed to redeem, or null for unrestricted.'), z.meta({ examples: [['example.org'], null] }), ) const nullableExpiry = z .nullable(z.iso.datetime({ offset: true })) .check( z.describe('When the link expires (ISO 8601), or null for no expiry.'), z.meta({ examples: ['2026-01-08T00:00:00.000Z', null] }), ) const nullableUses = z .nullable(z.number().check(z.int(), z.positive())) .check( z.describe('Maximum memberships the link may create, or null for unlimited.'), z.meta({ examples: [25, null] }), ) const role = z .literal('member') .check(z.describe('Role granted by the link.'), z.meta({ examples: ['member'] })) /** Management representation, including the recopyable bearer id. */ export const InviteLink = OpenApi.component( Schema.describe( z.object({ allowedEmailDomains, createdAt: z.iso .datetime() .check( z.describe('When the link was created (ISO 8601).'), z.meta({ examples: ['2026-01-01T00:00:00.000Z'] }), ), createdBy: z .string() .check( z.describe('User id that created the link.'), z.meta({ examples: ['usr_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), enabled: z .boolean() .check(z.describe('Whether the link is enabled.'), z.meta({ examples: [true] })), expiresAt: nullableExpiry, id: z .string() .check( z.describe('Opaque invite-link resource id (`iln_…`).'), z.meta({ examples: ['iln_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), lastUsedAt: z .nullable(z.iso.datetime()) .check( z.describe('When the link last created a membership, or null.'), z.meta({ examples: ['2026-01-02T00:00:00.000Z', null] }), ), maxUses: nullableUses, name: z .string() .check(z.describe('Human-readable link name.'), z.meta({ examples: ['Community'] })), orgId: z .string() .check( z.describe('Organization id (`org_…`) the link joins.'), z.meta({ examples: ['org_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), role, status: z .enum(['active', 'deleted', 'disabled', 'exhausted', 'expired']) .check(z.describe('Current link availability.'), z.meta({ examples: ['active'] })), token: z .string() .check( z.describe('Opaque bearer token embedded in the recipient URL (`lnk_…`).'), z.meta({ examples: ['lnk_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), updatedAt: z.iso .datetime() .check( z.describe('When the link was last changed (ISO 8601).'), z.meta({ examples: ['2026-01-01T00:00:00.000Z'] }), ), useCount: z .number() .check( z.int(), z.nonnegative(), z.describe('Memberships created through the link.'), z.meta({ examples: [3] }), ), }), 'A reusable organization invite link.', ), 'InviteLink', ) /** Redemption audit representation (never includes a bearer URL). */ export const Redemption = OpenApi.component( Schema.describe( z.object({ createdAt: z.iso .datetime() .check( z.describe('When the membership was created (ISO 8601).'), z.meta({ examples: ['2026-01-02T00:00:00.000Z'] }), ), email: z .email() .check( z.describe('Verified email used for redemption.'), z.meta({ examples: ['dev@example.com'] }), ), id: z .string() .check( z.describe('Opaque redemption id (`ilr_…`).'), z.meta({ examples: ['ilr_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), inviteLinkId: z .string() .check( z.describe('Invite-link resource id used for redemption.'), z.meta({ examples: ['iln_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), inviteLinkName: z .string() .check(z.describe('Link name at redemption time.'), z.meta({ examples: ['Community'] })), orgId: z .string() .check( z.describe('Organization id joined through the link.'), z.meta({ examples: ['org_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), userId: z .string() .check( z.describe('User id that redeemed the link.'), z.meta({ examples: ['usr_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), }), 'An invite-link redemption audit record.', ), 'InviteLinkRedemption', ) /** Create body. */ export const CreateInviteLinkRequest = OpenApi.component( z .object({ allowedEmailDomains: z.optional(allowedEmailDomains), expiresAt: z.optional(nullableExpiry), maxUses: z.optional(nullableUses), name: z.optional( z .string() .check( z.trim(), z.minLength(1), z.maxLength(100), z.describe('Human-readable link name.'), z.meta({ examples: ['Community'] }), ), ), role: z ._default(role, 'member') .check(z.describe('Role granted by the link.'), z.meta({ examples: ['member'] })), }) .check(z.describe('Fields for creating an invite link.')), 'CreateInviteLinkRequest', ) /** Update body. */ export const UpdateInviteLinkRequest = OpenApi.component( z .object({ enabled: z .boolean() .check(z.describe('Whether the link is enabled.'), z.meta({ examples: [false] })), }) .check(z.describe('Fields for updating an invite link.')), 'UpdateInviteLinkRequest', ) /** Bearer-token body for recipient operations. */ export const InviteLinkTokenRequest = OpenApi.component( z .object({ token: z .string() .check( z.describe('Opaque invite-link bearer token.'), z.meta({ examples: ['lnk_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), }) .check(z.describe('An invite-link bearer token.')), 'InviteLinkTokenRequest', ) /** Public active-link summary. */ export const ResolveResponse = OpenApi.component( Schema.describe( z.object({ orgName: z .string() .check( z.describe('Human-readable name of the organization.'), z.meta({ examples: ['Acme, Inc.'] }), ), role, }), 'A public summary of an active invite link.', ), 'ResolveInviteLinkResponse', ) /** Successful acceptance. */ export const AcceptResponse = OpenApi.component( Schema.describe( z.object({ orgId: z .string() .check( z.describe('Organization id joined through the link.'), z.meta({ examples: ['org_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), role, userId: z .string() .check( z.describe('Member user id.'), z.meta({ examples: ['usr_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), }), 'The membership granted through an invite link.', ), 'AcceptInviteLinkResponse', ) /** Link collection response. */ export const ListResponse = OpenApi.component( Schema.describe( z.object({ data: z .array(InviteLink) .check(z.describe('Invite links, newest first.'), z.meta({ examples: [[]] })), }), 'A non-paginated list of invite links.', ), 'InviteLinkList', ) /** Redemption collection response. */ export const RedemptionsResponse = OpenApi.component( Schema.describe( z.object({ data: z .array(Redemption) .check(z.describe('Redemptions, newest first.'), z.meta({ examples: [[]] })), }), 'A non-paginated list of invite-link redemptions.', ), 'InviteLinkRedemptionList', ) /** Path parameters addressing an organization's invite links. */ export const Params = z .object({ orgId: z .string() .check( z.describe('Organization id (`org_…`).'), z.meta({ examples: ['org_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), }) .check(z.describe("Path parameters for an organization's invite links.")) /** Path parameters addressing one organization invite link. */ export const LinkParams = z .object({ inviteLinkId: z .string() .check( z.describe('Invite-link resource id (`iln_…`).'), z.meta({ examples: ['iln_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), orgId: z .string() .check( z.describe('Organization id (`org_…`).'), z.meta({ examples: ['org_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), }) .check(z.describe('Path parameters for one organization invite link.')) /** Delete confirmation. */ export const DeleteResponse = OpenApi.component( z .object({ id: z .string() .check( z.describe('Deleted invite-link resource id.'), z.meta({ examples: ['iln_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), }) .check(z.describe('Confirmation that an invite link was deleted.')), 'DeleteInviteLinkResponse', ) } const bodyValidation = { code: 'body_invalid', message: 'Check the request body and try again.', } as const const paramValidation = { code: 'param_invalid', message: 'Check the path parameters and try again.', } as const /** Mounts reusable invite-link management and recipient operations. */ export function inviteLinks() { return new Hono() .post( '/v1/orgs/:orgId{org_[A-Za-z0-9_-]+}/invite-links', Auth.policy({ session: true }), Auth.ensureOrg({ role: 'admin' }), OpenApi.validate('param', schema.Params, paramValidation), OpenApi.validate('json', schema.CreateInviteLinkRequest, bodyValidation), OpenApi.describeRoute({ operationId: 'createInviteLink', 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: 'Created link.', schema: schema.InviteLink }, }), summary: 'Create invite link', tags: ['Invite Links'], }), async (c) => { if (Auth.narrowAccess) return Auth.superAdminAccessError(c) if (Auth.narrowScope) return Auth.ensureOrgError(c) if (Auth.narrowOrgRole) return Auth.ensureOrgRoleError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, paramValidation) if (OpenApi.narrowValidation) return OpenApi.validationError(c, bodyValidation) const body = c.req.valid('json') try { const record = await InviteLinks.create(Db.get(c.get('db')), { allowedEmailDomains: body.allowedEmailDomains ?? null, createdBy: Auth.membership(c)?.userId ?? 'super_admin', expiresAt: body.expiresAt === undefined ? new Date(Date.now() + 7 * 86_400_000).toISOString() : normalizeExpiry(body.expiresAt), maxUses: body.maxUses ?? null, ...(body.name === undefined ? {} : { name: body.name }), orgId: Auth.org(c).id, role: body.role, }) return c.json(Response.validated(schema.InviteLink, serialize(record)), 200) } catch (cause) { return Response.upstream(c, cause) } }, ) .get( '/v1/orgs/:orgId{org_[A-Za-z0-9_-]+}/invite-links', Auth.policy({ session: true }), Auth.ensureOrg({ role: 'admin' }), OpenApi.validate('param', schema.Params, paramValidation), OpenApi.describeRoute({ operationId: 'listInviteLinks', 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: 'Invite links.', schema: schema.ListResponse }, }), summary: 'List invite links', tags: ['Invite Links'], }), async (c) => { if (Auth.narrowAccess) return Auth.superAdminAccessError(c) if (Auth.narrowScope) return Auth.ensureOrgError(c) if (Auth.narrowOrgRole) return Auth.ensureOrgRoleError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, paramValidation) try { return c.json( Response.validated(schema.ListResponse, { data: (await InviteLinks.list(Db.get(c.get('db')), Auth.org(c).id)).map(serialize), }), 200, ) } catch (cause) { return Response.upstream(c, cause) } }, ) .patch( '/v1/orgs/:orgId{org_[A-Za-z0-9_-]+}/invite-links/:inviteLinkId{iln_[A-Za-z0-9_-]+}', Auth.policy({ session: true }), Auth.ensureOrg({ role: 'admin' }), OpenApi.validate('param', schema.LinkParams, paramValidation), OpenApi.validate('json', schema.UpdateInviteLinkRequest, bodyValidation), OpenApi.describeRoute({ operationId: 'updateInviteLink', responses: OpenApi.responses({ errors: { 400: { codes: ['body_invalid', 'param_invalid'], description: 'Malformed API key, invalid path, or invalid body.', }, 404: { codes: ['invite_link_not_found', 'organization_not_found'], description: 'No accessible organization or invite link was found.', }, }, success: { description: 'Updated link.', schema: schema.InviteLink }, }), summary: 'Update invite link', tags: ['Invite Links'], }), async (c) => { if (Auth.narrowAccess) return Auth.superAdminAccessError(c) if (Auth.narrowScope) return Auth.ensureOrgError(c) if (Auth.narrowOrgRole) return Auth.ensureOrgRoleError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, paramValidation) if (OpenApi.narrowValidation) return OpenApi.validationError(c, bodyValidation) const body = c.req.valid('json') try { const record = await InviteLinks.update( Db.get(c.get('db')), Auth.org(c).id, c.req.valid('param').inviteLinkId, body.enabled, ) if (!record) return notFound(c) return c.json(Response.validated(schema.InviteLink, serialize(record)), 200) } catch (cause) { return Response.upstream(c, cause) } }, ) .delete( '/v1/orgs/:orgId{org_[A-Za-z0-9_-]+}/invite-links/:inviteLinkId{iln_[A-Za-z0-9_-]+}', Auth.policy({ session: true }), Auth.ensureOrg({ role: 'admin' }), OpenApi.validate('param', schema.LinkParams, paramValidation), OpenApi.describeRoute({ operationId: 'deleteInviteLink', responses: OpenApi.responses({ errors: { 400: { codes: ['param_invalid'], description: 'Malformed API key or invalid path.' }, 404: { codes: ['invite_link_not_found', 'organization_not_found'], description: 'No accessible organization or invite link was found.', }, }, success: { description: 'Deleted link.', schema: schema.DeleteResponse }, }), summary: 'Delete invite link', tags: ['Invite Links'], }), async (c) => { if (Auth.narrowAccess) return Auth.superAdminAccessError(c) if (Auth.narrowScope) return Auth.ensureOrgError(c) if (Auth.narrowOrgRole) return Auth.ensureOrgRoleError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, paramValidation) try { const id = c.req.valid('param').inviteLinkId if (!(await InviteLinks.remove(Db.get(c.get('db')), Auth.org(c).id, id))) return notFound(c) return c.json({ id }, 200) } catch (cause) { return Response.upstream(c, cause) } }, ) .get( '/v1/orgs/:orgId{org_[A-Za-z0-9_-]+}/invite-links/:inviteLinkId{iln_[A-Za-z0-9_-]+}/redemptions', Auth.policy({ session: true }), Auth.ensureOrg({ role: 'admin' }), OpenApi.validate('param', schema.LinkParams, paramValidation), OpenApi.describeRoute({ operationId: 'listInviteLinkRedemptions', responses: OpenApi.responses({ errors: { 400: { codes: ['param_invalid'], description: 'Malformed API key or invalid path.' }, 404: { codes: ['invite_link_not_found', 'organization_not_found'], description: 'No accessible organization or invite link was found.', }, }, success: { description: 'Redemptions.', schema: schema.RedemptionsResponse }, }), summary: 'List link redemptions', tags: ['Invite Links'], }), async (c) => { if (Auth.narrowAccess) return Auth.superAdminAccessError(c) if (Auth.narrowScope) return Auth.ensureOrgError(c) if (Auth.narrowOrgRole) return Auth.ensureOrgRoleError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, paramValidation) try { const db = Db.get(c.get('db')) const id = c.req.valid('param').inviteLinkId const link = await InviteLinks.get(db, id) if (!link || link.orgId !== Auth.org(c).id) return notFound(c) return c.json( Response.validated(schema.RedemptionsResponse, { data: await InviteLinks.listRedemptions(db, Auth.org(c).id, id), }), 200, ) } catch (cause) { return Response.upstream(c, cause) } }, ) .post( '/v1/invite-links/resolve', Auth.policy({ public: true, session: true }), OpenApi.validate('json', schema.InviteLinkTokenRequest, bodyValidation), OpenApi.describeRoute({ operationId: 'resolveInviteLink', responses: OpenApi.responses({ errors: { 400: { codes: ['body_invalid'], description: 'Malformed API key or invalid body.' }, 404: { codes: ['invite_link_not_found'], description: 'No active invite link was found.', }, }, success: { description: 'Active link summary.', schema: schema.ResolveResponse }, }), summary: 'Resolve invite link', tags: ['Invite Links'], }), async (c) => { if (Auth.narrowAccess) return Auth.superAdminAccessError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, bodyValidation) try { const db = Db.get(c.get('db')) const link = await InviteLinks.getByToken(db, c.req.valid('json').token) if (!link || InviteLinks.status(link) !== 'active') return notFound(c) const org = await Organizations.get(db, link.orgId) if (!org) return notFound(c) return c.json( Response.validated(schema.ResolveResponse, { orgName: org.name, role: link.role }), 200, ) } catch (cause) { return Response.upstream(c, cause) } }, ) .post( '/v1/invite-links/accept', Auth.policy({ session: true }), OpenApi.validate('json', schema.InviteLinkTokenRequest, bodyValidation), OpenApi.describeRoute({ operationId: 'acceptInviteLink', responses: OpenApi.responses({ errors: { 400: { codes: ['body_invalid'], description: 'Malformed API key or invalid body.' }, 403: { codes: ['invite_link_email_forbidden'], description: 'A verified session email from an allowed domain is required.', }, 404: { codes: ['invite_link_not_found'], description: 'No active invite link was found.', }, }, success: { description: 'Membership.', schema: schema.AcceptResponse }, }), summary: 'Accept invite link', tags: ['Invite Links'], }), async (c) => { if (Auth.narrowAccess) return Auth.superAdminAccessError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, bodyValidation) const principal = Auth.getPrincipal(c) if (principal?.type !== 'session' || !principal.email) return Response.error(c, { code: 'forbidden', message: 'Verified session email required', status: 403, }) try { const accepted = await InviteLinks.accept( Db.get(c.get('db')), c.req.valid('json').token, principal.id, principal.email, ) if (!accepted) return notFound(c) return c.json( Response.validated(schema.AcceptResponse, { orgId: accepted.link.orgId, role: accepted.link.role, userId: principal.id, }), 200, ) } catch (cause) { if (cause instanceof InviteLinks.EmailDomainForbiddenError) return Response.error(c, { code: 'invite_link_email_forbidden', message: 'Sign in with an email address on an allowed domain', status: 403, }) return Response.upstream(c, cause) } }, ) } function serialize(record: InviteLinks.Record) { return { allowedEmailDomains: record.allowedEmailDomains, createdAt: record.createdAt, createdBy: record.createdBy, enabled: record.enabled, expiresAt: record.expiresAt, id: record.id, lastUsedAt: record.lastUsedAt, maxUses: record.maxUses, name: record.name, orgId: record.orgId, role: record.role, status: InviteLinks.status(record), token: record.token, updatedAt: record.updatedAt, useCount: record.useCount, } } /** Normalizes accepted offset timestamps so persisted expiry comparisons remain chronological. */ function normalizeExpiry(value: string | null) { return value === null ? null : new Date(value).toISOString() } function notFound(c: Context) { return Response.error(c, { code: 'invite_link_not_found', message: 'Invite link not found', status: 404, }) }