import { type Context, Hono } from 'hono' import * as z from 'zod/mini' import * as Auth from '../../../internal/Auth.js' import * as Db from '../../../db/Db.js' import * as Invitations from '../../../db/tables/invitations.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 ManagementError from '../Error.js' import type { Email, Environment } from '../App.js' /** How long an invitation stays acceptable. */ const ttlDays = 7 /** 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.', }, }, 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 grant only members. 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 db = Db.get(c.get('db')) try { const record = await Invitations.create(db, { email: body.email, expiresAt: new Date(Date.now() + ttlDays * 86_400_000).toISOString(), invitedBy, orgId: Auth.org(c).id, role: body.role, }) dispatchEmail(c, { email: c.get('email'), org: Auth.org(c).name, record }) return c.json(Response.validated(schema.Invitation, serializeInvitation(record)), 200) } catch (cause) { 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 { // Bind the invitation to the scoped org before revoking. const record = await Invitations.get(db, invitationId) if (!record || record.orgId !== Auth.org(c).id) return invitationNotFound(c) const revoked = await Invitations.revoke(db, invitationId) if (!revoked) return invitationNotFound(c) return c.json( Response.validated(schema.revokeInvitation.Response, { id: invitationId }), 200, ) } catch (cause) { 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) } }, ) } /** Dispatches the invitation email through the app's sender, best-effort. */ function dispatchEmail( c: Context, options: { email: Email | undefined; org: string; record: Invitations.Record }, ) { 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 { c.executionCtx.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, }) }