feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
399
server/supabase/functions/_shared/admin-contract.ts
Normal file
399
server/supabase/functions/_shared/admin-contract.ts
Normal file
|
|
@ -0,0 +1,399 @@
|
|||
export type ManagedRole = 'user' | 'manager' | 'admin' | 'super_admin'
|
||||
export type SubscriptionTier = 'free' | 'pro' | 'pro_plus'
|
||||
export type SubscriptionStatus = 'active' | 'canceled' | 'past_due' | 'expired'
|
||||
export type SubscriptionAction = 'create' | 'update' | 'delete'
|
||||
|
||||
export class AdminPublicError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
) {
|
||||
super(code)
|
||||
}
|
||||
}
|
||||
|
||||
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 ROLE_VALUES = new Set<ManagedRole>(['user', 'manager', 'admin', 'super_admin'])
|
||||
const TIER_VALUES = new Set<SubscriptionTier>(['free', 'pro', 'pro_plus'])
|
||||
const STATUS_VALUES = new Set<SubscriptionStatus>(['active', 'canceled', 'past_due', 'expired'])
|
||||
|
||||
function recordValue(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new AdminPublicError(400, 'invalid_json_body')
|
||||
}
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
function assertOnlyKeys(value: Record<string, unknown>, allowed: readonly string[]): void {
|
||||
const allowedSet = new Set(allowed)
|
||||
if (Object.keys(value).some((key) => !allowedSet.has(key))) {
|
||||
throw new AdminPublicError(400, 'unexpected_request_field')
|
||||
}
|
||||
}
|
||||
|
||||
function requiredString(
|
||||
value: Record<string, unknown>,
|
||||
key: string,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): string {
|
||||
const raw = value[key]
|
||||
if (typeof raw !== 'string') throw new AdminPublicError(400, `invalid_${key}`)
|
||||
const normalized = raw.trim()
|
||||
if (normalized.length < minimum || normalized.length > maximum) {
|
||||
throw new AdminPublicError(400, `invalid_${key}`)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function optionalString(
|
||||
value: Record<string, unknown>,
|
||||
key: string,
|
||||
maximum: number,
|
||||
): string | undefined {
|
||||
if (!(key in value)) return undefined
|
||||
const raw = value[key]
|
||||
if (typeof raw !== 'string' || raw.length > maximum) {
|
||||
throw new AdminPublicError(400, `invalid_${key}`)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
export function requireUuid(value: string | null | undefined, code: string): string {
|
||||
if (!value || !UUID_PATTERN.test(value)) throw new AdminPublicError(400, code)
|
||||
return value.toLowerCase()
|
||||
}
|
||||
|
||||
export function requireIdempotencyKey(req: Request): string {
|
||||
return requireUuid(req.headers.get('idempotency-key')?.trim(), 'valid_idempotency_key_required')
|
||||
}
|
||||
|
||||
export async function readAdminJson(req: Request, maximumBytes = 16 * 1024): Promise<Record<string, unknown>> {
|
||||
const contentType = req.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase()
|
||||
if (contentType !== 'application/json') throw new AdminPublicError(415, 'application_json_required')
|
||||
|
||||
const declaredLength = req.headers.get('content-length')
|
||||
if (declaredLength && (!/^\d+$/.test(declaredLength) || Number(declaredLength) > maximumBytes)) {
|
||||
throw new AdminPublicError(413, 'request_too_large')
|
||||
}
|
||||
|
||||
const text = await req.text()
|
||||
if (new TextEncoder().encode(text).byteLength > maximumBytes) {
|
||||
throw new AdminPublicError(413, 'request_too_large')
|
||||
}
|
||||
try {
|
||||
return recordValue(JSON.parse(text) as unknown)
|
||||
} catch (error) {
|
||||
if (error instanceof AdminPublicError) throw error
|
||||
throw new AdminPublicError(400, 'invalid_json_body')
|
||||
}
|
||||
}
|
||||
|
||||
export function actorEmail(user: { email?: string | null }): string {
|
||||
const email = user.email?.trim().toLowerCase() ?? ''
|
||||
if (email.length < 3 || email.length > 150 || !email.includes('@')) {
|
||||
throw new AdminPublicError(403, 'admin_identity_unavailable')
|
||||
}
|
||||
return email
|
||||
}
|
||||
|
||||
export interface RoleChangeRequest {
|
||||
idempotencyKey: string
|
||||
userId: string
|
||||
newRole: ManagedRole
|
||||
memo: string
|
||||
}
|
||||
|
||||
export function parseRoleChangeRequest(
|
||||
value: unknown,
|
||||
idempotencyKey: string,
|
||||
): RoleChangeRequest {
|
||||
const body = recordValue(value)
|
||||
assertOnlyKeys(body, ['userId', 'newRole', 'memo'])
|
||||
const newRole = requiredString(body, 'newRole', 3, 20) as ManagedRole
|
||||
if (!ROLE_VALUES.has(newRole)) throw new AdminPublicError(400, 'invalid_newRole')
|
||||
return {
|
||||
idempotencyKey: requireUuid(idempotencyKey, 'valid_idempotency_key_required'),
|
||||
userId: requireUuid(requiredString(body, 'userId', 36, 36), 'invalid_userId'),
|
||||
newRole,
|
||||
memo: requiredString(body, 'memo', 3, 1000),
|
||||
}
|
||||
}
|
||||
|
||||
export interface SubscriptionMutationRequest {
|
||||
idempotencyKey: string
|
||||
action: SubscriptionAction
|
||||
userId: string
|
||||
tier?: SubscriptionTier
|
||||
status?: SubscriptionStatus
|
||||
currentPeriodEnd?: string
|
||||
overageCredits?: number
|
||||
adminNote?: string
|
||||
memo: string
|
||||
}
|
||||
|
||||
function optionalDateTime(body: Record<string, unknown>): string | undefined {
|
||||
if (!('currentPeriodEnd' in body)) return undefined
|
||||
const raw = body.currentPeriodEnd
|
||||
if (typeof raw !== 'string' || raw.length > 40 || !/^\d{4}-\d{2}-\d{2}T/.test(raw)) {
|
||||
throw new AdminPublicError(400, 'invalid_currentPeriodEnd')
|
||||
}
|
||||
const timestamp = Date.parse(raw)
|
||||
if (!Number.isFinite(timestamp)) throw new AdminPublicError(400, 'invalid_currentPeriodEnd')
|
||||
return new Date(timestamp).toISOString()
|
||||
}
|
||||
|
||||
export function parseSubscriptionMutationRequest(
|
||||
action: SubscriptionAction,
|
||||
value: unknown,
|
||||
idempotencyKey: string,
|
||||
queryUserId?: string | null,
|
||||
): SubscriptionMutationRequest {
|
||||
const body = recordValue(value)
|
||||
const allowed = action === 'create'
|
||||
? ['userId', 'tier', 'status', 'currentPeriodEnd', 'overageCredits', 'adminNote', 'memo']
|
||||
: action === 'update'
|
||||
? ['tier', 'status', 'currentPeriodEnd', 'overageCredits', 'adminNote', 'memo']
|
||||
: ['memo']
|
||||
assertOnlyKeys(body, allowed)
|
||||
|
||||
const userId = action === 'create'
|
||||
? requireUuid(requiredString(body, 'userId', 36, 36), 'invalid_userId')
|
||||
: requireUuid(queryUserId, 'invalid_userId')
|
||||
const result: SubscriptionMutationRequest = {
|
||||
idempotencyKey: requireUuid(idempotencyKey, 'valid_idempotency_key_required'),
|
||||
action,
|
||||
userId,
|
||||
memo: requiredString(body, 'memo', 3, 1000),
|
||||
}
|
||||
|
||||
if ('tier' in body) {
|
||||
const tier = requiredString(body, 'tier', 3, 20) as SubscriptionTier
|
||||
if (!TIER_VALUES.has(tier)) throw new AdminPublicError(400, 'invalid_tier')
|
||||
result.tier = tier
|
||||
}
|
||||
if (action === 'create' && !result.tier) throw new AdminPublicError(400, 'tier_required')
|
||||
|
||||
if ('status' in body) {
|
||||
const status = requiredString(body, 'status', 6, 20) as SubscriptionStatus
|
||||
if (!STATUS_VALUES.has(status)) throw new AdminPublicError(400, 'invalid_status')
|
||||
result.status = status
|
||||
}
|
||||
|
||||
const currentPeriodEnd = optionalDateTime(body)
|
||||
if (currentPeriodEnd) result.currentPeriodEnd = currentPeriodEnd
|
||||
|
||||
if ('overageCredits' in body) {
|
||||
const credits = body.overageCredits
|
||||
if (!Number.isInteger(credits) || (credits as number) < 0 || (credits as number) > 1_000_000) {
|
||||
throw new AdminPublicError(400, 'invalid_overageCredits')
|
||||
}
|
||||
result.overageCredits = credits as number
|
||||
}
|
||||
|
||||
const adminNote = optionalString(body, 'adminNote', 2000)
|
||||
if (adminNote !== undefined) result.adminNote = adminNote
|
||||
return result
|
||||
}
|
||||
|
||||
export function validateQueryKeys(url: URL, allowed: readonly string[]): void {
|
||||
const allowedSet = new Set(allowed)
|
||||
for (const key of url.searchParams.keys()) {
|
||||
if (!allowedSet.has(key)) throw new AdminPublicError(400, 'unexpected_query_parameter')
|
||||
}
|
||||
}
|
||||
|
||||
export function parsePagination(url: URL): { page: number; limit: number; from: number; to: number } {
|
||||
const pageRaw = url.searchParams.get('page') ?? '1'
|
||||
const limitRaw = url.searchParams.get('limit') ?? '20'
|
||||
if (!/^\d+$/.test(pageRaw) || !/^\d+$/.test(limitRaw)) {
|
||||
throw new AdminPublicError(400, 'invalid_pagination')
|
||||
}
|
||||
const page = Number(pageRaw)
|
||||
const limit = Number(limitRaw)
|
||||
if (!Number.isSafeInteger(page) || page < 1 || page > 1_000_000 ||
|
||||
!Number.isSafeInteger(limit) || limit < 1 || limit > 100) {
|
||||
throw new AdminPublicError(400, 'invalid_pagination')
|
||||
}
|
||||
const from = (page - 1) * limit
|
||||
return { page, limit, from, to: from + limit - 1 }
|
||||
}
|
||||
|
||||
export function optionalEnum<T extends string>(
|
||||
value: string | null,
|
||||
allowed: ReadonlySet<T>,
|
||||
code: string,
|
||||
): T | undefined {
|
||||
if (!value) return undefined
|
||||
if (!allowed.has(value as T)) throw new AdminPublicError(400, code)
|
||||
return value as T
|
||||
}
|
||||
|
||||
export function managedRoleFilter(value: string | null): ManagedRole | undefined {
|
||||
return optionalEnum(value, ROLE_VALUES, 'invalid_role_filter')
|
||||
}
|
||||
|
||||
export function subscriptionTierFilter(value: string | null): SubscriptionTier | undefined {
|
||||
return optionalEnum(value, TIER_VALUES, 'invalid_tier_filter')
|
||||
}
|
||||
|
||||
export function subscriptionStatusFilter(value: string | null): SubscriptionStatus | undefined {
|
||||
return optionalEnum(value, STATUS_VALUES, 'invalid_status_filter')
|
||||
}
|
||||
|
||||
export function parseSearch(value: string | null): string | undefined {
|
||||
if (value === null || value === '') return undefined
|
||||
const normalized = value.trim()
|
||||
if (!normalized || normalized.length > 100 ||
|
||||
Array.from(normalized).some((character) => character.charCodeAt(0) < 32)) {
|
||||
throw new AdminPublicError(400, 'invalid_search')
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function parsePositiveInteger(value: string | null, code: string): number | undefined {
|
||||
if (value === null) return undefined
|
||||
if (!/^[1-9]\d{0,9}$/.test(value)) throw new AdminPublicError(400, code)
|
||||
const parsed = Number(value)
|
||||
if (!Number.isSafeInteger(parsed)) throw new AdminPublicError(400, code)
|
||||
return parsed
|
||||
}
|
||||
|
||||
export function parseIsoDate(value: string | null, code: string): string | undefined {
|
||||
if (value === null || value === '') return undefined
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) throw new AdminPublicError(400, code)
|
||||
const timestamp = Date.parse(`${value}T00:00:00Z`)
|
||||
if (!Number.isFinite(timestamp) || new Date(timestamp).toISOString().slice(0, 10) !== value) {
|
||||
throw new AdminPublicError(400, code)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const SENSITIVE_AUDIT_KEYS = new Set([
|
||||
'api_key',
|
||||
'password',
|
||||
'payload',
|
||||
'payload_digest',
|
||||
'payple_payer_id',
|
||||
'payple_pay_oid',
|
||||
'provider_event_id',
|
||||
'provider_resource_id',
|
||||
'raw_payload',
|
||||
'secret',
|
||||
'token',
|
||||
])
|
||||
|
||||
function sanitizeAuditValue(value: unknown, depth: number): unknown {
|
||||
if (depth > 8) return '[REDACTED]'
|
||||
if (Array.isArray(value)) return value.map((item) => sanitizeAuditValue(item, depth + 1))
|
||||
if (!value || typeof value !== 'object') return value
|
||||
|
||||
const sanitized: Record<string, unknown> = {}
|
||||
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (SENSITIVE_AUDIT_KEYS.has(key.toLowerCase())) continue
|
||||
sanitized[key] = sanitizeAuditValue(item, depth + 1)
|
||||
}
|
||||
return sanitized
|
||||
}
|
||||
|
||||
export function sanitizeAuditRecord(value: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
...value,
|
||||
before_data: sanitizeAuditValue(value.before_data, 0),
|
||||
after_data: sanitizeAuditValue(value.after_data, 0),
|
||||
}
|
||||
}
|
||||
|
||||
interface RpcErrorLike {
|
||||
code?: string | null
|
||||
message?: string | null
|
||||
}
|
||||
|
||||
export function adminRpcError(error: RpcErrorLike): AdminPublicError {
|
||||
const message = error.message ?? ''
|
||||
const mappings: Array<[string, number, string]> = [
|
||||
['admin_identity_not_linked', 403, 'admin_identity_not_linked'],
|
||||
['insufficient_admin_role', 403, 'admin_forbidden'],
|
||||
['super_admin_required_for_privileged_role', 403, 'super_admin_required'],
|
||||
['admin_role_required', 403, 'admin_role_required'],
|
||||
['target_user_not_found', 404, 'user_not_found'],
|
||||
['subscription_not_found', 404, 'subscription_not_found'],
|
||||
['subscription_already_exists', 409, 'subscription_already_exists'],
|
||||
['idempotency_key_reused_with_different_request', 409, 'idempotency_conflict'],
|
||||
['operation_in_progress', 409, 'operation_in_progress'],
|
||||
['cannot_demote_last_super_admin', 409, 'last_super_admin_protected'],
|
||||
['memo_must_be_3_to_1000_characters', 400, 'invalid_memo'],
|
||||
['invalid_', 400, 'invalid_request'],
|
||||
['tier_required', 400, 'tier_required'],
|
||||
]
|
||||
for (const [fragment, status, code] of mappings) {
|
||||
if (message.includes(fragment)) return new AdminPublicError(status, code)
|
||||
}
|
||||
if (error.code === '42501') return new AdminPublicError(403, 'admin_forbidden')
|
||||
if (error.code === 'P0002') return new AdminPublicError(404, 'resource_not_found')
|
||||
if (error.code === '23505' || error.code === '23514' || error.code === '55P03') {
|
||||
return new AdminPublicError(409, 'admin_operation_conflict')
|
||||
}
|
||||
if (error.code === '22023') return new AdminPublicError(400, 'invalid_request')
|
||||
return new AdminPublicError(500, 'admin_operation_failed')
|
||||
}
|
||||
|
||||
export function validateRoleRpcResult(
|
||||
value: unknown,
|
||||
expected: RoleChangeRequest,
|
||||
): Record<string, unknown> {
|
||||
const result = recordValue(value)
|
||||
if (result.success !== true || result.userId !== expected.userId || result.newRole !== expected.newRole) {
|
||||
throw new AdminPublicError(502, 'invalid_admin_rpc_response')
|
||||
}
|
||||
return { success: true, userId: expected.userId, newRole: expected.newRole }
|
||||
}
|
||||
|
||||
export function validateSubscriptionRpcResult(
|
||||
value: unknown,
|
||||
expected: SubscriptionMutationRequest,
|
||||
): Record<string, unknown> {
|
||||
const result = recordValue(value)
|
||||
const subscription = recordValue(result.subscription)
|
||||
if (result.success !== true || subscription.user_id !== expected.userId ||
|
||||
!TIER_VALUES.has(subscription.tier as SubscriptionTier) ||
|
||||
!STATUS_VALUES.has(subscription.status as SubscriptionStatus)) {
|
||||
throw new AdminPublicError(502, 'invalid_admin_rpc_response')
|
||||
}
|
||||
return { success: true, subscription }
|
||||
}
|
||||
|
||||
function isStatusError(error: unknown): error is { status: number } {
|
||||
return !!error && typeof error === 'object' &&
|
||||
'status' in error && typeof (error as { status?: unknown }).status === 'number'
|
||||
}
|
||||
|
||||
export function publicAdminError(error: unknown): AdminPublicError {
|
||||
if (error instanceof AdminPublicError) return error
|
||||
if (isStatusError(error)) {
|
||||
if (error.status === 401) return new AdminPublicError(401, 'authentication_required')
|
||||
if (error.status === 403) return new AdminPublicError(403, 'admin_forbidden')
|
||||
}
|
||||
return new AdminPublicError(500, 'internal_error')
|
||||
}
|
||||
|
||||
export function adminJsonResponse(
|
||||
body: Record<string, unknown>,
|
||||
corsHeaders: Record<string, string>,
|
||||
status = 200,
|
||||
): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function adminErrorResponse(error: unknown, corsHeaders: Record<string, string>): Response {
|
||||
const publicError = publicAdminError(error)
|
||||
return adminJsonResponse({ error: publicError.code }, corsHeaders, publicError.status)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue