import { type Context, Hono } from 'hono' import { Hash, Hex } from 'ox' import * as z from 'zod/mini' import * as Auth from '../../../internal/Auth.js' import * as Db from '../../../db/Db.js' import * as Email from '../../../internal/Email.js' import * as Invitations from '../../../db/tables/invitations.js' import * as Memberships from '../../../db/tables/memberships.js' import type * as Metrics from '../../../Metrics.js' import * as OpenApi from '../../../internal/OpenApi.js' import * as RateLimit from '../../../internal/RateLimit.js' import * as Response from '../../../internal/Response.js' import * as Schema from '../../../internal/Schema.js' import * as ManagementError from '../Error.js' import type { Environment } from '../App.js' /** How long an invitation stays acceptable. */ const ttlDays = 7 const actorRateLimit = { limit: 10, period: 'minute' } satisfies RateLimit.Limit const globalRateLimit = { limit: 100, period: 'minute' } satisfies RateLimit.Limit const recipientRateLimit = { limit: 2, period: 'minute' } satisfies RateLimit.Limit /** Zod schemas owned by the invitations resource. */ export namespace schema { /** A membership role. */ const role = z .enum(Memberships.roles) .check(z.describe('Role granted when the invitation is accepted.'), z.meta({ examples: ['member'] })) // prettier-ignore /** One pending invitation. */ export const Invitation = OpenApi.component( Schema.describe( z.object({ createdAt: z.iso .datetime() .check( z.describe('When the invitation was created (ISO 8601).'), z.meta({ examples: ['2026-01-01T00:00:00.000Z'] }), ), email: z .email() .check( z.describe('Invitee email (lowercase).'), z.meta({ examples: ['dev@example.com'] }), ), expiresAt: z.iso .datetime() .check( z.describe('When the invitation expires (ISO 8601).'), z.meta({ examples: ['2026-01-08T00:00:00.000Z'] }), ), id: z .string() .check( z.describe('Opaque invitation id (`inv_…`).'), z.meta({ examples: ['inv_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), invitedBy: z .string() .check( z.describe('User or API key id that created the invitation.'), z.meta({ examples: ['usr_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), orgId: z .string() .check( z.describe('Organization id (`org_…`) the invitation joins.'), z.meta({ examples: ['org_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), role, }), 'One pending invitation.', ), 'Invitation', ) /** Path parameters addressing an organization's invitation 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 invitations.")) /** Path parameters addressing one org-scoped invitation. */ export const InvitationParams = z .object({ invitationId: z .string() .check( z.describe('The invitation id (`inv_…`).'), z.meta({ examples: ['inv_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), orgId: z .string() .check( z.describe('The organization id (`org_…`).'), z.meta({ examples: ['org_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), }) .check(z.describe('Path parameters for one invitation.')) /** Path parameters addressing one of the caller's invitations. */ export const AcceptParams = z .object({ invitationId: z .string() .check( z.describe('The invitation id (`inv_…`).'), z.meta({ examples: ['inv_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), }) .check(z.describe("Path parameters for one of the caller's invitations.")) /** Request body creating an invitation. */ export const CreateInvitationRequest = OpenApi.component( z .object({ email: z .email() .check(z.describe('Invitee email.'), z.meta({ examples: ['dev@example.com'] })), role, }) .check(z.describe('Fields for creating an invitation.')), 'CreateInvitationRequest', ) /** Schemas for the listInvitations / listMyInvitations operations. */ export namespace listInvitations { /** Non-paginated list of pending invitations. */ export const Response = OpenApi.component( Schema.describe( z.object({ data: z .array(Invitation) .check(z.describe('Pending invitations, newest first.'), z.meta({ examples: [[]] })), }), 'A non-paginated list of pending invitations.', ), 'InvitationList', ) } /** Schemas for the listMyInvitations operation. */ export namespace listMyInvitations { /** A pending invitation addressed to the caller, with the inviting org's name. */ export const Item = OpenApi.component( Schema.describe( z.extend(Invitation, { orgName: z .string() .check( z.describe('Human-readable name of the inviting organization.'), z.meta({ examples: ['Acme, Inc.'] }), ), }), "One pending invitation addressed to the caller, with the inviting organization's name.", ), 'MyInvitation', ) /** Non-paginated list of the caller's pending invitations. */ export const Response = OpenApi.component( Schema.describe( z.object({ data: z .array(Item) .check(z.describe('Pending invitations, newest first.'), z.meta({ examples: [[]] })), }), "A non-paginated list of the caller's pending invitations.", ), 'MyInvitationList', ) } /** Schemas for the acceptInvitation operation. */ export namespace acceptInvitation { /** The membership granted by accepting. */ export const Response = OpenApi.component( Schema.describe( z.object({ orgId: z .string() .check( z.describe('Organization id (`org_…`) joined.'), z.meta({ examples: ['org_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), role: z .enum(Memberships.roles) .check(z.describe('Granted role.'), z.meta({ examples: ['member'] })), userId: z .string() .check( z.describe('Member user id (`usr_…`).'), z.meta({ examples: ['usr_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), }), 'The membership granted by accepting.', ), 'AcceptInvitationResponse', ) } /** Schemas for the declineInvitation operation. */ export namespace declineInvitation { /** Confirmation that the invitation was declined. */ export const Response = OpenApi.component( z .object({ id: z .string() .check( z.describe('ID of the invitation that was declined.'), z.meta({ examples: ['inv_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), }) .check(z.describe('Confirmation that the invitation was declined.')), 'DeclineInvitationResponse', ) } /** Schemas for the revokeInvitation operation. */ export namespace revokeInvitation { /** Confirmation that the invitation was revoked. */ export const Response = OpenApi.component( z .object({ id: z .string() .check( z.describe('ID of the invitation that was revoked.'), z.meta({ examples: ['inv_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), }) .check(z.describe('Confirmation that the invitation was revoked.')), 'RevokeInvitationResponse', ) } } /** * Mounts the invitations surface: org-scoped management * (`/orgs/:orgId/invitations`) plus session-only invitee actions. Invitation * emails dispatch through the management group's sender, best-effort. */ export function invitations() { return new Hono() .post( '/v1/orgs/:orgId{org_[A-Za-z0-9_-]+}/invitations', Auth.policy({ apiKey: { scopes: ['management:write'] }, session: true }), Auth.ensureOrg({ role: 'admin' }), OpenApi.validate('param', schema.Params, { code: 'param_invalid', message: 'Check the path parameters and try again.', }), OpenApi.validate('json', schema.CreateInvitationRequest, { code: 'body_invalid', message: 'Check the request body and try again.', }), OpenApi.describeRoute({ description: 'Invite an email to the organization with a role. The invitee accepts after signing in with that verified email.', // prettier-ignore operationId: 'createInvitation', 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.', }, 429: { codes: ['rate_limit_exceeded'], description: 'The invitation email rate limit was exceeded.', }, }, success: { description: 'The created invitation.', schema: schema.Invitation }, }), summary: 'Create invitation', tags: ['Invitations'], }), 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') // Owners, write-scoped keys, and the super admin grant any role; admins cannot grant owner. const caller = Auth.membership(c) if ( caller && caller.role !== 'owner' && Memberships.rank[body.role] > Memberships.rank[caller.role] ) return Response.error(c, { code: 'forbidden', message: 'Cannot invite at or above your own role', status: 403, }) const principal = Auth.getPrincipal(c) const invitedBy = principal?.type === 'api_key' ? principal.id : (caller?.userId ?? 'super_admin') const invitationEmail = Email.get(c) const limited = await consumeEmailRateLimit({ actor: principal ? `${principal.type}:${principal.id}` : undefined, email: body.email, enabled: !!invitationEmail, metrics: c.get('metrics'), rateLimit: c.get('auth').rateLimit, }) if (limited) return rateLimitError(c, limited) const db = Db.get(c.get('db')) try { const record = await createInvitation({ ...(caller ? { callerUserId: caller.userId } : {}), db, email: body.email, invitationEmail, invitedBy, org: Auth.org(c), role: body.role, waitUntil: (promise) => c.executionCtx.waitUntil(promise), }) return c.json(Response.validated(schema.Invitation, serializeInvitation(record)), 200) } catch (cause) { if (cause instanceof Invitations.InvitationRoleForbiddenError) return invitationRoleForbidden(c) return Response.upstream(c, cause) } }, ) .get( '/v1/orgs/:orgId{org_[A-Za-z0-9_-]+}/invitations', Auth.policy({ apiKey: { scopes: ['management:read'] }, session: true }), Auth.ensureOrg({ role: 'admin' }), OpenApi.validate('param', schema.Params, { code: 'param_invalid', message: 'Check the path parameters and try again.', }), OpenApi.describeRoute({ operationId: 'listInvitations', 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: 'Pending invitations, newest first.', schema: schema.listInvitations.Response, }, }), summary: 'List invitations', tags: ['Invitations'], }), 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 { const records = await Invitations.listByOrg(db, Auth.org(c).id) return c.json( Response.validated(schema.listInvitations.Response, { data: records.map(serializeInvitation), }), 200, ) } catch (cause) { return Response.upstream(c, cause) } }, ) .delete( '/v1/orgs/:orgId{org_[A-Za-z0-9_-]+}/invitations/:invitationId{inv_[A-Za-z0-9_-]+}', Auth.policy({ apiKey: { scopes: ['management:write'] }, session: true }), Auth.ensureOrg({ role: 'admin' }), OpenApi.validate('param', schema.InvitationParams, { code: 'param_invalid', message: 'Check the path parameters and try again.', }), OpenApi.describeRoute({ operationId: 'revokeInvitation', responses: OpenApi.responses({ errors: { 400: { codes: ['param_invalid'], description: 'Malformed API key or invalid path.' }, 404: { codes: ['invitation_not_found', 'organization_not_found'], description: 'No accessible organization or pending invitation was found.', }, }, success: { description: 'Confirmation that the invitation was revoked.', schema: schema.revokeInvitation.Response, }, }), summary: 'Revoke invitation', tags: ['Invitations'], }), 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 { invitationId } = c.req.valid('param') const db = Db.get(c.get('db')) try { const caller = Auth.membership(c) const revoked = await Invitations.revoke(db, invitationId, { ...(caller ? { callerUserId: caller.userId } : {}), orgId: Auth.org(c).id, }) if (!revoked) return invitationNotFound(c) return c.json( Response.validated(schema.revokeInvitation.Response, { id: invitationId }), 200, ) } catch (cause) { if (cause instanceof Invitations.InvitationRoleForbiddenError) return invitationRoleForbidden(c) return Response.upstream(c, cause) } }, ) .get( '/v1/invitations', Auth.policy({ session: true }), OpenApi.describeRoute({ description: "Pending invitations addressed to the session's verified email.", operationId: 'listMyInvitations', responses: OpenApi.responses({ errors: { 400: { codes: [], description: 'Malformed API key.' } }, success: { description: 'Pending invitations, newest first.', schema: schema.listMyInvitations.Response, }, }), summary: 'List my invitations', tags: ['Invitations'], }), async (c) => { if (Auth.narrowAccess) return Auth.superAdminAccessError(c) const principal = Auth.getPrincipal(c) if (principal?.type !== 'session') return ManagementError.sessionRequired(c) // Without a verified email there is nothing to match against. if (!principal.email) return c.json(Response.validated(schema.listMyInvitations.Response, { data: [] }), 200) const db = Db.get(c.get('db')) try { const records = await Invitations.listByEmail(db, principal.email) return c.json( Response.validated(schema.listMyInvitations.Response, { data: records.map((record) => ({ ...serializeInvitation(record), orgName: record.orgName, })), }), 200, ) } catch (cause) { return Response.upstream(c, cause) } }, ) .post( '/v1/invitations/:invitationId{inv_[A-Za-z0-9_-]+}/accept', Auth.policy({ session: true }), OpenApi.validate('param', schema.AcceptParams, { code: 'param_invalid', message: 'Check the path parameters and try again.', }), OpenApi.describeRoute({ description: "Accept an invitation addressed to the session's verified email, joining the organization with the invited role.", // prettier-ignore operationId: 'acceptInvitation', responses: OpenApi.responses({ errors: { 400: { codes: ['param_invalid'], description: 'Malformed API key or invalid path.', }, 404: { codes: ['invitation_not_found'], description: 'No accessible pending invitation was found for this id.', }, }, success: { description: 'The membership granted by accepting.', schema: schema.acceptInvitation.Response, }, }), summary: 'Accept invitation', tags: ['Invitations'], }), async (c) => { if (Auth.narrowAccess) return Auth.superAdminAccessError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'param_invalid', message: 'Check the path parameters and try again.', }) const principal = Auth.getPrincipal(c) if (principal?.type !== 'session') return ManagementError.sessionRequired(c) const { invitationId } = c.req.valid('param') const db = Db.get(c.get('db')) try { // An invitation is visible only to the verified holder of its email; // everyone else sees 404. const record = await Invitations.get(db, invitationId) if (!record || record.email !== principal.email?.toLowerCase()) return invitationNotFound(c) const accepted = await Invitations.accept(db, invitationId, principal.id) if (!accepted) return invitationNotFound(c) const membership = await Memberships.get(db, accepted.orgId, principal.id) return c.json( Response.validated(schema.acceptInvitation.Response, { orgId: accepted.orgId, role: membership?.role ?? accepted.role, userId: principal.id, }), 200, ) } catch (cause) { return Response.upstream(c, cause) } }, ) .post( '/v1/invitations/:invitationId{inv_[A-Za-z0-9_-]+}/decline', Auth.policy({ session: true }), OpenApi.validate('param', schema.AcceptParams, { code: 'param_invalid', message: 'Check the path parameters and try again.', }), OpenApi.describeRoute({ description: "Decline an invitation addressed to the session's verified email. Declining settles the invitation; it can no longer be accepted.", // prettier-ignore operationId: 'declineInvitation', responses: OpenApi.responses({ errors: { 400: { codes: ['param_invalid'], description: 'Malformed API key or invalid path.', }, 404: { codes: ['invitation_not_found'], description: 'No accessible pending invitation was found for this id.', }, }, success: { description: 'Confirmation that the invitation was declined.', schema: schema.declineInvitation.Response, }, }), summary: 'Decline invitation', tags: ['Invitations'], }), async (c) => { if (Auth.narrowAccess) return Auth.superAdminAccessError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'param_invalid', message: 'Check the path parameters and try again.', }) const principal = Auth.getPrincipal(c) if (principal?.type !== 'session') return ManagementError.sessionRequired(c) const { invitationId } = c.req.valid('param') const db = Db.get(c.get('db')) try { // An invitation is visible only to the verified holder of its email; // everyone else sees 404. const record = await Invitations.get(db, invitationId) if (!record || record.email !== principal.email?.toLowerCase()) return invitationNotFound(c) // Declining reuses the revoke settle; no surface distinguishes the two. const declined = await Invitations.revoke(db, invitationId) if (!declined) return invitationNotFound(c) return c.json( Response.validated(schema.declineInvitation.Response, { id: invitationId }), 200, ) } catch (cause) { return Response.upstream(c, cause) } }, ) } /** Consumes the shared quotas protecting invitation email dispatch. */ export async function consumeEmailRateLimit(options: consumeEmailRateLimit.Options) { if (!options.enabled) return undefined if (!options.rateLimit) return undefined const auth = { rateLimit: options.rateLimit } if (options.actor) { const actor = await Auth.consumeRateLimit(auth, { key: `invitation-email:actor:${options.actor}`, limit: actorRateLimit, }) if (!actor) recordEmailRateLimitDegradation({ metrics: options.metrics, scope: 'invitation-email-actor' }) if (actor && !actor.allowed) return { result: actor, scope: 'invitation-email-actor' } } const recipientHash = Hash.sha256(Hex.fromString(options.email.toLowerCase())).slice(2) const recipient = await Auth.consumeRateLimit(auth, { key: `invitation-email:recipient:${recipientHash}`, limit: recipientRateLimit, }) if (!recipient) recordEmailRateLimitDegradation({ metrics: options.metrics, scope: 'invitation-email-recipient', }) if (recipient && !recipient.allowed) return { result: recipient, scope: 'invitation-email-recipient' } const global = await Auth.consumeRateLimit(auth, { key: 'invitation-email:global', limit: globalRateLimit, }) if (!global) recordEmailRateLimitDegradation({ metrics: options.metrics, scope: 'invitation-email-global' }) if (global && !global.allowed) return { result: global, scope: 'invitation-email-global' } return undefined } export declare namespace consumeEmailRateLimit { /** Fields used to protect one invitation email dispatch. */ type Options = { /** Stable identity for the initiating actor, when the caller has one. */ actor?: string | undefined /** Intended invitation recipient. */ email: string /** Whether this app has an email sender configured. */ enabled: boolean /** Bounded operational metrics sink for fail-open degradation. */ metrics?: Metrics.Metrics | undefined /** Shared counter store used by every invitation email entrypoint. */ rateLimit?: RateLimit.Store | undefined } } /** Records bounded invitation limiter degradation for operational alerting. */ export function recordEmailRateLimitDegradation(options: recordEmailRateLimitDegradation.Options) { options.metrics?.count('invitation_email_rate_limit_degraded_count', 1, { scope: options.scope, }) } export declare namespace recordEmailRateLimitDegradation { /** Inputs for recording one invitation limiter degradation. */ type Options = { /** Bounded operational metrics sink. */ metrics?: Metrics.Metrics | undefined /** Quota dimension whose counter degraded. */ scope: 'invitation-email-actor' | 'invitation-email-global' | 'invitation-email-recipient' } } function rateLimitError( c: Context, limited: { /** Denied quota result. */ result: RateLimit.Result /** Quota dimension that denied the request. */ scope: string }, ) { const { result, scope } = limited c.header('RateLimit-Limit', String(result.limit)) c.header('RateLimit-Remaining', String(result.remaining)) c.header('RateLimit-Reset', String(result.reset)) c.header('RateLimit-Scope', scope) c.header('Retry-After', String(Math.max(result.reset - Math.ceil(Date.now() / 1_000), 1))) return Response.error(c, { code: 'rate_limit_exceeded', message: 'Rate limit exceeded', status: 429, }) } /** Creates an organization invitation and dispatches its email. */ export async function createInvitation(options: createInvitation.Options) { const record = await Invitations.create(options.db, { ...(options.callerUserId === undefined ? {} : { callerUserId: options.callerUserId }), email: options.email, expiresAt: options.expiresAt ?? new Date(Date.now() + ttlDays * 86_400_000).toISOString(), invitedBy: options.invitedBy, orgId: options.org.id, role: options.role, }) dispatchEmail({ email: options.invitationEmail, org: options.org.name, record, waitUntil: options.waitUntil }) // prettier-ignore return record } export declare namespace createInvitation { /** Fields required to create and deliver an invitation. */ type Options = { /** Session user whose current organization role authorizes this write. */ callerUserId?: string | undefined /** Database receiving the pending invitation. */ db: Db.Db /** Invitee email. */ email: string /** Invitation expiry timestamp. */ expiresAt?: string | undefined /** Transactional email sender, when configured. */ invitationEmail?: Email.Sender | undefined /** Identity attributed with creating the invitation. */ invitedBy: string /** Organization receiving the invited member. */ org: { id: string; name: string } /** Role granted after acceptance. */ role: Memberships.Role /** Keeps email delivery alive after the response. */ waitUntil?: ((promise: Promise) => void) | undefined } } function invitationRoleForbidden(c: Context) { return Response.error(c, { code: 'forbidden', message: 'Cannot manage an invitation above your own role', status: 403, }) } /** Dispatches the invitation email through the app's sender, best-effort. */ function dispatchEmail(options: { email: Email.Sender | undefined org: string record: Invitations.Record waitUntil?: ((promise: Promise) => void) | undefined }) { const { email: sender, org, record } = options if (!sender) return const days = Math.max(1, Math.round((Date.parse(record.expiresAt) - Date.now()) / 86_400_000)) const expiry = days === 1 ? '1 day' : `${days} days` const link = new URL(sender.consoleUrl ?? 'https://console.tempo.xyz') link.searchParams.set('email', record.email) // The console's index route steers `?invitation=` to the accept screen. link.searchParams.set('invitation', record.id) const url = link.href const dispatch = Promise.resolve( sender.send({ from: sender.from, html: `

You've been invited to join ${escapeHtml(org)} on the Tempo Developer Platform

Sign in at ${url} to accept. This invitation expires in ${expiry}.

`, subject: `You've been invited to ${org} on the Tempo Developer Platform`, text: `You've been invited to join ${org} on the Tempo Developer Platform\n\nSign in at ${url} to accept. This invitation expires in ${expiry}.`, to: record.email, }), ).catch((error) => console.error('invitation email dispatch failed', error)) try { options.waitUntil?.(dispatch) } catch { // Non-Workers runtime: the floating promise settles on its own. } } /** Escapes org-controlled text for the HTML body. */ function escapeHtml(text: string) { return text .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') } /** Projects an invitation row onto the pending-invitation shape. */ function serializeInvitation(record: Invitations.Record) { return { createdAt: record.createdAt, email: record.email, expiresAt: record.expiresAt, id: record.id, invitedBy: record.invitedBy, orgId: record.orgId, role: record.role, } } function invitationNotFound(c: Context) { return Response.error(c, { code: 'invitation_not_found', message: 'Invitation not found', status: 404, }) }