refactor: quality workflow, docs, tests

This commit is contained in:
Esdras Renan 2025-10-16 19:14:46 -03:00
parent a9caf36b01
commit 68ace0a858
27 changed files with 758 additions and 330 deletions

View file

@ -1,6 +1,4 @@
import { randomBytes } from "crypto"
import { ConvexHttpClient } from "convex/browser"
import { Prisma } from "@prisma/client"
import { api } from "@/convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
@ -8,6 +6,7 @@ import { env } from "@/lib/env"
import { normalizeSlug, slugify } from "@/lib/slug"
import { prisma } from "@/lib/prisma"
import { createCorsPreflight, jsonWithCors } from "@/server/cors"
import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client"
export const runtime = "nodejs"
@ -33,10 +32,7 @@ function extractSecret(request: Request, url: URL): string | null {
}
async function ensureConvexCompany(params: { tenantId: string; slug: string; name: string; provisioningCode: string }) {
if (!env.NEXT_PUBLIC_CONVEX_URL) {
throw new Error("Convex não configurado")
}
const client = new ConvexHttpClient(env.NEXT_PUBLIC_CONVEX_URL)
const client = createConvexClient()
await client.mutation(api.companies.ensureProvisioned, params)
}
@ -161,12 +157,19 @@ export async function POST(request: Request) {
},
}))
await ensureConvexCompany({
tenantId,
slug: company.slug,
name: company.name,
provisioningCode: company.provisioningCode,
})
try {
await ensureConvexCompany({
tenantId,
slug: company.slug,
name: company.name,
provisioningCode: company.provisioningCode,
})
} catch (error) {
if (error instanceof ConvexConfigurationError) {
return jsonWithCors({ error: error.message }, 500, origin, CORS_METHODS)
}
throw error
}
return jsonWithCors(
{
@ -188,12 +191,19 @@ export async function POST(request: Request) {
try {
const fallback = await prisma.company.findFirst({ where: { tenantId, slug: normalizedSlug } })
if (fallback) {
await ensureConvexCompany({
tenantId,
slug: fallback.slug,
name: fallback.name,
provisioningCode: fallback.provisioningCode,
})
try {
await ensureConvexCompany({
tenantId,
slug: fallback.slug,
name: fallback.name,
provisioningCode: fallback.provisioningCode,
})
} catch (error) {
if (error instanceof ConvexConfigurationError) {
return jsonWithCors({ error: error.message }, 500, origin, CORS_METHODS)
}
throw error
}
return jsonWithCors(
{
company: {

View file

@ -1,9 +1,8 @@
import { z } from "zod"
import { ConvexHttpClient } from "convex/browser"
import { api } from "@/convex/_generated/api"
import { env } from "@/lib/env"
import { createCorsPreflight, jsonWithCors } from "@/server/cors"
import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client"
const heartbeatSchema = z.object({
machineToken: z.string().min(1),
@ -28,13 +27,19 @@ export async function OPTIONS(request: Request) {
}
export async function POST(request: Request) {
const origin = request.headers.get("origin")
if (request.method !== "POST") {
return jsonWithCors({ error: "Método não permitido" }, 405, request.headers.get("origin"), CORS_METHODS)
return jsonWithCors({ error: "Método não permitido" }, 405, origin, CORS_METHODS)
}
const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
if (!convexUrl) {
return jsonWithCors({ error: "Convex não configurado" }, 500, request.headers.get("origin"), CORS_METHODS)
let client
try {
client = createConvexClient()
} catch (error) {
if (error instanceof ConvexConfigurationError) {
return jsonWithCors({ error: error.message }, 500, origin, CORS_METHODS)
}
throw error
}
let payload
@ -45,19 +50,17 @@ export async function POST(request: Request) {
return jsonWithCors(
{ error: "Payload inválido", details: error instanceof Error ? error.message : String(error) },
400,
request.headers.get("origin"),
origin,
CORS_METHODS
)
}
const client = new ConvexHttpClient(convexUrl)
try {
const response = await client.mutation(api.machines.heartbeat, payload)
return jsonWithCors(response, 200, request.headers.get("origin"), CORS_METHODS)
return jsonWithCors(response, 200, origin, CORS_METHODS)
} catch (error) {
console.error("[machines.heartbeat] Falha ao registrar heartbeat", error)
const details = error instanceof Error ? error.message : String(error)
return jsonWithCors({ error: "Falha ao registrar heartbeat", details }, 500, request.headers.get("origin"), CORS_METHODS)
return jsonWithCors({ error: "Falha ao registrar heartbeat", details }, 500, origin, CORS_METHODS)
}
}

View file

@ -1,9 +1,8 @@
import { z } from "zod"
import { ConvexHttpClient } from "convex/browser"
import { api } from "@/convex/_generated/api"
import { env } from "@/lib/env"
import { createCorsPreflight, jsonWithCors } from "@/server/cors"
import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client"
const tokenModeSchema = z.object({
machineToken: z.string().min(1),
@ -41,9 +40,16 @@ export async function OPTIONS(request: Request) {
}
export async function POST(request: Request) {
const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
if (!convexUrl) {
return jsonWithCors({ error: "Convex não configurado" }, 500, request.headers.get("origin"), CORS_METHODS)
const origin = request.headers.get("origin")
let client
try {
client = createConvexClient()
} catch (error) {
if (error instanceof ConvexConfigurationError) {
return jsonWithCors({ error: error.message }, 500, origin, CORS_METHODS)
}
throw error
}
let raw: unknown
@ -53,13 +59,11 @@ export async function POST(request: Request) {
return jsonWithCors(
{ error: "Payload inválido", details: error instanceof Error ? error.message : String(error) },
400,
request.headers.get("origin"),
origin,
CORS_METHODS
)
}
const client = new ConvexHttpClient(convexUrl)
// Modo A: com token da máquina (usa heartbeat para juntar inventário)
const tokenParsed = tokenModeSchema.safeParse(raw)
if (tokenParsed.success) {
@ -71,11 +75,11 @@ export async function POST(request: Request) {
metrics: tokenParsed.data.metrics,
inventory: tokenParsed.data.inventory,
})
return jsonWithCors({ ok: true, machineId: result.machineId, expiresAt: result.expiresAt }, 200, request.headers.get("origin"), CORS_METHODS)
return jsonWithCors({ ok: true, machineId: result.machineId, expiresAt: result.expiresAt }, 200, origin, CORS_METHODS)
} catch (error) {
console.error("[machines.inventory:token] Falha ao atualizar inventário", error)
const details = error instanceof Error ? error.message : String(error)
return jsonWithCors({ error: "Falha ao atualizar inventário", details }, 500, request.headers.get("origin"), CORS_METHODS)
return jsonWithCors({ error: "Falha ao atualizar inventário", details }, 500, origin, CORS_METHODS)
}
}
@ -90,16 +94,16 @@ export async function POST(request: Request) {
macAddresses: provParsed.data.macAddresses,
serialNumbers: provParsed.data.serialNumbers,
inventory: provParsed.data.inventory,
metrics: provParsed.data.metrics,
registeredBy: provParsed.data.registeredBy ?? "agent:inventory",
metrics: provParsed.data.metrics,
registeredBy: provParsed.data.registeredBy ?? "agent:inventory",
})
return jsonWithCors({ ok: true, machineId: result.machineId, status: result.status }, 200, request.headers.get("origin"), CORS_METHODS)
return jsonWithCors({ ok: true, machineId: result.machineId, status: result.status }, 200, origin, CORS_METHODS)
} catch (error) {
console.error("[machines.inventory:prov] Falha ao fazer upsert de inventário", error)
const details = error instanceof Error ? error.message : String(error)
return jsonWithCors({ error: "Falha ao fazer upsert de inventário", details }, 500, request.headers.get("origin"), CORS_METHODS)
return jsonWithCors({ error: "Falha ao fazer upsert de inventário", details }, 500, origin, CORS_METHODS)
}
}
return jsonWithCors({ error: "Formato de payload não suportado" }, 400, request.headers.get("origin"), CORS_METHODS)
return jsonWithCors({ error: "Formato de payload não suportado" }, 400, origin, CORS_METHODS)
}

View file

@ -1,9 +1,7 @@
import { ConvexHttpClient } from "convex/browser"
import { api } from "@/convex/_generated/api"
import { prisma } from "@/lib/prisma"
import { env } from "@/lib/env"
import { createCorsPreflight, jsonWithCors } from "@/server/cors"
import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client"
export const runtime = "nodejs"
@ -54,16 +52,18 @@ export async function POST(request: Request) {
return jsonWithCors({ error: "Código de provisionamento inválido" }, 404, origin, CORS_METHODS)
}
if (env.NEXT_PUBLIC_CONVEX_URL) {
try {
const client = new ConvexHttpClient(env.NEXT_PUBLIC_CONVEX_URL)
await client.mutation(api.companies.ensureProvisioned, {
tenantId: company.tenantId,
slug: company.slug,
name: company.name,
provisioningCode: company.provisioningCode,
})
} catch (error) {
try {
const client = createConvexClient()
await client.mutation(api.companies.ensureProvisioned, {
tenantId: company.tenantId,
slug: company.slug,
name: company.name,
provisioningCode: company.provisioningCode,
})
} catch (error) {
if (error instanceof ConvexConfigurationError) {
console.warn("[machines.provisioning] Convex não configurado; ignorando sincronização de empresa.")
} else {
console.error("[machines.provisioning] Falha ao sincronizar empresa no Convex", error)
}
}

View file

@ -1,13 +1,12 @@
import { z } from "zod"
import { ConvexHttpClient } from "convex/browser"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { env } from "@/lib/env"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { ensureCollaboratorAccount, ensureMachineAccount } from "@/server/machines-auth"
import { createCorsPreflight, jsonWithCors } from "@/server/cors"
import { prisma } from "@/lib/prisma"
import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client"
const registerSchema = z
.object({
@ -42,13 +41,19 @@ export async function OPTIONS(request: Request) {
}
export async function POST(request: Request) {
const origin = request.headers.get("origin")
if (request.method !== "POST") {
return jsonWithCors({ error: "Método não permitido" }, 405, request.headers.get("origin"), CORS_METHODS)
return jsonWithCors({ error: "Método não permitido" }, 405, origin, CORS_METHODS)
}
const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
if (!convexUrl) {
return jsonWithCors({ error: "Convex não configurado" }, 500, request.headers.get("origin"), CORS_METHODS)
let client
try {
client = createConvexClient()
} catch (error) {
if (error instanceof ConvexConfigurationError) {
return jsonWithCors({ error: error.message }, 500, origin, CORS_METHODS)
}
throw error
}
let payload
@ -59,12 +64,11 @@ export async function POST(request: Request) {
return jsonWithCors(
{ error: "Payload inválido", details: error instanceof Error ? error.message : String(error) },
400,
request.headers.get("origin"),
origin,
CORS_METHODS
)
}
const client = new ConvexHttpClient(convexUrl)
try {
const provisioningCode = payload.provisioningCode.trim().toLowerCase()
const companyRecord = await prisma.company.findFirst({
@ -76,7 +80,7 @@ export async function POST(request: Request) {
return jsonWithCors(
{ error: "Código de provisionamento inválido" },
404,
request.headers.get("origin"),
origin,
CORS_METHODS
)
}
@ -89,7 +93,7 @@ export async function POST(request: Request) {
return jsonWithCors(
{ error: "Informe os dados do colaborador/gestor ao definir o perfil de acesso." },
400,
request.headers.get("origin"),
origin,
CORS_METHODS
)
}
@ -195,7 +199,7 @@ export async function POST(request: Request) {
collaborator: collaborator ?? null,
},
{ status: 201 },
request.headers.get("origin"),
origin,
CORS_METHODS
)
} catch (error) {
@ -207,6 +211,6 @@ export async function POST(request: Request) {
const isConvexError = msg.includes("convexerror")
const status = isInvalidCode ? 401 : isCompanyNotFound ? 404 : isConvexError ? 400 : 500
const payload = { error: "Falha ao provisionar máquina", details }
return jsonWithCors(payload, status, request.headers.get("origin"), CORS_METHODS)
return jsonWithCors(payload, status, origin, CORS_METHODS)
}
}

View file

@ -1,105 +1,43 @@
import { NextRequest, NextResponse } from "next/server"
import { cookies } from "next/headers"
import { ConvexHttpClient } from "convex/browser"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { env } from "@/lib/env"
import { assertAuthenticatedSession } from "@/lib/auth-server"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
const MACHINE_CTX_COOKIE = "machine_ctx"
import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client"
import {
MACHINE_CTX_COOKIE,
extractCollaboratorFromMetadata,
parseMachineCookie,
serializeMachineCookie,
type CollaboratorMetadata,
type MachineContextCookiePayload,
} from "@/server/machines/context"
// Força runtime Node.js para leitura consistente de cookies de sessão
export const runtime = "nodejs"
type CollaboratorMetadata = {
email: string
name: string | null
role: "collaborator" | "manager" | null
}
function decodeMachineCookie(value: string) {
try {
const json = Buffer.from(value, "base64url").toString("utf8")
return JSON.parse(json) as {
machineId: string
persona: string | null
assignedUserId: string | null
assignedUserEmail: string | null
assignedUserName: string | null
assignedUserRole: string | null
}
} catch {
return null
}
}
function encodeMachineCookie(payload: {
machineId: string
persona: string | null
assignedUserId: string | null
assignedUserEmail: string | null
assignedUserName: string | null
assignedUserRole: string | null
}) {
return Buffer.from(JSON.stringify(payload)).toString("base64url")
}
function extractCollaboratorFromMetadata(metadata: unknown): CollaboratorMetadata | null {
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
return null
}
const record = metadata as Record<string, unknown>
const raw = record["collaborator"]
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return null
}
const base = raw as Record<string, unknown>
const emailValue = base["email"]
if (typeof emailValue !== "string") {
return null
}
const email = emailValue.trim().toLowerCase()
if (!email) {
return null
}
const nameValue = base["name"]
const roleValue = base["role"]
const name = typeof nameValue === "string" ? (nameValue.trim() || null) : null
const normalizedRole =
typeof roleValue === "string" ? roleValue.trim().toLowerCase() : null
const role =
normalizedRole === "manager"
? "manager"
: normalizedRole === "collaborator"
? "collaborator"
: null
return {
email,
name,
role,
}
}
export async function GET(request: NextRequest) {
const session = await assertAuthenticatedSession()
if (!session || session.user?.role !== "machine") {
return NextResponse.json({ error: "Sessão de máquina não encontrada." }, { status: 403 })
}
const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
if (!convexUrl) {
return NextResponse.json({ error: "Convex não configurado." }, { status: 500 })
let client
try {
client = createConvexClient()
} catch (error) {
if (error instanceof ConvexConfigurationError) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
throw error
}
const client = new ConvexHttpClient(convexUrl)
const cookieStore = await cookies()
const cookieValue = cookieStore.get(MACHINE_CTX_COOKIE)?.value ?? null
const decoded = cookieValue ? decodeMachineCookie(cookieValue) : null
const decoded = parseMachineCookie(cookieValue)
let machineId: Id<"machines"> | null = decoded?.machineId ? (decoded.machineId as Id<"machines">) : null
if (!machineId) {
@ -136,7 +74,7 @@ export async function GET(request: NextRequest) {
authEmail: string | null
}
const metadataCollaborator = extractCollaboratorFromMetadata(context.metadata)
const metadataCollaborator: CollaboratorMetadata | null = extractCollaboratorFromMetadata(context.metadata)
let ensuredAssignedUserId = context.assignedUserId
let ensuredAssignedUserEmail = context.assignedUserEmail ?? metadataCollaborator?.email ?? null
@ -200,13 +138,13 @@ export async function GET(request: NextRequest) {
ensuredPersona ??
(ensuredAssignedUserRole ? ensuredAssignedUserRole.toLowerCase() : null)
const responsePayload = {
const responsePayload: MachineContextCookiePayload = {
machineId: context.id,
persona: resolvedPersona,
assignedUserId: ensuredAssignedUserId,
assignedUserEmail: ensuredAssignedUserEmail,
assignedUserName: ensuredAssignedUserName,
assignedUserRole: ensuredAssignedUserRole,
persona: resolvedPersona ?? null,
assignedUserId: ensuredAssignedUserId ?? null,
assignedUserEmail: ensuredAssignedUserEmail ?? null,
assignedUserName: ensuredAssignedUserName ?? null,
assignedUserRole: ensuredAssignedUserRole ?? null,
}
const response = NextResponse.json({
@ -224,7 +162,7 @@ export async function GET(request: NextRequest) {
const isSecure = request.nextUrl.protocol === "https:"
response.cookies.set({
name: MACHINE_CTX_COOKIE,
value: encodeMachineCookie(responsePayload),
value: serializeMachineCookie(responsePayload),
httpOnly: true,
sameSite: "lax",
secure: isSecure,

View file

@ -2,6 +2,11 @@ import { NextResponse } from "next/server"
import { z } from "zod"
import { createMachineSession } from "@/server/machines-session"
import { applyCorsHeaders, createCorsPreflight, jsonWithCors } from "@/server/cors"
import {
MACHINE_CTX_COOKIE,
serializeMachineCookie,
type MachineContextCookiePayload,
} from "@/server/machines/context"
const sessionSchema = z.object({
machineToken: z.string().min(1),
@ -96,18 +101,18 @@ export async function POST(request: Request) {
response.cookies.set(name, value, options)
}
const machineCookiePayload = {
const machineCookiePayload: MachineContextCookiePayload = {
machineId: session.machine.id,
persona: session.machine.persona,
assignedUserId: session.machine.assignedUserId,
assignedUserEmail: session.machine.assignedUserEmail,
assignedUserName: session.machine.assignedUserName,
assignedUserRole: session.machine.assignedUserRole,
persona: session.machine.persona ?? null,
assignedUserId: session.machine.assignedUserId ?? null,
assignedUserEmail: session.machine.assignedUserEmail ?? null,
assignedUserName: session.machine.assignedUserName ?? null,
assignedUserRole: session.machine.assignedUserRole ?? null,
}
const isSecure = new URL(request.url).protocol === "https:"
response.cookies.set({
name: "machine_ctx",
value: Buffer.from(JSON.stringify(machineCookiePayload)).toString("base64url"),
name: MACHINE_CTX_COOKIE,
value: serializeMachineCookie(machineCookiePayload),
httpOnly: true,
sameSite: "lax",
secure: isSecure,

View file

@ -21,23 +21,30 @@ export function LoginPageClient() {
const { data: session, isPending } = useSession()
const callbackUrl = searchParams?.get("callbackUrl") ?? undefined
const [isHydrated, setIsHydrated] = useState(false)
const sessionUser = session?.user
const userId = sessionUser?.id ?? null
const normalizedRole = sessionUser?.role ? sessionUser.role.toLowerCase() : null
const persona = typeof sessionUser?.machinePersona === "string" ? sessionUser.machinePersona.toLowerCase() : null
useEffect(() => {
if (isPending) return
if (!session?.user) return
const role = (session.user.role ?? "").toLowerCase()
const persona = (session.user as any).machinePersona
? String((session.user as any).machinePersona).toLowerCase()
: null
if (!userId) return
const defaultDest =
role === "machine"
normalizedRole === "machine"
? persona === "manager"
? "/dashboard"
: "/portal/tickets"
: "/dashboard"
const destination = callbackUrl ?? defaultDest
router.replace(destination)
}, [callbackUrl, isPending, router, session?.user])
}, [
callbackUrl,
isPending,
normalizedRole,
persona,
router,
userId,
])
useEffect(() => {
setIsHydrated(true)

View file

@ -307,7 +307,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
})
setQueueSelection(ticket.queue ?? "")
setAssigneeSelection(ticket.assignee?.id ?? "")
}, [editing, ticket.category?.id, ticket.subcategory?.id, ticket.queue])
}, [editing, ticket.category?.id, ticket.subcategory?.id, ticket.queue, ticket.assignee?.id])
useEffect(() => {
if (!editing) return

View file

@ -0,0 +1,49 @@
"use client"
import { useCallback, useState } from "react"
import { Copy, Sparkles } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
interface CopyButtonProps {
value: string
onCopied?: () => void
}
export function CopyButton({ value, onCopied }: CopyButtonProps) {
const [copied, setCopied] = useState(false)
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(value)
setCopied(true)
onCopied?.()
setTimeout(() => setCopied(false), 2000)
} catch (error) {
console.error("Falha ao copiar código", error)
}
}, [onCopied, value])
return (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleCopy}
className="relative overflow-hidden border border-dashed border-slate-300 bg-white px-3 py-2 text-sm font-semibold text-neutral-700 transition-all hover:border-slate-400 hover:bg-white active:scale-[0.97]"
>
<span className="pointer-events-none absolute inset-0 rounded-md bg-neutral-900/5 opacity-0 transition-opacity duration-100 ease-out active:opacity-100" />
<span
className={cn(
"flex items-center gap-2 transition-all duration-200 ease-out",
copied ? "text-emerald-600" : "text-neutral-700"
)}
>
{copied ? <Sparkles className="size-3.5" /> : <Copy className="size-3.5" />}
{copied ? "Copiado!" : "Copiar código"}
</span>
<span className="sr-only">Copiar código de provisionamento</span>
</Button>
)
}

View file

@ -0,0 +1,21 @@
"use client"
import { cn } from "@/lib/utils"
export function Crossblur({ active }: { active: boolean }) {
return (
<span
className={cn(
"pointer-events-none absolute inset-0 overflow-hidden rounded-xl transition-opacity duration-200 ease-out",
active ? "opacity-100" : "opacity-0"
)}
>
<span
className={cn(
"absolute inset-[-40%] rounded-full bg-[radial-gradient(circle_at_center,_rgba(59,130,246,0.25),_transparent_70%)] blur-lg transition-transform duration-500 ease-in-out",
active ? "scale-[1.05] rotate-6" : "scale-100 -rotate-12"
)}
/>
</span>
)
}

View file

@ -0,0 +1,24 @@
import { ConvexHttpClient } from "convex/browser"
import { env } from "@/lib/env"
export class ConvexConfigurationError extends Error {
constructor(message = "Convex não configurado.") {
super(message)
this.name = "ConvexConfigurationError"
}
}
export function requireConvexUrl(): string {
const url = env.NEXT_PUBLIC_CONVEX_URL
if (!url) {
throw new ConvexConfigurationError()
}
return url
}
export function createConvexClient(): ConvexHttpClient {
const url = requireConvexUrl()
return new ConvexHttpClient(url)
}

View file

@ -0,0 +1,70 @@
import { z } from "zod"
export const MACHINE_CTX_COOKIE = "machine_ctx"
const machineCookieSchema = z.object({
machineId: z.string(),
persona: z.string().nullable().optional(),
assignedUserId: z.string().nullable().optional(),
assignedUserEmail: z.string().nullable().optional(),
assignedUserName: z.string().nullable().optional(),
assignedUserRole: z.string().nullable().optional(),
})
const collaboratorSchema = z
.object({
email: z.string().email(),
name: z.string().optional(),
role: z.string().optional(),
})
.transform(({ email, name, role }) => {
const trimmedRole = role?.trim().toLowerCase()
const normalizedRole =
trimmedRole === "manager"
? "manager"
: trimmedRole === "collaborator"
? "collaborator"
: null
const normalizedName = typeof name === "string" ? name.trim() || null : null
return {
email: email.trim().toLowerCase(),
name: normalizedName,
role: normalizedRole,
}
})
const metadataSchema = z
.object({
collaborator: collaboratorSchema,
})
.passthrough()
export type MachineContextCookiePayload = z.infer<typeof machineCookieSchema>
export type CollaboratorMetadata = z.output<typeof collaboratorSchema>
export function parseMachineCookie(value: string | null | undefined): MachineContextCookiePayload | null {
if (!value) return null
try {
const json = Buffer.from(value, "base64url").toString("utf8")
const parsed = JSON.parse(json)
return machineCookieSchema.parse(parsed)
} catch {
return null
}
}
export function serializeMachineCookie(payload: MachineContextCookiePayload): string {
return Buffer.from(JSON.stringify(machineCookieSchema.parse(payload))).toString("base64url")
}
export function extractCollaboratorFromMetadata(metadata: unknown): CollaboratorMetadata | null {
if (!metadata) return null
const parsed = metadataSchema.safeParse(metadata)
if (!parsed.success) {
return null
}
return parsed.data.collaborator
}