d3ro-voice/server/supabase/functions/_shared/team-contract.ts
2026-08-29 18:33:45 +09:00

199 lines
6.8 KiB
TypeScript

export type TeamInviteRole = 'admin' | 'member'
export interface TeamInviteInput {
teamId: string
email: string
role: TeamInviteRole
}
export interface TeamAcceptInput {
token: string
}
export class TeamContractError extends Error {
constructor(
public readonly code: string,
public readonly status: number,
) {
super(code)
this.name = 'TeamContractError'
}
}
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const INVITE_TOKEN_PATTERN = /^[A-Za-z0-9_-]{20,128}$/
export const CANONICAL_SITE_ORIGIN = 'https://d3ro.chanpaca.net'
function requireRecord(value: unknown, code: string): Record<string, unknown> {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
throw new TeamContractError(code, 400)
}
return value as Record<string, unknown>
}
function hasOnlyKeys(value: Record<string, unknown>, allowed: readonly string[]): boolean {
const allowlist = new Set(allowed)
return Object.keys(value).every((key) => allowlist.has(key))
}
export function parseTeamInviteInput(value: unknown): TeamInviteInput {
const body = requireRecord(value, 'invalid_invite_request')
if (!hasOnlyKeys(body, ['team_id', 'email', 'role'])) {
throw new TeamContractError('invalid_invite_request', 400)
}
const teamId = typeof body.team_id === 'string' ? body.team_id.trim() : ''
const email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : ''
const role = body.role === undefined ? 'member' : body.role
if (
!UUID_PATTERN.test(teamId)
|| email.length < 3
|| email.length > 254
|| !EMAIL_PATTERN.test(email)
|| (role !== 'admin' && role !== 'member')
) {
throw new TeamContractError('invalid_invite_request', 400)
}
return { teamId, email, role }
}
export function parseTeamAcceptInput(value: unknown): TeamAcceptInput {
const body = requireRecord(value, 'invalid_invite_token')
if (!hasOnlyKeys(body, ['token'])) {
throw new TeamContractError('invalid_invite_token', 400)
}
const token = typeof body.token === 'string' ? body.token.trim() : ''
if (!INVITE_TOKEN_PATTERN.test(token)) {
throw new TeamContractError('invalid_invite_token', 400)
}
return { token }
}
export function normalizeSiteOrigin(value: string | undefined): string {
const configured = value?.trim() ?? ''
if (!configured) throw new TeamContractError('site_url_not_configured', 503)
let url: URL
try {
url = new URL(configured)
} catch {
throw new TeamContractError('site_url_invalid', 503)
}
if (
url.protocol !== 'https:'
|| url.username !== ''
|| url.password !== ''
|| url.search !== ''
|| url.hash !== ''
) {
throw new TeamContractError('site_url_invalid', 503)
}
if (url.origin !== CANONICAL_SITE_ORIGIN) {
throw new TeamContractError('site_url_invalid', 503)
}
return url.origin
}
export function buildInviteUrl(siteOrigin: string, token: string): string {
if (!INVITE_TOKEN_PATTERN.test(token)) {
throw new TeamContractError('invalid_invite_response', 502)
}
const url = new URL('/accept-invite/', normalizeSiteOrigin(siteOrigin))
url.searchParams.set('token', token)
return url.toString()
}
export function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
export function buildInviteEmailHtml(input: {
inviteUrl: string
inviterEmail: string
teamName: string
role: TeamInviteRole
}): string {
const inviteUrl = escapeHtml(input.inviteUrl)
const inviterEmail = escapeHtml(input.inviterEmail.slice(0, 254))
const teamName = escapeHtml(input.teamName.slice(0, 80) || 'D3RO Voice')
const role = input.role === 'admin' ? 'admin' : 'member'
return `
<div style="font-family:system-ui,sans-serif;max-width:560px;margin:0 auto;padding:24px;color:#1a1a1c">
<h1 style="color:#f25b29;font-weight:400">D3RO VOICE</h1>
<p>Hello.</p>
<p><strong>${inviterEmail}</strong> invited you to <strong>${teamName}</strong> as a ${role}.</p>
<p style="margin:32px 0"><a href="${inviteUrl}" style="display:inline-block;padding:14px 32px;background:#f25b29;color:#fff;text-decoration:none;border-radius:8px;font-weight:600">Accept invitation</a></p>
<p style="color:#6b7280;font-size:13px">This invitation expires in 7 days.</p>
<p style="background:#f5f5f7;padding:12px;border-radius:6px;font-family:monospace;font-size:11px;word-break:break-all">${inviteUrl}</p>
</div>
`
}
export function validateEmailSender(value: string): string {
const normalized = value.trim()
if (
normalized.length < 3
|| normalized.length > 320
|| /[\r\n]/.test(normalized)
) {
throw new TeamContractError('email_configuration_invalid', 503)
}
return normalized
}
export function mapTeamRpcError(
operation: 'invite' | 'accept',
error: { code?: string; message?: string } | null,
): TeamContractError {
const message = (error?.message ?? '').toLowerCase()
const code = error?.code ?? ''
if (operation === 'invite') {
if (message.includes('invite_rate_limited') || code === '54000') {
return new TeamContractError('invite_rate_limited', 429)
}
if (message.includes('already_team_member') || code === '23505') {
return new TeamContractError('already_team_member', 409)
}
if (message.includes('team_admin_required') || message.includes('owner_required')) {
return new TeamContractError('team_admin_required', 403)
}
if (message.includes('cannot_invite_self') || message.includes('invalid_invite')) {
return new TeamContractError('invalid_invite_request', 400)
}
if (code === '42883' || message.includes('create_team_invite')) {
return new TeamContractError('team_contract_unavailable', 503)
}
return new TeamContractError('team_invite_failed', 500)
}
if (message.includes('invite_not_found') || code === 'P0002') {
return new TeamContractError('invite_not_found', 404)
}
if (message.includes('invite_expired')) {
return new TeamContractError('invite_expired', 410)
}
if (message.includes('invite_email_mismatch')) {
return new TeamContractError('invite_email_mismatch', 403)
}
if (message.includes('invite_already_accepted') || code === '23505') {
return new TeamContractError('invite_already_accepted', 409)
}
if (message.includes('invalid_invite_token')) {
return new TeamContractError('invalid_invite_token', 400)
}
if (message.includes('invite_acceptance_conflict') || message.includes('team_membership_failed')) {
return new TeamContractError('invite_acceptance_conflict', 409)
}
if (code === '42883' || message.includes('accept_team_invite')) {
return new TeamContractError('team_contract_unavailable', 503)
}
return new TeamContractError('team_accept_failed', 500)
}