Files
Moheeb ZaraandClaude Opus 4.8 1aa0cd818a fix(comms): send messages via a server action, derive identity server-side (SoC)
The chat composer inserted comms_messages straight from the browser with
CLIENT-SUPPLIED user_id/display_name/handle/space_id — a member could forge
another person's name/handle. Add a sendMessage server action that derives
identity from the session, verifies the channel belongs to the caller's space,
validates content, and logs the send. The optimistic insert + realtime
subscription are preserved, so latency is unchanged. loadMessages stays on the
browser client (the realtime subscription needs it) with a comment noting it.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-08-04 19:10:42 -07:00

102 lines
4.2 KiB
TypeScript

import { z } from 'zod'
import { INVITE_ROLES } from '../invite-logic'
import { flexibleDateTime } from './primitives'
export const createForumThreadSchema = z.object({
title: z.string().min(1, 'Title is required').max(200),
body: z.string().max(20000).optional().nullable(),
category: z.string().min(1).max(50).optional(),
})
export const updateForumThreadSchema = z.object({
title: z.string().min(1).max(200).optional(),
body: z.string().max(20000).optional().nullable(),
category: z.string().min(1).max(50).optional(),
pinned: z.boolean().optional(),
locked: z.boolean().optional(),
})
export const commentEntityTypeSchema = z.enum(['forum_thread', 'proposal', 'incident', 'policy'])
export const createCommentSchema = z.object({
entity_type: commentEntityTypeSchema,
entity_id: z.string().uuid('Invalid entity ID'),
body: z.string().min(1, 'Comment is required').max(10000),
parent_id: z.string().uuid().optional().nullable(),
})
export const updateCommentSchema = z.object({
body: z.string().min(1).max(10000),
})
export const createTierSchema = z.object({
slug: z.string().min(1).max(50).regex(/^[a-z0-9][a-z0-9_-]{0,49}$/, 'Slug must be lowercase letters, numbers, _ or -'),
name: z.string().min(1).max(100),
description: z.string().max(2000).optional().nullable(),
monthly_price_cents: z.number().int().min(0).max(100_000_000),
billing_cadence: z.enum(['monthly', 'quarterly', 'annual', 'one_time', 'custom']).default('monthly'),
sort_order: z.number().int().min(0).max(100000).optional(),
})
export const updateTierSchema = z.object({
name: z.string().min(1).max(100).optional(),
description: z.string().max(2000).optional().nullable(),
monthly_price_cents: z.number().int().min(0).max(100_000_000).optional(),
billing_cadence: z.enum(['monthly', 'quarterly', 'annual', 'one_time', 'custom']).optional(),
sort_order: z.number().int().min(0).max(100000).optional(),
is_archived: z.boolean().optional(),
})
export const upsertRoleLabelSchema = z.object({
role: z.enum(['admin', 'board', 'treasurer', 'member', 'associate']),
display_name: z.string().max(100).optional().nullable(),
description: z.string().max(500).optional().nullable(),
color: z.string().max(20).optional().nullable(),
sort_order: z.number().int().min(0).max(100000).optional(),
})
export const createCustomRoleSchema = z.object({
slug: z.string().min(1).max(50).regex(/^[a-z0-9][a-z0-9_-]{0,49}$/, 'Slug must be lowercase letters, numbers, _ or -'),
name: z.string().min(1).max(100),
description: z.string().max(500).optional().nullable(),
color: z.string().max(20).optional().nullable(),
sort_order: z.number().int().min(0).max(100000).optional(),
})
export const updateCustomRoleSchema = z.object({
name: z.string().min(1).max(100).optional(),
description: z.string().max(500).optional().nullable(),
color: z.string().max(20).optional().nullable(),
sort_order: z.number().int().min(0).max(100000).optional(),
})
export const createInviteSchema = z.object({
code: z.string().min(4).max(32).regex(/^[A-Z0-9-]+$/, 'Code must be uppercase letters, numbers, or hyphens').optional(),
label: z.string().max(100).optional().nullable(),
expires_at: flexibleDateTime(),
max_uses: z.number().int().min(1).max(100000).optional().nullable(),
is_enabled: z.boolean().optional(),
role: z.enum(INVITE_ROLES).optional().default('member'),
})
export const updateInviteSchema = z.object({
label: z.string().max(100).optional().nullable(),
expires_at: flexibleDateTime(),
max_uses: z.number().int().min(1).max(100000).optional().nullable(),
is_enabled: z.boolean().optional(),
role: z.enum(INVITE_ROLES).optional(),
})
export const createChannelSchema = z.object({
name: z.string().min(1).max(50).regex(/^[a-z0-9][a-z0-9-]{0,49}$/, 'Channel names must be lowercase letters, numbers, or hyphens'),
description: z.string().max(500).optional().nullable(),
channel_type: z.enum(['general', 'area', 'ops', 'project']).default('general'),
})
// Only the channel and the text are client-supplied; sender identity and
// space_id are derived server-side from the session (never trusted here).
export const sendMessageSchema = z.object({
channel_id: z.string().uuid('Invalid channel ID'),
content: z.string().min(1, 'Message is required').max(4000),
})