chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+259
View File
@@ -0,0 +1,259 @@
/**
* @vitest-environment node
*/
import type { AgentCard, Artifact, Message, Part, Task } from '@a2a-js/sdk'
import { Role, TaskState } from '@a2a-js/sdk'
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/core/security/input-validation.server', () => ({
validateUrlWithDNS: vi.fn(),
secureFetchWithPinnedIP: vi.fn(),
}))
import {
agentCardOutput,
buildUserMessage,
extractText,
isTaskResult,
messageOutput,
taskErrored,
taskOutput,
} from '@/lib/a2a/client'
function textPart(value: string): Part {
return { content: { $case: 'text', value }, metadata: undefined, filename: '', mediaType: '' }
}
function message(role: Role, parts: Part[]): Message {
return {
messageId: 'm-1',
contextId: 'ctx-1',
taskId: 'task-1',
role,
parts,
metadata: undefined,
extensions: [],
referenceTaskIds: [],
}
}
function artifact(name: string, description: string, parts: Part[]): Artifact {
return {
artifactId: 'a-1',
name,
description,
parts,
metadata: undefined,
extensions: [],
} as unknown as Artifact
}
function task(opts: {
state?: TaskState
statusMessage?: Message
hasStatus?: boolean
history?: Message[]
artifacts?: Artifact[]
}): Task {
const hasStatus = opts.hasStatus ?? (opts.state !== undefined || opts.statusMessage !== undefined)
return {
id: 'task-1',
contextId: 'ctx-1',
status: hasStatus
? {
state: opts.state ?? TaskState.TASK_STATE_UNSPECIFIED,
message: opts.statusMessage,
timestamp: undefined,
}
: undefined,
artifacts: opts.artifacts ?? [],
history: opts.history ?? [],
metadata: undefined,
}
}
describe('buildUserMessage', () => {
it('builds a text-only user message', () => {
const m = buildUserMessage({ text: 'hello' })
expect(m.role).toBe(Role.ROLE_USER)
expect(m.parts).toHaveLength(1)
expect(m.parts[0].content).toEqual({ $case: 'text', value: 'hello' })
expect(m.taskId).toBe('')
expect(m.contextId).toBe('')
})
it('passes through taskId and contextId', () => {
const m = buildUserMessage({ text: 'hi', taskId: 't', contextId: 'c' })
expect(m.taskId).toBe('t')
expect(m.contextId).toBe('c')
})
it('appends a data part for structured data', () => {
const data = { foo: 'bar' }
const m = buildUserMessage({ text: 'hi', data })
expect(m.parts).toHaveLength(2)
expect(m.parts[1].content).toEqual({ $case: 'data', value: data })
})
it('builds a raw file part from resolved bytes', () => {
const bytes = new TextEncoder().encode('hello')
const m = buildUserMessage({
text: 'hi',
files: [{ bytes, name: 'f.txt', mediaType: 'text/plain' }],
})
const content = m.parts[1].content
expect(content?.$case).toBe('raw')
if (content?.$case === 'raw') expect(Buffer.from(content.value).toString()).toBe('hello')
expect(m.parts[1].filename).toBe('f.txt')
expect(m.parts[1].mediaType).toBe('text/plain')
})
})
describe('extractText', () => {
it('joins text parts and ignores non-text parts', () => {
const m = message(Role.ROLE_AGENT, [
textPart('a'),
{ content: { $case: 'data', value: {} }, metadata: undefined, filename: '', mediaType: '' },
textPart('b'),
])
expect(extractText(m)).toBe('a\nb')
})
})
describe('isTaskResult', () => {
it('treats objects with a status key as tasks', () => {
expect(isTaskResult(task({ state: TaskState.TASK_STATE_COMPLETED }))).toBe(true)
})
it('treats messages as non-tasks', () => {
expect(isTaskResult(message(Role.ROLE_AGENT, [textPart('hi')]))).toBe(false)
})
})
describe('taskErrored', () => {
it.each([
[TaskState.TASK_STATE_FAILED, true],
[TaskState.TASK_STATE_REJECTED, true],
[TaskState.TASK_STATE_COMPLETED, false],
[TaskState.TASK_STATE_INPUT_REQUIRED, false],
[TaskState.TASK_STATE_AUTH_REQUIRED, false],
[TaskState.TASK_STATE_CANCELED, false],
[TaskState.TASK_STATE_WORKING, false],
])('state %s -> errored %s', (state, expected) => {
expect(taskErrored(task({ state }))).toBe(expected)
})
it('is false when status is missing', () => {
expect(taskErrored(task({ hasStatus: false }))).toBe(false)
})
})
describe('taskOutput', () => {
it('maps state to a friendly label', () => {
expect(taskOutput(task({ state: TaskState.TASK_STATE_COMPLETED })).state).toBe('completed')
expect(taskOutput(task({ state: TaskState.TASK_STATE_INPUT_REQUIRED })).state).toBe(
'input-required'
)
})
it('uses the latest agent message from history for content', () => {
const out = taskOutput(
task({
state: TaskState.TASK_STATE_COMPLETED,
history: [
message(Role.ROLE_USER, [textPart('q')]),
message(Role.ROLE_AGENT, [textPart('first')]),
message(Role.ROLE_AGENT, [textPart('latest')]),
],
})
)
expect(out.content).toBe('latest')
})
it('falls back to the status message when history has no agent text', () => {
const out = taskOutput(
task({
state: TaskState.TASK_STATE_INPUT_REQUIRED,
statusMessage: message(Role.ROLE_AGENT, [textPart('need more info')]),
})
)
expect(out.content).toBe('need more info')
})
it('maps artifacts to flattened output', () => {
const out = taskOutput(
task({
state: TaskState.TASK_STATE_COMPLETED,
artifacts: [artifact('report', 'the report', [textPart('body')])],
})
)
expect(out.artifacts).toEqual([{ name: 'report', description: 'the report', content: 'body' }])
})
it('carries task and context ids', () => {
const out = taskOutput(task({ state: TaskState.TASK_STATE_COMPLETED }))
expect(out.taskId).toBe('task-1')
expect(out.contextId).toBe('ctx-1')
})
})
describe('messageOutput', () => {
it('maps a direct message to a completed output', () => {
const out = messageOutput(message(Role.ROLE_AGENT, [textPart('done')]))
expect(out).toEqual({
content: 'done',
taskId: 'task-1',
contextId: 'ctx-1',
state: 'completed',
artifacts: [],
})
})
})
describe('agentCardOutput', () => {
function makeCard(fields: {
supportedInterfaces?: Array<{ url: string; protocolVersion: string }>
skills?: Array<{ id: string; name: string; description: string }>
}): AgentCard {
return {
name: 'Agent',
description: 'desc',
version: '1.2.3',
supportedInterfaces: fields.supportedInterfaces ?? [],
capabilities: undefined,
skills: fields.skills ?? [],
defaultInputModes: [],
defaultOutputModes: [],
} as unknown as AgentCard
}
it('falls back to the agent url when there are no interfaces', () => {
const out = agentCardOutput(makeCard({}), 'https://fallback.example/a2a')
expect(out.url).toBe('https://fallback.example/a2a')
expect(out.protocolVersion).toBe('')
expect(out.capabilities).toEqual({
streaming: false,
pushNotifications: false,
extendedAgentCard: false,
})
})
it('prefers the first supported interface', () => {
const out = agentCardOutput(
makeCard({
supportedInterfaces: [{ url: 'https://agent.example/rpc', protocolVersion: '1.0' }],
}),
'https://fallback.example/a2a'
)
expect(out.url).toBe('https://agent.example/rpc')
expect(out.protocolVersion).toBe('1.0')
})
it('maps skills to id, name, and description', () => {
const out = agentCardOutput(
makeCard({ skills: [{ id: 's1', name: 'Search', description: 'searches' }] }),
'https://fallback.example/a2a'
)
expect(out.skills).toEqual([{ id: 's1', name: 'Search', description: 'searches' }])
})
})
+394
View File
@@ -0,0 +1,394 @@
import {
AGENT_CARD_PATH,
type AgentCard,
type Artifact,
type Message,
type Part,
Role,
type Task,
TaskState,
taskStateToJSON,
} from '@a2a-js/sdk'
import {
type BeforeArgs,
type CallInterceptor,
type Client,
ClientFactory,
ClientFactoryOptions,
DefaultAgentCardResolver,
JsonRpcTransportFactory,
RestTransportFactory,
} from '@a2a-js/sdk/client'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import {
secureFetchWithPinnedIP,
validateUrlWithDNS,
} from '@/lib/core/security/input-validation.server'
const logger = createLogger('A2AClient')
/** Upper bound on a single agent HTTP response body, to bound memory on a hostile/large reply. */
const A2A_MAX_RESPONSE_BYTES = 10 * 1024 * 1024
/** Per-hop timeout for agent RPC calls; blocking sends may legitimately run long. */
const RPC_TIMEOUT_MS = 300_000
/** Shorter timeout for agent-card discovery so a slow or hanging agent fails fast. */
const CARD_RESOLUTION_TIMEOUT_MS = 30_000
/** How long a resolved agent card is reused before it is re-fetched. */
const CARD_CACHE_TTL_MS = 5 * 60 * 1000
/** Upper bound on cached cards; the oldest entry is evicted past this size. */
const CARD_CACHE_MAX_ENTRIES = 256
/** Well-known agent-card paths tried in order, ending with the URL itself. */
const CARD_CANDIDATE_PATHS = [AGENT_CARD_PATH, '/.well-known/agent.json', ''] as const
const agentCardCache = new Map<string, { card: AgentCard; expiresAt: number }>()
function getCachedCard(agentUrl: string): AgentCard | undefined {
const cached = agentCardCache.get(agentUrl)
if (!cached) return undefined
if (cached.expiresAt <= Date.now()) {
agentCardCache.delete(agentUrl)
return undefined
}
return cached.card
}
function cacheCard(agentUrl: string, card: AgentCard): void {
if (agentCardCache.size >= CARD_CACHE_MAX_ENTRIES) {
const oldest = agentCardCache.keys().next().value
if (oldest !== undefined) agentCardCache.delete(oldest)
}
agentCardCache.set(agentUrl, { card, expiresAt: Date.now() + CARD_CACHE_TTL_MS })
}
/** Attaches the `X-API-Key` header to every outgoing request. */
class ApiKeyInterceptor implements CallInterceptor {
constructor(private readonly apiKey: string) {}
before(args: BeforeArgs): Promise<void> {
args.options = {
...args.options,
serviceParameters: { ...args.options?.serviceParameters, 'X-API-Key': this.apiKey },
}
return Promise.resolve()
}
after(): Promise<void> {
return Promise.resolve()
}
}
/**
* Build a `fetch` bound to a DNS-validated, pinned IP so that calls to
* user-supplied agent URLs cannot be rebound to internal hosts (SSRF).
*
* Redirects are not followed: an authenticated agent call must not have its
* `X-API-Key` carried to a redirected host. A per-hop `timeout` and an optional
* caller `signal` bound how long the call can run and let the request abort
* propagate to the outbound connection.
*/
function createPinnedFetch(
resolvedIP: string,
config: { timeout: number; signal?: AbortSignal }
): typeof fetch {
return async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
const url = input instanceof Request ? input.url : input.toString()
const method = init?.method ?? (input instanceof Request ? input.method : undefined)
const rawHeaders = init?.headers ?? (input instanceof Request ? input.headers : undefined)
const headers =
rawHeaders instanceof Headers
? Object.fromEntries(rawHeaders.entries())
: Array.isArray(rawHeaders)
? Object.fromEntries(rawHeaders as string[][])
: (rawHeaders as Record<string, string> | undefined)
let body: string | Uint8Array | undefined
if (init?.body != null) {
if (typeof init.body === 'string' || init.body instanceof Uint8Array) {
body = init.body
} else if (init.body instanceof ArrayBuffer) {
body = new Uint8Array(init.body)
} else {
const text = await new Response(init.body as BodyInit).text()
if (text) body = text
}
} else if (input instanceof Request && !input.bodyUsed) {
const text = await input.text()
if (text) body = text
}
const callSignal =
config.signal ??
(init?.signal instanceof AbortSignal
? init.signal
: input instanceof Request && input.signal instanceof AbortSignal
? input.signal
: undefined)
const res = await secureFetchWithPinnedIP(url, resolvedIP, {
method,
headers,
body,
signal: callSignal,
timeout: config.timeout,
maxRedirects: 0,
maxResponseBytes: A2A_MAX_RESPONSE_BYTES,
})
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers: new Headers(res.headers.toRecord()),
})
}
}
/**
* Resolve (and cache) an agent card. The card is resolved by trying the SDK
* default well-known path, the legacy pre-0.3 path, and finally the URL itself
* (some agents serve the card at the same URL they serve requests). Resolution
* uses a short timeout so a hanging agent fails fast; successful cards are
* cached per URL so subsequent operations skip the discovery round-trips.
*/
async function resolveAgentCard(
agentUrl: string,
resolvedIP: string,
signal?: AbortSignal
): Promise<AgentCard> {
const cached = getCachedCard(agentUrl)
if (cached) return cached
const resolver = new DefaultAgentCardResolver({
fetchImpl: createPinnedFetch(resolvedIP, { timeout: CARD_RESOLUTION_TIMEOUT_MS, signal }),
})
let lastError: unknown
for (const path of CARD_CANDIDATE_PATHS) {
try {
const card = await resolver.resolve(agentUrl, path)
cacheCard(agentUrl, card)
return card
} catch (error) {
lastError = error
logger.debug('Agent card resolution failed', {
agentUrl,
path,
error: toError(error).message,
})
}
}
throw toError(lastError)
}
/**
* Create an A2A client for an external agent URL. The URL is validated against
* SSRF and its DNS pinned on every call; an optional API key is sent via
* `X-API-Key`. The caller `signal` aborts the outbound connection when the
* request is cancelled.
*/
export async function createA2AClient(
agentUrl: string,
apiKey?: string,
options: { signal?: AbortSignal } = {}
): Promise<Client> {
const validation = await validateUrlWithDNS(agentUrl, 'agentUrl')
if (!validation.isValid || !validation.resolvedIP) {
throw new Error(validation.error || 'Agent URL validation failed')
}
const { resolvedIP } = validation
const card = await resolveAgentCard(agentUrl, resolvedIP, options.signal)
const pinnedFetch = createPinnedFetch(resolvedIP, {
timeout: RPC_TIMEOUT_MS,
signal: options.signal,
})
const factory = new ClientFactory(
ClientFactoryOptions.createFrom(ClientFactoryOptions.default, {
transports: [
new JsonRpcTransportFactory({ fetchImpl: pinnedFetch }),
new RestTransportFactory({ fetchImpl: pinnedFetch }),
],
...(apiKey ? { clientConfig: { interceptors: [new ApiKeyInterceptor(apiKey)] } } : {}),
})
)
return factory.createFromAgentCard(card)
}
/** A file to attach to an outgoing message, resolved to raw bytes from Sim storage. */
export interface A2AFileInput {
bytes: Uint8Array
name: string
mediaType: string
}
function textPart(value: string): Part {
return { content: { $case: 'text', value }, metadata: undefined, filename: '', mediaType: '' }
}
function dataPart(value: unknown): Part {
return { content: { $case: 'data', value }, metadata: undefined, filename: '', mediaType: '' }
}
function filePart(file: A2AFileInput): Part {
return {
content: { $case: 'raw', value: Buffer.from(file.bytes) },
metadata: undefined,
filename: file.name,
mediaType: file.mediaType,
}
}
/** Construct a user `Message` with a text part plus optional data and file parts. */
export function buildUserMessage(opts: {
text: string
data?: unknown
files?: A2AFileInput[]
taskId?: string
contextId?: string
}): Message {
const parts: Part[] = [textPart(opts.text)]
if (opts.data !== undefined) parts.push(dataPart(opts.data))
for (const file of opts.files ?? []) parts.push(filePart(file))
return {
messageId: generateId(),
contextId: opts.contextId ?? '',
taskId: opts.taskId ?? '',
role: Role.ROLE_USER,
parts,
metadata: undefined,
extensions: [],
referenceTaskIds: [],
}
}
function partsText(parts: Part[]): string {
return parts
.map((part) => (part.content?.$case === 'text' ? part.content.value : ''))
.filter(Boolean)
.join('\n')
}
/** Concatenated text of all text parts in a message. */
export function extractText(message: Message): string {
return partsText(message.parts)
}
function latestAgentText(task: Task): string {
const lastAgentMessage = task.history.filter((message) => message.role === Role.ROLE_AGENT).at(-1)
if (lastAgentMessage) return extractText(lastAgentMessage)
// Interrupted states (input-required, auth-required) carry the agent's prompt
// in the status message rather than the history.
const statusMessage = task.status?.message
return statusMessage ? extractText(statusMessage) : ''
}
/** A flattened artifact for block output. */
export interface A2AArtifactOutput {
name: string
description: string
content: string
}
function mapArtifacts(artifacts: Artifact[]): A2AArtifactOutput[] {
return artifacts.map((artifact) => ({
name: artifact.name,
description: artifact.description,
content: partsText(artifact.parts),
}))
}
function taskStateLabel(state: TaskState): string {
return taskStateToJSON(state)
.replace(/^TASK_STATE_/, '')
.toLowerCase()
.replace(/_/g, '-')
}
/** A send result is a `Task` when it carries status; otherwise it is a `Message`. */
export function isTaskResult(result: Message | Task): result is Task {
return 'status' in result
}
/** Normalized task fields for block output. */
export interface A2ATaskOutput {
content: string
taskId: string
contextId: string
state: string
artifacts: A2AArtifactOutput[]
}
export function taskOutput(task: Task): A2ATaskOutput {
const state = task.status?.state ?? TaskState.TASK_STATE_UNSPECIFIED
return {
content: latestAgentText(task),
taskId: task.id,
contextId: task.contextId,
state: taskStateLabel(state),
artifacts: mapArtifacts(task.artifacts),
}
}
/** Normalized output for a direct (non-task) message reply. */
export function messageOutput(message: Message): A2ATaskOutput {
return {
content: extractText(message),
taskId: message.taskId,
contextId: message.contextId,
state: 'completed',
artifacts: [],
}
}
/**
* True when the task ended in a hard-failure state (failed or rejected). Used to
* decide whether a send surfaces as a block error. Interrupted states such as
* input-required/auth-required are not failures — the caller branches on `state`.
*/
export function taskErrored(task: Task): boolean {
const state = task.status?.state ?? TaskState.TASK_STATE_UNSPECIFIED
return state === TaskState.TASK_STATE_FAILED || state === TaskState.TASK_STATE_REJECTED
}
/** Flattened agent card fields for block output. */
export interface A2AAgentCardOutput {
name: string
description: string
url: string
version: string
protocolVersion: string
capabilities: { streaming: boolean; pushNotifications: boolean; extendedAgentCard: boolean }
skills: Array<{ id: string; name: string; description: string }>
defaultInputModes: string[]
defaultOutputModes: string[]
}
export function agentCardOutput(card: AgentCard, fallbackUrl: string): A2AAgentCardOutput {
const iface = card.supportedInterfaces.at(0)
return {
name: card.name,
description: card.description,
url: iface?.url ?? fallbackUrl,
version: card.version,
protocolVersion: iface?.protocolVersion ?? '',
capabilities: {
streaming: card.capabilities?.streaming ?? false,
pushNotifications: card.capabilities?.pushNotifications ?? false,
extendedAgentCard: card.capabilities?.extendedAgentCard ?? false,
},
skills: card.skills.map((skill) => ({
id: skill.id,
name: skill.name,
description: skill.description,
})),
defaultInputModes: card.defaultInputModes,
defaultOutputModes: card.defaultOutputModes,
}
}
+122
View File
@@ -0,0 +1,122 @@
/**
* Profound Analytics - Custom log integration
*
* Buffers HTTP request logs in memory and flushes them in batches to Profound's API.
* Runs in Node.js (proxy.ts on ECS), so module-level state persists across requests.
* @see https://docs.tryprofound.com/agent-analytics/custom
*/
import { createLogger } from '@sim/logger'
import { env } from '@/lib/core/config/env'
import { isHosted } from '@/lib/core/config/env-flags'
import { getClientIp } from '@/lib/core/utils/request'
import { getBaseDomain } from '@/lib/core/utils/urls'
const logger = createLogger('ProfoundAnalytics')
const FLUSH_INTERVAL_MS = 10_000
const MAX_BATCH_SIZE = 500
interface ProfoundLogEntry {
timestamp: string
method: string
host: string
path: string
status_code: number
ip: string
user_agent: string
query_params?: Record<string, string>
referer?: string
}
let buffer: ProfoundLogEntry[] = []
let flushTimer: NodeJS.Timeout | null = null
/**
* Returns true if Profound analytics is configured.
*/
export function isProfoundEnabled(): boolean {
return isHosted && Boolean(env.PROFOUND_API_KEY) && Boolean(env.PROFOUND_ENDPOINT)
}
/**
* Flushes buffered log entries to Profound's API.
*/
async function flush(): Promise<void> {
if (buffer.length === 0) return
const apiKey = env.PROFOUND_API_KEY
if (!apiKey) {
buffer = []
return
}
const endpoint = env.PROFOUND_ENDPOINT
if (!endpoint) {
buffer = []
return
}
const entries = buffer.splice(0, MAX_BATCH_SIZE)
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'x-api-key': apiKey,
'Content-Type': 'application/json',
},
body: JSON.stringify(entries),
})
if (!response.ok) {
logger.error(`Profound API returned ${response.status}`)
}
} catch (error) {
logger.error('Failed to flush logs to Profound', error)
}
}
function ensureFlushTimer(): void {
if (flushTimer) return
flushTimer = setInterval(() => {
flush().catch(() => {})
}, FLUSH_INTERVAL_MS)
flushTimer.unref()
}
/**
* Queues a request log entry for the next batch flush to Profound.
*/
export function sendToProfound(request: Request, statusCode: number): void {
if (!isProfoundEnabled()) return
try {
const url = new URL(request.url)
const queryParams: Record<string, string> = {}
url.searchParams.forEach((value, key) => {
queryParams[key] = value
})
buffer.push({
timestamp: new Date().toISOString(),
method: request.method,
host: getBaseDomain(),
path: url.pathname,
status_code: statusCode,
ip: (() => {
const resolved = getClientIp(request)
return resolved === 'unknown' ? '0.0.0.0' : resolved
})(),
user_agent: request.headers.get('user-agent') || '',
...(Object.keys(queryParams).length > 0 && { query_params: queryParams }),
...(request.headers.get('referer') && { referer: request.headers.get('referer')! }),
})
ensureFlushTimer()
if (buffer.length >= MAX_BATCH_SIZE) {
flush().catch(() => {})
}
} catch (error) {
logger.error('Failed to enqueue log entry', error)
}
}
+222
View File
@@ -0,0 +1,222 @@
/**
* Tests for API key authentication utilities.
*
* Tests cover:
* - API key format detection (legacy vs encrypted)
* - Authentication against stored keys
* - Key encryption and decryption
* - Display formatting
* - Edge cases
*/
import { randomBytes } from 'crypto'
import { createEncryptedApiKey, createLegacyApiKey } from '@sim/testing'
import { describe, expect, it, vi } from 'vitest'
const cryptoMock = vi.hoisted(() => ({
isEncryptedApiKeyFormat: (key: string) => key.startsWith('sk-sim-'),
isLegacyApiKeyFormat: (key: string) => key.startsWith('sim_') && !key.startsWith('sk-sim-'),
generateApiKey: () => `sim_${randomBytes(24).toString('base64url')}`,
generateEncryptedApiKey: () => `sk-sim-${randomBytes(24).toString('base64url')}`,
encryptApiKey: async (apiKey: string) => ({
encrypted: `mock-iv:${Buffer.from(apiKey).toString('hex')}:mock-tag`,
iv: 'mock-iv',
}),
decryptApiKey: async (encryptedValue: string) => {
if (!encryptedValue.includes(':') || encryptedValue.split(':').length !== 3) {
return { decrypted: encryptedValue }
}
const parts = encryptedValue.split(':')
const hexPart = parts[1]
return { decrypted: Buffer.from(hexPart, 'hex').toString('utf8') }
},
}))
vi.mock('@/lib/api-key/crypto', () => cryptoMock)
import {
formatApiKeyForDisplay,
getApiKeyLast4,
isEncryptedKey,
isValidApiKeyFormat,
} from '@/lib/api-key/auth'
const { generateApiKey, generateEncryptedApiKey, isEncryptedApiKeyFormat, isLegacyApiKeyFormat } =
cryptoMock
describe('isEncryptedKey', () => {
it('should detect encrypted storage format (iv:encrypted:authTag)', () => {
const encryptedStorage = 'abc123:encrypted-data:tag456'
expect(isEncryptedKey(encryptedStorage)).toBe(true)
})
it('should detect plain text storage (no colons)', () => {
const plainKey = 'sim_abcdef123456'
expect(isEncryptedKey(plainKey)).toBe(false)
})
it('should detect plain text with single colon', () => {
const singleColon = 'part1:part2'
expect(isEncryptedKey(singleColon)).toBe(false)
})
it('should detect encrypted format with exactly 3 parts', () => {
const threeParts = 'iv:data:tag'
expect(isEncryptedKey(threeParts)).toBe(true)
})
it('should reject format with more than 3 parts', () => {
const fourParts = 'a:b:c:d'
expect(isEncryptedKey(fourParts)).toBe(false)
})
it('should reject empty string', () => {
expect(isEncryptedKey('')).toBe(false)
})
})
describe('isEncryptedApiKeyFormat (key prefix)', () => {
it('should detect sk-sim- prefix as encrypted format', () => {
const { key } = createEncryptedApiKey()
expect(isEncryptedApiKeyFormat(key)).toBe(true)
})
it('should not detect sim_ prefix as encrypted format', () => {
const { key } = createLegacyApiKey()
expect(isEncryptedApiKeyFormat(key)).toBe(false)
})
it('should not detect random string as encrypted format', () => {
expect(isEncryptedApiKeyFormat('random-string')).toBe(false)
})
})
describe('isLegacyApiKeyFormat', () => {
it('should detect sim_ prefix as legacy format', () => {
const { key } = createLegacyApiKey()
expect(isLegacyApiKeyFormat(key)).toBe(true)
})
it('should not detect sk-sim- prefix as legacy format', () => {
const { key } = createEncryptedApiKey()
expect(isLegacyApiKeyFormat(key)).toBe(false)
})
it('should not detect random string as legacy format', () => {
expect(isLegacyApiKeyFormat('random-string')).toBe(false)
})
})
describe('isValidApiKeyFormat', () => {
it('should accept valid length keys', () => {
expect(isValidApiKeyFormat(`sim_${'a'.repeat(20)}`)).toBe(true)
})
it('should reject too short keys', () => {
expect(isValidApiKeyFormat('short')).toBe(false)
})
it('should reject too long keys (>200 chars)', () => {
expect(isValidApiKeyFormat('a'.repeat(201))).toBe(false)
})
it('should accept keys at boundary (11 chars)', () => {
expect(isValidApiKeyFormat('a'.repeat(11))).toBe(true)
})
it('should reject keys at boundary (10 chars)', () => {
expect(isValidApiKeyFormat('a'.repeat(10))).toBe(false)
})
it('should reject non-string input', () => {
expect(isValidApiKeyFormat(null as any)).toBe(false)
expect(isValidApiKeyFormat(undefined as any)).toBe(false)
expect(isValidApiKeyFormat(123 as any)).toBe(false)
})
it('should reject empty string', () => {
expect(isValidApiKeyFormat('')).toBe(false)
})
})
describe('getApiKeyLast4', () => {
it('should return last 4 characters of key', () => {
expect(getApiKeyLast4('sim_abcdefghijklmnop')).toBe('mnop')
})
it('should return last 4 characters of encrypted format key', () => {
expect(getApiKeyLast4('sk-sim-abcdefghijkl')).toBe('ijkl')
})
it('should return entire key if less than 4 chars', () => {
expect(getApiKeyLast4('abc')).toBe('abc')
})
it('should handle exactly 4 chars', () => {
expect(getApiKeyLast4('abcd')).toBe('abcd')
})
})
describe('formatApiKeyForDisplay', () => {
it('should format encrypted format key with sk-sim- prefix', () => {
const key = 'sk-sim-abcdefghijklmnopqrstuvwx'
const formatted = formatApiKeyForDisplay(key)
expect(formatted).toBe('sk-sim-...uvwx')
})
it('should format legacy key with sim_ prefix', () => {
const key = 'sim_abcdefghijklmnopqrstuvwx'
const formatted = formatApiKeyForDisplay(key)
expect(formatted).toBe('sim_...uvwx')
})
it('should format unknown format key with just ellipsis', () => {
const key = 'custom-key-format-abcd'
const formatted = formatApiKeyForDisplay(key)
expect(formatted).toBe('...abcd')
})
it('should show last 4 characters correctly', () => {
const key = 'sk-sim-xxxxxxxxxxxxxxxxr6AA'
const formatted = formatApiKeyForDisplay(key)
expect(formatted).toContain('r6AA')
})
})
describe('generateApiKey', () => {
it('should generate key with sim_ prefix', () => {
const key = generateApiKey()
expect(key).toMatch(/^sim_/)
})
it('should generate unique keys', () => {
const key1 = generateApiKey()
const key2 = generateApiKey()
expect(key1).not.toBe(key2)
})
it('should generate key of valid length', () => {
const key = generateApiKey()
expect(key.length).toBeGreaterThan(10)
expect(key.length).toBeLessThan(100)
})
})
describe('generateEncryptedApiKey', () => {
it('should generate key with sk-sim- prefix', () => {
const key = generateEncryptedApiKey()
expect(key).toMatch(/^sk-sim-/)
})
it('should generate unique keys', () => {
const key1 = generateEncryptedApiKey()
const key2 = generateEncryptedApiKey()
expect(key1).not.toBe(key2)
})
it('should generate key of valid length', () => {
const key = generateEncryptedApiKey()
expect(key.length).toBeGreaterThan(10)
expect(key.length).toBeLessThan(100)
})
})
+212
View File
@@ -0,0 +1,212 @@
import { db } from '@sim/db'
import { apiKey } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateShortId } from '@sim/utils/id'
import { and, eq } from 'drizzle-orm'
import {
decryptApiKey,
encryptApiKey,
generateApiKey,
generateEncryptedApiKey,
hashApiKey,
isEncryptedApiKeyFormat,
isLegacyApiKeyFormat,
} from '@/lib/api-key/crypto'
import { env } from '@/lib/core/config/env'
const logger = createLogger('ApiKeyAuth')
/**
* API key authentication utilities supporting both legacy plain text keys
* and modern encrypted keys for gradual migration without breaking existing keys
*/
/**
* Checks if a stored key is in the new encrypted format
* @param storedKey - The key stored in the database
* @returns true if the key is encrypted, false if it's plain text
*/
export function isEncryptedKey(storedKey: string): boolean {
// Check if it follows the encrypted format: iv:encrypted:authTag
return storedKey.includes(':') && storedKey.split(':').length === 3
}
/**
* Encrypts an API key for secure storage
* @param apiKey - The plain text API key to encrypt
* @returns Promise<string> - The encrypted key
*/
async function encryptApiKeyForStorage(apiKey: string): Promise<string> {
try {
const { encrypted } = await encryptApiKey(apiKey)
return encrypted
} catch (error) {
logger.error('API key encryption error:', { error })
throw new Error('Failed to encrypt API key')
}
}
/**
* Creates a new API key
* @param useStorage - Whether to encrypt the key before storage (default: true)
* @returns Promise<{key: string, encryptedKey?: string}> - The plain key and optionally encrypted version
*/
export async function createApiKey(useStorage = true): Promise<{
key: string
encryptedKey?: string
}> {
try {
const hasEncryptionKey = env.API_ENCRYPTION_KEY !== undefined
const plainKey = hasEncryptionKey ? generateEncryptedApiKey() : generateApiKey()
if (useStorage) {
const encryptedKey = await encryptApiKeyForStorage(plainKey)
return { key: plainKey, encryptedKey }
}
return { key: plainKey }
} catch (error) {
logger.error('API key creation error:', { error })
throw new Error('Failed to create API key')
}
}
/**
* Decrypts an API key from storage for display purposes
* @param encryptedKey - The encrypted API key from the database
* @returns Promise<string> - The decrypted API key
*/
async function decryptApiKeyFromStorage(encryptedKey: string): Promise<string> {
try {
const { decrypted } = await decryptApiKey(encryptedKey)
return decrypted
} catch (error) {
logger.error('API key decryption error:', { error })
throw new Error('Failed to decrypt API key')
}
}
/**
* Gets the last 4 characters of an API key for display purposes
* @param apiKey - The API key (plain text)
* @returns string - The last 4 characters
*/
export function getApiKeyLast4(apiKey: string): string {
return apiKey.slice(-4)
}
/**
* Gets the display format for an API key showing prefix and last 4 characters
* @param encryptedKey - The encrypted API key from the database
* @returns Promise<string> - The display format like "sk-sim-...r6AA"
*/
export async function getApiKeyDisplayFormat(encryptedKey: string): Promise<string> {
try {
if (isEncryptedKey(encryptedKey)) {
const decryptedKey = await decryptApiKeyFromStorage(encryptedKey)
return formatApiKeyForDisplay(decryptedKey)
}
// For plain text keys (legacy), format directly
return formatApiKeyForDisplay(encryptedKey)
} catch (error) {
logger.error('Failed to format API key for display:', { error })
return '****'
}
}
/**
* Formats an API key for display showing prefix and last 4 characters
* @param apiKey - The API key (plain text)
* @returns string - The display format like "sk-sim-...r6AA" or "sim_...r6AA"
*/
export function formatApiKeyForDisplay(apiKey: string): string {
if (isEncryptedApiKeyFormat(apiKey)) {
// For sk-sim- format: "sk-sim-...r6AA"
const last4 = getApiKeyLast4(apiKey)
return `sk-sim-...${last4}`
}
if (isLegacyApiKeyFormat(apiKey)) {
// For sim_ format: "sim_...r6AA"
const last4 = getApiKeyLast4(apiKey)
return `sim_...${last4}`
}
// Unknown format, just show last 4
const last4 = getApiKeyLast4(apiKey)
return `...${last4}`
}
/**
* Gets the last 4 characters of an encrypted API key by decrypting it first
* @param encryptedKey - The encrypted API key from the database
* @returns Promise<string> - The last 4 characters
*/
async function getEncryptedApiKeyLast4(encryptedKey: string): Promise<string> {
try {
if (isEncryptedKey(encryptedKey)) {
const decryptedKey = await decryptApiKeyFromStorage(encryptedKey)
return getApiKeyLast4(decryptedKey)
}
// For plain text keys (legacy), return last 4 directly
return getApiKeyLast4(encryptedKey)
} catch (error) {
logger.error('Failed to get last 4 characters of API key:', { error })
return '****'
}
}
/**
* Validates API key format (basic validation)
* @param apiKey - The API key to validate
* @returns boolean - true if the format appears valid
*/
export function isValidApiKeyFormat(apiKeyValue: string): boolean {
return typeof apiKeyValue === 'string' && apiKeyValue.length > 10 && apiKeyValue.length < 200
}
export async function createWorkspaceApiKey(params: {
workspaceId: string
userId: string
name: string
}) {
const existingKey = await db
.select({ id: apiKey.id })
.from(apiKey)
.where(
and(
eq(apiKey.workspaceId, params.workspaceId),
eq(apiKey.name, params.name),
eq(apiKey.type, 'workspace')
)
)
.limit(1)
if (existingKey.length > 0) {
throw new Error(
`A workspace API key named "${params.name}" already exists. Choose a different name.`
)
}
const { key: plainKey, encryptedKey } = await createApiKey(true)
if (!encryptedKey) {
throw new Error('Failed to encrypt API key for storage')
}
const [newKey] = await db
.insert(apiKey)
.values({
id: generateShortId(),
workspaceId: params.workspaceId,
userId: params.userId,
createdBy: params.userId,
name: params.name,
key: encryptedKey,
keyHash: hashApiKey(plainKey),
type: 'workspace',
createdAt: new Date(),
updatedAt: new Date(),
})
.returning({ id: apiKey.id, name: apiKey.name, createdAt: apiKey.createdAt })
return { id: newKey.id, name: newKey.name, key: plainKey, createdAt: newKey.createdAt }
}
+162
View File
@@ -0,0 +1,162 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockOrderBy, mockDecryptSecret } = vi.hoisted(() => ({
mockOrderBy: vi.fn(),
mockDecryptSecret: vi.fn(),
}))
vi.mock('@sim/db', () => ({
db: {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({ orderBy: mockOrderBy })),
})),
})),
},
}))
vi.mock('@/lib/core/security/encryption', () => ({
decryptSecret: mockDecryptSecret,
}))
vi.mock('@/lib/core/config/api-keys', () => ({
getRotatingApiKey: vi.fn(),
}))
vi.mock('@/lib/core/config/env', () => ({
env: {},
}))
vi.mock('@/lib/core/config/env-flags', () => ({
isHosted: false,
}))
vi.mock('@/providers/models', () => ({
getProviderFileAttachment: vi
.fn()
.mockReturnValue({ maxBytes: 10 * 1024 * 1024, strategy: 'inline' }),
INLINE_ATTACHMENT_MAX_BYTES: 10 * 1024 * 1024,
getHostedModels: vi.fn(() => []),
}))
vi.mock('@/providers/utils', () => ({
PROVIDER_PLACEHOLDER_KEY: 'placeholder',
}))
vi.mock('@/stores/providers/store', () => ({
useProvidersStore: { getState: vi.fn() },
}))
import { getBYOKKey } from '@/lib/api-key/byok'
/**
* Rotation counters in the module under test are keyed by
* `${workspaceId}:${providerId}` and persist for the process lifetime, so
* each test uses a unique workspace id to start from a fresh cursor.
*/
let testIndex = 0
const uniqueWorkspaceId = () => `workspace-${++testIndex}`
const storedKey = (id: string) => ({ id, encryptedApiKey: `encrypted-${id}` })
describe('getBYOKKey', () => {
beforeEach(() => {
vi.clearAllMocks()
mockOrderBy.mockResolvedValue([])
mockDecryptSecret.mockImplementation(async (encrypted: string) => ({
decrypted: encrypted.replace('encrypted-', 'decrypted-'),
}))
})
it('returns null when no workspaceId is provided', async () => {
expect(await getBYOKKey(undefined, 'openai')).toBeNull()
expect(await getBYOKKey(null, 'openai')).toBeNull()
})
it('returns null when the workspace has no keys for the provider', async () => {
expect(await getBYOKKey(uniqueWorkspaceId(), 'openai')).toBeNull()
})
it('returns the same key on every call when only one key is stored', async () => {
const workspaceId = uniqueWorkspaceId()
mockOrderBy.mockResolvedValue([storedKey('key-1')])
for (let call = 0; call < 3; call++) {
expect(await getBYOKKey(workspaceId, 'openai')).toEqual({
apiKey: 'decrypted-key-1',
isBYOK: true,
})
}
})
it('round-robins across multiple keys in creation order', async () => {
const workspaceId = uniqueWorkspaceId()
mockOrderBy.mockResolvedValue([storedKey('key-1'), storedKey('key-2'), storedKey('key-3')])
const apiKeys = []
for (let call = 0; call < 4; call++) {
const result = await getBYOKKey(workspaceId, 'openai')
apiKeys.push(result?.apiKey)
}
expect(apiKeys).toEqual([
'decrypted-key-1',
'decrypted-key-2',
'decrypted-key-3',
'decrypted-key-1',
])
})
it('reads the key list fresh from the database on every call', async () => {
const workspaceId = uniqueWorkspaceId()
mockOrderBy.mockResolvedValue([storedKey('key-1')])
await getBYOKKey(workspaceId, 'openai')
await getBYOKKey(workspaceId, 'openai')
await getBYOKKey(workspaceId, 'openai')
expect(mockOrderBy).toHaveBeenCalledTimes(3)
})
it('tracks rotation independently per provider within a workspace', async () => {
const workspaceId = uniqueWorkspaceId()
mockOrderBy.mockResolvedValue([storedKey('key-1'), storedKey('key-2')])
expect((await getBYOKKey(workspaceId, 'openai'))?.apiKey).toBe('decrypted-key-1')
expect((await getBYOKKey(workspaceId, 'anthropic'))?.apiKey).toBe('decrypted-key-1')
expect((await getBYOKKey(workspaceId, 'openai'))?.apiKey).toBe('decrypted-key-2')
})
it('skips a key that fails to decrypt and returns the next one', async () => {
const workspaceId = uniqueWorkspaceId()
mockOrderBy.mockResolvedValue([storedKey('key-1'), storedKey('key-2')])
mockDecryptSecret.mockImplementation(async (encrypted: string) => {
if (encrypted === 'encrypted-key-1') {
throw new Error('corrupt ciphertext')
}
return { decrypted: encrypted.replace('encrypted-', 'decrypted-') }
})
expect(await getBYOKKey(workspaceId, 'openai')).toEqual({
apiKey: 'decrypted-key-2',
isBYOK: true,
})
})
it('returns null when every key fails to decrypt', async () => {
const workspaceId = uniqueWorkspaceId()
mockOrderBy.mockResolvedValue([storedKey('key-1'), storedKey('key-2')])
mockDecryptSecret.mockRejectedValue(new Error('corrupt ciphertext'))
expect(await getBYOKKey(workspaceId, 'openai')).toBeNull()
})
it('returns null when the keys query throws', async () => {
mockOrderBy.mockRejectedValue(new Error('database unavailable'))
expect(await getBYOKKey(uniqueWorkspaceId(), 'openai')).toBeNull()
})
})
+255
View File
@@ -0,0 +1,255 @@
import { db } from '@sim/db'
import { workspaceBYOKKeys } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, asc, eq } from 'drizzle-orm'
import { getRotatingApiKey } from '@/lib/core/config/api-keys'
import { env } from '@/lib/core/config/env'
import { isHosted } from '@/lib/core/config/env-flags'
import { decryptSecret } from '@/lib/core/security/encryption'
import { getHostedModels } from '@/providers/models'
import { PROVIDER_PLACEHOLDER_KEY } from '@/providers/utils'
import { useProvidersStore } from '@/stores/providers/store'
import type { BYOKProviderId } from '@/tools/types'
const logger = createLogger('BYOKKeys')
export interface BYOKKeyResult {
apiKey: string
isBYOK: true
}
const rotationCounters = new Map<string, number>()
/**
* Advances the per-process round-robin cursor for a rotation pool and returns
* the next index. Counters are per server instance, which keeps rotation free
* of database writes; aggregate load still spreads evenly across keys.
*/
function nextRotationIndex(poolKey: string, poolSize: number): number {
const cursor = (rotationCounters.get(poolKey) ?? -1) + 1
rotationCounters.set(poolKey, cursor)
return cursor % poolSize
}
/**
* Resolves a workspace BYOK key for a provider. When the workspace has
* multiple keys stored for the provider, requests round-robin across them in
* creation order. A key that fails to decrypt is skipped in favor of the next
* one in the pool.
*
* The key list is read fresh every call (not cached): BYOK is not a hot query,
* and reading fresh keeps revocation immediate across ECS tasks.
*/
export async function getBYOKKey(
workspaceId: string | undefined | null,
providerId: BYOKProviderId
): Promise<BYOKKeyResult | null> {
if (!workspaceId) {
return null
}
try {
const keys = await db
.select({ id: workspaceBYOKKeys.id, encryptedApiKey: workspaceBYOKKeys.encryptedApiKey })
.from(workspaceBYOKKeys)
.where(
and(
eq(workspaceBYOKKeys.workspaceId, workspaceId),
eq(workspaceBYOKKeys.providerId, providerId)
)
)
.orderBy(asc(workspaceBYOKKeys.createdAt), asc(workspaceBYOKKeys.id))
if (!keys.length) {
return null
}
const startIndex = nextRotationIndex(`${workspaceId}:${providerId}`, keys.length)
for (let offset = 0; offset < keys.length; offset++) {
const key = keys[(startIndex + offset) % keys.length]
try {
const { decrypted } = await decryptSecret(key.encryptedApiKey)
return { apiKey: decrypted, isBYOK: true }
} catch (error) {
logger.error('Failed to decrypt BYOK key, skipping', {
workspaceId,
providerId,
keyId: key.id,
error,
})
}
}
return null
} catch (error) {
logger.error('Failed to get BYOK key', { workspaceId, providerId, error })
return null
}
}
export async function getApiKeyWithBYOK(
provider: string,
model: string,
workspaceId: string | undefined | null,
userProvidedKey?: string
): Promise<{ apiKey: string; isBYOK: boolean }> {
const isOllamaModel =
provider === 'ollama' || useProvidersStore.getState().providers.ollama.models.includes(model)
if (isOllamaModel) {
return { apiKey: 'empty', isBYOK: false }
}
const isVllmModel =
provider === 'vllm' || useProvidersStore.getState().providers.vllm.models.includes(model)
if (isVllmModel) {
return { apiKey: userProvidedKey || env.VLLM_API_KEY || 'empty', isBYOK: false }
}
const isLitellmModel =
provider === 'litellm' || useProvidersStore.getState().providers.litellm.models.includes(model)
if (isLitellmModel) {
return { apiKey: userProvidedKey || env.LITELLM_API_KEY || 'empty', isBYOK: false }
}
const isFireworksModel =
provider === 'fireworks' ||
useProvidersStore.getState().providers.fireworks.models.includes(model)
if (isFireworksModel) {
if (workspaceId) {
const byokResult = await getBYOKKey(workspaceId, 'fireworks')
if (byokResult) {
logger.info('Using BYOK key for Fireworks', { model, workspaceId })
return byokResult
}
}
if (userProvidedKey) {
return { apiKey: userProvidedKey, isBYOK: false }
}
if (env.FIREWORKS_API_KEY) {
return { apiKey: env.FIREWORKS_API_KEY, isBYOK: false }
}
throw new Error(`API key is required for Fireworks ${model}`)
}
const isTogetherModel =
provider === 'together' ||
useProvidersStore.getState().providers.together.models.includes(model)
if (isTogetherModel) {
if (workspaceId) {
const byokResult = await getBYOKKey(workspaceId, 'together')
if (byokResult) {
logger.info('Using BYOK key for Together AI', { model, workspaceId })
return byokResult
}
}
if (userProvidedKey) {
return { apiKey: userProvidedKey, isBYOK: false }
}
if (env.TOGETHER_API_KEY) {
return { apiKey: env.TOGETHER_API_KEY, isBYOK: false }
}
throw new Error(`API key is required for Together AI ${model}`)
}
const isBasetenModel =
provider === 'baseten' || useProvidersStore.getState().providers.baseten.models.includes(model)
if (isBasetenModel) {
if (workspaceId) {
const byokResult = await getBYOKKey(workspaceId, 'baseten')
if (byokResult) {
logger.info('Using BYOK key for Baseten', { model, workspaceId })
return byokResult
}
}
if (userProvidedKey) {
return { apiKey: userProvidedKey, isBYOK: false }
}
if (env.BASETEN_API_KEY) {
return { apiKey: env.BASETEN_API_KEY, isBYOK: false }
}
throw new Error(`API key is required for Baseten ${model}`)
}
const isOllamaCloudModel =
provider === 'ollama-cloud' ||
useProvidersStore.getState().providers['ollama-cloud'].models.includes(model)
if (isOllamaCloudModel) {
if (workspaceId) {
const byokResult = await getBYOKKey(workspaceId, 'ollama-cloud')
if (byokResult) {
logger.info('Using BYOK key for Ollama Cloud', { model, workspaceId })
return byokResult
}
}
if (userProvidedKey) {
return { apiKey: userProvidedKey, isBYOK: false }
}
throw new Error(`API key is required for Ollama Cloud ${model}`)
}
const isBedrockModel = provider === 'bedrock' || model.startsWith('bedrock/')
if (isBedrockModel) {
return { apiKey: PROVIDER_PLACEHOLDER_KEY, isBYOK: false }
}
if (provider === 'azure-openai') {
return { apiKey: userProvidedKey || env.AZURE_OPENAI_API_KEY || '', isBYOK: false }
}
if (provider === 'azure-anthropic') {
return { apiKey: userProvidedKey || env.AZURE_ANTHROPIC_API_KEY || '', isBYOK: false }
}
const isOpenAIModel = provider === 'openai'
const isClaudeModel = provider === 'anthropic'
const isGeminiModel = provider === 'google'
const isMistralModel = provider === 'mistral'
const isZaiModel = provider === 'zai'
const isXaiModel = provider === 'xai'
const byokProviderId = isGeminiModel ? 'google' : (provider as BYOKProviderId)
if (
isHosted &&
workspaceId &&
(isOpenAIModel || isClaudeModel || isGeminiModel || isMistralModel || isZaiModel || isXaiModel)
) {
const hostedModels = getHostedModels()
const isModelHosted = hostedModels.some((m) => m.toLowerCase() === model.toLowerCase())
logger.debug('BYOK check', { provider, model, workspaceId, isHosted, isModelHosted })
if (isModelHosted || isMistralModel) {
const byokResult = await getBYOKKey(workspaceId, byokProviderId)
if (byokResult) {
logger.info('Using BYOK key', { provider, model, workspaceId })
return byokResult
}
logger.debug('No BYOK key found, falling back', { provider, model, workspaceId })
if (isModelHosted) {
try {
const serverKey = getRotatingApiKey(isGeminiModel ? 'gemini' : provider)
return { apiKey: serverKey, isBYOK: false }
} catch (_error) {
if (userProvidedKey) {
return { apiKey: userProvidedKey, isBYOK: false }
}
throw new Error(`No API key available for ${provider} ${model}`)
}
}
}
}
if (!userProvidedKey) {
logger.debug('BYOK not applicable, no user key provided', {
provider,
model,
workspaceId,
isHosted,
})
throw new Error(`API key is required for ${provider} ${model}`)
}
return { apiKey: userProvidedKey, isBYOK: false }
}
+89
View File
@@ -0,0 +1,89 @@
/**
* Tests for the API-key crypto primitives.
*
* `hashApiKey` is the foundation of both the new hash-first authentication
* path and the `backfill-api-key-hash` script — the backfill is idempotent
* precisely because `hashApiKey` is deterministic and the encrypted round-trip
* recovers the same plain-text key on every run.
*
* @vitest-environment node
*/
import { randomBytes } from 'crypto'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockEnv } = vi.hoisted(() => ({
mockEnv: { API_ENCRYPTION_KEY: undefined as string | undefined },
}))
vi.mock('@/lib/core/config/env', () => ({
env: mockEnv,
}))
import {
decryptApiKey,
encryptApiKey,
hashApiKey,
isEncryptedApiKeyFormat,
isLegacyApiKeyFormat,
} from '@/lib/api-key/crypto'
const FIXED_ENCRYPTION_KEY = '0'.repeat(64)
describe('hashApiKey', () => {
it('is deterministic — same input produces same hash', () => {
const h1 = hashApiKey('sk-sim-example')
const h2 = hashApiKey('sk-sim-example')
expect(h1).toBe(h2)
})
it('produces a 64-char hex SHA-256 digest', () => {
const hash = hashApiKey('sk-sim-example')
expect(hash).toMatch(/^[0-9a-f]{64}$/)
})
it('produces different hashes for different inputs', () => {
expect(hashApiKey('sk-sim-a')).not.toBe(hashApiKey('sk-sim-b'))
})
it('matches the published SHA-256 vector for the empty string', () => {
expect(hashApiKey('')).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855')
})
})
describe('backfill idempotency — encrypted round-trip', () => {
beforeEach(() => {
mockEnv.API_ENCRYPTION_KEY = FIXED_ENCRYPTION_KEY
})
it('re-running the backfill on the same row yields the same keyHash', async () => {
const plainKey = `sk-sim-${randomBytes(12).toString('hex')}`
const { encrypted } = await encryptApiKey(plainKey)
const { decrypted: first } = await decryptApiKey(encrypted)
const { decrypted: second } = await decryptApiKey(encrypted)
expect(first).toBe(plainKey)
expect(second).toBe(plainKey)
expect(hashApiKey(first)).toBe(hashApiKey(second))
})
it('is stable whether the stored key is legacy plain text or encrypted', async () => {
const plainKey = 'sim_legacy-format-key'
const { encrypted } = await encryptApiKey(plainKey)
const { decrypted } = await decryptApiKey(encrypted)
expect(hashApiKey(decrypted)).toBe(hashApiKey(plainKey))
})
})
describe('api-key format helpers', () => {
it('treats sk-sim- prefix as the encrypted format', () => {
expect(isEncryptedApiKeyFormat('sk-sim-abc')).toBe(true)
expect(isLegacyApiKeyFormat('sk-sim-abc')).toBe(false)
})
it('treats sim_ prefix as the legacy format', () => {
expect(isLegacyApiKeyFormat('sim_abc')).toBe(true)
expect(isEncryptedApiKeyFormat('sim_abc')).toBe(false)
})
})
+106
View File
@@ -0,0 +1,106 @@
import { createLogger } from '@sim/logger'
import { decrypt, encrypt } from '@sim/security/encryption'
import { sha256Hex } from '@sim/security/hash'
import { generateSecureToken } from '@sim/security/tokens'
import { toError } from '@sim/utils/errors'
import { env } from '@/lib/core/config/env'
const logger = createLogger('ApiKeyCrypto')
function getApiEncryptionKey(): Buffer | null {
const key = env.API_ENCRYPTION_KEY
if (!key) {
logger.warn(
'API_ENCRYPTION_KEY not set - API keys will be stored in plain text. Consider setting this for better security.'
)
return null
}
if (key.length !== 64) {
throw new Error('API_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)')
}
return Buffer.from(key, 'hex')
}
/**
* Encrypts an API key using the dedicated API encryption key. Falls back to
* returning the plain key when `API_ENCRYPTION_KEY` is unset, for backward
* compatibility with deployments that predate encryption-at-rest.
*/
export async function encryptApiKey(apiKey: string): Promise<{ encrypted: string; iv: string }> {
const key = getApiEncryptionKey()
if (!key) {
return { encrypted: apiKey, iv: '' }
}
return encrypt(apiKey, key)
}
/**
* Decrypts an API key previously produced by {@link encryptApiKey}. Values
* that lack the `iv:ciphertext:authTag` shape are assumed to be legacy plain
* text and returned unchanged.
*/
export async function decryptApiKey(encryptedValue: string): Promise<{ decrypted: string }> {
const parts = encryptedValue.split(':')
if (parts.length !== 3) {
return { decrypted: encryptedValue }
}
const key = getApiEncryptionKey()
if (!key) {
return { decrypted: encryptedValue }
}
try {
return await decrypt(encryptedValue, key)
} catch (error) {
logger.error('API key decryption error:', { error: toError(error).message })
throw error
}
}
/**
* Generates a standardized API key with the 'sim_' prefix (legacy format)
* @returns A new API key string
*/
export function generateApiKey(): string {
return `sim_${generateSecureToken(24)}`
}
/**
* Generates a new encrypted API key with the 'sk-sim-' prefix
* @returns A new encrypted API key string
*/
export function generateEncryptedApiKey(): string {
return `sk-sim-${generateSecureToken(24)}`
}
/**
* Determines if an API key uses the new encrypted format based on prefix
* @param apiKey - The API key to check
* @returns true if the key uses the new encrypted format (sk-sim- prefix)
*/
export function isEncryptedApiKeyFormat(apiKey: string): boolean {
return apiKey.startsWith('sk-sim-')
}
/**
* Determines if an API key uses the legacy format based on prefix
* @param apiKey - The API key to check
* @returns true if the key uses the legacy format (sim_ prefix)
*/
export function isLegacyApiKeyFormat(apiKey: string): boolean {
return apiKey.startsWith('sim_') && !apiKey.startsWith('sk-sim-')
}
/**
* Deterministically hashes a plain-text API key for indexed lookup. The hash
* column has a unique index so authentication can match an incoming key via a
* single `WHERE key_hash = $hash` lookup instead of scanning and decrypting
* every stored encrypted key.
*
* @param plainKey - The plain-text API key as presented by the client
* @returns The hex-encoded SHA-256 digest
*/
export function hashApiKey(plainKey: string): string {
return sha256Hex(plainKey)
}
@@ -0,0 +1,82 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { createWorkspaceApiKey } from '@/lib/api-key/auth'
import { PlatformEvents } from '@/lib/core/telemetry'
const logger = createLogger('ApiKeyOrchestration')
export type ApiKeyOrchestrationErrorCode = 'conflict' | 'internal'
export interface PerformCreateWorkspaceApiKeyParams {
workspaceId: string
userId: string
name: string
source?: string
actorName?: string | null
actorEmail?: string | null
}
export interface PerformCreateWorkspaceApiKeyResult {
success: boolean
error?: string
errorCode?: ApiKeyOrchestrationErrorCode
key?: {
id: string
name: string
key: string
createdAt: Date
}
}
export async function performCreateWorkspaceApiKey(
params: PerformCreateWorkspaceApiKeyParams
): Promise<PerformCreateWorkspaceApiKeyResult> {
try {
const key = await createWorkspaceApiKey({
workspaceId: params.workspaceId,
userId: params.userId,
name: params.name,
})
try {
PlatformEvents.apiKeyGenerated({
userId: params.userId,
keyName: params.name,
})
} catch {}
logger.info('Created workspace API key', {
workspaceId: params.workspaceId,
keyId: key.id,
name: params.name,
})
recordAudit({
workspaceId: params.workspaceId,
actorId: params.userId,
actorName: params.actorName ?? undefined,
actorEmail: params.actorEmail ?? undefined,
action: AuditAction.API_KEY_CREATED,
resourceType: AuditResourceType.API_KEY,
resourceId: key.id,
resourceName: params.name,
description: `Created API key "${params.name}"`,
metadata: {
keyName: params.name,
keyType: 'workspace',
source: params.source ?? 'settings',
},
})
return { success: true, key }
} catch (error) {
const message = toError(error).message
logger.error('Failed to create workspace API key', { error })
return {
success: false,
error: message,
errorCode: message.includes('already exists') ? 'conflict' : 'internal',
}
}
}
+173
View File
@@ -0,0 +1,173 @@
/**
* Tests for authenticateApiKeyFromHeader.
*
* Authentication looks up a single row by the SHA-256 hash of the incoming
* API key and applies the scope / expiry / permission gates. Any miss — no
* matching hash or a failed gate — returns an invalid result.
*
* @vitest-environment node
*/
import { dbChainMock, dbChainMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@sim/db', () => dbChainMock)
const { serviceLogger } = vi.hoisted(() => {
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
trace: vi.fn(),
fatal: vi.fn(),
child: vi.fn(),
withMetadata: vi.fn(),
}
logger.child.mockReturnValue(logger)
logger.withMetadata.mockReturnValue(logger)
return { serviceLogger: logger }
})
vi.mock('@sim/logger', () => ({
createLogger: vi.fn(() => serviceLogger),
logger: serviceLogger,
runWithRequestContext: vi.fn(<T>(_ctx: unknown, fn: () => T): T => fn()),
getRequestContext: vi.fn(() => undefined),
}))
const { mockGetWorkspaceBillingSettings } = vi.hoisted(() => ({
mockGetWorkspaceBillingSettings: vi.fn(),
}))
vi.mock('@/lib/workspaces/utils', () => ({
getWorkspaceBillingSettings: mockGetWorkspaceBillingSettings,
}))
const { mockGetUserEntityPermissions } = vi.hoisted(() => ({
mockGetUserEntityPermissions: vi.fn(),
}))
vi.mock('@/lib/workspaces/permissions/utils', () => ({
getUserEntityPermissions: mockGetUserEntityPermissions,
}))
import { hashApiKey } from '@/lib/api-key/crypto'
import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service'
function personalKeyRecord(overrides: Partial<Record<string, unknown>> = {}) {
return {
id: 'key-1',
userId: 'user-1',
workspaceId: null as string | null,
type: 'personal',
expiresAt: null as Date | null,
...overrides,
}
}
describe('authenticateApiKeyFromHeader', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetWorkspaceBillingSettings.mockReset()
mockGetUserEntityPermissions.mockReset()
})
it('returns error when no header is provided', async () => {
const result = await authenticateApiKeyFromHeader('')
expect(result).toEqual({ success: false, error: 'API key required' })
expect(dbChainMockFns.where).not.toHaveBeenCalled()
})
it('resolves when the hash lookup finds a row', async () => {
const record = personalKeyRecord()
dbChainMockFns.where.mockResolvedValueOnce([record])
const result = await authenticateApiKeyFromHeader('sk-sim-plain-key', {
userId: 'user-1',
})
expect(result).toEqual({
success: true,
userId: 'user-1',
keyId: 'key-1',
keyType: 'personal',
workspaceId: undefined,
})
expect(dbChainMockFns.where).toHaveBeenCalledTimes(1)
})
it('returns invalid when the hash lookup finds a row that fails scope checks', async () => {
const record = personalKeyRecord({ userId: 'other-user' })
dbChainMockFns.where.mockResolvedValueOnce([record])
const result = await authenticateApiKeyFromHeader('sk-sim-plain-key', {
userId: 'user-1',
})
expect(result).toEqual({ success: false, error: 'Invalid API key' })
expect(dbChainMockFns.where).toHaveBeenCalledTimes(1)
})
it('returns invalid when the key belongs to a banned user', async () => {
const record = personalKeyRecord({ userBanned: true })
dbChainMockFns.where.mockResolvedValueOnce([record])
const result = await authenticateApiKeyFromHeader('sk-sim-plain-key', {
userId: 'user-1',
})
expect(result).toEqual({ success: false, error: 'Invalid API key' })
expect(dbChainMockFns.where).toHaveBeenCalledTimes(1)
})
it('returns invalid when the hash lookup finds no row', async () => {
dbChainMockFns.where.mockResolvedValueOnce([])
const result = await authenticateApiKeyFromHeader('sk-sim-plain-key', {
userId: 'user-1',
})
expect(result).toEqual({ success: false, error: 'Invalid API key' })
expect(dbChainMockFns.where).toHaveBeenCalledTimes(1)
})
it('queries by the sha256 hash of the incoming header', async () => {
dbChainMockFns.where.mockResolvedValueOnce([personalKeyRecord()])
await authenticateApiKeyFromHeader('sk-sim-plain-key', { userId: 'user-1' })
const [filter] = dbChainMockFns.where.mock.calls[0]
const expected = hashApiKey('sk-sim-plain-key')
expect(JSON.stringify(filter)).toContain(expected)
})
})
describe('updateApiKeyLastUsed', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('only writes when the stored lastUsed is missing or stale', async () => {
await updateApiKeyLastUsed('key-1')
expect(dbChainMockFns.update).toHaveBeenCalledTimes(1)
expect(dbChainMockFns.set).toHaveBeenCalledWith({ lastUsed: expect.any(Date) })
const [condition] = dbChainMockFns.where.mock.calls[0]
expect(condition).toMatchObject({
type: 'and',
conditions: [
{ type: 'eq', right: 'key-1' },
{ type: 'or', conditions: [{ type: 'isNull' }, { type: 'lt' }] },
],
})
})
it('swallows database errors instead of failing the request', async () => {
dbChainMockFns.update.mockImplementationOnce(() => {
throw new Error('connection lost')
})
await expect(updateApiKeyLastUsed('key-1')).resolves.toBeUndefined()
expect(serviceLogger.error).toHaveBeenCalled()
})
})
+166
View File
@@ -0,0 +1,166 @@
import { db } from '@sim/db'
import { apiKey as apiKeyTable, user as userTable } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull, lt, or } from 'drizzle-orm'
import { hashApiKey } from '@/lib/api-key/crypto'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { getWorkspaceBillingSettings, type WorkspaceBillingSettings } from '@/lib/workspaces/utils'
const logger = createLogger('ApiKeyService')
export async function listApiKeys(workspaceId: string) {
return db
.select({
id: apiKeyTable.id,
name: apiKeyTable.name,
type: apiKeyTable.type,
lastUsed: apiKeyTable.lastUsed,
createdAt: apiKeyTable.createdAt,
expiresAt: apiKeyTable.expiresAt,
createdBy: apiKeyTable.createdBy,
})
.from(apiKeyTable)
.where(and(eq(apiKeyTable.workspaceId, workspaceId), eq(apiKeyTable.type, 'workspace')))
.orderBy(apiKeyTable.createdAt)
}
export interface ApiKeyAuthOptions {
userId?: string
workspaceId?: string
keyTypes?: ('personal' | 'workspace')[]
}
export interface ApiKeyAuthResult {
success: boolean
userId?: string
keyId?: string
keyType?: 'personal' | 'workspace'
workspaceId?: string
error?: string
}
const INVALID = { success: false, error: 'Invalid API key' } as const
interface HashCandidate {
id: string
userId: string
workspaceId: string | null
type: string
expiresAt: Date | null
userBanned: boolean | null
}
/**
* Authenticate an API key from header with flexible filtering options.
*
* Looks up a single row by `sha256(apiKeyHeader)` and applies the scope /
* expiry / permission gates. Any miss — no matching hash or a failed gate —
* returns `INVALID`.
*/
export async function authenticateApiKeyFromHeader(
apiKeyHeader: string,
options: ApiKeyAuthOptions = {}
): Promise<ApiKeyAuthResult> {
if (!apiKeyHeader) {
return { success: false, error: 'API key required' }
}
try {
let workspaceSettings: WorkspaceBillingSettings | null = null
if (options.workspaceId) {
workspaceSettings = await getWorkspaceBillingSettings(options.workspaceId)
if (!workspaceSettings) {
return { success: false, error: 'Workspace not found' }
}
}
const keyHash = hashApiKey(apiKeyHeader)
const rows: HashCandidate[] = await db
.select({
id: apiKeyTable.id,
userId: apiKeyTable.userId,
workspaceId: apiKeyTable.workspaceId,
type: apiKeyTable.type,
expiresAt: apiKeyTable.expiresAt,
userBanned: userTable.banned,
})
.from(apiKeyTable)
.leftJoin(userTable, eq(apiKeyTable.userId, userTable.id))
.where(eq(apiKeyTable.keyHash, keyHash))
if (rows.length === 0) return INVALID
const record = rows[0]
const keyType = record.type as 'personal' | 'workspace'
// Defense in depth: banning deletes a user's keys, but reject any survivor too.
if (record.userBanned) return INVALID
if (options.userId && record.userId !== options.userId) return INVALID
if (options.keyTypes?.length && !options.keyTypes.includes(keyType)) return INVALID
if (record.expiresAt && record.expiresAt < new Date()) return INVALID
if (
options.workspaceId &&
keyType === 'workspace' &&
record.workspaceId !== options.workspaceId
) {
return INVALID
}
if (options.workspaceId && keyType === 'personal') {
if (!workspaceSettings?.allowPersonalApiKeys) return INVALID
if (!record.userId) return INVALID
const permission = await getUserEntityPermissions(
record.userId,
'workspace',
options.workspaceId
)
if (permission === null) return INVALID
}
logger.debug('API key matched via hash lookup', { keyId: record.id, keyType })
return {
success: true,
userId: record.userId,
keyId: record.id,
keyType,
workspaceId: record.workspaceId || options.workspaceId || undefined,
}
} catch (error) {
logger.error('API key authentication error:', error)
return { success: false, error: 'Authentication failed' }
}
}
const LAST_USED_STALENESS_WINDOW_MS = 10 * 60 * 1000
/**
* Update the last used timestamp for an API key.
*
* `lastUsed` is display-only, so the write uses a staleness window: it only
* fires when the stored value is older than
* {@link LAST_USED_STALENESS_WINDOW_MS}. High-traffic keys otherwise rewrite
* the same row on every request, serializing concurrent requests behind row
* locks. The 10-minute window matches GitLab's personal-access-token
* last-used tracking.
*/
export async function updateApiKeyLastUsed(keyId: string): Promise<void> {
try {
const staleBefore = new Date(Date.now() - LAST_USED_STALENESS_WINDOW_MS)
await db
.update(apiKeyTable)
.set({ lastUsed: new Date() })
.where(
and(
eq(apiKeyTable.id, keyId),
or(isNull(apiKeyTable.lastUsed), lt(apiKeyTable.lastUsed, staleBefore))
)
)
} catch (error) {
logger.error('Error updating API key last used:', error)
}
}
+107
View File
@@ -0,0 +1,107 @@
export interface ApiClientErrorOptions {
status: number
message: string
body: unknown
rawBody?: string
code?: string
}
export class ApiClientError extends Error {
readonly status: number
readonly body: unknown
readonly rawBody?: string
readonly code?: string
constructor(options: ApiClientErrorOptions) {
super(options.message)
this.name = 'ApiClientError'
this.status = options.status
this.body = options.body
this.rawBody = options.rawBody
this.code = options.code
}
}
export function isApiClientError(error: unknown): error is ApiClientError {
return error instanceof ApiClientError
}
export interface ValidationIssue {
/** Path of the failing field, e.g. ['updates', 'name']. */
path: ReadonlyArray<string | number>
/** Human-readable message — uses the schema's custom error string when set. */
message: string
}
interface UnknownIssue {
path?: unknown
message?: unknown
}
function normalizeIssue(raw: unknown): ValidationIssue | null {
if (!raw || typeof raw !== 'object') return null
const { path, message } = raw as UnknownIssue
if (typeof message !== 'string' || message.length === 0) return null
if (!Array.isArray(path)) return null
const cleanPath = path.filter(
(segment): segment is string | number =>
typeof segment === 'string' || typeof segment === 'number'
)
return { path: cleanPath, message }
}
/**
* Pull a list of validation issues out of an unknown error. Recognises both
* shapes the boundary produces:
*
* - Client-side contract validation: `requestJson` calls `schema.parse(input)`
* before fetch; failure throws a raw `ZodError` whose `.issues` is the array.
* - Server-side contract validation: route returns `{ error, details: [...] }`,
* which `requestJson` re-throws as `ApiClientError` carrying the body.
*
* Returns an empty array when the error isn't a recognised validation shape so
* callers can fall back to toast/log paths.
*/
export function extractValidationIssues(error: unknown): ValidationIssue[] {
if (!error || typeof error !== 'object') return []
if (isApiClientError(error)) {
const body = error.body
if (body && typeof body === 'object') {
const details = (body as { details?: unknown }).details
if (Array.isArray(details)) {
return details.map(normalizeIssue).filter((i): i is ValidationIssue => i !== null)
}
}
return []
}
const issues = (error as { issues?: unknown }).issues
if (Array.isArray(issues)) {
return issues.map(normalizeIssue).filter((i): i is ValidationIssue => i !== null)
}
return []
}
/**
* Match a single issue by suffix path. `pathSuffix` lets callers ignore the
* outer body wrapper — `findValidationIssue(err, ['name'])` matches both
* `path: ['name']` and `path: ['updates', 'name']`.
*/
export function findValidationIssue(
error: unknown,
pathSuffix: ReadonlyArray<string | number>
): ValidationIssue | null {
const issues = extractValidationIssues(error)
for (const issue of issues) {
if (issue.path.length < pathSuffix.length) continue
const tail = issue.path.slice(issue.path.length - pathSuffix.length)
if (tail.every((segment, i) => segment === pathSuffix[i])) return issue
}
return null
}
/** True when the error is a recognised validation failure (client or server). */
export function isValidationError(error: unknown): boolean {
return extractValidationIssues(error).length > 0
}
+2
View File
@@ -0,0 +1,2 @@
export * from './errors'
export * from './request'
+71
View File
@@ -0,0 +1,71 @@
/**
* @vitest-environment node
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { z } from 'zod'
import { requestJson } from '@/lib/api/client/request'
import { listKnowledgeDocumentsContract } from '@/lib/api/contracts/knowledge'
import { defineRouteContract } from '@/lib/api/contracts/types'
/**
* Captures the URL of the last fetch call and returns a valid JSON response so
* `requestJson`'s response validation passes.
*/
function mockFetchReturning(body: unknown) {
const fetchMock = vi.fn(
async () =>
new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json' },
})
)
vi.stubGlobal('fetch', fetchMock)
return fetchMock
}
describe('requestJson query serialization', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('serializes a JSON-string query param verbatim (regression: tagFilters)', async () => {
const fetchMock = mockFetchReturning({
success: true,
data: {
documents: [],
pagination: { total: 0, limit: 50, offset: 0, hasMore: false },
},
})
const tagFilters = JSON.stringify([
{ tagSlot: 'tag1', fieldType: 'text', operator: 'contains', value: 'Ada Lovelace' },
])
await requestJson(listKnowledgeDocumentsContract, {
params: { id: 'kb-1' },
query: { tagFilters },
})
const calledUrl = String(fetchMock.mock.calls[0][0])
const url = new URL(calledUrl, 'https://example.test')
// The param must round-trip as the exact JSON, never "[object Object]".
expect(url.searchParams.get('tagFilters')).toBe(tagFilters)
expect(calledUrl).not.toContain('object+Object')
expect(calledUrl).not.toContain('[object Object]')
})
it('throws instead of silently corrupting an array-of-objects query param', async () => {
mockFetchReturning({ ok: true })
const badContract = defineRouteContract({
method: 'GET',
path: '/api/test',
query: z.object({ items: z.array(z.object({ a: z.string() })) }),
response: { mode: 'json', schema: z.object({ ok: z.boolean() }) },
})
await expect(requestJson(badContract, { query: { items: [{ a: 'x' }] } })).rejects.toThrow(
/arrays of objects are not URL-safe/
)
})
})
+249
View File
@@ -0,0 +1,249 @@
import { ApiClientError } from '@/lib/api/client/errors'
import type {
AnyApiRouteContract,
ApiSchema,
ContractBodyInput,
ContractHeadersInput,
ContractJsonResponse,
ContractParamsInput,
ContractQueryInput,
EmptySchemaOutput,
} from '@/lib/api/contracts'
// Tuple-wrapped to suppress distributive conditionals: when `Value` is a
// union (e.g. a discriminated union body), naked `Value extends undefined`
// distributes and produces `{ body: A } | { body: B }` instead of
// `{ body: A | B }`. The `[Value] extends [undefined]` form preserves the
// union as-is. See request.test.ts for repro and rationale.
type MaybeField<Key extends string, Value> = [Value] extends [undefined]
? { [K in Key]?: never }
: { [K in Key]: Value }
export type ApiClientRequest<C extends AnyApiRouteContract> = MaybeField<
'params',
ContractParamsInput<C>
> &
MaybeField<'query', ContractQueryInput<C>> &
MaybeField<'body', ContractBodyInput<C>> &
MaybeField<'headers', ContractHeadersInput<C>> & {
signal?: AbortSignal
}
export interface ApiRawRequestOptions {
cache?: RequestCache
headers?: Record<string, string>
}
function replacePathParams(path: string, params: unknown): string {
if (!params || typeof params !== 'object') return path
const values = params as Record<string, unknown>
return path.replace(
/\[\[?(\.\.\.)?([^\][]+)\]\]?/g,
(match, rest: string | undefined, key: string) => {
const value = values[key]
const isOptionalCatchAll = match.startsWith('[[...')
if (rest && Array.isArray(value)) {
return value.map((item) => encodeURIComponent(String(item))).join('/')
}
if (value === undefined && isOptionalCatchAll) return ''
if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {
throw new Error(`Missing route param "${key}"`)
}
return encodeURIComponent(String(value))
}
)
}
function appendQuery(path: string, query: unknown): string {
if (!query || typeof query !== 'object') return path
const searchParams = new URLSearchParams()
for (const [key, value] of Object.entries(query as Record<string, unknown>)) {
if (value === undefined || value === null || value === '') continue
if (Array.isArray(value)) {
for (const item of value) {
if (item === undefined || item === null || item === '') continue
// A non-scalar in a query array would stringify to "[object Object]" and
// silently corrupt the request. Encode such values as a single JSON
// string param and decode them server-side instead. Failing loudly here
// keeps the boundary honest (this is how the knowledge tagFilters bug
// shipped undetected).
if (typeof item === 'object') {
throw new Error(
`Cannot serialize query param "${key}": arrays of objects are not URL-safe — ` +
'encode the value as a JSON string param and decode it server-side.'
)
}
searchParams.append(key, String(item))
}
continue
}
if (typeof value === 'object') {
searchParams.set(key, JSON.stringify(value))
continue
}
searchParams.set(key, String(value))
}
const queryString = searchParams.toString()
if (!queryString) return path
return `${path}${path.includes('?') ? '&' : '?'}${queryString}`
}
function buildHeaders(headers: unknown, hasBody: boolean): Record<string, string> {
const output: Record<string, string> = {}
if (hasBody) {
output['Content-Type'] = 'application/json'
}
if (headers && typeof headers === 'object') {
for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {
if (typeof value === 'string') output[key] = value
}
}
return output
}
function parseOptionalSchema<S extends ApiSchema | undefined>(
schema: S,
value: unknown
): EmptySchemaOutput<S> {
if (!schema) return undefined as EmptySchemaOutput<S>
return schema.parse(value) as EmptySchemaOutput<S>
}
async function readResponseBody(response: Response): Promise<{ parsed: unknown; raw?: string }> {
const text = await response.text()
if (!text) return { parsed: undefined }
try {
return { parsed: JSON.parse(text) as unknown, raw: text }
} catch {
return { parsed: text, raw: text }
}
}
function messageFromErrorBody(body: unknown, fallback: string): string {
if (body && typeof body === 'object') {
const record = body as Record<string, unknown>
const message = record.message ?? record.error
if (typeof message === 'string' && message.length > 0) return message
}
return fallback
}
function codeFromErrorBody(body: unknown): string | undefined {
if (body && typeof body === 'object') {
const record = body as Record<string, unknown>
if (typeof record.code === 'string' && record.code.length > 0) return record.code
}
return undefined
}
function isSchemaValidationError(error: unknown): boolean {
return Boolean(
error &&
typeof error === 'object' &&
'issues' in error &&
Array.isArray((error as { issues?: unknown }).issues)
)
}
export async function requestJson<C extends AnyApiRouteContract>(
contract: C,
input: ApiClientRequest<C>
): Promise<ContractJsonResponse<C>> {
if (contract.response.mode !== 'json') {
throw new Error(`Contract ${contract.method} ${contract.path} does not declare a JSON response`)
}
const parsedParams = parseOptionalSchema(contract.params, input.params)
const parsedQuery = parseOptionalSchema(contract.query, input.query)
const parsedBody = parseOptionalSchema(contract.body, input.body)
const parsedHeaders = parseOptionalSchema(contract.headers, input.headers)
const url = appendQuery(replacePathParams(contract.path, parsedParams), parsedQuery)
const hasBody = parsedBody !== undefined && contract.method !== 'GET'
const response = await fetch(url, {
method: contract.method,
headers: buildHeaders(parsedHeaders, hasBody),
body: hasBody ? JSON.stringify(parsedBody) : undefined,
signal: input.signal,
})
const { parsed, raw } = await readResponseBody(response)
if (!response.ok) {
throw new ApiClientError({
status: response.status,
message: messageFromErrorBody(parsed, `Request failed with ${response.status}`),
body: parsed,
rawBody: raw,
code: codeFromErrorBody(parsed),
})
}
try {
return contract.response.schema.parse(parsed) as ContractJsonResponse<C>
} catch (error) {
if (isSchemaValidationError(error)) {
throw new ApiClientError({
status: response.status,
message: 'Response failed contract validation',
body: parsed,
rawBody: raw,
})
}
throw error
}
}
export async function requestRaw<C extends AnyApiRouteContract>(
contract: C,
input: ApiClientRequest<C>,
options: ApiRawRequestOptions = {}
): Promise<Response> {
const parsedParams = parseOptionalSchema(contract.params, input.params)
const parsedQuery = parseOptionalSchema(contract.query, input.query)
const parsedBody = parseOptionalSchema(contract.body, input.body)
const parsedHeaders = parseOptionalSchema(contract.headers, input.headers)
const url = appendQuery(replacePathParams(contract.path, parsedParams), parsedQuery)
const hasBody = parsedBody !== undefined && contract.method !== 'GET'
const headers = {
...buildHeaders(parsedHeaders, hasBody),
...options.headers,
}
const response = await fetch(url, {
method: contract.method,
headers,
body: hasBody ? JSON.stringify(parsedBody) : undefined,
signal: input.signal,
cache: options.cache,
})
if (!response.ok) {
const { parsed, raw } = await readResponseBody(response)
throw new ApiClientError({
status: response.status,
message: messageFromErrorBody(parsed, `Request failed with ${response.status}`),
body: parsed,
rawBody: raw,
})
}
return response
}
+575
View File
@@ -0,0 +1,575 @@
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts/types'
import { workflowStateSchema } from '@/lib/api/contracts/workflows'
import { workspacePermissionSchema } from '@/lib/api/contracts/workspaces'
const DEFAULT_LIMIT = 50
const MAX_LIMIT = 250
const lastQueryValue = (value: unknown) => (Array.isArray(value) ? value.at(-1) : value)
const queryStringSchema = z.preprocess(lastQueryValue, z.string().optional())
const adminPaginationValueSchema = (defaultValue: number, normalize: (value: number) => number) =>
z.preprocess(lastQueryValue, z.union([z.string(), z.number()]).optional()).transform((value) => {
const parsed =
typeof value === 'number'
? value
: typeof value === 'string'
? Number.parseInt(value, 10)
: Number.NaN
if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
return defaultValue
}
return normalize(parsed)
})
export const adminPaginationQuerySchema = z.object({
limit: adminPaginationValueSchema(DEFAULT_LIMIT, (limit) =>
limit < 1 ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT)
),
offset: adminPaginationValueSchema(0, (offset) => (offset < 0 ? 0 : offset)),
})
export const adminIdParamsSchema = z.object({
id: z.string().min(1),
})
export const adminWorkflowVersionParamsSchema = z.object({
id: z.string().min(1),
versionId: z
.string()
.transform((value) => Number(value))
.refine((value) => Number.isFinite(value) && value >= 1, {
error: 'Invalid version number',
}),
})
export const adminWorkspaceMemberParamsSchema = z.object({
id: z.string().min(1),
memberId: z.string().min(1),
})
export const adminExportFormatQuerySchema = z.object({
format: z.preprocess(lastQueryValue, z.enum(['zip', 'json']).catch('zip')),
})
export const adminDeleteWorkspaceMemberQuerySchema = z.object({
userId: z.preprocess(
lastQueryValue,
z
.string({ error: 'userId query parameter is required' })
.min(1, { error: 'userId query parameter is required' })
),
})
export const adminWorkspaceMemberBodySchema = z.object({
userId: z.string({ error: 'userId is required' }).min(1, { error: 'userId is required' }),
permissions: workspacePermissionSchema.refine((value) => value !== null, {
error: 'permissions must be "admin", "write", or "read"',
}),
})
export const adminUpdateWorkspaceMemberBodySchema = z.object({
permissions: workspacePermissionSchema.refine((value) => value !== null, {
error: 'permissions must be "admin", "write", or "read"',
}),
})
export const adminExportWorkflowsBodySchema = z.object({
ids: z
.array(z.string(), { error: 'ids must be a non-empty array of workflow IDs' })
.nonempty({ error: 'ids must be a non-empty array of workflow IDs' }),
})
export const adminWorkflowImportBodySchema = z.object({
workspaceId: z
.string({ error: 'workspaceId is required' })
.min(1, { error: 'workspaceId is required' }),
folderId: z.string().optional(),
name: z.string().optional(),
workflow: z.union([
z.string({ error: 'workflow is required' }).min(1, { error: 'workflow is required' }),
z.record(z.string(), z.unknown()),
]),
})
export const adminWorkspaceImportQuerySchema = z.object({
createFolders: z
.preprocess(lastQueryValue, z.enum(['true', 'false']).catch('true'))
.transform((value) => value !== 'false'),
rootFolderName: queryStringSchema,
})
export const adminWorkspaceImportBodySchema = z.object({
workflows: z.array(
z.object({
content: z.union([z.string(), z.record(z.string(), z.unknown())]),
name: z.string().optional(),
folderPath: z.array(z.string()).optional(),
}),
{ error: 'Invalid JSON body. Expected { workflows: [...] }' }
),
})
export const adminPaginationMetaSchema = z.object({
total: z.number(),
limit: z.number(),
offset: z.number(),
hasMore: z.boolean(),
})
export const adminWorkspaceSchema = z.object({
id: z.string(),
name: z.string(),
ownerId: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
})
export const adminWorkspaceDetailSchema = adminWorkspaceSchema.extend({
workflowCount: z.number(),
folderCount: z.number(),
})
export const adminFolderSchema = z.object({
id: z.string(),
name: z.string(),
parentId: z.string().nullable(),
color: z.string().nullable(),
sortOrder: z.number(),
createdAt: z.string(),
updatedAt: z.string(),
})
export const adminWorkflowSchema = z.object({
id: z.string(),
name: z.string(),
description: z.string().nullable(),
workspaceId: z.string().nullable(),
folderId: z.string().nullable(),
isDeployed: z.boolean(),
deployedAt: z.string().nullable(),
runCount: z.number(),
lastRunAt: z.string().nullable(),
createdAt: z.string(),
updatedAt: z.string(),
})
export const adminWorkflowDetailSchema = adminWorkflowSchema.extend({
blockCount: z.number(),
edgeCount: z.number(),
})
export const adminWorkspaceMemberSchema = z.object({
id: z.string(),
workspaceId: z.string(),
userId: z.string(),
permissions: workspacePermissionSchema,
createdAt: z.string(),
updatedAt: z.string(),
userName: z.string(),
userEmail: z.string(),
userImage: z.string().nullable(),
})
export const adminWorkflowVariableSchema = z.object({
id: z.string(),
name: z.string(),
type: z.enum(['string', 'number', 'boolean', 'object', 'array', 'plain']),
value: z.unknown(),
})
export const adminWorkflowExportStateSchema = workflowStateSchema.extend({
metadata: z
.object({
name: z.string().optional(),
description: z.string().optional(),
exportedAt: z.string().optional(),
})
.optional(),
variables: z.record(z.string(), adminWorkflowVariableSchema).optional(),
})
export const adminWorkflowExportPayloadSchema = z.object({
version: z.literal('1.0'),
exportedAt: z.string(),
workflow: z.object({
id: z.string(),
name: z.string(),
description: z.string().nullable(),
workspaceId: z.string().nullable(),
folderId: z.string().nullable(),
}),
state: adminWorkflowExportStateSchema,
})
export const adminFolderExportPayloadSchema = z.object({
id: z.string(),
name: z.string(),
parentId: z.string().nullable(),
})
export const adminWorkspaceExportPayloadSchema = z.object({
version: z.literal('1.0'),
exportedAt: z.string(),
workspace: z.object({
id: z.string(),
name: z.string(),
}),
workflows: z.array(
z.object({
workflow: adminWorkflowExportPayloadSchema.shape.workflow,
state: adminWorkflowExportStateSchema,
})
),
folders: z.array(adminFolderExportPayloadSchema),
})
export const adminFolderFullExportPayloadSchema = z.object({
version: z.literal('1.0'),
exportedAt: z.string(),
folder: z.object({
id: z.string(),
name: z.string(),
}),
workflows: z.array(
z.object({
workflow: adminWorkflowExportPayloadSchema.shape.workflow.omit({ workspaceId: true }),
state: adminWorkflowExportStateSchema,
})
),
folders: z.array(adminFolderExportPayloadSchema),
})
export const adminImportResultSchema = z.object({
workflowId: z.string(),
name: z.string(),
success: z.boolean(),
error: z.string().optional(),
})
export const adminWorkflowImportResponseSchema = z.object({
workflowId: z.string(),
name: z.string(),
success: z.literal(true),
})
export const adminWorkspaceImportResponseSchema = z.object({
imported: z.number(),
failed: z.number(),
results: z.array(adminImportResultSchema),
})
export const adminDeploymentVersionSchema = z.object({
id: z.string(),
version: z.number(),
name: z.string().nullable(),
isActive: z.boolean(),
createdAt: z.string(),
createdBy: z.string().nullable(),
deployedByName: z.string().nullable(),
})
export const adminDeployResultSchema = z.object({
isDeployed: z.literal(true),
version: z.number(),
deployedAt: z.string(),
warnings: z.array(z.string()).optional(),
})
export const adminUndeployResultSchema = z.object({
isDeployed: z.literal(false),
})
const adminSingleResponseSchema = <TSchema extends z.ZodType>(schema: TSchema) =>
z.object({ data: schema })
const adminListResponseSchema = <TSchema extends z.ZodType>(schema: TSchema) =>
z.object({
data: z.array(schema),
pagination: adminPaginationMetaSchema,
})
export const adminListWorkflowsContract = defineRouteContract({
method: 'GET',
path: '/api/v1/admin/workflows',
query: adminPaginationQuerySchema,
response: {
mode: 'json',
schema: adminListResponseSchema(adminWorkflowSchema),
},
})
export const adminGetWorkflowContract = defineRouteContract({
method: 'GET',
path: '/api/v1/admin/workflows/[id]',
params: adminIdParamsSchema,
response: {
mode: 'json',
schema: adminSingleResponseSchema(adminWorkflowDetailSchema),
},
})
export const adminDeleteWorkflowContract = defineRouteContract({
method: 'DELETE',
path: '/api/v1/admin/workflows/[id]',
params: adminIdParamsSchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
workflowId: z.string(),
}),
},
})
export const adminDeployWorkflowContract = defineRouteContract({
method: 'POST',
path: '/api/v1/admin/workflows/[id]/deploy',
params: adminIdParamsSchema,
response: {
mode: 'json',
schema: adminSingleResponseSchema(adminDeployResultSchema),
},
})
export const adminUndeployWorkflowContract = defineRouteContract({
method: 'DELETE',
path: '/api/v1/admin/workflows/[id]/deploy',
params: adminIdParamsSchema,
response: {
mode: 'json',
schema: adminSingleResponseSchema(adminUndeployResultSchema),
},
})
export const adminListWorkflowVersionsContract = defineRouteContract({
method: 'GET',
path: '/api/v1/admin/workflows/[id]/versions',
params: adminIdParamsSchema,
response: {
mode: 'json',
schema: adminSingleResponseSchema(
z.object({
versions: z.array(adminDeploymentVersionSchema),
})
),
},
})
export const adminActivateWorkflowVersionContract = defineRouteContract({
method: 'POST',
path: '/api/v1/admin/workflows/[id]/versions/[versionId]/activate',
params: adminWorkflowVersionParamsSchema,
response: {
mode: 'json',
schema: adminSingleResponseSchema(
z.object({
success: z.literal(true),
version: z.number(),
deployedAt: z.string(),
warnings: z.array(z.string()).optional(),
})
),
},
})
export const adminExportWorkflowContract = defineRouteContract({
method: 'GET',
path: '/api/v1/admin/workflows/[id]/export',
params: adminIdParamsSchema,
response: {
mode: 'json',
schema: adminSingleResponseSchema(adminWorkflowExportPayloadSchema),
},
})
export const adminExportWorkflowsContract = defineRouteContract({
method: 'POST',
path: '/api/v1/admin/workflows/export',
query: adminExportFormatQuerySchema,
body: adminExportWorkflowsBodySchema,
response: {
mode: 'binary',
},
})
export const adminImportWorkflowContract = defineRouteContract({
method: 'POST',
path: '/api/v1/admin/workflows/import',
body: adminWorkflowImportBodySchema,
response: {
mode: 'json',
schema: adminWorkflowImportResponseSchema,
},
})
export const adminListWorkspacesContract = defineRouteContract({
method: 'GET',
path: '/api/v1/admin/workspaces',
query: adminPaginationQuerySchema,
response: {
mode: 'json',
schema: adminListResponseSchema(adminWorkspaceSchema),
},
})
export const adminGetWorkspaceContract = defineRouteContract({
method: 'GET',
path: '/api/v1/admin/workspaces/[id]',
params: adminIdParamsSchema,
response: {
mode: 'json',
schema: adminSingleResponseSchema(adminWorkspaceDetailSchema),
},
})
export const adminListWorkspaceWorkflowsContract = defineRouteContract({
method: 'GET',
path: '/api/v1/admin/workspaces/[id]/workflows',
params: adminIdParamsSchema,
query: adminPaginationQuerySchema,
response: {
mode: 'json',
schema: adminListResponseSchema(adminWorkflowSchema),
},
})
export const adminDeleteWorkspaceWorkflowsContract = defineRouteContract({
method: 'DELETE',
path: '/api/v1/admin/workspaces/[id]/workflows',
params: adminIdParamsSchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
deleted: z.number(),
}),
},
})
export const adminListWorkspaceFoldersContract = defineRouteContract({
method: 'GET',
path: '/api/v1/admin/workspaces/[id]/folders',
params: adminIdParamsSchema,
query: adminPaginationQuerySchema,
response: {
mode: 'json',
schema: adminListResponseSchema(adminFolderSchema),
},
})
export const adminExportWorkspaceContract = defineRouteContract({
method: 'GET',
path: '/api/v1/admin/workspaces/[id]/export',
params: adminIdParamsSchema,
query: adminExportFormatQuerySchema,
response: {
mode: 'binary',
},
})
export const adminImportWorkspaceContract = defineRouteContract({
method: 'POST',
path: '/api/v1/admin/workspaces/[id]/import',
params: adminIdParamsSchema,
query: adminWorkspaceImportQuerySchema,
response: {
mode: 'json',
schema: adminWorkspaceImportResponseSchema,
},
})
export const adminListWorkspaceMembersContract = defineRouteContract({
method: 'GET',
path: '/api/v1/admin/workspaces/[id]/members',
params: adminIdParamsSchema,
query: adminPaginationQuerySchema,
response: {
mode: 'json',
schema: adminListResponseSchema(adminWorkspaceMemberSchema),
},
})
export const adminCreateWorkspaceMemberContract = defineRouteContract({
method: 'POST',
path: '/api/v1/admin/workspaces/[id]/members',
params: adminIdParamsSchema,
body: adminWorkspaceMemberBodySchema,
response: {
mode: 'json',
schema: adminSingleResponseSchema(
adminWorkspaceMemberSchema.extend({
action: z.enum(['created', 'updated', 'already_member']),
})
),
},
})
export const adminDeleteWorkspaceMemberContract = defineRouteContract({
method: 'DELETE',
path: '/api/v1/admin/workspaces/[id]/members',
params: adminIdParamsSchema,
query: adminDeleteWorkspaceMemberQuerySchema,
response: {
mode: 'json',
schema: adminSingleResponseSchema(
z.object({
removed: z.literal(true),
userId: z.string(),
workspaceId: z.string(),
})
),
},
})
export const adminGetWorkspaceMemberContract = defineRouteContract({
method: 'GET',
path: '/api/v1/admin/workspaces/[id]/members/[memberId]',
params: adminWorkspaceMemberParamsSchema,
response: {
mode: 'json',
schema: adminSingleResponseSchema(adminWorkspaceMemberSchema),
},
})
export const adminUpdateWorkspaceMemberContract = defineRouteContract({
method: 'PATCH',
path: '/api/v1/admin/workspaces/[id]/members/[memberId]',
params: adminWorkspaceMemberParamsSchema,
body: adminUpdateWorkspaceMemberBodySchema,
response: {
mode: 'json',
schema: adminSingleResponseSchema(adminWorkspaceMemberSchema),
},
})
export const adminRemoveWorkspaceMemberContract = defineRouteContract({
method: 'DELETE',
path: '/api/v1/admin/workspaces/[id]/members/[memberId]',
params: adminWorkspaceMemberParamsSchema,
response: {
mode: 'json',
schema: adminSingleResponseSchema(
z.object({
removed: z.literal(true),
memberId: z.string(),
userId: z.string(),
workspaceId: z.string(),
})
),
},
})
export const adminExportFolderContract = defineRouteContract({
method: 'GET',
path: '/api/v1/admin/folders/[id]/export',
params: adminIdParamsSchema,
query: adminExportFormatQuerySchema,
response: {
mode: 'binary',
},
})
+142
View File
@@ -0,0 +1,142 @@
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts/types'
export const apiKeySchema = z.object({
id: z.string(),
name: z.string(),
key: z.string(),
displayKey: z.string().optional(),
lastUsed: z.string().nullable().optional(),
createdAt: z.string(),
expiresAt: z.string().nullable().optional(),
createdBy: z.string().nullable().optional(),
})
export type ApiKey = z.output<typeof apiKeySchema>
export const createApiKeyBodySchema = z.object({
name: z.string().trim().min(1, 'Name is required'),
source: z.enum(['settings', 'deploy_modal']).optional(),
})
export const createPersonalApiKeyBodySchema = createApiKeyBodySchema.pick({ name: true })
export const apiKeyIdParamsSchema = z.object({
id: z.string({ error: 'API key ID is required' }).min(1, 'API key ID is required'),
})
export const workspaceApiKeyParamsSchema = z.object({
id: z.string().min(1),
})
export const workspaceApiKeyIdParamsSchema = z.object({
id: z.string().min(1),
keyId: z.string().min(1),
})
export const updateWorkspaceApiKeyBodySchema = z.object({
name: z.string().min(1, 'Name is required'),
})
export const deleteWorkspaceApiKeysBodySchema = z.object({
keys: z.array(z.string()).min(1),
})
export const listPersonalApiKeysContract = defineRouteContract({
method: 'GET',
path: '/api/users/me/api-keys',
response: {
mode: 'json',
schema: z.object({
keys: z.array(apiKeySchema),
}),
},
})
export const createPersonalApiKeyContract = defineRouteContract({
method: 'POST',
path: '/api/users/me/api-keys',
body: createPersonalApiKeyBodySchema,
response: {
mode: 'json',
schema: z.object({
key: apiKeySchema,
}),
},
})
export const deletePersonalApiKeyContract = defineRouteContract({
method: 'DELETE',
path: '/api/users/me/api-keys/[id]',
params: apiKeyIdParamsSchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
}),
},
})
export const listWorkspaceApiKeysContract = defineRouteContract({
method: 'GET',
path: '/api/workspaces/[id]/api-keys',
params: workspaceApiKeyParamsSchema,
response: {
mode: 'json',
schema: z.object({
keys: z.array(apiKeySchema),
}),
},
})
export const createWorkspaceApiKeyContract = defineRouteContract({
method: 'POST',
path: '/api/workspaces/[id]/api-keys',
params: workspaceApiKeyParamsSchema,
body: createApiKeyBodySchema,
response: {
mode: 'json',
schema: z.object({
key: apiKeySchema,
}),
},
})
export const deleteWorkspaceApiKeyContract = defineRouteContract({
method: 'DELETE',
path: '/api/workspaces/[id]/api-keys/[keyId]',
params: workspaceApiKeyIdParamsSchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
}),
},
})
export const updateWorkspaceApiKeyContract = defineRouteContract({
method: 'PUT',
path: '/api/workspaces/[id]/api-keys/[keyId]',
params: workspaceApiKeyIdParamsSchema,
body: updateWorkspaceApiKeyBodySchema,
response: {
mode: 'json',
schema: z.object({
key: apiKeySchema,
}),
},
})
export const deleteWorkspaceApiKeysContract = defineRouteContract({
method: 'DELETE',
path: '/api/workspaces/[id]/api-keys',
params: workspaceApiKeyParamsSchema,
body: deleteWorkspaceApiKeysBodySchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
deletedCount: z.number(),
}),
},
})
+87
View File
@@ -0,0 +1,87 @@
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts/types'
export const auditLogsQuerySchema = z.object({
search: z
.string()
.optional()
.transform((value) => value?.trim() || undefined),
action: z.string().optional(),
resourceType: z.string().optional(),
actorId: z.string().optional(),
startDate: z
.string()
.optional()
.refine((value) => !value || !Number.isNaN(Date.parse(value)), {
message: 'Invalid startDate format',
}),
endDate: z
.string()
.optional()
.refine((value) => !value || !Number.isNaN(Date.parse(value)), {
message: 'Invalid endDate format',
}),
includeDeparted: z
.string()
.optional()
.transform((value) => value === 'true'),
limit: z
.string()
.optional()
.transform((value) => Math.min(Math.max(Number(value) || 50, 1), 100)),
cursor: z.string().optional(),
})
export type AuditLogsQuery = z.output<typeof auditLogsQuerySchema>
export const enterpriseAuditLogEntrySchema = z.object({
id: z.string(),
workspaceId: z.string().nullable(),
actorId: z.string().nullable(),
actorName: z.string().nullable(),
actorEmail: z.string().nullable(),
action: z.string(),
resourceType: z.string(),
resourceId: z.string().nullable(),
resourceName: z.string().nullable(),
description: z.string().nullable(),
metadata: z.unknown(),
createdAt: z.string(),
})
export type EnterpriseAuditLogEntry = z.output<typeof enterpriseAuditLogEntrySchema>
export const listAuditLogsResponseSchema = z.object({
success: z.boolean(),
data: z.array(enterpriseAuditLogEntrySchema),
nextCursor: z.string().optional(),
})
export type AuditLogPage = z.output<typeof listAuditLogsResponseSchema>
export const listAuditLogsContract = defineRouteContract({
method: 'GET',
path: '/api/audit-logs',
query: auditLogsQuerySchema,
response: {
mode: 'json',
schema: listAuditLogsResponseSchema,
},
})
export const exportAuditLogsQuerySchema = auditLogsQuerySchema.omit({ limit: true, cursor: true })
export type ExportAuditLogsQuery = z.output<typeof exportAuditLogsQuerySchema>
/**
* CSV download of every audit log matching the filter (no pagination). `mode:
* 'text'` because a CSV response has no JSON schema to validate; the client
* triggers this via `fetch` + blob (not `requestJson`), so there's no
* response shape for a consumer to type.
*/
export const exportAuditLogsContract = defineRouteContract({
method: 'GET',
path: '/api/audit-logs/export',
query: exportAuditLogsQuerySchema,
response: {
mode: 'text',
},
})
+127
View File
@@ -0,0 +1,127 @@
import { z } from 'zod'
import type { ContractJsonResponse } from '@/lib/api/contracts/types'
import { defineRouteContract } from '@/lib/api/contracts/types'
export const ssoProvidersQuerySchema = z.object({
organizationId: z.string().min(1).optional(),
})
export const authProviderStatusResponseSchema = z.object({
githubAvailable: z.boolean(),
googleAvailable: z.boolean(),
microsoftAvailable: z.boolean(),
registrationDisabled: z.boolean(),
})
const ssoMappingSchema = z
.object({
id: z.string().default('sub'),
email: z.string().default('email'),
name: z.string().default('name'),
image: z.string().default('picture'),
})
.default({
id: 'sub',
email: 'email',
name: 'name',
image: 'picture',
})
export const ssoRegistrationBodySchema = z.discriminatedUnion('providerType', [
z.object({
providerType: z.literal('oidc').default('oidc'),
providerId: z.string().min(1, 'Provider ID is required'),
issuer: z.string().url('Issuer must be a valid URL'),
domain: z.string().min(1, 'Domain is required'),
orgId: z.string().optional(),
mapping: ssoMappingSchema,
clientId: z.string().min(1, 'Client ID is required for OIDC'),
clientSecret: z.string().min(1, 'Client Secret is required for OIDC'),
scopes: z
.union([
z.string().transform((s) =>
s
.split(',')
.map((value) => value.trim())
.filter((value) => value !== '')
),
z.array(z.string()),
])
.default(['openid', 'profile', 'email']),
pkce: z.boolean().default(true),
authorizationEndpoint: z.string().url().optional(),
tokenEndpoint: z.string().url().optional(),
userInfoEndpoint: z.string().url().optional(),
skipUserInfoEndpoint: z.boolean().default(false),
jwksEndpoint: z.string().url().optional(),
}),
z.object({
providerType: z.literal('saml'),
providerId: z.string().min(1, 'Provider ID is required'),
issuer: z.string().url('Issuer must be a valid URL'),
domain: z.string().min(1, 'Domain is required'),
orgId: z.string().optional(),
mapping: ssoMappingSchema,
entryPoint: z.string().url('Entry point must be a valid URL for SAML'),
cert: z.string().min(1, 'Certificate is required for SAML'),
callbackUrl: z.string().url().optional(),
audience: z.string().optional(),
wantAssertionsSigned: z.boolean().optional(),
signatureAlgorithm: z.string().optional(),
digestAlgorithm: z.string().optional(),
identifierFormat: z.string().optional(),
idpMetadata: z.string().optional(),
}),
])
export type SsoRegistrationBody = z.input<typeof ssoRegistrationBodySchema>
export const ssoRegistrationContract = defineRouteContract({
method: 'POST',
path: '/api/auth/sso/register',
body: ssoRegistrationBodySchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
providerId: z.string(),
providerType: z.enum(['oidc', 'saml']),
message: z.string(),
}),
},
})
const ssoProviderListEntrySchema = z.object({
id: z.string().optional(),
providerId: z.string().optional(),
domain: z.string().nullable(),
issuer: z.string().nullable().optional(),
oidcConfig: z.string().nullable().optional(),
samlConfig: z.string().nullable().optional(),
userId: z.string().nullable().optional(),
organizationId: z.string().nullable().optional(),
providerType: z.enum(['oidc', 'saml']).optional(),
})
export const listSsoProvidersContract = defineRouteContract({
method: 'GET',
path: '/api/auth/sso/providers',
query: ssoProvidersQuerySchema,
response: {
mode: 'json',
schema: z.object({
providers: z.array(ssoProviderListEntrySchema),
}),
},
})
export const getAuthProvidersContract = defineRouteContract({
method: 'GET',
path: '/api/auth/providers',
response: {
mode: 'json',
schema: authProviderStatusResponseSchema,
},
})
export type AuthProviderStatusResponse = ContractJsonResponse<typeof getAuthProvidersContract>
@@ -0,0 +1,37 @@
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts'
import { workspaceIdSchema } from '@/lib/api/contracts/primitives'
/**
* Per-viewer block visibility projection (see
* `@/lib/core/config/block-visibility`): which `preview: true` block types this
* viewer may see, which types are kill-switched, and which revealed types carry
* the " (Preview)" display tag.
*/
const getBlockVisibilityQuerySchema = z.object({
workspaceId: workspaceIdSchema,
})
export type GetBlockVisibilityQuery = z.input<typeof getBlockVisibilityQuerySchema>
const blockVisibilityResponseSchema = z.object({
/** Preview block types revealed to this viewer. */
revealed: z.array(z.string()),
/** Block types kill-switched (hidden from discovery) for this viewer. */
disabled: z.array(z.string()),
/** Revealed types not globally GA — displayed with a " (Preview)" suffix. */
previewTagged: z.array(z.string()),
})
export type BlockVisibilityResponse = z.output<typeof blockVisibilityResponseSchema>
export const getBlockVisibilityContract = defineRouteContract({
method: 'GET',
path: '/api/blocks/visibility',
query: getBlockVisibilityQuerySchema,
response: {
mode: 'json',
schema: blockVisibilityResponseSchema,
},
})
+124
View File
@@ -0,0 +1,124 @@
import { z } from 'zod'
import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types'
export const byokProviderIdSchema = z.enum([
'openai',
'anthropic',
'google',
'mistral',
'zai',
'xai',
'fireworks',
'together',
'baseten',
'ollama-cloud',
'falai',
'firecrawl',
'exa',
'context_dev',
'serper',
'linkup',
'perplexity',
'jina',
'google_cloud',
'parallel_ai',
'brandfetch',
'cohere',
'hunter',
'peopledatalabs',
'findymail',
'prospeo',
'wiza',
'zerobounce',
'neverbounce',
'millionverifier',
'datagma',
'dropcontact',
'leadmagic',
'icypeas',
'enrow',
])
/** Maximum number of keys a workspace may store per provider. */
export const MAX_BYOK_KEYS_PER_PROVIDER = 10
export const byokKeySchema = z.object({
id: z.string(),
providerId: byokProviderIdSchema,
name: z.string().nullable(),
maskedKey: z.string(),
createdBy: z.string().nullable(),
createdAt: z.string(),
updatedAt: z.string(),
})
export type BYOKKey = z.output<typeof byokKeySchema>
export const byokKeyMutationSchema = z.object({
id: z.string(),
providerId: byokProviderIdSchema,
name: z.string().nullable(),
maskedKey: z.string(),
createdAt: z.string().optional(),
updatedAt: z.string().optional(),
})
export const byokWorkspaceParamsSchema = z.object({
id: z.string().min(1),
})
export const upsertByokKeyBodySchema = z.object({
providerId: byokProviderIdSchema,
apiKey: z.string().min(1, 'API key is required'),
/** When set, updates that specific key; otherwise a new key is added for the provider. */
keyId: z.string().min(1, 'keyId cannot be empty').optional(),
/** Display label for the key. An empty string clears the label. */
name: z.string().trim().max(120, 'Name must be 120 characters or fewer').optional(),
})
export const deleteByokKeyBodySchema = z.object({
providerId: byokProviderIdSchema,
/** When set, deletes only that key; otherwise every key for the provider is removed. */
keyId: z.string().min(1, 'keyId cannot be empty').optional(),
})
export const listByokKeysContract = defineRouteContract({
method: 'GET',
path: '/api/workspaces/[id]/byok-keys',
params: byokWorkspaceParamsSchema,
response: {
mode: 'json',
schema: z.object({
keys: z.array(byokKeySchema),
}),
},
})
export const upsertByokKeyContract = defineRouteContract({
method: 'POST',
path: '/api/workspaces/[id]/byok-keys',
params: byokWorkspaceParamsSchema,
body: upsertByokKeyBodySchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
key: byokKeyMutationSchema,
}),
},
})
export const deleteByokKeyContract = defineRouteContract({
method: 'DELETE',
path: '/api/workspaces/[id]/byok-keys',
params: byokWorkspaceParamsSchema,
body: deleteByokKeyBodySchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
}),
},
})
export type BYOKKeysResponse = ContractJsonResponse<typeof listByokKeysContract>
+279
View File
@@ -0,0 +1,279 @@
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts/types'
export const chatAuthTypeSchema = z.enum(['public', 'password', 'email', 'sso'])
export type ChatAuthType = z.output<typeof chatAuthTypeSchema>
export const chatIdParamsSchema = z.object({
id: z.string().min(1),
})
export const chatIdentifierParamsSchema = z.object({
identifier: z.string().min(1),
})
export const chatOutputConfigSchema = z.object({
blockId: z.string().min(1),
path: z.string().min(1),
})
export const deployedChatOutputConfigSchema = z.object({
blockId: z.string(),
path: z.string().optional(),
})
export const chatCustomizationsSchema = z.object({
primaryColor: z.string(),
welcomeMessage: z.string(),
imageUrl: z.string().optional(),
})
export const createChatBodySchema = z.object({
workflowId: z.string().min(1, 'Workflow ID is required'),
identifier: z
.string()
.min(1, 'Identifier is required')
.regex(/^[a-z0-9-]+$/, 'Identifier can only contain lowercase letters, numbers, and hyphens'),
title: z.string().min(1, 'Title is required'),
description: z.string().optional(),
customizations: chatCustomizationsSchema,
authType: chatAuthTypeSchema.default('public'),
password: z.string().optional(),
allowedEmails: z.array(z.string()).optional().default([]),
outputConfigs: z.array(chatOutputConfigSchema).optional().default([]),
})
export type CreateChatBody = z.input<typeof createChatBodySchema>
export const updateChatBodySchema = z.object({
workflowId: z.string().min(1, 'Workflow ID is required').optional(),
identifier: z
.string()
.min(1, 'Identifier is required')
.regex(/^[a-z0-9-]+$/, 'Identifier can only contain lowercase letters, numbers, and hyphens')
.optional(),
title: z.string().min(1, 'Title is required').optional(),
description: z.string().optional(),
customizations: chatCustomizationsSchema.optional(),
authType: chatAuthTypeSchema.optional(),
password: z.string().optional(),
allowedEmails: z.array(z.string()).optional(),
outputConfigs: z.array(chatOutputConfigSchema).optional(),
})
export type UpdateChatBody = z.input<typeof updateChatBodySchema>
export const createChatResponseSchema = z.object({
id: z.string(),
chatId: z.string(),
chatUrl: z.string(),
message: z.string(),
})
export type CreateChatResponse = z.output<typeof createChatResponseSchema>
export const updateChatResponseSchema = z.object({
id: z.string(),
chatUrl: z.string(),
message: z.string(),
})
export type UpdateChatResponse = z.output<typeof updateChatResponseSchema>
export const deleteChatResponseSchema = z.object({
message: z.string(),
})
export const deployedChatConfigSchema = z.object({
id: z.string(),
title: z.string(),
description: z.preprocess((value) => value ?? '', z.string()),
customizations: z.preprocess(
(value) => value ?? {},
z
.object({
primaryColor: z.string().optional(),
logoUrl: z.string().optional(),
imageUrl: z.string().optional(),
welcomeMessage: z.string().optional(),
headerText: z.string().optional(),
})
.passthrough()
),
authType: z.preprocess((value) => value ?? 'public', chatAuthTypeSchema),
outputConfigs: z.preprocess(
(value) => value ?? undefined,
z.array(deployedChatOutputConfigSchema).optional()
),
})
export type DeployedChatConfig = z.output<typeof deployedChatConfigSchema>
export const deployedChatAuthBodySchema = z.object({
password: z.string().max(1024, 'Password is too long').optional(),
email: z.string().email('Invalid email format').optional().or(z.literal('')),
})
export type DeployedChatAuthBody = z.input<typeof deployedChatAuthBodySchema>
const MAX_CHAT_INPUT_CHARS = 1_000_000
const MAX_CHAT_FILE_DATA_CHARS = 14 * 1024 * 1024
const MAX_CHAT_FILES = 15
export const deployedChatFileSchema = z.object({
name: z.string().min(1, 'File name is required').max(255, 'File name is too long'),
type: z.string().min(1, 'File type is required').max(255, 'File type is too long'),
size: z.number().positive('File size must be positive'),
data: z
.string()
.min(1, 'File data is required')
.max(MAX_CHAT_FILE_DATA_CHARS, 'File data exceeds the maximum allowed size'),
lastModified: z.number().optional(),
})
export const deployedChatPostBodySchema = z.object({
input: z.string().max(MAX_CHAT_INPUT_CHARS, 'Input is too long').optional(),
password: z.string().max(1024, 'Password is too long').optional(),
email: z.string().email('Invalid email format').optional().or(z.literal('')),
conversationId: z.string().max(256, 'Conversation ID is too long').optional(),
files: z
.array(deployedChatFileSchema)
.max(MAX_CHAT_FILES, `A maximum of ${MAX_CHAT_FILES} files is allowed`)
.optional()
.default([]),
})
export type DeployedChatPostBody = z.input<typeof deployedChatPostBodySchema>
export const chatSSOBodySchema = z.object({
email: z.string().email('Invalid email address'),
})
export const chatSSOResponseSchema = z.object({
eligible: z.boolean(),
})
export type ChatSSOResponse = z.output<typeof chatSSOResponseSchema>
export const chatEmailOtpRequestBodySchema = z.object({
email: z.string().email('Invalid email address'),
})
export const chatEmailOtpVerifyBodySchema = chatEmailOtpRequestBodySchema.extend({
otp: z.string().length(6, 'OTP must be 6 digits'),
})
export const chatEmailOtpRequestResponseSchema = z.object({
message: z.string(),
})
export const identifierValidationQuerySchema = z.object({
identifier: z
.string()
.min(1, 'Identifier is required')
.regex(/^[a-z0-9-]+$/, 'Identifier can only contain lowercase letters, numbers, and hyphens')
.max(100, 'Identifier must be 100 characters or less'),
})
export const identifierValidationResponseSchema = z.object({
available: z.boolean(),
error: z.string().nullable().optional(),
})
export const createChatContract = defineRouteContract({
method: 'POST',
path: '/api/chat',
body: createChatBodySchema,
response: {
mode: 'json',
schema: createChatResponseSchema,
},
})
export const getDeployedChatConfigContract = defineRouteContract({
method: 'GET',
path: '/api/chat/[identifier]',
params: chatIdentifierParamsSchema,
response: {
mode: 'json',
schema: deployedChatConfigSchema,
},
})
export const authenticateDeployedChatContract = defineRouteContract({
method: 'POST',
path: '/api/chat/[identifier]',
params: chatIdentifierParamsSchema,
body: deployedChatAuthBodySchema,
response: {
mode: 'json',
schema: deployedChatConfigSchema,
},
})
export const deployedChatPostContract = defineRouteContract({
method: 'POST',
path: '/api/chat/[identifier]',
params: chatIdentifierParamsSchema,
body: deployedChatPostBodySchema,
response: {
mode: 'json',
schema: deployedChatConfigSchema,
},
})
export const chatSSOContract = defineRouteContract({
method: 'POST',
path: '/api/chat/[identifier]/sso',
params: chatIdentifierParamsSchema,
body: chatSSOBodySchema,
response: {
mode: 'json',
schema: chatSSOResponseSchema,
},
})
export const requestChatEmailOtpContract = defineRouteContract({
method: 'POST',
path: '/api/chat/[identifier]/otp',
params: chatIdentifierParamsSchema,
body: chatEmailOtpRequestBodySchema,
response: {
mode: 'json',
schema: chatEmailOtpRequestResponseSchema,
},
})
export const verifyChatEmailOtpContract = defineRouteContract({
method: 'PUT',
path: '/api/chat/[identifier]/otp',
params: chatIdentifierParamsSchema,
body: chatEmailOtpVerifyBodySchema,
response: {
mode: 'json',
schema: deployedChatConfigSchema,
},
})
export const validateChatIdentifierContract = defineRouteContract({
method: 'GET',
path: '/api/chat/validate',
query: identifierValidationQuerySchema,
response: {
mode: 'json',
schema: identifierValidationResponseSchema,
},
})
export const updateChatContract = defineRouteContract({
method: 'PATCH',
path: '/api/chat/manage/[id]',
params: chatIdParamsSchema,
body: updateChatBodySchema,
response: {
mode: 'json',
schema: updateChatResponseSchema,
},
})
export const deleteChatContract = defineRouteContract({
method: 'DELETE',
path: '/api/chat/manage/[id]',
params: chatIdParamsSchema,
response: {
mode: 'json',
schema: deleteChatResponseSchema,
},
})
+130
View File
@@ -0,0 +1,130 @@
import { z } from 'zod'
import { jobIdParamsSchema } from '@/lib/api/contracts/primitives'
import { defineRouteContract } from '@/lib/api/contracts/types'
const NO_EMAIL_HEADER_CONTROL_CHARS_REGEX = /^[^\r\n\u0000-\u001F\u007F]+$/
export const helpFormBodySchema = z.object({
subject: z
.string()
.trim()
.min(1, 'Subject is required')
.regex(NO_EMAIL_HEADER_CONTROL_CHARS_REGEX, 'Invalid characters'),
message: z.string().min(1, 'Message is required'),
type: z.enum(['bug', 'feedback', 'feature_request', 'other']),
})
export type HelpFormBody = z.input<typeof helpFormBodySchema>
export const emailPreviewQuerySchema = z.object({
template: z.string().optional(),
})
export const integrationRequestBodySchema = z.object({
integrationName: z
.string()
.trim()
.min(1, 'Integration name is required')
.max(200)
.regex(NO_EMAIL_HEADER_CONTROL_CHARS_REGEX, 'Invalid characters'),
email: z.string().email('A valid email is required'),
useCase: z.string().max(2000).optional(),
})
export type IntegrationRequestBody = z.input<typeof integrationRequestBodySchema>
export const integrationRequestResponseSchema = z.object({
success: z.literal(true),
message: z.string(),
})
export const integrationRequestContract = defineRouteContract({
method: 'POST',
path: '/api/help/integration-request',
body: integrationRequestBodySchema,
response: {
mode: 'json',
schema: integrationRequestResponseSchema,
},
})
export const getAllowedProvidersContract = defineRouteContract({
method: 'GET',
path: '/api/settings/allowed-providers',
response: {
mode: 'json',
schema: z.object({
blacklistedProviders: z.array(z.string()),
}),
},
})
export const getAllowedIntegrationsContract = defineRouteContract({
method: 'GET',
path: '/api/settings/allowed-integrations',
response: {
mode: 'json',
schema: z.object({
// `null` means "no env-derived allowlist" (unrestricted); a non-null
// array narrows the visible integrations.
allowedIntegrations: z.array(z.string()).nullable(),
}),
},
})
export const getVoiceSettingsContract = defineRouteContract({
method: 'GET',
path: '/api/settings/voice',
response: {
mode: 'json',
schema: z.object({
sttAvailable: z.boolean(),
}),
},
})
export const getStarsContract = defineRouteContract({
method: 'GET',
path: '/api/stars',
response: {
mode: 'json',
schema: z.object({
stars: z.string(),
}),
},
})
export const getStatusContract = defineRouteContract({
method: 'GET',
path: '/api/status',
response: {
mode: 'json',
schema: z.object({
status: z.enum(['operational', 'degraded', 'outage', 'maintenance', 'loading', 'error']),
message: z.string(),
url: z.string().url(),
lastUpdated: z.string(),
}),
},
})
const jobStatusSchema = z.enum(['pending', 'processing', 'completed', 'failed'])
const jobStatusResponseSchema = z
.object({
success: z.literal(true),
taskId: z.string(),
status: jobStatusSchema,
metadata: z.record(z.string(), z.unknown()).nullable().optional(),
output: z.unknown().optional(),
error: z.string().optional(),
})
.passthrough()
export const getJobStatusContract = defineRouteContract({
method: 'GET',
path: '/api/jobs/[jobId]',
params: jobIdParamsSchema,
response: {
mode: 'json',
schema: jobStatusResponseSchema,
},
})
+106
View File
@@ -0,0 +1,106 @@
import { z } from 'zod'
import type { ContractBodyInput } from '@/lib/api/contracts/types'
import { defineRouteContract } from '@/lib/api/contracts/types'
import { NO_EMAIL_HEADER_CONTROL_CHARS_REGEX } from '@/lib/messaging/email/utils'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
export const CONTACT_TOPIC_VALUES = [
'general',
'support',
'integration',
'feature_request',
'sales',
'partnership',
'billing',
'other',
] as const
export const CONTACT_TOPIC_OPTIONS = [
{ value: 'general', label: 'General question' },
{ value: 'support', label: 'Technical support' },
{ value: 'integration', label: 'Integration request' },
{ value: 'feature_request', label: 'Feature request' },
{ value: 'sales', label: 'Sales & pricing' },
{ value: 'partnership', label: 'Partnership' },
{ value: 'billing', label: 'Billing' },
{ value: 'other', label: 'Other' },
] as const
export const contactRequestSchema = z.object({
name: z
.string()
.trim()
.min(1, 'Name is required')
.max(120, 'Name must be 120 characters or less')
.regex(NO_EMAIL_HEADER_CONTROL_CHARS_REGEX, 'Invalid characters'),
email: z
.string()
.trim()
.min(1, 'Email is required')
.max(320)
.transform((value) => value.toLowerCase())
.refine((value) => quickValidateEmail(value).isValid, 'Enter a valid email'),
company: z
.string()
.trim()
.max(120, 'Company must be 120 characters or less')
.optional()
.transform((value) => (value && value.length > 0 ? value : undefined)),
topic: z.enum(CONTACT_TOPIC_VALUES, {
error: 'Please select a topic',
}),
subject: z
.string()
.trim()
.min(1, 'Subject is required')
.max(200, 'Subject must be 200 characters or less')
.regex(NO_EMAIL_HEADER_CONTROL_CHARS_REGEX, 'Invalid characters'),
message: z
.string()
.trim()
.min(1, 'Message is required')
.max(5000, 'Message must be 5,000 characters or less'),
})
export type ContactRequestPayload = z.infer<typeof contactRequestSchema>
export type ContactRequestBody = z.input<typeof contactRequestSchema>
export const submitContactBodySchema = contactRequestSchema.extend({
website: z.string().optional(),
captchaToken: z.string().optional(),
})
export function getContactTopicLabel(value: ContactRequestPayload['topic']): string {
return CONTACT_TOPIC_OPTIONS.find((option) => option.value === value)?.label ?? value
}
export type HelpEmailType = 'bug' | 'feedback' | 'feature_request' | 'other'
/**
* Map a contact topic to the confirmation-email type. Only `feature_request` has
* a matching email label ("Feature Request"); every other topic — support, sales,
* billing, etc. — resolves to `other` ("General Inquiry") so the confirmation copy
* never mislabels the request (e.g. calling a support inquiry a "bug report").
*/
export function mapContactTopicToHelpType(topic: ContactRequestPayload['topic']): HelpEmailType {
return topic === 'feature_request' ? 'feature_request' : 'other'
}
export const contactResponseSchema = z.object({
success: z.literal(true),
message: z.string(),
})
export type SubmitContactResult = z.output<typeof contactResponseSchema>
export const submitContactContract = defineRouteContract({
method: 'POST',
path: '/api/contact',
body: submitContactBodySchema,
response: {
mode: 'json',
schema: contactResponseSchema,
},
})
export type SubmitContactBody = ContractBodyInput<typeof submitContactContract>
+775
View File
@@ -0,0 +1,775 @@
import { z } from 'zod'
import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types'
import { cleanedWorkflowStateSchema } from '@/lib/api/contracts/workflows'
import {
ASYNC_TOOL_CONFIRMATION_STATUS,
type AsyncConfirmationStatus,
} from '@/lib/copilot/async-runs/lifecycle'
export const copilotApiKeySchema = z.object({
id: z.string(),
displayKey: z.string(),
name: z.string().nullable(),
createdAt: z.string().nullable(),
lastUsed: z.string().nullable(),
})
export type CopilotApiKey = z.output<typeof copilotApiKeySchema>
export const deleteCopilotApiKeyQuerySchema = z.object({
id: z.string().min(1),
})
export const generateCopilotApiKeyBodySchema = z.object({
name: z.string().min(1, 'Name is required').max(255, 'Name is too long'),
})
export const submitCopilotFeedbackBodySchema = z.object({
chatId: z.string().uuid('Chat ID must be a valid UUID'),
userQuery: z.string().min(1, 'User query is required'),
agentResponse: z.string().min(1, 'Agent response is required'),
isPositiveFeedback: z.boolean(),
feedback: z.string().optional(),
workflowYaml: z.string().optional(),
})
export type SubmitCopilotFeedbackBody = z.input<typeof submitCopilotFeedbackBodySchema>
export const copilotCredentialsQuerySchema = z.object({})
export const copilotConfirmBodySchema = z.object({
toolCallId: z.string().min(1, 'Tool call ID is required'),
status: z.enum(
Object.values(ASYNC_TOOL_CONFIRMATION_STATUS) as [
AsyncConfirmationStatus,
...AsyncConfirmationStatus[],
],
{ error: 'Invalid notification status' }
),
message: z.string().optional(),
data: z.unknown().optional(),
})
export type CopilotConfirmBody = z.input<typeof copilotConfirmBodySchema>
export const createWorkflowCopilotChatBodySchema = z.object({
workspaceId: z.string().min(1),
workflowId: z.string().min(1),
})
export type CreateWorkflowCopilotChatBody = z.input<typeof createWorkflowCopilotChatBodySchema>
export const copilotTrainingExampleBodySchema = z.object({
json: z.string().min(1, 'JSON string is required'),
title: z.string().min(1, 'Title is required'),
tags: z.array(z.string()).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
})
export type CopilotTrainingExampleBody = z.input<typeof copilotTrainingExampleBodySchema>
const copilotTrainingOperationSchema = z.object({
operation_type: z.string(),
block_id: z.string(),
params: z.record(z.string(), z.unknown()).optional(),
})
export const copilotTrainingDataBodySchema = z.object({
title: z.string().min(1, 'Title is required'),
prompt: z.string().min(1, 'Prompt is required'),
input: z.record(z.string(), z.unknown()),
output: z.record(z.string(), z.unknown()),
operations: z.array(copilotTrainingOperationSchema),
})
export type CopilotTrainingDataBody = z.input<typeof copilotTrainingDataBodySchema>
export const renameCopilotChatBodySchema = z.object({
chatId: z.string().min(1),
title: z.string().min(1).max(200),
})
export type RenameCopilotChatBody = z.input<typeof renameCopilotChatBodySchema>
const copilotResourceTypeSchema = z.enum([
'table',
'file',
'workflow',
'knowledgebase',
'folder',
'scheduledtask',
'log',
])
export const addCopilotChatResourceBodySchema = z.object({
chatId: z.string(),
resource: z.object({
type: copilotResourceTypeSchema,
id: z.string(),
title: z.string(),
}),
})
export type AddCopilotChatResourceBody = z.input<typeof addCopilotChatResourceBodySchema>
export const removeCopilotChatResourceBodySchema = z.object({
chatId: z.string(),
resourceType: copilotResourceTypeSchema,
resourceId: z.string(),
})
export type RemoveCopilotChatResourceBody = z.input<typeof removeCopilotChatResourceBodySchema>
export const reorderCopilotChatResourcesBodySchema = z.object({
chatId: z.string(),
resources: z.array(
z.object({
type: copilotResourceTypeSchema,
id: z.string(),
title: z.string(),
})
),
})
export type ReorderCopilotChatResourcesBody = z.input<typeof reorderCopilotChatResourcesBodySchema>
export const revertCopilotCheckpointBodySchema = z.object({
checkpointId: z.string().min(1),
})
export type RevertCopilotCheckpointBody = z.input<typeof revertCopilotCheckpointBodySchema>
export const copilotChatAbortBodySchema = z.object({
streamId: z.string().optional(),
chatId: z.string().optional(),
})
export type CopilotChatAbortBody = z.input<typeof copilotChatAbortBodySchema>
export const copilotChatGetQuerySchema = z
.object({
workflowId: z.string().optional(),
workspaceId: z.string().optional(),
chatId: z.string().optional(),
})
.passthrough()
export const copilotModelsQuerySchema = z.object({})
export const createCopilotCheckpointBodySchema = z.object({
workflowId: z.string(),
chatId: z.string(),
messageId: z.string().optional(),
workflowState: z.string(),
})
export type CreateCopilotCheckpointBody = z.input<typeof createCopilotCheckpointBodySchema>
export const listCopilotCheckpointsQuerySchema = z.object({
chatId: z.string({ error: 'chatId is required' }).min(1, 'chatId is required'),
})
export type ListCopilotCheckpointsQuery = z.input<typeof listCopilotCheckpointsQuerySchema>
export const copilotChatStreamQuerySchema = z.object({
streamId: z.string().optional().default(''),
after: z.string().optional().default(''),
batch: z
.string()
.optional()
.transform((value) => value === 'true'),
})
const storedToolCallSchema = z
.object({
id: z.string().optional(),
name: z.string().optional(),
state: z.string().optional(),
params: z.record(z.string(), z.unknown()).optional(),
result: z
.object({
success: z.boolean(),
output: z.unknown().optional(),
error: z.string().optional(),
})
.optional(),
display: z
.object({
text: z.string().optional(),
title: z.string().optional(),
phaseLabel: z.string().optional(),
})
.optional(),
calledBy: z.string().optional(),
durationMs: z.number().optional(),
error: z.string().optional(),
})
.nullable()
const copilotContentBlockSchema = z.object({
type: z.string(),
lane: z.enum(['main', 'subagent']).optional(),
content: z.string().optional(),
channel: z.enum(['assistant', 'thinking']).optional(),
phase: z.enum(['call', 'args_delta', 'result']).optional(),
kind: z.enum(['subagent', 'structured_result', 'subagent_result']).optional(),
lifecycle: z.enum(['start', 'end']).optional(),
status: z.enum(['complete', 'error', 'cancelled']).optional(),
parentToolCallId: z.string().optional(),
toolCall: storedToolCallSchema.optional(),
timestamp: z.number().optional(),
endedAt: z.number().optional(),
})
export const copilotChatStopBodySchema = z.object({
chatId: z.string(),
streamId: z.string(),
content: z.string(),
contentBlocks: z.array(copilotContentBlockSchema).optional(),
requestId: z.string().optional(),
})
export type CopilotChatStopBody = z.input<typeof copilotChatStopBodySchema>
export const deleteCopilotChatBodySchema = z.object({
chatId: z.string(),
})
export type DeleteCopilotChatBody = z.input<typeof deleteCopilotChatBodySchema>
const copilotPersistedMessageSchema = z
.object({
id: z.string(),
role: z.enum(['user', 'assistant', 'system']),
content: z.string(),
timestamp: z.string(),
toolCalls: z.array(z.any()).optional(),
contentBlocks: z.array(z.any()).optional(),
fileAttachments: z
.array(
z.object({
id: z.string(),
key: z.string(),
filename: z.string(),
media_type: z.string(),
size: z.number(),
})
)
.optional(),
contexts: z.array(z.any()).optional(),
citations: z.array(z.any()).optional(),
errorType: z.string().optional(),
})
.passthrough()
export const updateCopilotMessagesBodySchema = z.object({
chatId: z.string(),
messages: z.array(copilotPersistedMessageSchema),
planArtifact: z.string().nullable().optional(),
config: z
.object({
mode: z.string().optional(),
model: z.string().optional(),
})
.nullable()
.optional(),
})
export type UpdateCopilotMessagesBody = z.input<typeof updateCopilotMessagesBodySchema>
export const validateCopilotApiKeyBodySchema = z.object({
userId: z.string().min(1, 'userId is required'),
/**
* Originating workspace. Used to enforce per-member org-workspace credit limits
* at mothership/copilot request time. Required: the Go mothership always resolves
* a workspace for a chat request, so a missing value must fail closed (block the
* request) rather than silently skip the per-member gate.
*/
workspaceId: z.string().min(1),
})
export type ValidateCopilotApiKeyBody = z.input<typeof validateCopilotApiKeyBodySchema>
export const listCopilotApiKeysContract = defineRouteContract({
method: 'GET',
path: '/api/copilot/api-keys',
response: {
mode: 'json',
schema: z.object({
keys: z.array(copilotApiKeySchema),
}),
},
})
export const copilotChatListItemSchema = z.object({
id: z.string(),
title: z.string().nullable(),
workflowId: z.string().nullable().optional(),
workspaceId: z.string().nullable().optional(),
activeStreamId: z.string().nullable(),
updatedAt: z.string().nullable(),
})
export type CopilotChatListItem = z.output<typeof copilotChatListItemSchema>
export const listCopilotChatsContract = defineRouteContract({
method: 'GET',
path: '/api/copilot/chats',
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
chats: z.array(copilotChatListItemSchema),
}),
},
})
export const generateCopilotApiKeyContract = defineRouteContract({
method: 'POST',
path: '/api/copilot/api-keys/generate',
body: generateCopilotApiKeyBodySchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
key: z.object({
id: z.string(),
apiKey: z.string(),
}),
}),
},
})
export type GenerateCopilotApiKeyResult = ContractJsonResponse<typeof generateCopilotApiKeyContract>
export const deleteCopilotApiKeyContract = defineRouteContract({
method: 'DELETE',
path: '/api/copilot/api-keys',
query: deleteCopilotApiKeyQuerySchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
}),
},
})
export const submitCopilotFeedbackContract = defineRouteContract({
method: 'POST',
path: '/api/copilot/feedback',
body: submitCopilotFeedbackBodySchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
feedbackId: z.string(),
message: z.string(),
metadata: z.object({
requestId: z.string(),
duration: z.number(),
}),
}),
},
})
export type SubmitCopilotFeedbackResult = ContractJsonResponse<typeof submitCopilotFeedbackContract>
const successFlagSchema = z.object({ success: z.literal(true) })
const copilotCheckpointSchema = z.object({
id: z.string(),
userId: z.string(),
workflowId: z.string(),
chatId: z.string(),
messageId: z.string().nullable().optional(),
createdAt: z.string().nullable(),
updatedAt: z.string().nullable(),
})
const copilotChatResourceSchema = z.object({
type: copilotResourceTypeSchema,
id: z.string(),
title: z.string(),
})
const copilotAvailableModelSchema = z.object({
id: z.string(),
friendlyName: z.string(),
provider: z.string(),
})
const copilotChatGetChatSchema = z
.object({
id: z.string(),
title: z.string().nullable(),
model: z.string().nullable(),
messages: z.array(z.unknown()),
messageCount: z.number(),
planArtifact: z.unknown().nullable(),
config: z.unknown().nullable(),
activeStreamId: z.string().nullable().optional(),
resources: z.array(z.unknown()).optional(),
createdAt: z.string().nullable(),
updatedAt: z.string().nullable(),
streamSnapshot: z
.object({
events: z.array(z.unknown()),
previewSessions: z.array(z.unknown()),
status: z.string(),
})
.optional(),
})
.passthrough()
const copilotChatGetListItemSchema = z
.object({
id: z.string(),
title: z.string().nullable(),
model: z.string().nullable(),
createdAt: z.string().nullable(),
updatedAt: z.string().nullable(),
})
.passthrough()
const copilotConnectedCredentialSchema = z.object({
id: z.string(),
name: z.string(),
provider: z.string(),
serviceName: z.string(),
lastUsed: z.string(),
isDefault: z.boolean(),
})
const copilotNotConnectedServiceSchema = z.object({
providerId: z.string(),
name: z.string(),
description: z.string(),
baseProvider: z.string(),
})
const copilotCredentialsResultSchema = z.object({
oauth: z.object({
connected: z.object({
credentials: z.array(copilotConnectedCredentialSchema),
total: z.number(),
}),
notConnected: z.object({
services: z.array(copilotNotConnectedServiceSchema),
total: z.number(),
}),
}),
environment: z.object({
variableNames: z.array(z.string()),
count: z.number(),
personalVariables: z.array(z.string()),
workspaceVariables: z.array(z.string()),
conflicts: z.array(z.string()),
}),
})
export const copilotCredentialsContract = defineRouteContract({
method: 'GET',
path: '/api/copilot/credentials',
query: copilotCredentialsQuerySchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
result: copilotCredentialsResultSchema,
}),
},
})
export const validateCopilotApiKeyContract = defineRouteContract({
method: 'POST',
path: '/api/copilot/api-keys/validate',
body: validateCopilotApiKeyBodySchema,
response: { mode: 'empty' },
})
export const validateCopilotByokBodySchema = z.object({
workspaceId: z.string().min(1, 'workspaceId is required'),
userId: z.string().min(1, 'userId is required'),
})
export type ValidateCopilotByokBody = z.input<typeof validateCopilotByokBodySchema>
/**
* Server-to-server entitlement gate called by the mothership (Go) before it
* uses a workspace's own provider key. Empty 200/401/403 responses signal the
* outcome; the Go caller fails closed to hosted keys on anything but a 200.
*/
export const validateCopilotByokContract = defineRouteContract({
method: 'POST',
path: '/api/copilot/byok/validate',
body: validateCopilotByokBodySchema,
response: { mode: 'empty' },
})
export const listCopilotByokKeysQuerySchema = z.object({
workspaceId: z.string().min(1, 'workspaceId is required'),
})
export type ListCopilotByokKeysQuery = z.input<typeof listCopilotByokKeysQuerySchema>
export const upsertCopilotByokKeyBodySchema = z.object({
workspaceId: z.string().min(1, 'workspaceId is required'),
provider: z.string().min(1, 'provider is required'),
apiKey: z.string().min(1, 'apiKey is required'),
})
export type UpsertCopilotByokKeyBody = z.input<typeof upsertCopilotByokKeyBodySchema>
export const deleteCopilotByokKeyQuerySchema = z.object({
workspaceId: z.string().min(1, 'workspaceId is required'),
provider: z.string().min(1, 'provider is required'),
})
export type DeleteCopilotByokKeyQuery = z.input<typeof deleteCopilotByokKeyQuerySchema>
/**
* Superuser-gated proxies to the copilot's `/api/admin/byok` endpoints. The
* responses are owned by the copilot service and forwarded verbatim.
*/
export const listCopilotByokKeysContract = defineRouteContract({
method: 'GET',
path: '/api/copilot/byok',
query: listCopilotByokKeysQuerySchema,
response: {
mode: 'json',
// untyped-response: forwards the copilot /api/admin/byok response unchanged; shape is owned by the copilot service
schema: z.unknown(),
},
})
export const upsertCopilotByokKeyContract = defineRouteContract({
method: 'POST',
path: '/api/copilot/byok',
body: upsertCopilotByokKeyBodySchema,
response: {
mode: 'json',
// untyped-response: forwards the copilot /api/admin/byok response unchanged; shape is owned by the copilot service
schema: z.unknown(),
},
})
export const deleteCopilotByokKeyContract = defineRouteContract({
method: 'DELETE',
path: '/api/copilot/byok',
query: deleteCopilotByokKeyQuerySchema,
response: {
mode: 'json',
// untyped-response: forwards the copilot /api/admin/byok response unchanged; shape is owned by the copilot service
schema: z.unknown(),
},
})
export const createWorkflowCopilotChatContract = defineRouteContract({
method: 'POST',
path: '/api/copilot/chats',
body: createWorkflowCopilotChatBodySchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
id: z.string(),
}),
},
})
export const createCopilotCheckpointContract = defineRouteContract({
method: 'POST',
path: '/api/copilot/checkpoints',
body: createCopilotCheckpointBodySchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
checkpoint: copilotCheckpointSchema,
}),
},
})
export const listCopilotCheckpointsContract = defineRouteContract({
method: 'GET',
path: '/api/copilot/checkpoints',
query: listCopilotCheckpointsQuerySchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
checkpoints: z.array(copilotCheckpointSchema),
}),
},
})
export const copilotConfirmContract = defineRouteContract({
method: 'POST',
path: '/api/copilot/confirm',
body: copilotConfirmBodySchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
message: z.string(),
toolCallId: z.string(),
status: z.string(),
}),
},
})
export const copilotModelsContract = defineRouteContract({
method: 'GET',
path: '/api/copilot/models',
query: copilotModelsQuerySchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
models: z.array(copilotAvailableModelSchema),
}),
},
})
export const addCopilotChatResourceContract = defineRouteContract({
method: 'POST',
path: '/api/copilot/chat/resources',
body: addCopilotChatResourceBodySchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
resources: z.array(copilotChatResourceSchema).optional(),
}),
},
})
export const reorderCopilotChatResourcesContract = defineRouteContract({
method: 'PATCH',
path: '/api/copilot/chat/resources',
body: reorderCopilotChatResourcesBodySchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
resources: z.array(copilotChatResourceSchema),
}),
},
})
export const removeCopilotChatResourceContract = defineRouteContract({
method: 'DELETE',
path: '/api/copilot/chat/resources',
body: removeCopilotChatResourceBodySchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
resources: z.array(copilotChatResourceSchema),
}),
},
})
/**
* Forwards the agent indexer's free-form JSON response.
* Shape varies by upstream version.
*/
export const copilotTrainingDataContract = defineRouteContract({
method: 'POST',
path: '/api/copilot/training',
body: copilotTrainingDataBodySchema,
response: {
mode: 'json',
// untyped-response: forwards external agent indexer /operations/add response unchanged; shape varies by upstream version
schema: z.unknown(),
},
})
/**
* Forwards the agent indexer's free-form JSON response.
* Shape varies by upstream version.
*/
export const copilotTrainingExampleContract = defineRouteContract({
method: 'POST',
path: '/api/copilot/training/examples',
body: copilotTrainingExampleBodySchema,
response: {
mode: 'json',
// untyped-response: forwards external agent indexer /examples/add response unchanged; shape varies by upstream version
schema: z.unknown(),
},
})
export const renameCopilotChatContract = defineRouteContract({
method: 'PATCH',
path: '/api/copilot/chat/rename',
body: renameCopilotChatBodySchema,
response: { mode: 'json', schema: successFlagSchema },
})
export const revertCopilotCheckpointContract = defineRouteContract({
method: 'POST',
path: '/api/copilot/checkpoints/revert',
body: revertCopilotCheckpointBodySchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
workflowId: z.string(),
checkpointId: z.string(),
revertedAt: z.string(),
checkpoint: z.object({
id: z.string(),
workflowState: cleanedWorkflowStateSchema,
}),
}),
},
})
export const copilotChatAbortContract = defineRouteContract({
method: 'POST',
path: '/api/copilot/chat/abort',
body: copilotChatAbortBodySchema,
response: {
mode: 'json',
schema: z.object({
aborted: z.boolean(),
settled: z.boolean().optional(),
// True when the stream did not settle within the grace window and the
// chat stream lock was force-broken so the chat is immediately usable.
forceReleased: z.boolean().optional(),
}),
},
})
export const copilotChatStreamContract = defineRouteContract({
method: 'GET',
path: '/api/copilot/chat/stream',
query: copilotChatStreamQuerySchema,
response: { mode: 'stream' },
})
export const copilotChatStopContract = defineRouteContract({
method: 'POST',
path: '/api/copilot/chat/stop',
body: copilotChatStopBodySchema,
response: { mode: 'json', schema: successFlagSchema },
})
export const copilotChatGetContract = defineRouteContract({
method: 'GET',
path: '/api/copilot/chat',
query: copilotChatGetQuerySchema,
response: {
mode: 'json',
schema: z.union([
z.object({
success: z.literal(true),
chat: copilotChatGetChatSchema,
}),
z.object({
success: z.literal(true),
chats: z.array(copilotChatGetListItemSchema),
}),
]),
},
})
export const deleteCopilotChatContract = defineRouteContract({
method: 'DELETE',
path: '/api/copilot/chat/delete',
body: deleteCopilotChatBodySchema,
response: { mode: 'json', schema: successFlagSchema },
})
export const updateCopilotMessagesContract = defineRouteContract({
method: 'POST',
path: '/api/copilot/chat/update-messages',
body: updateCopilotMessagesBodySchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
messageCount: z.number(),
}),
},
})
+439
View File
@@ -0,0 +1,439 @@
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts/types'
import {
ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
type OAuthProvider,
SLACK_CUSTOM_BOT_PROVIDER_ID,
} from '@/lib/oauth/types'
const ENV_VAR_NAME_REGEX = /^[A-Za-z0-9_]+$/
export function normalizeCredentialEnvKey(raw: string): string {
const trimmed = raw.trim()
const wrappedMatch = /^\{\{\s*([A-Za-z0-9_]+)\s*\}\}$/.exec(trimmed)
return wrappedMatch ? wrappedMatch[1] : trimmed
}
export const workspaceCredentialTypeSchema = z.enum([
'oauth',
'env_workspace',
'env_personal',
'service_account',
])
export const workspaceCredentialRoleSchema = z.enum(['admin', 'member'])
export const workspaceCredentialMemberStatusSchema = z.enum(['active', 'pending', 'revoked'])
export const workspaceCredentialSchema = z.object({
id: z.string(),
workspaceId: z.string(),
type: workspaceCredentialTypeSchema,
displayName: z.string(),
description: z.string().nullable(),
providerId: z.string().nullable(),
accountId: z.string().nullable(),
envKey: z.string().nullable(),
envOwnerUserId: z.string().nullable(),
createdBy: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
role: workspaceCredentialRoleSchema.optional(),
status: workspaceCredentialMemberStatusSchema.optional(),
})
export type WorkspaceCredentialType = z.output<typeof workspaceCredentialTypeSchema>
export type WorkspaceCredentialRole = z.output<typeof workspaceCredentialRoleSchema>
export type WorkspaceCredentialMemberStatus = z.output<typeof workspaceCredentialMemberStatusSchema>
export type WorkspaceCredential = z.output<typeof workspaceCredentialSchema>
export const credentialsListQuerySchema = z.object({
workspaceId: z.string().uuid('Workspace ID must be a valid UUID'),
type: workspaceCredentialTypeSchema.optional(),
providerId: z.string().optional(),
})
export const credentialIdParamsSchema = z.object({
id: z.string().min(1),
})
export const credentialsListGetQuerySchema = z.object({
workspaceId: z.string().uuid('Workspace ID must be a valid UUID'),
type: workspaceCredentialTypeSchema.optional(),
providerId: z.string().optional(),
credentialId: z.string().optional(),
})
export const serviceAccountJsonSchema = z
.string()
.min(1, 'Service account JSON key is required')
.transform((val, ctx) => {
try {
const parsed = JSON.parse(val)
if (parsed.type !== 'service_account') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'JSON key must have type "service_account"',
})
return z.NEVER
}
if (!parsed.client_email || typeof parsed.client_email !== 'string') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'JSON key must contain a valid client_email',
})
return z.NEVER
}
if (!parsed.private_key || typeof parsed.private_key !== 'string') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'JSON key must contain a valid private_key',
})
return z.NEVER
}
if (!parsed.project_id || typeof parsed.project_id !== 'string') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'JSON key must contain a valid project_id',
})
return z.NEVER
}
return parsed as {
type: 'service_account'
client_email: string
private_key: string
project_id: string
[key: string]: unknown
}
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid JSON format',
})
return z.NEVER
}
})
export const createCredentialBodySchema = z
.object({
workspaceId: z.string().uuid('Workspace ID must be a valid UUID'),
type: workspaceCredentialTypeSchema,
displayName: z.string().trim().min(1).max(255).optional(),
description: z.string().trim().max(500).optional(),
providerId: z.string().trim().min(1).optional(),
accountId: z.string().trim().min(1).optional(),
envKey: z.string().trim().min(1).optional(),
envOwnerUserId: z.string().trim().min(1).optional(),
serviceAccountJson: z.string().optional(),
apiToken: z.string().trim().min(1).optional(),
domain: z.string().trim().min(1).optional(),
/**
* Client-supplied credential id, honored only for `slack-custom-bot` creates:
* the setup modal shows the ingest URL `/api/webhooks/slack/custom/{id}`
* before secrets exist, so the id must be known up front.
*/
id: z.string().uuid('id must be a valid UUID').optional(),
signingSecret: z.string().trim().min(1).optional(),
botToken: z.string().trim().min(1).optional(),
})
.superRefine((data, ctx) => {
if (data.type === 'oauth') {
if (!data.accountId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'accountId is required for oauth credentials',
path: ['accountId'],
})
}
if (!data.providerId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'providerId is required for oauth credentials',
path: ['providerId'],
})
}
if (!data.displayName) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'displayName is required for oauth credentials',
path: ['displayName'],
})
}
return
}
if (data.type === 'service_account') {
if (data.providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) {
if (!data.apiToken) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'apiToken is required for Atlassian service account credentials',
path: ['apiToken'],
})
}
if (!data.domain) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'domain is required for Atlassian service account credentials',
path: ['domain'],
})
}
return
}
if (data.providerId === SLACK_CUSTOM_BOT_PROVIDER_ID) {
if (!data.signingSecret) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'signingSecret is required for a custom Slack bot credential',
path: ['signingSecret'],
})
}
if (!data.botToken) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'botToken is required for a custom Slack bot credential',
path: ['botToken'],
})
}
return
}
if (!data.serviceAccountJson) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'serviceAccountJson is required for service account credentials',
path: ['serviceAccountJson'],
})
}
return
}
const normalizedEnvKey = data.envKey ? normalizeCredentialEnvKey(data.envKey) : ''
if (!normalizedEnvKey) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'envKey is required for env credentials',
path: ['envKey'],
})
return
}
if (!ENV_VAR_NAME_REGEX.test(normalizedEnvKey)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'envKey must contain only letters, numbers, and underscores',
path: ['envKey'],
})
}
})
export const updateCredentialByIdBodySchema = z
.object({
displayName: z.string().trim().min(1).max(255).optional(),
description: z.string().trim().max(500).nullish(),
serviceAccountJson: z.string().min(1).optional(),
/** Slack custom-bot secret rotation (reconnect). */
signingSecret: z.string().trim().min(1).optional(),
botToken: z.string().trim().min(1).optional(),
/** Atlassian service-account secret rotation (reconnect). */
apiToken: z.string().trim().min(1).optional(),
domain: z.string().trim().min(1).optional(),
})
.strict()
.refine(
(data) =>
data.displayName !== undefined ||
data.description !== undefined ||
data.serviceAccountJson !== undefined ||
data.signingSecret !== undefined ||
data.botToken !== undefined ||
data.apiToken !== undefined ||
data.domain !== undefined,
{
message: 'At least one field must be provided',
path: ['displayName'],
}
)
export const leaveCredentialQuerySchema = z.object({
credentialId: z.string().min(1),
})
export const workspaceCredentialMemberSchema = z.object({
id: z.string(),
userId: z.string(),
role: workspaceCredentialRoleSchema,
status: workspaceCredentialMemberStatusSchema,
joinedAt: z.string().nullable(),
invitedBy: z.string().nullable().optional(),
createdAt: z.string().optional(),
updatedAt: z.string().optional(),
userName: z.string().nullable(),
userEmail: z.string().nullable(),
userImage: z.string().nullable().optional(),
/** `workspace-admin` roles are derived from workspace admin and cannot be changed. */
roleSource: z.enum(['explicit', 'workspace-admin']).optional(),
})
export type WorkspaceCredentialMember = z.output<typeof workspaceCredentialMemberSchema>
export const createCredentialDraftBodySchema = z.object({
workspaceId: z.string().min(1),
providerId: z.string().min(1),
displayName: z.string().min(1),
description: z.string().trim().max(500).optional(),
credentialId: z.string().min(1).optional(),
})
export const upsertWorkspaceCredentialMemberBodySchema = z.object({
userId: z.string().min(1),
role: workspaceCredentialRoleSchema.default('member'),
})
export const removeWorkspaceCredentialMemberQuerySchema = z.object({
userId: z.string().min(1),
})
export const oauthCredentialSchema = z.object({
id: z.string(),
name: z.string(),
provider: z.custom<OAuthProvider>((value) => typeof value === 'string'),
type: z.enum(['oauth', 'service_account']).optional(),
serviceId: z.string().optional(),
lastUsed: z.string().optional(),
isDefault: z.boolean().optional(),
scopes: z.array(z.string()).optional(),
})
export const oauthCredentialsQuerySchema = z
.object({
provider: z.string().nullish(),
workflowId: z.string().uuid('Workflow ID must be a valid UUID').nullish(),
workspaceId: z.string().uuid('Workspace ID must be a valid UUID').nullish(),
credentialId: z.string().min(1, 'Credential ID must not be empty').max(255).nullish(),
})
.refine((data) => data.provider || data.credentialId, {
message: 'Provider or credentialId is required',
path: ['provider'],
})
export const listWorkspaceCredentialsContract = defineRouteContract({
method: 'GET',
path: '/api/credentials',
query: credentialsListQuerySchema,
response: {
mode: 'json',
schema: z.object({
credentials: z.array(workspaceCredentialSchema),
}),
},
})
export const getWorkspaceCredentialContract = defineRouteContract({
method: 'GET',
path: '/api/credentials/[id]',
params: credentialIdParamsSchema,
response: {
mode: 'json',
schema: z.object({
credential: workspaceCredentialSchema.nullable(),
}),
},
})
export const listOAuthCredentialsContract = defineRouteContract({
method: 'GET',
path: '/api/auth/oauth/credentials',
query: oauthCredentialsQuerySchema,
response: {
mode: 'json',
schema: z.object({
credentials: z.array(oauthCredentialSchema),
}),
},
})
export const listWorkspaceCredentialMembersContract = defineRouteContract({
method: 'GET',
path: '/api/credentials/[id]/members',
params: credentialIdParamsSchema,
response: {
mode: 'json',
schema: z.object({
members: z.array(workspaceCredentialMemberSchema).optional(),
}),
},
})
export const createCredentialDraftContract = defineRouteContract({
method: 'POST',
path: '/api/credentials/draft',
body: createCredentialDraftBodySchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
}),
},
})
export const createWorkspaceCredentialContract = defineRouteContract({
method: 'POST',
path: '/api/credentials',
body: createCredentialBodySchema,
response: {
mode: 'json',
schema: z.object({
credential: workspaceCredentialSchema,
}),
},
})
export const updateWorkspaceCredentialContract = defineRouteContract({
method: 'PUT',
path: '/api/credentials/[id]',
params: credentialIdParamsSchema,
body: updateCredentialByIdBodySchema,
response: {
mode: 'json',
schema: z.object({
credential: workspaceCredentialSchema.nullable(),
}),
},
})
export const deleteWorkspaceCredentialContract = defineRouteContract({
method: 'DELETE',
path: '/api/credentials/[id]',
params: credentialIdParamsSchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
}),
},
})
export const upsertWorkspaceCredentialMemberContract = defineRouteContract({
method: 'POST',
path: '/api/credentials/[id]/members',
params: credentialIdParamsSchema,
body: upsertWorkspaceCredentialMemberBodySchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
member: workspaceCredentialMemberSchema.optional(),
}),
},
})
export const removeWorkspaceCredentialMemberContract = defineRouteContract({
method: 'DELETE',
path: '/api/credentials/[id]/members',
params: credentialIdParamsSchema,
query: removeWorkspaceCredentialMemberQuerySchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
}),
},
})
+178
View File
@@ -0,0 +1,178 @@
import { z } from 'zod'
import { workflowIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives'
import { defineRouteContract } from '@/lib/api/contracts/types'
const inputFieldSchema = z.object({
/** Stable per-field id — preserved so client block configs key sub-blocks on it
* (rename-safe wiring) instead of the display name. Absent on legacy fields. */
id: z.string().optional(),
name: z.string(),
type: z.string(),
description: z.string().optional(),
/** Consumer-facing placeholder hint (curated inputs only). */
placeholder: z.string().optional(),
/** Consumers must fill this input (curated inputs only). */
required: z.boolean().optional(),
})
/**
* The authored per-input data: a placeholder and a required flag, keyed by the
* source Start field's stable `id`. The field's name/type/description are NOT
* stored — they're always derived from the live deployed Start (so they can't go
* stale); an override whose field was removed from the Start is silently ignored.
*/
const inputPlaceholderSchema = z.object({
id: z.string().min(1),
placeholder: z.string().max(200).optional(),
required: z.boolean().optional(),
})
export type CustomBlockInputPlaceholder = z.input<typeof inputPlaceholderSchema>
/** A curated output: a child-workflow block output (blockId + dot-path) exposed under `name`. */
const exposedOutputSchema = z.object({
blockId: z.string().min(1),
path: z.string().min(1),
name: z.string().min(1).max(60),
})
export const customBlockSchema = z.object({
id: z.string(),
organizationId: z.string(),
workflowId: z.string(),
/** Name of the bound source workflow (for display; the source can't be changed). */
workflowName: z.string(),
/** Source workflow's home workspace id — used client-side to gate manage affordances. */
workspaceId: z.string().nullable(),
/** Name of the source workflow's home workspace (display only). */
workspaceName: z.string().nullable(),
type: z.string(),
name: z.string(),
description: z.string(),
/** Uploaded icon image URL, or null for the default icon. */
iconUrl: z.string().nullable(),
enabled: z.boolean(),
inputFields: z.array(inputFieldSchema),
/** Curated outputs exposed to consumers; empty = expose the child's whole result. */
exposedOutputs: z.array(exposedOutputSchema),
})
export type CustomBlock = z.output<typeof customBlockSchema>
export const listCustomBlocksQuerySchema = z.object({
workspaceId: workspaceIdSchema,
})
/**
* Icon URLs are rendered as org-wide `<img>` sources, so only https URLs and
* internal file-serve paths (what the icon upload UI stores) are accepted —
* never data:/blob:/other schemes an admin could smuggle into shared metadata.
* Shared with the copilot deploy_custom_block handler's pass-through branch.
*/
export function isAllowedCustomBlockIconUrl(value: string): boolean {
return value.startsWith('https://') || value.startsWith('/api/files/serve/')
}
const iconUrlSchema = z.string().min(1).max(2048).refine(isAllowedCustomBlockIconUrl, {
message: 'iconUrl must be an https URL or an internal /api/files/serve/ path',
})
export const publishCustomBlockBodySchema = z.object({
workspaceId: workspaceIdSchema,
workflowId: workflowIdSchema,
name: z.string().min(1, 'Name is required').max(60, 'Name must be 60 characters or fewer'),
description: z.string().max(280, 'Description must be 280 characters or fewer').default(''),
/** Uploaded icon image URL (https or internal serve path); omit for the default icon. */
iconUrl: iconUrlSchema.optional(),
/** Per-input placeholder hints keyed by Start field id; the field set itself is always derived from the deployment. */
inputs: z.array(inputPlaceholderSchema).max(50).optional(),
/** Curated outputs; omit/empty to expose the child's whole result. */
exposedOutputs: z.array(exposedOutputSchema).max(50).optional(),
})
export type PublishCustomBlockBody = z.input<typeof publishCustomBlockBodySchema>
export const customBlockIdParamsSchema = z.object({
id: z.string().min(1),
})
export const updateCustomBlockBodySchema = z
.object({
name: z.string().min(1).max(60).optional(),
description: z.string().max(280).optional(),
enabled: z.boolean().optional(),
/** A URL (https or internal serve path) sets/replaces the icon; `null` clears it (default icon). */
iconUrl: iconUrlSchema.nullable().optional(),
inputs: z.array(inputPlaceholderSchema).max(50).optional(),
exposedOutputs: z.array(exposedOutputSchema).max(50).optional(),
})
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field is required' })
export type UpdateCustomBlockBody = z.input<typeof updateCustomBlockBodySchema>
/**
* How many workflows in the org place this block. Live editor state and the
* active deployment snapshot can diverge, so a workflow counts when the block
* appears in either; `deployedUsageCount` counts active deployments only.
*/
export const customBlockUsageCountsSchema = z.object({
usageCount: z.number().int().min(0),
deployedUsageCount: z.number().int().min(0),
})
export type CustomBlockUsageCounts = z.output<typeof customBlockUsageCountsSchema>
export const listCustomBlocksContract = defineRouteContract({
method: 'GET',
path: '/api/custom-blocks',
query: listCustomBlocksQuerySchema,
response: {
mode: 'json',
schema: z.object({
/** Whether this workspace can publish/use custom blocks (feature flag + enterprise plan). */
enabled: z.boolean(),
customBlocks: z.array(customBlockSchema),
}),
},
})
export const publishCustomBlockContract = defineRouteContract({
method: 'POST',
path: '/api/custom-blocks',
body: publishCustomBlockBodySchema,
response: {
mode: 'json',
schema: z.object({ customBlock: customBlockSchema }),
},
})
export const updateCustomBlockContract = defineRouteContract({
method: 'PATCH',
path: '/api/custom-blocks/[id]',
params: customBlockIdParamsSchema,
body: updateCustomBlockBodySchema,
response: {
mode: 'json',
schema: z.object({ success: z.literal(true) }),
},
})
export const deleteCustomBlockContract = defineRouteContract({
method: 'DELETE',
path: '/api/custom-blocks/[id]',
params: customBlockIdParamsSchema,
response: {
mode: 'json',
schema: z.object({ success: z.literal(true) }),
},
})
export const getCustomBlockUsageCountsContract = defineRouteContract({
method: 'GET',
path: '/api/custom-blocks/[id]/usages',
params: customBlockIdParamsSchema,
response: {
mode: 'json',
schema: customBlockUsageCountsSchema,
},
})
+567
View File
@@ -0,0 +1,567 @@
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts/types'
import { validateExternalUrl } from '@/lib/core/security/input-validation'
import { CADENCE_TYPES, DESTINATION_TYPES, SOURCE_TYPES } from '@/lib/data-drains/types'
/** AWS S3 bucket: 3-63 chars, lowercase alnum + . / -, see s3.ts for full rules. */
const S3_BUCKET_NAME_RE = /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/
const S3_IPV4_LIKE_RE = /^(\d{1,3}\.){3}\d{1,3}$/
const AWS_REGION_RE = /^[a-z]{2,}(-[a-z]+)+-\d+$/
/** GCS bucket component: lowercase alnum + _ / -, start/end alnum. Mirrors gcs.ts. */
const GCS_BUCKET_COMPONENT_RE = /^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$/
const GOOGLE_RESERVED_PREFIX_RE = /^(goog|google|g00gle)/i
const GOOGLE_CONTAINS_RE = /(google|g00gle)/i
function validateGcsBucketComponents(v: string): string | null {
if (v.length < 3 || v.length > 222) return 'bucket must be 3-222 characters'
const components = v.split('.')
for (const c of components) {
if (c.length < 1 || c.length > 63) {
return 'each dot-separated component must be 1-63 characters'
}
if (!GCS_BUCKET_COMPONENT_RE.test(c)) {
return 'each component must be lowercase, start/end alphanumeric, letters/digits/_/- only'
}
}
return null
}
/** Azure storage account: 3-24 lowercase alnum. */
const AZURE_ACCOUNT_NAME_RE = /^[a-z0-9]{3,24}$/
/** Azure container: 3-63 chars, lowercase alnum + single hyphens. */
const AZURE_CONTAINER_NAME_RE = /^[a-z0-9]([a-z0-9]|-(?!-))+[a-z0-9]$/
/** Azure Blob Storage endpoint suffixes (Public, US Gov, China, Germany). */
const AZURE_ENDPOINT_SUFFIXES = [
'blob.core.windows.net',
'blob.core.usgovcloudapi.net',
'blob.core.chinacloudapi.cn',
'blob.core.cloudapi.de',
] as const
/** BigQuery project / dataset / table identifiers. */
const BQ_PROJECT_ID_RE = /^([a-z][a-z0-9.-]{0,61}[a-z0-9]:)?[a-z][a-z0-9-]{4,28}[a-z0-9]$/
const BQ_DATASET_RE = /^[A-Za-z0-9_]{1,1024}$/
const BQ_TABLE_RE = /^[\p{L}\p{M}\p{N}\p{Pc}\p{Pd} ]{1,1024}$/u
/** Snowflake account + identifier shapes — mirrored from snowflake.ts. */
const SNOWFLAKE_ACCOUNT_ORG_RE = /^[A-Za-z0-9][A-Za-z0-9_]*(?:-[A-Za-z0-9_]+)+$/
/** First segment allows hyphens so org-account identifiers carrying a region/cloud suffix match. Mirrors snowflake.ts. */
const SNOWFLAKE_ACCOUNT_LOCATOR_RE =
/^[A-Za-z0-9][A-Za-z0-9_-]*(?:\.[A-Za-z0-9][A-Za-z0-9_-]*){0,2}$/
const SNOWFLAKE_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_$]{0,254}$/
/** Reserved Sim-namespaced header names that cannot be reused as the webhook signature header. */
const RESERVED_WEBHOOK_SIGNATURE_HEADER_NAMES = new Set([
'authorization',
'content-type',
'user-agent',
'idempotency-key',
'x-sim-timestamp',
'x-sim-signature-version',
'x-sim-drain-id',
'x-sim-run-id',
'x-sim-source',
'x-sim-sequence',
'x-sim-row-count',
'x-sim-probe',
'x-sim-signature',
])
export const dataDrainSourceSchema = z.enum(SOURCE_TYPES)
export const dataDrainDestinationTypeSchema = z.enum(DESTINATION_TYPES)
export const dataDrainCadenceSchema = z.enum(CADENCE_TYPES)
export const dataDrainRunStatusSchema = z.enum(['running', 'success', 'failed'])
export const dataDrainRunTriggerSchema = z.enum(['cron', 'manual'])
export const dataDrainOrgParamsSchema = z.object({
id: z.string().min(1, 'organization id is required'),
})
export const dataDrainParamsSchema = z.object({
id: z.string().min(1, 'organization id is required'),
drainId: z.string().min(1, 'drain id is required'),
})
const drainNameSchema = z.string().trim().min(1, 'name is required').max(120)
const s3ConfigBodySchema = z.object({
bucket: z
.string()
.min(3, 'bucket must be 3-63 characters')
.max(63, 'bucket must be 3-63 characters')
.refine((v) => S3_BUCKET_NAME_RE.test(v), {
message: 'bucket must be lowercase, 3-63 chars, start/end alphanumeric',
})
.refine((v) => !v.includes('..'), { message: 'bucket must not contain consecutive dots' })
.refine((v) => !v.includes('-.') && !v.includes('.-'), {
message: 'bucket must not contain a dash adjacent to a dot',
})
.refine((v) => !S3_IPV4_LIKE_RE.test(v), { message: 'bucket must not look like an IP address' })
.refine((v) => !v.startsWith('xn--'), { message: 'bucket must not start with "xn--"' })
.refine((v) => !v.startsWith('sthree-'), { message: 'bucket must not start with "sthree-"' })
.refine((v) => !v.startsWith('amzn-s3-demo-'), {
message: 'bucket must not start with "amzn-s3-demo-" (reserved by AWS)',
})
.refine(
(v) =>
!v.endsWith('-s3alias') &&
!v.endsWith('--ol-s3') &&
!v.endsWith('.mrap') &&
!v.endsWith('--x-s3') &&
!v.endsWith('--table-s3'),
{
message:
'bucket must not end with reserved suffix (-s3alias, --ol-s3, .mrap, --x-s3, --table-s3)',
}
),
region: z
.string()
.min(1, 'region is required')
.max(32, 'region is too long')
.refine((v) => AWS_REGION_RE.test(v), {
message: 'region must look like an AWS region code, e.g. us-east-1',
}),
prefix: z
.string()
.max(512)
.refine((v) => Buffer.byteLength(v, 'utf8') <= 512, {
message: 'prefix must be at most 512 bytes (UTF-8)',
})
.optional(),
endpoint: z
.string()
.url()
.refine((v) => v.startsWith('https://'), { message: 'endpoint must use https://' })
.refine((value) => validateExternalUrl(value, 'endpoint').isValid, {
message: 'endpoint must be HTTPS and not point at a private, loopback, or metadata address',
})
.optional(),
forcePathStyle: z.boolean().optional(),
})
const s3CredentialsBodySchema = z.object({
accessKeyId: z.string().min(1, 'accessKeyId is required'),
secretAccessKey: z.string().min(1, 'secretAccessKey is required'),
})
const gcsConfigBodySchema = z.object({
bucket: z
.string()
.min(3, 'bucket must be 3-222 characters')
.max(222, 'bucket must be 3-222 characters')
.superRefine((v, ctx) => {
const err = validateGcsBucketComponents(v)
if (err) ctx.addIssue({ code: z.ZodIssueCode.custom, message: err })
})
.refine((v) => !S3_IPV4_LIKE_RE.test(v), { message: 'bucket must not look like an IP address' })
.refine((v) => !v.includes('..'), { message: 'bucket must not contain consecutive dots' })
.refine((v) => !v.includes('-.') && !v.includes('.-'), {
message: 'bucket must not contain "-." or ".-"',
})
.refine((v) => !GOOGLE_RESERVED_PREFIX_RE.test(v) && !GOOGLE_CONTAINS_RE.test(v), {
message: 'bucket name cannot begin with "goog" or contain "google" / close misspellings',
}),
prefix: z
.string()
.max(512)
.refine((v) => Buffer.byteLength(v, 'utf8') <= 512, {
message: 'prefix must be at most 512 bytes (UTF-8)',
})
.refine((v) => !v.startsWith('.well-known/acme-challenge/'), {
message: 'prefix must not start with ".well-known/acme-challenge/" (reserved by GCS)',
})
.optional(),
})
const gcsCredentialsBodySchema = z.object({
serviceAccountJson: z.string().min(1, 'serviceAccountJson is required'),
})
const azureBlobConfigBodySchema = z.object({
accountName: z
.string()
.min(1, 'accountName is required')
.refine((v) => AZURE_ACCOUNT_NAME_RE.test(v), {
message: 'accountName must be 3-24 lowercase letters or digits',
}),
containerName: z
.string()
.min(3, 'containerName must be 3-63 characters')
.max(63)
.refine((v) => AZURE_CONTAINER_NAME_RE.test(v), {
message: 'containerName must use lowercase letters, digits, or single hyphens',
}),
prefix: z.string().max(512).optional(),
endpointSuffix: z
.string()
.refine((v) => (AZURE_ENDPOINT_SUFFIXES as readonly string[]).includes(v), {
message: `endpointSuffix must be one of: ${AZURE_ENDPOINT_SUFFIXES.join(', ')}`,
})
.optional(),
})
const azureBlobCredentialsBodySchema = z.object({
accountKey: z
.string()
.length(88, 'accountKey must be 88 base64 characters (64-byte Azure storage key)')
.regex(/^[A-Za-z0-9+/]+={0,2}$/, {
message: 'accountKey must be a base64-encoded Azure storage account key',
}),
})
const DATADOG_TAG_PAIR_RE = /^[A-Za-z][A-Za-z0-9_./-]*:[^,\s][^,]*$/
const datadogConfigBodySchema = z.object({
site: z.enum(['us1', 'us3', 'us5', 'eu1', 'ap1', 'ap2', 'gov']),
service: z.string().min(1).max(100).optional(),
tags: z
.string()
.min(1)
.max(1024)
.refine(
(v) =>
v
.split(',')
.map((t) => t.trim())
.filter((t) => t.length > 0)
.every((t) => DATADOG_TAG_PAIR_RE.test(t)),
{ message: 'tags must be comma-separated key:value pairs' }
)
.optional(),
})
const datadogCredentialsBodySchema = z.object({
apiKey: z.string().min(1, 'apiKey is required'),
})
const bigqueryConfigBodySchema = z.object({
projectId: z
.string()
.min(6, 'projectId is required')
.max(94)
.refine((v) => BQ_PROJECT_ID_RE.test(v), {
message: 'projectId must match Google Cloud project ID rules',
}),
datasetId: z
.string()
.min(1, 'datasetId is required')
.refine((v) => BQ_DATASET_RE.test(v), {
message: 'datasetId may only contain letters, digits, and underscores (max 1024)',
}),
tableId: z
.string()
.min(1, 'tableId is required')
.refine((v) => BQ_TABLE_RE.test(v), {
message:
'tableId may contain Unicode letters, marks, numbers, connectors, dashes, and spaces (max 1024)',
})
.refine((v) => Buffer.byteLength(v, 'utf8') <= 1024, {
message: 'tableId must be at most 1024 bytes (UTF-8)',
}),
})
const bigqueryCredentialsBodySchema = z.object({
serviceAccountJson: z.string().min(1, 'serviceAccountJson is required'),
})
const snowflakeConfigBodySchema = z.object({
account: z
.string()
.min(3, 'account is required')
.max(256)
.refine((v) => SNOWFLAKE_ACCOUNT_ORG_RE.test(v) || SNOWFLAKE_ACCOUNT_LOCATOR_RE.test(v), {
message:
'account must be a Snowflake org-account identifier (orgname-accountname) or legacy locator (locator[.region[.cloud]])',
}),
user: z.string().min(1, 'user is required').regex(SNOWFLAKE_IDENTIFIER_RE, {
message: 'user must be a valid Snowflake identifier',
}),
warehouse: z.string().min(1).regex(SNOWFLAKE_IDENTIFIER_RE, {
message: 'warehouse must be a valid Snowflake identifier',
}),
database: z.string().min(1).regex(SNOWFLAKE_IDENTIFIER_RE, {
message: 'database must be a valid Snowflake identifier',
}),
schema: z.string().min(1).regex(SNOWFLAKE_IDENTIFIER_RE, {
message: 'schema must be a valid Snowflake identifier',
}),
table: z.string().min(1).regex(SNOWFLAKE_IDENTIFIER_RE, {
message: 'table must be a valid Snowflake identifier',
}),
column: z
.string()
.min(1)
.regex(SNOWFLAKE_IDENTIFIER_RE, { message: 'column must be a valid Snowflake identifier' })
.optional(),
role: z
.string()
.min(1)
.regex(SNOWFLAKE_IDENTIFIER_RE, { message: 'role must be a valid Snowflake identifier' })
.optional(),
})
const snowflakeCredentialsBodySchema = z.object({
privateKey: z.string().min(1, 'privateKey is required'),
})
const webhookConfigBodySchema = z.object({
url: z
.string()
.url('url must be a valid URL')
.max(2048, 'url must be at most 2048 characters')
.refine((value) => validateExternalUrl(value, 'url').isValid, {
message: 'url must be HTTPS and not point at a private, loopback, or metadata address',
}),
signatureHeader: z
.string()
.min(1)
.max(128)
.refine((value) => !RESERVED_WEBHOOK_SIGNATURE_HEADER_NAMES.has(value.toLowerCase()), {
message: 'signatureHeader cannot reuse a reserved Sim header name',
})
.refine((value) => /^[A-Za-z0-9\-_]+$/.test(value) && !/[\r\n\0]/.test(value), {
message: 'signatureHeader must contain only letters, digits, hyphens, and underscores',
})
.optional(),
})
const webhookCredentialsBodySchema = z.object({
signingSecret: z
.string()
.min(32, 'signingSecret must be at least 32 characters')
.max(512, 'signingSecret must be at most 512 characters'),
bearerToken: z
.string()
.min(1)
.max(4096, 'bearerToken must be at most 4096 characters')
.refine((value) => !/[\r\n\0]/.test(value), {
message: 'bearerToken cannot contain CR, LF, or NUL characters',
})
.optional(),
})
/**
* Discriminated body shape used by both create and update. Each destination
* variant carries its own typed `destinationConfig` and optional
* `destinationCredentials`. On update, omitting `destinationCredentials`
* leaves the encrypted blob in place.
*/
export const dataDrainDestinationBodySchema = z.discriminatedUnion('destinationType', [
z.object({
destinationType: z.literal('s3'),
destinationConfig: s3ConfigBodySchema,
destinationCredentials: s3CredentialsBodySchema.optional(),
}),
z.object({
destinationType: z.literal('gcs'),
destinationConfig: gcsConfigBodySchema,
destinationCredentials: gcsCredentialsBodySchema.optional(),
}),
z.object({
destinationType: z.literal('azure_blob'),
destinationConfig: azureBlobConfigBodySchema,
destinationCredentials: azureBlobCredentialsBodySchema.optional(),
}),
z.object({
destinationType: z.literal('datadog'),
destinationConfig: datadogConfigBodySchema,
destinationCredentials: datadogCredentialsBodySchema.optional(),
}),
z.object({
destinationType: z.literal('bigquery'),
destinationConfig: bigqueryConfigBodySchema,
destinationCredentials: bigqueryCredentialsBodySchema.optional(),
}),
z.object({
destinationType: z.literal('snowflake'),
destinationConfig: snowflakeConfigBodySchema,
destinationCredentials: snowflakeCredentialsBodySchema.optional(),
}),
z.object({
destinationType: z.literal('webhook'),
destinationConfig: webhookConfigBodySchema,
destinationCredentials: webhookCredentialsBodySchema.optional(),
}),
])
const drainCommonBodyFieldsSchema = z.object({
name: drainNameSchema,
source: dataDrainSourceSchema,
scheduleCadence: dataDrainCadenceSchema,
enabled: z.boolean().optional(),
})
export const createDataDrainBodySchema = z.intersection(
drainCommonBodyFieldsSchema,
dataDrainDestinationBodySchema
)
/**
* Update bodies are partial — every field is optional. We deliberately don't
* use a discriminated union here: clients sending `{ enabled: false }` should
* not be forced to also send `destinationType`. The route validates the
* destination payloads against the typed `configSchema` / `credentialsSchema`
* for the existing drain's destination type before persisting, so the
* structural shape is still enforced — just at the route layer rather than at
* the contract boundary.
*/
export const updateDataDrainBodySchema = drainCommonBodyFieldsSchema.partial().extend({
destinationType: dataDrainDestinationTypeSchema.optional(),
destinationConfig: z.record(z.string(), z.unknown()).optional(),
destinationCredentials: z.record(z.string(), z.unknown()).optional(),
})
const drainDestinationResponseSchema = z.discriminatedUnion('destinationType', [
z.object({
destinationType: z.literal('s3'),
destinationConfig: s3ConfigBodySchema,
}),
z.object({
destinationType: z.literal('gcs'),
destinationConfig: gcsConfigBodySchema,
}),
z.object({
destinationType: z.literal('azure_blob'),
destinationConfig: azureBlobConfigBodySchema,
}),
z.object({
destinationType: z.literal('datadog'),
destinationConfig: datadogConfigBodySchema,
}),
z.object({
destinationType: z.literal('bigquery'),
destinationConfig: bigqueryConfigBodySchema,
}),
z.object({
destinationType: z.literal('snowflake'),
destinationConfig: snowflakeConfigBodySchema,
}),
z.object({
destinationType: z.literal('webhook'),
destinationConfig: webhookConfigBodySchema,
}),
])
const drainCommonResponseFieldsSchema = z.object({
id: z.string(),
organizationId: z.string(),
name: z.string(),
source: dataDrainSourceSchema,
scheduleCadence: dataDrainCadenceSchema,
enabled: z.boolean(),
cursor: z.string().nullable(),
lastRunAt: z.string().nullable(),
lastSuccessAt: z.string().nullable(),
createdBy: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
})
export const dataDrainSchema = z.intersection(
drainCommonResponseFieldsSchema,
drainDestinationResponseSchema
)
export type DataDrain = z.output<typeof dataDrainSchema>
export type CreateDataDrainBody = z.input<typeof createDataDrainBodySchema>
export type UpdateDataDrainBody = z.input<typeof updateDataDrainBodySchema>
export const dataDrainListResponseSchema = z.object({
drains: z.array(dataDrainSchema),
})
export const dataDrainResponseSchema = z.object({
drain: dataDrainSchema,
})
export const dataDrainRunSchema = z.object({
id: z.string(),
drainId: z.string(),
status: dataDrainRunStatusSchema,
trigger: dataDrainRunTriggerSchema,
startedAt: z.string(),
finishedAt: z.string().nullable(),
rowsExported: z.number().int(),
bytesWritten: z.number().int(),
cursorBefore: z.string().nullable(),
cursorAfter: z.string().nullable(),
error: z.string().nullable(),
locators: z.array(z.string()),
})
export type DataDrainRun = z.output<typeof dataDrainRunSchema>
export const dataDrainRunListResponseSchema = z.object({
runs: z.array(dataDrainRunSchema),
})
export const runDataDrainResponseSchema = z.object({
jobId: z.string(),
})
export const testDataDrainResponseSchema = z.object({
ok: z.literal(true),
})
export const listDataDrainsContract = defineRouteContract({
method: 'GET',
path: '/api/organizations/[id]/data-drains',
params: dataDrainOrgParamsSchema,
response: { mode: 'json', schema: dataDrainListResponseSchema },
})
export const createDataDrainContract = defineRouteContract({
method: 'POST',
path: '/api/organizations/[id]/data-drains',
params: dataDrainOrgParamsSchema,
body: createDataDrainBodySchema,
response: { mode: 'json', schema: dataDrainResponseSchema },
})
export const getDataDrainContract = defineRouteContract({
method: 'GET',
path: '/api/organizations/[id]/data-drains/[drainId]',
params: dataDrainParamsSchema,
response: { mode: 'json', schema: dataDrainResponseSchema },
})
export const updateDataDrainContract = defineRouteContract({
method: 'PUT',
path: '/api/organizations/[id]/data-drains/[drainId]',
params: dataDrainParamsSchema,
body: updateDataDrainBodySchema,
response: { mode: 'json', schema: dataDrainResponseSchema },
})
export const deleteDataDrainContract = defineRouteContract({
method: 'DELETE',
path: '/api/organizations/[id]/data-drains/[drainId]',
params: dataDrainParamsSchema,
response: { mode: 'json', schema: z.object({ success: z.literal(true) }) },
})
export const runDataDrainContract = defineRouteContract({
method: 'POST',
path: '/api/organizations/[id]/data-drains/[drainId]/run',
params: dataDrainParamsSchema,
response: { mode: 'json', schema: runDataDrainResponseSchema },
})
export const testDataDrainContract = defineRouteContract({
method: 'POST',
path: '/api/organizations/[id]/data-drains/[drainId]/test',
params: dataDrainParamsSchema,
response: { mode: 'json', schema: testDataDrainResponseSchema },
})
export const listDataDrainRunsContract = defineRouteContract({
method: 'GET',
path: '/api/organizations/[id]/data-drains/[drainId]/runs',
params: dataDrainParamsSchema,
query: z
.object({
limit: z
.preprocess(
(v) => (typeof v === 'string' ? Number.parseInt(v, 10) : v),
z.number().int().min(1).max(200)
)
.optional(),
})
.optional(),
response: { mode: 'json', schema: dataDrainRunListResponseSchema },
})
@@ -0,0 +1,157 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { updateOrganizationDataRetentionBodySchema } from '@/lib/api/contracts/organization'
import {
piiRedactionRuleSchema,
piiRedactionSettingsSchema,
retentionOverridesSchema,
} from '@/lib/api/contracts/primitives'
describe('retentionOverridesSchema', () => {
it('accepts an override that overrides one field and inherits the rest', () => {
const result = retentionOverridesSchema.safeParse([
{ workspaceId: 'ws-1', logRetentionHours: 168 },
])
expect(result.success).toBe(true)
})
it('accepts null (forever) for a field', () => {
const result = retentionOverridesSchema.safeParse([
{ workspaceId: 'ws-1', logRetentionHours: null },
])
expect(result.success).toBe(true)
})
it('rejects two overrides for the same workspace', () => {
const result = retentionOverridesSchema.safeParse([
{ workspaceId: 'ws-1', logRetentionHours: 168 },
{ workspaceId: 'ws-1', softDeleteRetentionHours: 720 },
])
expect(result.success).toBe(false)
})
it('allows distinct workspaces', () => {
const result = retentionOverridesSchema.safeParse([
{ workspaceId: 'ws-1', logRetentionHours: 168 },
{ workspaceId: 'ws-2', logRetentionHours: 720 },
])
expect(result.success).toBe(true)
})
it('rejects hours below the 24h minimum and above the ~5y maximum', () => {
expect(
retentionOverridesSchema.safeParse([{ workspaceId: 'ws-1', logRetentionHours: 1 }]).success
).toBe(false)
expect(
retentionOverridesSchema.safeParse([{ workspaceId: 'ws-1', logRetentionHours: 100000 }])
.success
).toBe(false)
})
it('rejects an empty workspaceId', () => {
expect(
retentionOverridesSchema.safeParse([{ workspaceId: '', logRetentionHours: 168 }]).success
).toBe(false)
})
})
describe('updateOrganizationDataRetentionBodySchema', () => {
it('accepts retentionOverrides alongside the org hours', () => {
const result = updateOrganizationDataRetentionBodySchema.safeParse({
logRetentionHours: 720,
retentionOverrides: [{ workspaceId: 'ws-1', logRetentionHours: 168 }],
})
expect(result.success).toBe(true)
})
it('accepts a body with no retentionOverrides (field is optional)', () => {
const result = updateOrganizationDataRetentionBodySchema.safeParse({ logRetentionHours: 720 })
expect(result.success).toBe(true)
})
})
describe('piiRedactionRuleSchema', () => {
const stage = (enabled: boolean, entityTypes: string[], language?: string) => ({
enabled,
entityTypes,
...(language ? { language } : {}),
})
it('accepts a legacy flat rule (entityTypes only)', () => {
const result = piiRedactionRuleSchema.safeParse({
id: 'r-1',
workspaceId: null,
entityTypes: ['EMAIL_ADDRESS'],
})
expect(result.success).toBe(true)
})
it('accepts a per-stage rule', () => {
const result = piiRedactionRuleSchema.safeParse({
id: 'r-1',
workspaceId: 'ws-1',
stages: {
input: stage(true, ['PERSON'], 'es'),
blockOutputs: stage(false, []),
logs: stage(true, ['US_SSN']),
},
})
expect(result.success).toBe(true)
})
it('rejects a rule with neither stages nor entityTypes', () => {
const result = piiRedactionRuleSchema.safeParse({ id: 'r-1', workspaceId: null })
expect(result.success).toBe(false)
})
it('rejects an enabled stage with no entity types (redact-all is not expressible)', () => {
const result = piiRedactionRuleSchema.safeParse({
id: 'r-1',
workspaceId: null,
stages: {
input: stage(true, []),
blockOutputs: stage(false, []),
logs: stage(false, []),
},
})
expect(result.success).toBe(false)
})
it('accepts a disabled stage with no entity types (off)', () => {
const result = piiRedactionRuleSchema.safeParse({
id: 'r-1',
workspaceId: null,
stages: {
input: stage(false, []),
blockOutputs: stage(false, []),
logs: stage(true, ['PERSON']),
},
})
expect(result.success).toBe(true)
})
it('rejects an unsupported stage language', () => {
const result = piiRedactionRuleSchema.safeParse({
id: 'r-1',
workspaceId: null,
stages: {
input: stage(true, ['PERSON'], 'de'),
blockOutputs: stage(false, []),
logs: stage(false, []),
},
})
expect(result.success).toBe(false)
})
it('enforces one rule per scope (uniqueness refine still applies)', () => {
const result = piiRedactionSettingsSchema.safeParse({
rules: [
{ id: 'r-1', workspaceId: 'ws-1', entityTypes: ['PERSON'] },
{ id: 'r-2', workspaceId: 'ws-1', entityTypes: ['US_SSN'] },
],
})
expect(result.success).toBe(false)
})
})
@@ -0,0 +1,82 @@
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts/types'
import { isFreeEmailDomain } from '@/lib/messaging/email/free-email'
import { NO_EMAIL_HEADER_CONTROL_CHARS_REGEX } from '@/lib/messaging/email/utils'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
export const DEMO_REQUEST_COMPANY_SIZE_VALUES = [
'1_10',
'11_50',
'51_200',
'201_500',
'501_1000',
'1001_10000',
'10000_plus',
] as const
export const DEMO_REQUEST_COMPANY_SIZE_OPTIONS = [
{ value: '1_10', label: '110' },
{ value: '11_50', label: '1150' },
{ value: '51_200', label: '51200' },
{ value: '201_500', label: '201500' },
{ value: '501_1000', label: '5011,000' },
{ value: '1001_10000', label: '1,00110,000' },
{ value: '10000_plus', label: '10,000+' },
] as const
export const demoRequestSchema = z.object({
firstName: z
.string()
.trim()
.min(1, 'First name is required')
.max(100)
.regex(NO_EMAIL_HEADER_CONTROL_CHARS_REGEX, 'Invalid characters'),
lastName: z
.string()
.trim()
.min(1, 'Last name is required')
.max(100)
.regex(NO_EMAIL_HEADER_CONTROL_CHARS_REGEX, 'Invalid characters'),
companyEmail: z
.string()
.trim()
.min(1, 'Company email is required')
.max(320)
.transform((value) => value.toLowerCase())
.refine((value) => quickValidateEmail(value).isValid, 'Enter a valid work email')
.refine((value) => !isFreeEmailDomain(value), 'Please use your work email address'),
phoneNumber: z
.string()
.trim()
.max(50, 'Phone number must be 50 characters or less')
.optional()
.transform((value) => (value && value.length > 0 ? value : undefined)),
companySize: z.enum(DEMO_REQUEST_COMPANY_SIZE_VALUES, {
error: 'Please select company size',
}),
details: z.string().trim().min(1, 'Details are required').max(2000),
})
export type DemoRequestPayload = z.infer<typeof demoRequestSchema>
export type DemoRequestBody = z.input<typeof demoRequestSchema>
export function getDemoRequestCompanySizeLabel(value: DemoRequestPayload['companySize']): string {
return DEMO_REQUEST_COMPANY_SIZE_OPTIONS.find((option) => option.value === value)?.label ?? value
}
export const demoRequestResponseSchema = z.object({
success: z.literal(true),
message: z.string(),
})
export type DemoRequestResult = z.output<typeof demoRequestResponseSchema>
export const submitDemoRequestContract = defineRouteContract({
method: 'POST',
path: '/api/demo-requests',
body: demoRequestSchema,
response: {
mode: 'json',
schema: demoRequestResponseSchema,
},
})
+307
View File
@@ -0,0 +1,307 @@
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts/types'
import { workflowIdParamsSchema } from '@/lib/api/contracts/workflows'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const deployedWorkflowStateSchema = z.custom<WorkflowState>(
(value) => typeof value === 'object' && value !== null,
'Expected workflow state'
)
export const deploymentVersionParamsSchema = z.object({
id: z.string().min(1, 'Invalid workflow ID'),
version: z.coerce.number().int().positive(),
})
export const deploymentVersionOrActiveParamsSchema = z.object({
id: z.string().min(1, 'Invalid workflow ID'),
version: z.union([z.number().int().positive(), z.literal('active')]),
})
export const deploymentVersionRouteParamsSchema = z.object({
id: z.string().min(1, 'Invalid workflow ID'),
version: z.string().min(1, 'Invalid version'),
})
export const updatePublicApiBodySchema = z.object({
isPublicApi: z.boolean(),
})
export type UpdatePublicApiBody = z.input<typeof updatePublicApiBodySchema>
export const deploymentVersionMetadataFieldsSchema = z.object({
name: z
.string()
.trim()
.min(1, 'Name cannot be empty')
.max(100, 'Name must be 100 characters or less')
.optional(),
description: z.string().trim().max(50_000, 'Description is too long').nullable().optional(),
})
export const updateDeploymentVersionMetadataBodySchema =
deploymentVersionMetadataFieldsSchema.refine(
(data) => data.name !== undefined || data.description !== undefined,
{
message: 'At least one of name or description must be provided',
}
)
export type UpdateDeploymentVersionMetadataBody = z.input<
typeof updateDeploymentVersionMetadataBodySchema
>
export const activateDeploymentVersionBodySchema = z.object({
isActive: z.literal(true),
})
export type ActivateDeploymentVersionBody = z.input<typeof activateDeploymentVersionBodySchema>
export const deploymentVersionPatchBodySchema = deploymentVersionMetadataFieldsSchema
.extend({
isActive: z.literal(true).optional(),
})
.refine(
(data) => data.name !== undefined || data.description !== undefined || data.isActive === true,
{
message: 'At least one of name, description, or isActive must be provided',
}
)
export const deploymentInfoResponseSchema = z.object({
isDeployed: z.boolean(),
deployedAt: z.string().nullable().optional(),
apiKey: z.string().nullable().optional(),
needsRedeployment: z.boolean().optional(),
isPublicApi: z.boolean().optional(),
warnings: z.array(z.string()).optional(),
})
export type DeploymentInfoResponse = z.output<typeof deploymentInfoResponseSchema>
export type DeployWorkflowResponse = DeploymentInfoResponse
export type UndeployWorkflowResponse = DeploymentInfoResponse
export const deploymentVersionSchema = z.object({
id: z.string(),
version: z.number(),
name: z.string().nullable().optional(),
description: z.string().nullable().optional(),
isActive: z.boolean(),
createdAt: z.string(),
createdBy: z.string().nullable().optional(),
deployedBy: z.string().nullable().optional(),
})
export type DeploymentVersion = z.output<typeof deploymentVersionSchema>
export const deploymentVersionsResponseSchema = z.object({
versions: z.array(deploymentVersionSchema),
})
export type DeploymentVersionsResponse = z.output<typeof deploymentVersionsResponseSchema>
export const chatDeploymentStatusSchema = z.object({
isDeployed: z.boolean(),
deployment: z
.object({
id: z.string(),
identifier: z.string(),
})
.passthrough()
.nullable(),
})
export type ChatDeploymentStatus = z.output<typeof chatDeploymentStatusSchema>
export const chatDetailSchema = z.object({
id: z.string(),
identifier: z.string(),
title: z.string(),
description: z.preprocess((value) => value ?? '', z.string()),
authType: z.enum(['public', 'password', 'email', 'sso']),
allowedEmails: z.preprocess((value) => value ?? [], z.array(z.string())),
outputConfigs: z.preprocess(
(value) => value ?? [],
z.array(
z.object({
blockId: z.string(),
path: z.string(),
})
)
),
customizations: z.preprocess(
(value) => value ?? undefined,
z
.object({
welcomeMessage: z.string().optional(),
imageUrl: z.string().optional(),
primaryColor: z.string().optional(),
})
.optional()
),
isActive: z.boolean(),
chatUrl: z.string(),
hasPassword: z.boolean(),
})
export type ChatDetail = z.output<typeof chatDetailSchema>
export const updatePublicApiResponseSchema = z.object({
isPublicApi: z.boolean(),
})
export type UpdatePublicApiResponse = z.output<typeof updatePublicApiResponseSchema>
export const deployedWorkflowStateResponseSchema = z.object({
deployedState: deployedWorkflowStateSchema.nullable(),
})
export type DeployedWorkflowStateResponse = z.output<typeof deployedWorkflowStateResponseSchema>
export const updateDeploymentVersionMetadataResponseSchema = z.object({
name: z.string().nullable(),
description: z.string().nullable(),
})
export type UpdateDeploymentVersionMetadataResponse = z.output<
typeof updateDeploymentVersionMetadataResponseSchema
>
export const activateDeploymentVersionResponseSchema = z.object({
success: z.literal(true),
deployedAt: z.string(),
warnings: z.array(z.string()).optional(),
name: z.string().nullable().optional(),
description: z.string().nullable().optional(),
})
export type ActivateDeploymentVersionResponse = z.output<
typeof activateDeploymentVersionResponseSchema
>
export const getDeploymentInfoContract = defineRouteContract({
method: 'GET',
path: '/api/workflows/[id]/deploy',
params: workflowIdParamsSchema,
response: {
mode: 'json',
schema: deploymentInfoResponseSchema,
},
})
export const deployWorkflowContract = defineRouteContract({
method: 'POST',
path: '/api/workflows/[id]/deploy',
params: workflowIdParamsSchema,
response: {
mode: 'json',
schema: deploymentInfoResponseSchema,
},
})
export const undeployWorkflowContract = defineRouteContract({
method: 'DELETE',
path: '/api/workflows/[id]/deploy',
params: workflowIdParamsSchema,
response: {
mode: 'json',
schema: deploymentInfoResponseSchema,
},
})
export const updatePublicApiContract = defineRouteContract({
method: 'PATCH',
path: '/api/workflows/[id]/deploy',
params: workflowIdParamsSchema,
body: updatePublicApiBodySchema,
response: {
mode: 'json',
schema: updatePublicApiResponseSchema,
},
})
export const getDeployedWorkflowStateContract = defineRouteContract({
method: 'GET',
path: '/api/workflows/[id]/deployed',
params: workflowIdParamsSchema,
response: {
mode: 'json',
schema: deployedWorkflowStateResponseSchema,
},
})
export const listDeploymentVersionsContract = defineRouteContract({
method: 'GET',
path: '/api/workflows/[id]/deployments',
params: workflowIdParamsSchema,
response: {
mode: 'json',
schema: deploymentVersionsResponseSchema,
},
})
export const getDeploymentVersionStateContract = defineRouteContract({
method: 'GET',
path: '/api/workflows/[id]/deployments/[version]',
params: deploymentVersionParamsSchema,
response: {
mode: 'json',
schema: z.object({
deployedState: deployedWorkflowStateSchema,
}),
},
})
export const updateDeploymentVersionMetadataContract = defineRouteContract({
method: 'PATCH',
path: '/api/workflows/[id]/deployments/[version]',
params: deploymentVersionParamsSchema,
body: deploymentVersionPatchBodySchema,
response: {
mode: 'json',
schema: updateDeploymentVersionMetadataResponseSchema,
},
})
export const activateDeploymentVersionContract = defineRouteContract({
method: 'PATCH',
path: '/api/workflows/[id]/deployments/[version]',
params: deploymentVersionParamsSchema,
body: activateDeploymentVersionBodySchema,
response: {
mode: 'json',
schema: activateDeploymentVersionResponseSchema,
},
})
export const revertToDeploymentVersionContract = defineRouteContract({
method: 'POST',
path: '/api/workflows/[id]/deployments/[version]/revert',
params: deploymentVersionOrActiveParamsSchema,
response: {
mode: 'json',
schema: z.object({
message: z.string(),
lastSaved: z.number(),
}),
},
})
export const getChatDeploymentStatusContract = defineRouteContract({
method: 'GET',
path: '/api/workflows/[id]/chat/status',
params: workflowIdParamsSchema,
response: {
mode: 'json',
schema: chatDeploymentStatusSchema,
},
})
export const getChatDetailContract = defineRouteContract({
method: 'GET',
path: '/api/chat/manage/[id]',
params: workflowIdParamsSchema,
response: {
mode: 'json',
schema: chatDetailSchema,
},
})
+88
View File
@@ -0,0 +1,88 @@
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts/types'
export const environmentVariableSchema = z.object({
key: z.string(),
value: z.string(),
})
export const environmentVariablesSchema = z.record(z.string(), z.string())
export const personalEnvironmentDataSchema = z.record(z.string(), environmentVariableSchema)
export const workspaceEnvironmentDataSchema = z.object({
workspace: environmentVariablesSchema.default({}),
personal: environmentVariablesSchema.default({}),
conflicts: z.array(z.string()).default([]),
})
export const workspaceEnvironmentParamsSchema = z.object({
id: z.string().min(1),
})
export const savePersonalEnvironmentBodySchema = z.object({
variables: environmentVariablesSchema,
})
export const removeWorkspaceEnvironmentBodySchema = z.object({
keys: z.array(z.string()).min(1),
})
const successResponseSchema = z.object({
success: z.literal(true),
})
export const getPersonalEnvironmentContract = defineRouteContract({
method: 'GET',
path: '/api/environment',
response: {
mode: 'json',
schema: z.object({
data: personalEnvironmentDataSchema,
}),
},
})
export const savePersonalEnvironmentContract = defineRouteContract({
method: 'POST',
path: '/api/environment',
body: savePersonalEnvironmentBodySchema,
response: {
mode: 'json',
schema: successResponseSchema,
},
})
export const getWorkspaceEnvironmentContract = defineRouteContract({
method: 'GET',
path: '/api/workspaces/[id]/environment',
params: workspaceEnvironmentParamsSchema,
response: {
mode: 'json',
schema: z.object({
data: workspaceEnvironmentDataSchema,
}),
},
})
export const upsertWorkspaceEnvironmentContract = defineRouteContract({
method: 'PUT',
path: '/api/workspaces/[id]/environment',
params: workspaceEnvironmentParamsSchema,
body: savePersonalEnvironmentBodySchema,
response: {
mode: 'json',
schema: successResponseSchema,
},
})
export const removeWorkspaceEnvironmentContract = defineRouteContract({
method: 'DELETE',
path: '/api/workspaces/[id]/environment',
params: workspaceEnvironmentParamsSchema,
body: removeWorkspaceEnvironmentBodySchema,
response: {
mode: 'json',
schema: successResponseSchema,
},
})
@@ -0,0 +1,31 @@
import { z } from 'zod'
import {
isLargeValueStorageKey,
LARGE_VALUE_KINDS,
LARGE_VALUE_REF_MARKER,
LARGE_VALUE_REF_VERSION,
} from '@/lib/execution/payloads/large-value-ref'
export const largeValueRefSchema = z
.object({
[LARGE_VALUE_REF_MARKER]: z.literal(true),
version: z.literal(LARGE_VALUE_REF_VERSION),
id: z.string().regex(/^lv_[A-Za-z0-9_-]{12}$/, 'Invalid large value reference ID'),
kind: z.enum(LARGE_VALUE_KINDS),
size: z.number().int().positive(),
key: z.string().optional(),
executionId: z.string().optional(),
preview: z.unknown().optional(),
})
.strict()
.superRefine((value, ctx) => {
if (value.key && !isLargeValueStorageKey(value.key, value.id, value.executionId)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['key'],
message: 'Large value reference key must point to execution-scoped server storage',
})
}
})
export type LargeValueRefResponse = z.output<typeof largeValueRefSchema>
@@ -0,0 +1,88 @@
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts/types'
export const fileUploadTypeSchema = z.enum([
'knowledge-base',
'chat',
'copilot',
'profile-pictures',
])
export const fileUploadTypeQuerySchema = z.object({
type: fileUploadTypeSchema,
})
export const presignedUploadBodySchema = z
.object({
fileName: z.string().optional(),
contentType: z.string().optional(),
fileSize: z.number().optional(),
userId: z.string().optional(),
chatId: z.string().optional(),
})
.passthrough()
export const batchPresignedUploadBodySchema = z
.object({
files: z
.array(
z
.object({
fileName: z.string().optional(),
contentType: z.string().optional(),
fileSize: z.number().optional(),
})
.passthrough()
)
.optional(),
})
.passthrough()
export const presignedFileInfoSchema = z
.object({
path: z.string(),
key: z.string(),
name: z.string(),
size: z.number(),
type: z.string(),
})
.passthrough()
export const presignedUploadResponseSchema = z
.object({
fileName: z.string(),
presignedUrl: z.string(),
fileInfo: presignedFileInfoSchema,
uploadHeaders: z.record(z.string(), z.string()).optional(),
directUploadSupported: z.boolean(),
})
.passthrough()
export const batchPresignedUploadResponseSchema = z
.object({
files: z.array(presignedUploadResponseSchema),
directUploadSupported: z.boolean(),
})
.passthrough()
export const createPresignedUploadContract = defineRouteContract({
method: 'POST',
path: '/api/files/presigned',
query: fileUploadTypeQuerySchema,
body: presignedUploadBodySchema,
response: {
mode: 'json',
schema: presignedUploadResponseSchema,
},
})
export const createBatchPresignedUploadContract = defineRouteContract({
method: 'POST',
path: '/api/files/presigned/batch',
query: fileUploadTypeQuerySchema,
body: batchPresignedUploadBodySchema,
response: {
mode: 'json',
schema: batchPresignedUploadResponseSchema,
},
})
+162
View File
@@ -0,0 +1,162 @@
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts/types'
export const folderScopeSchema = z.enum(['active', 'archived'])
export const folderSchema = z.object({
id: z.string(),
name: z.string(),
userId: z.string(),
workspaceId: z.string(),
parentId: z.string().nullable(),
color: z.string().nullable(),
isExpanded: z.boolean(),
locked: z.boolean(),
sortOrder: z.number(),
createdAt: z.string(),
updatedAt: z.string(),
archivedAt: z.string().nullable(),
})
export type FolderApi = z.output<typeof folderSchema>
export const listFoldersQuerySchema = z.object({
workspaceId: z.string({ error: 'Workspace ID is required' }).min(1, 'Workspace ID is required'),
scope: folderScopeSchema.default('active'),
})
export const createFolderBodySchema = z.object({
id: z.string().uuid().optional(),
name: z.string().min(1, 'Name is required'),
workspaceId: z.string().min(1, 'Workspace ID is required'),
/** Mirrors `updateFolderBodySchema.parentId` so explicit `null` (root folder) is accepted on create. */
parentId: z.string().nullable().optional(),
color: z.string().optional(),
sortOrder: z.number().int().optional(),
})
export const folderIdParamsSchema = z.object({
id: z.string().min(1),
})
export const updateFolderBodySchema = z.object({
name: z.string().optional(),
color: z.string().optional(),
isExpanded: z.boolean().optional(),
locked: z.boolean().optional(),
parentId: z.string().nullable().optional(),
sortOrder: z.number().int().min(0).optional(),
})
export const restoreFolderBodySchema = z.object({
workspaceId: z.string({ error: 'Workspace ID is required' }).min(1, 'Workspace ID is required'),
})
export const duplicateFolderBodySchema = z.object({
name: z.string().min(1, 'Name is required'),
workspaceId: z.string().optional(),
parentId: z.string().nullable().optional(),
color: z.string().optional(),
newId: z.string().uuid().optional(),
})
export const reorderFoldersBodySchema = z.object({
workspaceId: z.string(),
updates: z.array(
z.object({
id: z.string(),
sortOrder: z.number().int().min(0),
parentId: z.string().nullable().optional(),
})
),
})
export const listFoldersContract = defineRouteContract({
method: 'GET',
path: '/api/folders',
query: listFoldersQuerySchema,
response: {
mode: 'json',
schema: z.object({
folders: z.array(folderSchema),
}),
},
})
export const createFolderContract = defineRouteContract({
method: 'POST',
path: '/api/folders',
body: createFolderBodySchema,
response: {
mode: 'json',
schema: z.object({
folder: folderSchema,
}),
},
})
export const updateFolderContract = defineRouteContract({
method: 'PUT',
path: '/api/folders/[id]',
params: folderIdParamsSchema,
body: updateFolderBodySchema,
response: {
mode: 'json',
schema: z.object({
folder: folderSchema,
}),
},
})
export const deleteFolderContract = defineRouteContract({
method: 'DELETE',
path: '/api/folders/[id]',
params: folderIdParamsSchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
deletedItems: z.unknown(),
}),
},
})
export const restoreFolderContract = defineRouteContract({
method: 'POST',
path: '/api/folders/[id]/restore',
params: folderIdParamsSchema,
body: restoreFolderBodySchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
restoredItems: z.unknown(),
}),
},
})
export const duplicateFolderContract = defineRouteContract({
method: 'POST',
path: '/api/folders/[id]/duplicate',
params: folderIdParamsSchema,
body: duplicateFolderBodySchema,
response: {
mode: 'json',
schema: z.object({
folder: folderSchema,
}),
},
})
export const reorderFoldersContract = defineRouteContract({
method: 'PUT',
path: '/api/folders/reorder',
body: reorderFoldersBodySchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
updated: z.number().int(),
}),
},
})
+214
View File
@@ -0,0 +1,214 @@
import { z } from 'zod'
import { unknownRecordSchema } from '@/lib/api/contracts/primitives'
import { defineRouteContract } from '@/lib/api/contracts/types'
import { DEFAULT_CODE_LANGUAGE } from '@/lib/execution/languages'
export const guardrailsValidateContract = defineRouteContract({
method: 'POST',
path: '/api/guardrails/validate',
body: z.object({
validationType: z.string().optional(),
input: z.unknown().optional(),
regex: z.string().optional(),
knowledgeBaseId: z.string().optional(),
threshold: z.string().optional(),
topK: z.string().optional(),
model: z.string().optional(),
apiKey: z.string().optional(),
azureEndpoint: z.string().optional(),
azureApiVersion: z.string().optional(),
vertexProject: z.string().optional(),
vertexLocation: z.string().optional(),
vertexCredential: z.string().optional(),
bedrockAccessKeyId: z.string().optional(),
bedrockSecretKey: z.string().optional(),
bedrockRegion: z.string().optional(),
workflowId: z.string().optional(),
piiEntityTypes: z.array(z.string()).optional(),
piiMode: z.string().optional(),
piiLanguage: z.string().optional(),
}),
response: {
mode: 'json',
schema: z.object({
success: z.boolean(),
output: z.object({
passed: z.boolean(),
validationType: z.string(),
input: z.unknown().optional(),
error: z.string().optional(),
score: z.number().optional(),
reasoning: z.string().optional(),
detectedEntities: z.array(z.unknown()).optional(),
maskedText: z.string().optional(),
}),
}),
},
})
const guardrailsMaskBatchBodySchema = z.object({
texts: z.array(z.string()).max(100_000),
entityTypes: z.array(z.string().min(1, 'Entity type cannot be empty')).max(200),
language: z.string().min(1).max(20).optional(),
})
const guardrailsMaskBatchResponseSchema = z.object({
masked: z.array(z.string()),
})
/**
* Internal batch PII masking. Called server-to-server (internal JWT) from the
* log-redaction persist path so Presidio always runs in the app container,
* including for async executions that persist inside the trigger.dev runtime.
*/
export const guardrailsMaskBatchContract = defineRouteContract({
method: 'POST',
path: '/api/guardrails/mask-batch',
body: guardrailsMaskBatchBodySchema,
response: {
mode: 'json',
schema: guardrailsMaskBatchResponseSchema,
},
})
export type GuardrailsMaskBatchBody = z.input<typeof guardrailsMaskBatchBodySchema>
export type GuardrailsMaskBatchResult = z.output<typeof guardrailsMaskBatchResponseSchema>
const chatMessageSchema = z.object({
role: z.enum(['user', 'assistant', 'system']),
content: z.string(),
})
const wandGenerateBodySchema = z.object({
prompt: z.string().min(1, 'Missing required field: prompt.'),
systemPrompt: z.string().optional(),
stream: z.boolean().optional().default(false),
history: z.array(chatMessageSchema).optional().default([]),
workflowId: z.string().optional(),
/** Falls back here for per-member usage attribution when no workflowId is sent. */
workspaceId: z.string().optional(),
generationType: z.string().optional(),
wandContext: unknownRecordSchema.optional().default({}),
})
export const wandGenerateContract = defineRouteContract({
method: 'POST',
path: '/api/wand',
body: wandGenerateBodySchema,
response: {
mode: 'json',
schema: unknownRecordSchema,
},
})
export const wandGenerateStreamContract = defineRouteContract({
method: 'POST',
path: '/api/wand',
body: wandGenerateBodySchema.extend({
stream: z.literal(true),
}),
response: {
mode: 'stream',
},
})
const functionFileInputSchema = z
.object({
path: z.string().min(1, 'Input file path is required'),
sandboxPath: z.string().optional(),
})
.strict()
const functionDirectoryInputSchema = z
.object({
path: z.string().min(1, 'Input directory path is required'),
sandboxPath: z.string().optional(),
})
.strict()
const functionTableInputSchema = z
.object({
path: z.string().optional(),
tableId: z.string().optional(),
sandboxPath: z.string().optional(),
})
.strict()
const functionOutputFileSchema = z
.object({
path: z.string().min(1, 'Output file path is required'),
mode: z.enum(['create', 'overwrite']).default('create'),
sandboxPath: z.string().optional(),
format: z.enum(['json', 'csv', 'txt', 'md', 'html']).optional(),
mimeType: z.string().optional(),
})
.strict()
export const functionExecuteContract = defineRouteContract({
method: 'POST',
path: '/api/function/execute',
body: z.object({
code: z.string().min(1, 'Code is required'),
sourceCode: z.string().optional(),
params: unknownRecordSchema.optional().default({}),
timeout: z.coerce.number().int().positive().optional(),
language: z.string().optional().default(DEFAULT_CODE_LANGUAGE),
title: z.string().optional(),
outputPath: z.string().optional(),
outputFormat: z.string().optional(),
outputTable: z.string().optional(),
outputMimeType: z.string().optional(),
outputSandboxPath: z.string().optional(),
overwriteFileId: z.string().optional(),
inputs: z
.object({
files: z.array(functionFileInputSchema).optional(),
directories: z.array(functionDirectoryInputSchema).optional(),
tables: z.array(functionTableInputSchema).optional(),
})
.strict()
.optional(),
outputs: z
.object({
files: z.array(functionOutputFileSchema).optional(),
})
.strict()
.optional(),
envVars: z.record(z.string(), z.string()).optional().default({}),
blockData: unknownRecordSchema.optional().default({}),
blockNameMapping: z.record(z.string(), z.string()).optional().default({}),
blockOutputSchemas: z.record(z.string(), unknownRecordSchema).optional().default({}),
workflowVariables: unknownRecordSchema.optional().default({}),
contextVariables: unknownRecordSchema.optional().default({}),
workflowId: z.string().optional(),
executionId: z.string().optional(),
largeValueExecutionIds: z.array(z.string()).optional(),
largeValueKeys: z.array(z.string()).optional(),
fileKeys: z.array(z.string()).optional(),
allowLargeValueWorkflowScope: z.boolean().optional(),
workspaceId: z.string().optional(),
userId: z.string().optional(),
isCustomTool: z.boolean().optional().default(false),
_sandboxFiles: z
.array(
z.union([
z.object({
type: z.literal('content').optional(),
path: z.string(),
content: z.string(),
encoding: z.literal('base64').optional(),
}),
// Mounted by reference: the sandbox fetches `url` itself (no bytes through the web tier).
z.object({
type: z.literal('url'),
path: z.string(),
url: z.string(),
}),
])
)
.optional(),
}),
response: {
mode: 'json',
schema: unknownRecordSchema,
},
})
+186
View File
@@ -0,0 +1,186 @@
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts/types'
export const inboxWorkspaceParamsSchema = z.object({
id: z.string().min(1),
})
export const inboxTaskStatusSchema = z.enum([
'all',
'received',
'processing',
'completed',
'failed',
'rejected',
])
export const inboxConfigSchema = z.object({
enabled: z.boolean(),
address: z.string().nullable(),
entitled: z.boolean(),
taskStats: z.object({
total: z.number(),
completed: z.number(),
processing: z.number(),
failed: z.number(),
}),
})
export type InboxConfig = z.output<typeof inboxConfigSchema>
export type InboxTaskStatus = z.output<typeof inboxTaskStatusSchema>
export const updateInboxConfigBodySchema = z.object({
enabled: z.boolean().optional(),
username: z.string().min(1).max(64).optional(),
})
export const updateInboxConfigResponseSchema = z.object({
enabled: z.boolean(),
address: z.string().nullable(),
providerId: z.string().nullable().optional(),
})
export const inboxSenderSchema = z.object({
id: z.string(),
email: z.string(),
label: z.string().nullable(),
createdAt: z.string(),
})
export type InboxSender = z.output<typeof inboxSenderSchema>
export const inboxMemberSchema = z.object({
email: z.string(),
name: z.string().nullable(),
isAutoAllowed: z.boolean(),
})
export type InboxMember = z.output<typeof inboxMemberSchema>
export const inboxSendersResponseSchema = z.object({
senders: z.array(inboxSenderSchema),
workspaceMembers: z.array(inboxMemberSchema),
})
export type InboxSendersResponseBody = z.output<typeof inboxSendersResponseSchema>
export const createInboxSenderBodySchema = z.object({
email: z.string().email('Invalid email address'),
label: z.string().max(100).optional(),
})
export const deleteInboxSenderBodySchema = z.object({
senderId: z.string().min(1),
})
export const inboxTasksQuerySchema = z.object({
status: inboxTaskStatusSchema.optional(),
cursor: z.string().optional(),
limit: z.preprocess(
(value) => (value === undefined || value === '' ? undefined : value),
z.coerce
.number()
.optional()
.transform((value) => (value === undefined ? undefined : Math.min(value, 50)))
),
})
export const inboxTaskSchema = z.object({
id: z.string(),
fromEmail: z.string(),
fromName: z.string().nullable(),
subject: z.string(),
bodyPreview: z.string().nullable(),
status: inboxTaskStatusSchema.exclude(['all']),
hasAttachments: z.boolean(),
resultSummary: z.string().nullable(),
errorMessage: z.string().nullable(),
rejectionReason: z.string().nullable(),
chatId: z.string().nullable(),
createdAt: z.string(),
completedAt: z.string().nullable(),
})
export type InboxTask = z.output<typeof inboxTaskSchema>
export const inboxTasksResponseSchema = z.object({
tasks: z.array(inboxTaskSchema),
pagination: z.object({
limit: z.number(),
hasMore: z.boolean(),
nextCursor: z.string().nullable(),
}),
})
export type InboxTasksResponseBody = z.output<typeof inboxTasksResponseSchema>
export const getInboxConfigContract = defineRouteContract({
method: 'GET',
path: '/api/workspaces/[id]/inbox',
params: inboxWorkspaceParamsSchema,
response: {
mode: 'json',
schema: inboxConfigSchema,
},
})
export const updateInboxConfigContract = defineRouteContract({
method: 'PATCH',
path: '/api/workspaces/[id]/inbox',
params: inboxWorkspaceParamsSchema,
body: updateInboxConfigBodySchema,
response: {
mode: 'json',
schema: updateInboxConfigResponseSchema,
},
})
export const listInboxSendersContract = defineRouteContract({
method: 'GET',
path: '/api/workspaces/[id]/inbox/senders',
params: inboxWorkspaceParamsSchema,
response: {
mode: 'json',
schema: inboxSendersResponseSchema,
},
})
export const addInboxSenderContract = defineRouteContract({
method: 'POST',
path: '/api/workspaces/[id]/inbox/senders',
params: inboxWorkspaceParamsSchema,
body: createInboxSenderBodySchema,
response: {
mode: 'json',
schema: z.object({
sender: inboxSenderSchema.extend({
workspaceId: z.string().optional(),
addedBy: z.string().optional(),
}),
}),
},
})
export const removeInboxSenderContract = defineRouteContract({
method: 'DELETE',
path: '/api/workspaces/[id]/inbox/senders',
params: inboxWorkspaceParamsSchema,
body: deleteInboxSenderBodySchema,
response: {
mode: 'json',
schema: z.object({
ok: z.literal(true),
}),
},
})
export const listInboxTasksContract = defineRouteContract({
method: 'GET',
path: '/api/workspaces/[id]/inbox/tasks',
params: inboxWorkspaceParamsSchema,
query: inboxTasksQuerySchema,
response: {
mode: 'json',
schema: inboxTasksResponseSchema,
},
})
+31
View File
@@ -0,0 +1,31 @@
export * from './admin'
export * from './api-keys'
export * from './audit-logs'
export * from './byok-keys'
export * from './chats'
export * from './common'
export * from './copilot'
export * from './credentials'
export * from './demo-requests'
export * from './environment'
export * from './execution-payloads'
export * from './file-uploads'
export * from './folders'
export * from './hotspots'
export * from './inbox'
export * from './media'
export * from './permission-groups'
export * from './primitives'
export * from './selectors'
export * from './skills'
export * from './storage-transfer'
export * from './subscription'
export * from './tool-primitives'
export * from './tools'
export * from './types'
export * from './user'
export * from './v1'
export * from './workflows'
export * from './workspace-file-folders'
export * from './workspace-files'
export * from './workspaces'
+213
View File
@@ -0,0 +1,213 @@
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts/types'
import { workspacePermissionSchema } from '@/lib/api/contracts/workspaces'
export const invitationParamsSchema = z.object({
id: z.string({ error: 'Invitation ID is required' }).min(1, 'Invitation ID is required'),
})
export const invitationQuerySchema = z.object({
token: z.string().min(1).optional(),
})
export const invitationGrantSchema = z.object({
workspaceId: z.string().min(1),
permission: workspacePermissionSchema,
})
export const updateInvitationBodySchema = z
.object({
role: z.enum(['admin', 'member']).optional(),
grants: z.array(invitationGrantSchema).optional(),
})
.refine((data) => data.role !== undefined || (data.grants && data.grants.length > 0), {
message: 'Provide a role or at least one grant update',
})
export const pendingWorkspaceInvitationSchema = z
.object({
id: z.string(),
workspaceId: z.string(),
email: z.string(),
token: z.string(),
permission: workspacePermissionSchema,
membershipIntent: z.enum(['internal', 'external']).optional(),
status: z.string(),
createdAt: z.string(),
})
.passthrough()
export const batchWorkspaceInvitationBodySchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
invitations: z
.array(
z.object({
email: z.string().trim().min(1, 'Invitation email is required'),
permission: workspacePermissionSchema.optional(),
})
)
.min(1, 'At least one invitation is required'),
})
export const batchInvitationResultSchema = z
.object({
success: z.boolean(),
successful: z.array(z.string()),
added: z.array(z.string()).optional(),
failed: z.array(z.object({ email: z.string(), error: z.string() })),
invitations: z.array(z.record(z.string(), z.unknown())),
})
.passthrough()
export const removeWorkspaceMemberBodySchema = z.object({
workspaceId: z.string().uuid(),
})
export const invitationActionParamsSchema = z.object({
id: z.string({ error: 'Invitation ID is required' }).min(1, 'Invitation ID is required'),
})
export const invitationActionBodySchema = z.object({
token: z.string().min(1).optional(),
})
export const invitationDetailsSchema = z.object({
id: z.string(),
kind: z.enum(['organization', 'workspace']),
email: z.string(),
organizationId: z.string().nullable(),
organizationName: z.string().nullable(),
membershipIntent: z.enum(['internal', 'external']),
role: z.string(),
status: z.string(),
expiresAt: z.string(),
createdAt: z.string(),
inviterName: z.string().nullable(),
inviterEmail: z.string().nullable(),
grants: z.array(
z.object({
workspaceId: z.string(),
workspaceName: z.string().nullable(),
permission: workspacePermissionSchema,
})
),
})
export const acceptInvitationResponseSchema = z.object({
success: z.literal(true),
redirectPath: z.string(),
invitation: z.object({
id: z.string(),
kind: z.enum(['organization', 'workspace']),
organizationId: z.string().nullable(),
acceptedWorkspaceIds: z.array(z.string()),
}),
})
const successResponseSchema = z
.object({
success: z.boolean(),
})
.passthrough()
export const listWorkspaceInvitationsContract = defineRouteContract({
method: 'GET',
path: '/api/workspaces/invitations',
response: {
mode: 'json',
schema: z.object({
invitations: z.array(pendingWorkspaceInvitationSchema),
}),
},
})
export const batchWorkspaceInvitationsContract = defineRouteContract({
method: 'POST',
path: '/api/workspaces/invitations/batch',
body: batchWorkspaceInvitationBodySchema,
response: {
mode: 'json',
schema: batchInvitationResultSchema,
},
})
export const updateInvitationContract = defineRouteContract({
method: 'PATCH',
path: '/api/invitations/[id]',
params: invitationParamsSchema,
body: updateInvitationBodySchema,
response: {
mode: 'json',
schema: successResponseSchema,
},
})
export const getInvitationContract = defineRouteContract({
method: 'GET',
path: '/api/invitations/[id]',
params: invitationParamsSchema,
query: invitationQuerySchema,
response: {
mode: 'json',
schema: z.object({
invitation: invitationDetailsSchema,
}),
},
})
export const acceptInvitationContract = defineRouteContract({
method: 'POST',
path: '/api/invitations/[id]/accept',
params: invitationActionParamsSchema,
body: invitationActionBodySchema,
response: {
mode: 'json',
schema: acceptInvitationResponseSchema,
},
})
export const rejectInvitationContract = defineRouteContract({
method: 'POST',
path: '/api/invitations/[id]/reject',
params: invitationActionParamsSchema,
body: invitationActionBodySchema,
response: {
mode: 'json',
schema: successResponseSchema,
},
})
export const cancelInvitationContract = defineRouteContract({
method: 'DELETE',
path: '/api/invitations/[id]',
params: invitationParamsSchema,
response: {
mode: 'json',
schema: successResponseSchema,
},
})
export const resendInvitationContract = defineRouteContract({
method: 'POST',
path: '/api/invitations/[id]/resend',
params: invitationParamsSchema,
response: {
mode: 'json',
schema: successResponseSchema,
},
})
export const removeWorkspaceMemberContract = defineRouteContract({
method: 'DELETE',
path: '/api/workspaces/members/[id]',
params: invitationParamsSchema,
body: removeWorkspaceMemberBodySchema,
response: {
mode: 'json',
schema: successResponseSchema,
},
})
export type PendingInvitationRow = z.infer<typeof pendingWorkspaceInvitationSchema>
export type BatchInvitationResult = z.infer<typeof batchInvitationResultSchema>
export type InvitationDetails = z.infer<typeof invitationDetailsSchema>
@@ -0,0 +1,174 @@
import { z } from 'zod'
import {
knowledgeBaseParamsSchema,
nullableWireDateSchema,
successResponseSchema,
wireDateSchema,
} from '@/lib/api/contracts/knowledge/shared'
import { defineRouteContract } from '@/lib/api/contracts/types'
import type { StrategyOptions } from '@/lib/chunkers/types'
import { KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH } from '@/lib/knowledge/constants'
export const knowledgeScopeSchema = z.enum(['active', 'archived', 'all'])
export type KnowledgeScope = z.output<typeof knowledgeScopeSchema>
export const listKnowledgeBasesQuerySchema = z.object({
workspaceId: z.string().min(1).optional(),
scope: knowledgeScopeSchema.default('active'),
})
export const chunkingStrategyOptionsSchema = z
.object({
pattern: z.string().max(500).optional(),
separators: z.array(z.string()).optional(),
recipe: z.enum(['plain', 'markdown', 'code']).optional(),
strictBoundaries: z.boolean().optional(),
})
.strict() satisfies z.ZodType<StrategyOptions>
export const chunkingConfigSchema = z
.object({
maxSize: z.number().min(100).max(4000),
minSize: z.number().min(1).max(2000),
overlap: z.number().min(0).max(500),
strategy: z.enum(['auto', 'text', 'regex', 'recursive', 'sentence', 'token']).optional(),
strategyOptions: chunkingStrategyOptionsSchema.optional(),
})
.refine((data) => data.minSize < data.maxSize * 4, {
message: 'Min chunk size (characters) must be less than max chunk size (tokens × 4)',
})
.refine((data) => data.overlap < data.maxSize, {
message: 'Overlap must be less than max chunk size',
})
.refine(
(data) => data.strategy !== 'regex' || typeof data.strategyOptions?.pattern === 'string',
{
message: 'Regex pattern is required when using the regex chunking strategy',
}
)
.refine((data) => data.strategy === 'regex' || data.strategyOptions?.strictBoundaries !== true, {
message: 'strictBoundaries is only valid for the regex chunking strategy',
})
export const createKnowledgeBaseBodySchema = z.object({
name: z.string().min(1, 'Name is required'),
description: z
.string()
.max(
KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH,
`Description must be ${KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH} characters or less`
)
.optional(),
workspaceId: z.string().min(1, 'Workspace ID is required'),
embeddingModel: z.literal('text-embedding-3-small').default('text-embedding-3-small'),
embeddingDimension: z.literal(1536).default(1536),
chunkingConfig: chunkingConfigSchema.default({
maxSize: 1024,
minSize: 100,
overlap: 200,
}),
})
export const updateKnowledgeBaseBodySchema = createKnowledgeBaseBodySchema
.pick({
name: true,
description: true,
})
.partial()
.extend({
chunkingConfig: chunkingConfigSchema.optional(),
workspaceId: z.string().nullable().optional(),
embeddingModel: z.literal('text-embedding-3-small').optional(),
embeddingDimension: z.literal(1536).optional(),
})
const knowledgeChunkingConfigSchema = z
.object({
maxSize: z.number(),
minSize: z.number(),
overlap: z.number(),
strategy: z.enum(['auto', 'text', 'regex', 'recursive', 'sentence', 'token']).optional(),
strategyOptions: chunkingStrategyOptionsSchema.optional(),
})
.passthrough()
export const knowledgeBaseDataSchema = z
.object({
id: z.string(),
userId: z.string(),
name: z.string(),
description: z.string().nullable(),
tokenCount: z.number(),
embeddingModel: z.string(),
embeddingDimension: z.number(),
chunkingConfig: knowledgeChunkingConfigSchema,
createdAt: wireDateSchema,
updatedAt: wireDateSchema,
deletedAt: nullableWireDateSchema,
workspaceId: z.string().nullable(),
docCount: z.number().optional(),
connectorTypes: z.array(z.string()).optional(),
})
.passthrough()
export type KnowledgeBaseData = z.output<typeof knowledgeBaseDataSchema>
export const listKnowledgeBasesContract = defineRouteContract({
method: 'GET',
path: '/api/knowledge',
query: listKnowledgeBasesQuerySchema,
response: {
mode: 'json',
schema: successResponseSchema(z.array(knowledgeBaseDataSchema)),
},
})
export const createKnowledgeBaseContract = defineRouteContract({
method: 'POST',
path: '/api/knowledge',
body: createKnowledgeBaseBodySchema,
response: {
mode: 'json',
schema: successResponseSchema(knowledgeBaseDataSchema),
},
})
export const getKnowledgeBaseContract = defineRouteContract({
method: 'GET',
path: '/api/knowledge/[id]',
params: knowledgeBaseParamsSchema,
response: {
mode: 'json',
schema: successResponseSchema(knowledgeBaseDataSchema),
},
})
export const updateKnowledgeBaseContract = defineRouteContract({
method: 'PUT',
path: '/api/knowledge/[id]',
params: knowledgeBaseParamsSchema,
body: updateKnowledgeBaseBodySchema,
response: {
mode: 'json',
schema: successResponseSchema(knowledgeBaseDataSchema),
},
})
export const deleteKnowledgeBaseContract = defineRouteContract({
method: 'DELETE',
path: '/api/knowledge/[id]',
params: knowledgeBaseParamsSchema,
response: {
mode: 'json',
schema: successResponseSchema(z.object({ message: z.string() })),
},
})
export const restoreKnowledgeBaseContract = defineRouteContract({
method: 'POST',
path: '/api/knowledge/[id]/restore',
params: knowledgeBaseParamsSchema,
response: {
mode: 'json',
schema: z.object({ success: z.literal(true) }).passthrough(),
},
})
@@ -0,0 +1,152 @@
import { z } from 'zod'
import {
documentBooleanFieldSchema,
documentDateFieldSchema,
documentNumberFieldSchema,
documentTagFieldSchema,
knowledgeChunkParamsSchema,
knowledgeDocumentParamsSchema,
paginationSchema,
successResponseSchema,
wireDateSchema,
} from '@/lib/api/contracts/knowledge/shared'
import { defineRouteContract } from '@/lib/api/contracts/types'
export const listKnowledgeChunksQuerySchema = z.object({
search: z.string().optional(),
enabled: z.enum(['true', 'false', 'all']).optional().default('all'),
limit: z.coerce.number().min(1).max(100).optional().default(50),
offset: z.coerce.number().min(0).optional().default(0),
sortBy: z.enum(['chunkIndex', 'tokenCount', 'enabled']).optional().default('chunkIndex'),
sortOrder: z.enum(['asc', 'desc']).optional().default('asc'),
})
export const createChunkBodySchema = z.object({
content: z.string().min(1, 'Content is required').max(10000, 'Content too long'),
enabled: z.boolean().optional().default(true),
})
export const updateChunkBodySchema = createChunkBodySchema.partial()
export const bulkChunkOperationBodySchema = z.object({
operation: z.enum(['enable', 'disable', 'delete']),
chunkIds: z.array(z.string()).min(1, 'At least one chunk ID is required').max(100),
})
export const bulkChunkOperationDataSchema = z.object({
operation: z.string(),
successCount: z.number(),
errorCount: z.number(),
processed: z.number(),
errors: z.array(z.string()),
})
export type BulkChunkOperationData = z.output<typeof bulkChunkOperationDataSchema>
export const chunkDataSchema = z
.object({
id: z.string(),
chunkIndex: z.number(),
content: z.string(),
contentLength: z.number(),
tokenCount: z.number(),
enabled: z.boolean(),
startOffset: z.number(),
endOffset: z.number(),
tag1: documentTagFieldSchema,
tag2: documentTagFieldSchema,
tag3: documentTagFieldSchema,
tag4: documentTagFieldSchema,
tag5: documentTagFieldSchema,
tag6: documentTagFieldSchema,
tag7: documentTagFieldSchema,
number1: documentNumberFieldSchema,
number2: documentNumberFieldSchema,
number3: documentNumberFieldSchema,
number4: documentNumberFieldSchema,
number5: documentNumberFieldSchema,
date1: documentDateFieldSchema,
date2: documentDateFieldSchema,
boolean1: documentBooleanFieldSchema,
boolean2: documentBooleanFieldSchema,
boolean3: documentBooleanFieldSchema,
createdAt: wireDateSchema,
updatedAt: wireDateSchema,
})
.passthrough()
export type ChunkData = z.output<typeof chunkDataSchema>
export const chunksPaginationSchema = paginationSchema
export type ChunksPagination = z.output<typeof chunksPaginationSchema>
export type KnowledgeChunksResponse = {
chunks: ChunkData[]
pagination: ChunksPagination
}
export const listKnowledgeChunksContract = defineRouteContract({
method: 'GET',
path: '/api/knowledge/[id]/documents/[documentId]/chunks',
params: knowledgeDocumentParamsSchema,
query: listKnowledgeChunksQuerySchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
data: z.array(chunkDataSchema),
pagination: chunksPaginationSchema,
}),
},
})
export const createKnowledgeChunkContract = defineRouteContract({
method: 'POST',
path: '/api/knowledge/[id]/documents/[documentId]/chunks',
params: knowledgeDocumentParamsSchema,
body: createChunkBodySchema,
response: {
mode: 'json',
schema: successResponseSchema(chunkDataSchema),
},
})
export const getKnowledgeChunkContract = defineRouteContract({
method: 'GET',
path: '/api/knowledge/[id]/documents/[documentId]/chunks/[chunkId]',
params: knowledgeChunkParamsSchema,
response: {
mode: 'json',
schema: successResponseSchema(chunkDataSchema),
},
})
export const updateKnowledgeChunkContract = defineRouteContract({
method: 'PUT',
path: '/api/knowledge/[id]/documents/[documentId]/chunks/[chunkId]',
params: knowledgeChunkParamsSchema,
body: updateChunkBodySchema,
response: {
mode: 'json',
schema: successResponseSchema(chunkDataSchema),
},
})
export const deleteKnowledgeChunkContract = defineRouteContract({
method: 'DELETE',
path: '/api/knowledge/[id]/documents/[documentId]/chunks/[chunkId]',
params: knowledgeChunkParamsSchema,
response: {
mode: 'json',
schema: successResponseSchema(z.object({ message: z.string() })),
},
})
export const bulkKnowledgeChunksContract = defineRouteContract({
method: 'PATCH',
path: '/api/knowledge/[id]/documents/[documentId]/chunks',
params: knowledgeDocumentParamsSchema,
body: bulkChunkOperationBodySchema,
response: {
mode: 'json',
schema: successResponseSchema(bulkChunkOperationDataSchema),
},
})
@@ -0,0 +1,194 @@
import { z } from 'zod'
import {
knowledgeBaseParamsSchema,
knowledgeConnectorParamsSchema,
successResponseSchema,
} from '@/lib/api/contracts/knowledge/shared'
import { defineRouteContract } from '@/lib/api/contracts/types'
export const createConnectorBodySchema = z.object({
connectorType: z.string().min(1),
credentialId: z.string().min(1).optional(),
apiKey: z.string().min(1).optional(),
sourceConfig: z.record(z.string(), z.unknown()),
syncIntervalMinutes: z.number().int().min(0).default(1440),
})
export const updateConnectorBodySchema = z.object({
sourceConfig: z.record(z.string(), z.unknown()).optional(),
syncIntervalMinutes: z.number().int().min(0).optional(),
status: z.enum(['active', 'paused']).optional(),
})
export const deleteConnectorQuerySchema = z.object({
deleteDocuments: z.boolean().optional(),
})
export const connectorDocumentsQuerySchema = z.object({
includeExcluded: z.boolean().optional(),
})
export const connectorDocumentsPatchBodySchema = z.object({
operation: z.enum(['restore', 'exclude']),
documentIds: z.array(z.string()).min(1),
})
export const connectorDataSchema = z
.object({
id: z.string(),
knowledgeBaseId: z.string(),
connectorType: z.string(),
credentialId: z.string().nullable(),
sourceConfig: z.record(z.string(), z.unknown()),
syncMode: z.string().nullable(),
syncIntervalMinutes: z.number(),
status: z.enum(['active', 'paused', 'syncing', 'error', 'disabled']),
lastSyncAt: z.string().nullable(),
lastSyncError: z.string().nullable(),
lastSyncDocCount: z.number().nullable(),
nextSyncAt: z.string().nullable(),
consecutiveFailures: z.number(),
createdAt: z.string(),
updatedAt: z.string(),
})
.passthrough()
export type ConnectorData = z.output<typeof connectorDataSchema>
export const syncLogDataSchema = z
.object({
id: z.string(),
connectorId: z.string(),
status: z.string(),
startedAt: z.string(),
completedAt: z.string().nullable(),
docsAdded: z.number(),
docsUpdated: z.number(),
docsDeleted: z.number(),
docsUnchanged: z.number(),
docsFailed: z.number(),
errorMessage: z.string().nullable(),
})
.passthrough()
export type SyncLogData = z.output<typeof syncLogDataSchema>
export const connectorDetailDataSchema = connectorDataSchema.extend({
syncLogs: z.array(syncLogDataSchema),
})
export type ConnectorDetailData = z.output<typeof connectorDetailDataSchema>
export const connectorDocumentDataSchema = z
.object({
id: z.string(),
filename: z.string(),
externalId: z.string().nullable(),
sourceUrl: z.string().nullable(),
enabled: z.boolean(),
deletedAt: z.string().nullable().default(null),
userExcluded: z.boolean(),
uploadedAt: z.string(),
processingStatus: z.string(),
})
.passthrough()
export type ConnectorDocumentData = z.output<typeof connectorDocumentDataSchema>
export const connectorDocumentsDataSchema = z.object({
documents: z.array(connectorDocumentDataSchema),
counts: z.object({ active: z.number(), excluded: z.number() }),
})
export type ConnectorDocumentsData = z.output<typeof connectorDocumentsDataSchema>
export const listKnowledgeConnectorsContract = defineRouteContract({
method: 'GET',
path: '/api/knowledge/[id]/connectors',
params: knowledgeBaseParamsSchema,
response: {
mode: 'json',
schema: successResponseSchema(z.array(connectorDataSchema)),
},
})
export const createKnowledgeConnectorContract = defineRouteContract({
method: 'POST',
path: '/api/knowledge/[id]/connectors',
params: knowledgeBaseParamsSchema,
body: createConnectorBodySchema,
response: {
mode: 'json',
schema: successResponseSchema(connectorDataSchema),
},
})
export const getKnowledgeConnectorContract = defineRouteContract({
method: 'GET',
path: '/api/knowledge/[id]/connectors/[connectorId]',
params: knowledgeConnectorParamsSchema,
response: {
mode: 'json',
schema: successResponseSchema(connectorDetailDataSchema),
},
})
export const updateKnowledgeConnectorContract = defineRouteContract({
method: 'PATCH',
path: '/api/knowledge/[id]/connectors/[connectorId]',
params: knowledgeConnectorParamsSchema,
body: updateConnectorBodySchema,
response: {
mode: 'json',
schema: successResponseSchema(connectorDataSchema),
},
})
export const deleteKnowledgeConnectorContract = defineRouteContract({
method: 'DELETE',
path: '/api/knowledge/[id]/connectors/[connectorId]',
params: knowledgeConnectorParamsSchema,
query: deleteConnectorQuerySchema,
response: {
mode: 'json',
schema: z.object({ success: z.literal(true) }),
},
})
export const triggerKnowledgeConnectorSyncContract = defineRouteContract({
method: 'POST',
path: '/api/knowledge/[id]/connectors/[connectorId]/sync',
params: knowledgeConnectorParamsSchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
message: z.string(),
}),
},
})
export const listKnowledgeConnectorDocumentsContract = defineRouteContract({
method: 'GET',
path: '/api/knowledge/[id]/connectors/[connectorId]/documents',
params: knowledgeConnectorParamsSchema,
query: connectorDocumentsQuerySchema,
response: {
mode: 'json',
schema: successResponseSchema(connectorDocumentsDataSchema),
},
})
export const patchKnowledgeConnectorDocumentsContract = defineRouteContract({
method: 'PATCH',
path: '/api/knowledge/[id]/connectors/[connectorId]/documents',
params: knowledgeConnectorParamsSchema,
body: connectorDocumentsPatchBodySchema,
response: {
mode: 'json',
schema: successResponseSchema(
z
.object({
excludedCount: z.number().optional(),
restoredCount: z.number().optional(),
documentIds: z.array(z.string()).optional(),
})
.passthrough()
),
},
})
@@ -0,0 +1,145 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
listKnowledgeDocumentsQuerySchema,
parseDocumentTagFiltersParam,
} from '@/lib/api/contracts/knowledge/documents'
describe('listKnowledgeDocumentsQuerySchema.tagFilters', () => {
it('keeps tagFilters a raw string (must NOT transform to an array)', () => {
// A transform-to-array here breaks requestJson outbound serialization
// (the array serializes as "[object Object]"). The wire type must stay a
// string; decoding happens server-side via parseDocumentTagFiltersParam.
const tagFilters = JSON.stringify([
{ tagSlot: 'tag1', fieldType: 'text', operator: 'contains', value: 'x' },
])
const parsed = listKnowledgeDocumentsQuerySchema.parse({ tagFilters })
expect(parsed.tagFilters).toBe(tagFilters)
expect(typeof parsed.tagFilters).toBe('string')
})
})
describe('parseDocumentTagFiltersParam', () => {
it('returns undefined for an absent param', () => {
expect(parseDocumentTagFiltersParam(undefined)).toBeUndefined()
expect(parseDocumentTagFiltersParam('')).toBeUndefined()
})
it('decodes a valid JSON array of filters', () => {
const filters = [
{ tagSlot: 'tag1', fieldType: 'text', operator: 'contains', value: 'x' },
{ tagSlot: 'date1', fieldType: 'date', operator: 'eq', value: '2026-04-21' },
]
expect(parseDocumentTagFiltersParam(JSON.stringify(filters))).toEqual(filters)
})
it('throws on malformed JSON', () => {
expect(() => parseDocumentTagFiltersParam('[object Object]')).toThrow()
expect(() => parseDocumentTagFiltersParam('{not json')).toThrow()
})
it('throws when the shape is wrong', () => {
expect(() => parseDocumentTagFiltersParam(JSON.stringify([{ tagSlot: '' }]))).toThrow()
})
it('rejects an operator that is not valid for the field type', () => {
// unknown operator
expect(() =>
parseDocumentTagFiltersParam(
JSON.stringify([{ tagSlot: 'tag1', fieldType: 'text', operator: 'bogus', value: 'x' }])
)
).toThrow()
// valid operator name, wrong field type (contains is text-only)
expect(() =>
parseDocumentTagFiltersParam(
JSON.stringify([
{ tagSlot: 'number1', fieldType: 'number', operator: 'contains', value: '1' },
])
)
).toThrow()
})
it('rejects a fieldType that does not match the tag slot', () => {
// number1 is a numeric column; claiming it is text must fail
expect(() =>
parseDocumentTagFiltersParam(
JSON.stringify([
{ tagSlot: 'number1', fieldType: 'text', operator: 'contains', value: 'x' },
])
)
).toThrow()
})
it('rejects an unknown tag slot', () => {
expect(() =>
parseDocumentTagFiltersParam(
JSON.stringify([{ tagSlot: 'tag99', fieldType: 'text', operator: 'eq', value: 'x' }])
)
).toThrow()
})
it('rejects values that are unusable for the field type', () => {
// non-numeric value on a number field
expect(() =>
parseDocumentTagFiltersParam(
JSON.stringify([{ tagSlot: 'number1', fieldType: 'number', operator: 'eq', value: 'abc' }])
)
).toThrow()
// non-date value on a date field
expect(() =>
parseDocumentTagFiltersParam(
JSON.stringify([{ tagSlot: 'date1', fieldType: 'date', operator: 'eq', value: 'nope' }])
)
).toThrow()
// well-formed but impossible calendar dates (would 500 on ::date)
for (const value of ['2026-02-30', '2026-99-99', '2026-13-01', '2026-00-10']) {
expect(() =>
parseDocumentTagFiltersParam(
JSON.stringify([{ tagSlot: 'date1', fieldType: 'date', operator: 'eq', value }])
)
).toThrow()
}
// non-boolean value on a boolean field
expect(() =>
parseDocumentTagFiltersParam(
JSON.stringify([
{ tagSlot: 'boolean1', fieldType: 'boolean', operator: 'eq', value: 'maybe' },
])
)
).toThrow()
})
it('rejects a between filter missing a usable upper bound', () => {
expect(() =>
parseDocumentTagFiltersParam(
JSON.stringify([
{
tagSlot: 'number1',
fieldType: 'number',
operator: 'between',
value: '1',
valueTo: 'x',
},
])
)
).toThrow()
})
it('accepts a valid number, date, boolean, and between filter', () => {
const filters = [
{ tagSlot: 'number1', fieldType: 'number', operator: 'gte', value: '42' },
{ tagSlot: 'date1', fieldType: 'date', operator: 'eq', value: '2026-04-21' },
{ tagSlot: 'boolean1', fieldType: 'boolean', operator: 'eq', value: 'true' },
{
tagSlot: 'number2',
fieldType: 'number',
operator: 'between',
value: '1',
valueTo: '10',
},
]
expect(parseDocumentTagFiltersParam(JSON.stringify(filters))).toEqual(filters)
})
})
@@ -0,0 +1,381 @@
import { z } from 'zod'
import {
documentBooleanFieldSchema,
documentDateFieldSchema,
documentNumberFieldSchema,
documentTagFieldSchema,
knowledgeBaseParamsSchema,
knowledgeDocumentFileUrlSchema,
knowledgeDocumentParamsSchema,
nullableWireDateSchema,
paginationSchema,
successResponseSchema,
wireDateSchema,
} from '@/lib/api/contracts/knowledge/shared'
import { defineRouteContract } from '@/lib/api/contracts/types'
import { getFieldTypeForSlot } from '@/lib/knowledge/constants'
import { getOperatorsForFieldType, isValidFilterValue } from '@/lib/knowledge/filters/types'
export const documentTagFilterSchema = z
.object({
tagSlot: z.string().min(1),
fieldType: z.enum(['text', 'number', 'date', 'boolean']),
operator: z.string().min(1),
value: z.unknown(),
valueTo: z.unknown().optional(),
})
.superRefine((filter, ctx) => {
// The tag slot determines the column type, so validate against the slot
// (the source of truth) — not just the client-supplied fieldType. Rejecting
// unknown slots, type mismatches, and bad operators at the boundary returns
// a 400 instead of the query builder silently dropping or mis-handling the
// filter (e.g. a text `contains` aimed at a numeric column).
const slotFieldType = getFieldTypeForSlot(filter.tagSlot)
if (slotFieldType === null) {
ctx.addIssue({
code: 'custom',
path: ['tagSlot'],
message: `Unknown tag slot "${filter.tagSlot}"`,
})
return
}
if (slotFieldType !== filter.fieldType) {
ctx.addIssue({
code: 'custom',
path: ['fieldType'],
message: `fieldType "${filter.fieldType}" does not match tag slot "${filter.tagSlot}" (expected "${slotFieldType}")`,
})
return
}
const validOperators = getOperatorsForFieldType(filter.fieldType).map((op) => op.value)
if (!validOperators.includes(filter.operator)) {
ctx.addIssue({
code: 'custom',
path: ['operator'],
message: `Unsupported operator "${filter.operator}" for a ${filter.fieldType} tag filter`,
})
return
}
if (!isValidFilterValue(filter.fieldType, filter.value)) {
ctx.addIssue({
code: 'custom',
path: ['value'],
message: `Invalid value for a ${filter.fieldType} tag filter`,
})
}
// `between` is only valid for number/date (enforced by the operator check
// above), and needs a usable upper bound.
if (filter.operator === 'between' && !isValidFilterValue(filter.fieldType, filter.valueTo)) {
ctx.addIssue({
code: 'custom',
path: ['valueTo'],
message: `Invalid second value for a ${filter.fieldType} "between" tag filter`,
})
}
})
export type DocumentTagFilter = z.output<typeof documentTagFilterSchema>
export const listKnowledgeDocumentsQuerySchema = z.object({
enabledFilter: z.enum(['all', 'enabled', 'disabled']).optional(),
search: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).optional().default(50),
offset: z.coerce.number().int().min(0).optional().default(0),
sortBy: z
.enum([
'filename',
'fileSize',
'tokenCount',
'chunkCount',
'uploadedAt',
'processingStatus',
'enabled',
])
.optional(),
sortOrder: z.enum(['asc', 'desc']).optional(),
// A query param is a string on the wire, so `tagFilters` is carried as a JSON
// string and decoded by the route via `parseDocumentTagFiltersParam`. It must
// NOT be a `.transform()` to an array here: the client's `requestJson` parses
// the query before serializing it, so a transform would turn the string into
// an array that serializes to `tagFilters=[object Object]` and 400s the route.
tagFilters: z.string().optional(),
})
/**
* Decodes the `tagFilters` query string (a JSON array) into validated filters.
* Throws on malformed JSON or a shape mismatch; callers map that to a 400.
*/
export function parseDocumentTagFiltersParam(
value: string | undefined
): DocumentTagFilter[] | undefined {
if (!value) return undefined
return z.array(documentTagFilterSchema).parse(JSON.parse(value))
}
export const createDocumentBodySchema = z.object({
filename: z.string().min(1, 'Filename is required'),
fileUrl: knowledgeDocumentFileUrlSchema,
fileSize: z.number().min(1, 'File size must be greater than 0'),
mimeType: z.string().min(1, 'MIME type is required'),
tag1: z.string().optional(),
tag2: z.string().optional(),
tag3: z.string().optional(),
tag4: z.string().optional(),
tag5: z.string().optional(),
tag6: z.string().optional(),
tag7: z.string().optional(),
documentTagsData: z.string().optional(),
})
export const bulkCreateDocumentsBodySchema = z.object({
documents: z.array(createDocumentBodySchema),
processingOptions: z
.object({
recipe: z.string().optional(),
lang: z.string().optional(),
})
.optional(),
bulk: z.literal(true),
workflowId: z.string().optional(),
})
const singleCreateDocumentBodySchema = createDocumentBodySchema.extend({
bulk: z.literal(false),
workflowId: z.string().optional(),
})
const createKnowledgeDocumentsBodyDiscriminatedUnion = z.discriminatedUnion('bulk', [
bulkCreateDocumentsBodySchema,
singleCreateDocumentBodySchema,
])
export const createKnowledgeDocumentsBodySchema = z
.object({ bulk: z.boolean().default(false) })
.passthrough()
.pipe(createKnowledgeDocumentsBodyDiscriminatedUnion)
export type CreateKnowledgeDocumentsBody = z.input<typeof createKnowledgeDocumentsBodySchema>
export type BulkCreateDocumentsBody = z.input<typeof bulkCreateDocumentsBodySchema>
export type SingleCreateDocumentBody = z.input<typeof singleCreateDocumentBodySchema>
export const upsertDocumentBodySchema = z.object({
documentId: z.string().optional(),
filename: z.string().min(1, 'Filename is required'),
fileUrl: knowledgeDocumentFileUrlSchema,
fileSize: z.number().min(1, 'File size must be greater than 0'),
mimeType: z.string().min(1, 'MIME type is required'),
documentTagsData: z.string().optional(),
processingOptions: z
.object({
recipe: z.string().optional(),
lang: z.string().optional(),
})
.optional(),
workflowId: z.string().optional(),
})
export type UpsertDocumentBody = z.output<typeof upsertDocumentBodySchema>
export const bulkCreateDocumentsResponseSchema = z.object({
total: z.number(),
documentsCreated: z.array(
z.object({
documentId: z.string(),
filename: z.string(),
status: z.string(),
})
),
processingMethod: z.string(),
processingConfig: z
.object({
maxConcurrentDocuments: z.number(),
batchSize: z.number(),
totalBatches: z.number(),
})
.passthrough(),
})
export const updateDocumentBodySchema = z.object({
filename: z.string().min(1, 'Filename is required').optional(),
enabled: z.boolean().optional(),
chunkCount: z.number().min(0).optional(),
tokenCount: z.number().min(0).optional(),
characterCount: z.number().min(0).optional(),
processingStatus: z.enum(['pending', 'processing', 'completed', 'failed']).optional(),
processingError: z.string().optional(),
markFailedDueToTimeout: z.boolean().optional(),
retryProcessing: z.boolean().optional(),
tag1: z.string().optional(),
tag2: z.string().optional(),
tag3: z.string().optional(),
tag4: z.string().optional(),
tag5: z.string().optional(),
tag6: z.string().optional(),
tag7: z.string().optional(),
number1: z.string().optional(),
number2: z.string().optional(),
number3: z.string().optional(),
number4: z.string().optional(),
number5: z.string().optional(),
date1: z.string().optional(),
date2: z.string().optional(),
boolean1: z.string().optional(),
boolean2: z.string().optional(),
boolean3: z.string().optional(),
})
export const updateDocumentTagsBodySchema = z.record(z.string(), z.string())
export const bulkDocumentOperationBodySchema = z
.object({
operation: z.enum(['enable', 'disable', 'delete']),
documentIds: z.array(z.string()).min(1).max(100).optional(),
selectAll: z.boolean().optional(),
enabledFilter: z.enum(['all', 'enabled', 'disabled']).optional(),
})
.refine((data) => data.selectAll || (data.documentIds && data.documentIds.length > 0), {
message: 'Either selectAll must be true or documentIds must be provided',
})
export const bulkDocumentOperationDataSchema = z.object({
operation: z.string().optional(),
successCount: z.number(),
failedCount: z.number().optional(),
updatedDocuments: z
.array(z.object({ id: z.string(), enabled: z.boolean().optional() }))
.optional(),
})
export type BulkDocumentOperationData = z.output<typeof bulkDocumentOperationDataSchema>
export const documentDataSchema = z
.object({
id: z.string(),
knowledgeBaseId: z.string(),
filename: z.string(),
fileUrl: z.string(),
fileSize: z.number(),
mimeType: z.string(),
chunkCount: z.number(),
tokenCount: z.number(),
characterCount: z.number(),
processingStatus: z.enum(['pending', 'processing', 'completed', 'failed']),
processingStartedAt: nullableWireDateSchema.optional(),
processingCompletedAt: nullableWireDateSchema.optional(),
processingError: z.string().nullable().optional(),
enabled: z.boolean(),
uploadedAt: wireDateSchema,
tag1: documentTagFieldSchema,
tag2: documentTagFieldSchema,
tag3: documentTagFieldSchema,
tag4: documentTagFieldSchema,
tag5: documentTagFieldSchema,
tag6: documentTagFieldSchema,
tag7: documentTagFieldSchema,
number1: documentNumberFieldSchema,
number2: documentNumberFieldSchema,
number3: documentNumberFieldSchema,
number4: documentNumberFieldSchema,
number5: documentNumberFieldSchema,
date1: documentDateFieldSchema,
date2: documentDateFieldSchema,
boolean1: documentBooleanFieldSchema,
boolean2: documentBooleanFieldSchema,
boolean3: documentBooleanFieldSchema,
connectorId: z.string().nullable().optional(),
connectorType: z.string().nullable().optional(),
sourceUrl: z.string().nullable().optional(),
})
.passthrough()
export type DocumentData = z.output<typeof documentDataSchema>
export const documentsPaginationSchema = paginationSchema
export type DocumentsPagination = z.output<typeof documentsPaginationSchema>
export const knowledgeDocumentsDataSchema = z.object({
documents: z.array(documentDataSchema),
pagination: documentsPaginationSchema,
})
export type KnowledgeDocumentsResponse = z.output<typeof knowledgeDocumentsDataSchema>
export const getKnowledgeDocumentContract = defineRouteContract({
method: 'GET',
path: '/api/knowledge/[id]/documents/[documentId]',
params: knowledgeDocumentParamsSchema,
response: {
mode: 'json',
schema: successResponseSchema(documentDataSchema),
},
})
export const listKnowledgeDocumentsContract = defineRouteContract({
method: 'GET',
path: '/api/knowledge/[id]/documents',
params: knowledgeBaseParamsSchema,
query: listKnowledgeDocumentsQuerySchema,
response: {
mode: 'json',
schema: successResponseSchema(knowledgeDocumentsDataSchema),
},
})
export const createKnowledgeDocumentsContract = defineRouteContract({
method: 'POST',
path: '/api/knowledge/[id]/documents',
params: knowledgeBaseParamsSchema,
body: createKnowledgeDocumentsBodySchema,
response: {
mode: 'json',
schema: successResponseSchema(z.union([bulkCreateDocumentsResponseSchema, documentDataSchema])),
},
})
export const updateKnowledgeDocumentContract = defineRouteContract({
method: 'PUT',
path: '/api/knowledge/[id]/documents/[documentId]',
params: knowledgeDocumentParamsSchema,
body: updateDocumentBodySchema,
response: {
mode: 'json',
schema: successResponseSchema(documentDataSchema),
},
})
export const updateKnowledgeDocumentTagsContract = defineRouteContract({
method: 'PUT',
path: '/api/knowledge/[id]/documents/[documentId]',
params: knowledgeDocumentParamsSchema,
body: updateDocumentTagsBodySchema,
response: {
mode: 'json',
schema: successResponseSchema(documentDataSchema),
},
})
export const deleteKnowledgeDocumentContract = defineRouteContract({
method: 'DELETE',
path: '/api/knowledge/[id]/documents/[documentId]',
params: knowledgeDocumentParamsSchema,
response: {
mode: 'json',
schema: successResponseSchema(z.unknown()),
},
})
export const bulkKnowledgeDocumentsContract = defineRouteContract({
method: 'PATCH',
path: '/api/knowledge/[id]/documents',
params: knowledgeBaseParamsSchema,
body: bulkDocumentOperationBodySchema,
response: {
mode: 'json',
schema: successResponseSchema(bulkDocumentOperationDataSchema),
},
})
export const upsertKnowledgeDocumentContract = defineRouteContract({
method: 'POST',
path: '/api/knowledge/[id]/documents/upsert',
params: knowledgeBaseParamsSchema,
body: upsertDocumentBodySchema,
response: {
mode: 'json',
schema: successResponseSchema(documentDataSchema),
},
})
@@ -0,0 +1,6 @@
export * from '@/lib/api/contracts/knowledge/base'
export * from '@/lib/api/contracts/knowledge/chunks'
export * from '@/lib/api/contracts/knowledge/connectors'
export * from '@/lib/api/contracts/knowledge/documents'
export * from '@/lib/api/contracts/knowledge/search'
export * from '@/lib/api/contracts/knowledge/tags'
@@ -0,0 +1,68 @@
import { z } from 'zod'
import { DEFAULT_RERANKER_MODEL, rerankerModelSchema } from '@/lib/knowledge/reranker-models'
export const knowledgeSearchTagFilterSchema = z.object({
tagName: z.string(),
tagSlot: z.string().optional(),
fieldType: z.enum(['text', 'number', 'date', 'boolean']).optional(),
operator: z.string().default('eq'),
value: z.union([z.string(), z.number(), z.boolean()]),
valueTo: z.union([z.string(), z.number()]).optional(),
})
export const knowledgeSearchBodySchema = z
.object({
knowledgeBaseIds: z.union([
z.string().min(1, 'Knowledge base ID is required'),
z.array(z.string().min(1)).min(1, 'At least one knowledge base ID is required'),
]),
query: z
.string()
.optional()
.nullable()
.transform((val) => val || undefined),
topK: z
.number()
.min(1)
.max(100)
.optional()
.nullable()
.default(10)
.transform((val) => val ?? 10),
tagFilters: z
.array(knowledgeSearchTagFilterSchema)
.optional()
.nullable()
.transform((val) => val || undefined),
rerankerEnabled: z.boolean().optional().default(false),
rerankerModel: rerankerModelSchema.optional().default(DEFAULT_RERANKER_MODEL),
/**
* Number of vector results sent to Cohere as the documents array for reranking. Capped at 100
* so each rerank call stays within a single Cohere search unit (1 query × ≤100 docs); see
* `RERANK_MODEL_PRICING` in `providers/models.ts`.
*/
rerankerInputCount: z
.number()
.int('rerankerInputCount must be an integer')
.min(1, 'rerankerInputCount must be at least 1')
.max(100, 'rerankerInputCount cannot exceed 100')
.optional()
.nullable()
.transform((val) => val ?? undefined),
rerankerApiKey: z
.string()
.optional()
.nullable()
.transform((val) => val || undefined),
})
.refine(
(data) => {
const hasQuery = data.query && data.query.trim().length > 0
const hasTagFilters = data.tagFilters && data.tagFilters.length > 0
return hasQuery || hasTagFilters
},
{
message: 'Please provide either a search query or tag filters to search your knowledge base',
}
)
export type KnowledgeSearchBody = z.output<typeof knowledgeSearchBodySchema>
@@ -0,0 +1,57 @@
/**
* Tests for shared knowledge contract schemas
*
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { knowledgeDocumentFileUrlSchema } from '@/lib/api/contracts/knowledge/shared'
describe('knowledgeDocumentFileUrlSchema', () => {
it('accepts data: URIs', () => {
const result = knowledgeDocumentFileUrlSchema.safeParse(
'data:text/plain;base64,SGVsbG8gd29ybGQ='
)
expect(result.success).toBe(true)
})
it('accepts https URLs', () => {
const result = knowledgeDocumentFileUrlSchema.safeParse('https://example.com/file.pdf')
expect(result.success).toBe(true)
})
it('accepts http URLs', () => {
const result = knowledgeDocumentFileUrlSchema.safeParse(
'http://localhost:3000/api/files/serve/kb/foo.pdf?context=knowledge-base'
)
expect(result.success).toBe(true)
})
it('is case-insensitive on the scheme', () => {
expect(knowledgeDocumentFileUrlSchema.safeParse('HTTPS://example.com/x').success).toBe(true)
expect(knowledgeDocumentFileUrlSchema.safeParse('Http://example.com/x').success).toBe(true)
})
it.each([
['absolute local path', '/etc/passwd'],
['app path', '/app/.env'],
['relative path', './secrets.txt'],
['parent traversal', '../../etc/shadow'],
['file:// scheme', 'file:///etc/passwd'],
['ftp scheme', 'ftp://example.com/x'],
['javascript scheme', 'javascript:alert(1)'],
['gopher scheme', 'gopher://example.com'],
['relative serve path', '/api/files/serve/kb/foo.pdf'],
['windows path', 'C:\\Windows\\System32\\config\\SAM'],
['empty string', ''],
['whitespace prefix', ' https://example.com/x'],
])('rejects %s', (_label, value) => {
const result = knowledgeDocumentFileUrlSchema.safeParse(value)
expect(result.success).toBe(false)
})
it('returns a useful error message for unsupported schemes', () => {
const result = knowledgeDocumentFileUrlSchema.safeParse('/etc/passwd')
if (result.success) throw new Error('expected failure')
expect(result.error.issues[0].message).toMatch(/data: URI or an http\(s\):\/\/ URL/)
})
})
@@ -0,0 +1,59 @@
import { z } from 'zod'
export const wireDateSchema = z.string()
export const nullableWireDateSchema = z.string().nullable()
export const knowledgeBaseParamsSchema = z.object({
id: z.string().min(1),
})
export const knowledgeDocumentParamsSchema = knowledgeBaseParamsSchema.extend({
documentId: z.string().min(1),
})
export const knowledgeChunkParamsSchema = knowledgeDocumentParamsSchema.extend({
chunkId: z.string().min(1),
})
export const knowledgeTagParamsSchema = knowledgeBaseParamsSchema.extend({
tagId: z.string().min(1),
})
export const knowledgeConnectorParamsSchema = knowledgeBaseParamsSchema.extend({
connectorId: z.string().min(1),
})
/**
* A `fileUrl` accepted by knowledge document ingestion endpoints.
*
* Must be a `data:` URI or an `http(s)://` URL. Local paths, `file://`,
* and other schemes are rejected at the boundary to prevent the background
* parser from reading arbitrary files off the Sim server's filesystem.
*/
export const knowledgeDocumentFileUrlSchema = z
.string()
.min(1, 'File URL is required')
.refine(
(value) => /^data:/i.test(value) || /^https?:\/\//i.test(value),
'File URL must be a data: URI or an http(s):// URL'
)
export const documentTagFieldSchema = z.string().nullable().optional()
export const documentNumberFieldSchema = z.number().nullable().optional()
export const documentBooleanFieldSchema = z.boolean().nullable().optional()
export const documentDateFieldSchema = nullableWireDateSchema.optional()
export const paginationSchema = z
.object({
total: z.number(),
limit: z.number(),
offset: z.number(),
hasMore: z.boolean(),
})
.passthrough()
export const successResponseSchema = <T extends z.ZodType>(dataSchema: T) =>
z.object({
success: z.literal(true),
data: dataSchema,
})
@@ -0,0 +1,161 @@
import { z } from 'zod'
import {
knowledgeBaseParamsSchema,
knowledgeDocumentParamsSchema,
knowledgeTagParamsSchema,
successResponseSchema,
} from '@/lib/api/contracts/knowledge/shared'
import { defineRouteContract } from '@/lib/api/contracts/types'
export const nextAvailableSlotQuerySchema = z.object({
fieldType: z.string().min(1),
})
export const createTagDefinitionBodySchema = z.object({
tagSlot: z.string().min(1, 'Tag slot is required'),
displayName: z.string().min(1, 'Display name is required'),
fieldType: z.string().min(1, 'Invalid field type'),
})
export const documentTagDefinitionInputSchema = z.object({
tagSlot: z.string().min(1, 'Tag slot is required'),
displayName: z.string().min(1, 'Display name is required').max(100, 'Display name too long'),
fieldType: z.string().default('text'),
_originalDisplayName: z.string().optional(),
})
export const saveDocumentTagDefinitionsBodySchema = z.object({
definitions: z.array(documentTagDefinitionInputSchema),
})
export const deleteDocumentTagDefinitionsQuerySchema = z.object({
action: z.enum(['cleanup', 'all']).optional(),
})
export const tagDefinitionDataSchema = z.object({
id: z.string(),
tagSlot: z.string(),
displayName: z.string(),
fieldType: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
})
export type TagDefinitionData = z.output<typeof tagDefinitionDataSchema>
export type DocumentTagDefinitionData = TagDefinitionData
export const nextAvailableSlotDataSchema = z.object({
nextAvailableSlot: z.string().nullable(),
fieldType: z.string(),
usedSlots: z.array(z.string()),
totalSlots: z.number(),
availableSlots: z.number(),
})
export type NextAvailableSlotData = z.output<typeof nextAvailableSlotDataSchema>
export const saveDocumentTagDefinitionsDataSchema = z
.object({
created: z.array(tagDefinitionDataSchema).optional(),
updated: z.array(tagDefinitionDataSchema).optional(),
errors: z.array(z.string()).optional(),
})
.or(z.array(tagDefinitionDataSchema))
export type SaveDocumentTagDefinitionsResult = z.output<typeof saveDocumentTagDefinitionsDataSchema>
export const tagUsageDocumentSchema = z.object({
id: z.string(),
name: z.string(),
tagValue: z.string(),
})
export const tagUsageDataSchema = z.object({
tagName: z.string(),
tagSlot: z.string(),
documentCount: z.number(),
documents: z.array(tagUsageDocumentSchema),
})
export type TagUsageData = z.output<typeof tagUsageDataSchema>
export const listTagDefinitionsContract = defineRouteContract({
method: 'GET',
path: '/api/knowledge/[id]/tag-definitions',
params: knowledgeBaseParamsSchema,
response: {
mode: 'json',
schema: successResponseSchema(z.array(tagDefinitionDataSchema)),
},
})
export const createTagDefinitionContract = defineRouteContract({
method: 'POST',
path: '/api/knowledge/[id]/tag-definitions',
params: knowledgeBaseParamsSchema,
body: createTagDefinitionBodySchema,
response: {
mode: 'json',
schema: successResponseSchema(tagDefinitionDataSchema),
},
})
export const deleteTagDefinitionContract = defineRouteContract({
method: 'DELETE',
path: '/api/knowledge/[id]/tag-definitions/[tagId]',
params: knowledgeTagParamsSchema,
response: {
mode: 'json',
schema: z.object({ success: z.literal(true) }).passthrough(),
},
})
export const nextAvailableSlotContract = defineRouteContract({
method: 'GET',
path: '/api/knowledge/[id]/next-available-slot',
params: knowledgeBaseParamsSchema,
query: nextAvailableSlotQuerySchema,
response: {
mode: 'json',
schema: successResponseSchema(nextAvailableSlotDataSchema),
},
})
export const listDocumentTagDefinitionsContract = defineRouteContract({
method: 'GET',
path: '/api/knowledge/[id]/documents/[documentId]/tag-definitions',
params: knowledgeDocumentParamsSchema,
response: {
mode: 'json',
schema: successResponseSchema(z.array(tagDefinitionDataSchema)),
},
})
export const saveDocumentTagDefinitionsContract = defineRouteContract({
method: 'POST',
path: '/api/knowledge/[id]/documents/[documentId]/tag-definitions',
params: knowledgeDocumentParamsSchema,
body: saveDocumentTagDefinitionsBodySchema,
response: {
mode: 'json',
schema: successResponseSchema(saveDocumentTagDefinitionsDataSchema),
},
})
export const deleteDocumentTagDefinitionsContract = defineRouteContract({
method: 'DELETE',
path: '/api/knowledge/[id]/documents/[documentId]/tag-definitions',
params: knowledgeDocumentParamsSchema,
response: {
mode: 'json',
schema: z.object({ success: z.literal(true) }).passthrough(),
},
})
export const getTagUsageContract = defineRouteContract({
method: 'GET',
path: '/api/knowledge/[id]/tag-usage',
params: knowledgeBaseParamsSchema,
response: {
mode: 'json',
schema: successResponseSchema(z.array(tagUsageDataSchema)),
},
})
+403
View File
@@ -0,0 +1,403 @@
import { z } from 'zod'
import { userFileSchema } from '@/lib/api/contracts/primitives'
import { defineRouteContract } from '@/lib/api/contracts/types'
const comparisonOperatorSchema = z.enum(['=', '>', '<', '>=', '<=', '!='])
export const logIdParamsSchema = z.object({
id: z.string().min(1),
})
export const executionIdParamsSchema = z.object({
executionId: z.string().min(1),
})
export const cancelWorkflowExecutionParamsSchema = z.object({
id: z.string().min(1, 'Invalid workflow ID'),
executionId: z.string().min(1, 'Invalid execution ID'),
})
const logFilterQuerySchema = z.object({
workspaceId: z.string(),
level: z.string().optional(),
workflowIds: z.string().optional(),
folderIds: z.string().optional(),
triggers: z.string().optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
search: z.string().optional(),
workflowName: z.string().optional(),
folderName: z.string().optional(),
executionId: z.string().optional(),
costOperator: comparisonOperatorSchema.optional(),
costValue: z.coerce.number().optional(),
durationOperator: comparisonOperatorSchema.optional(),
durationValue: z.coerce.number().optional(),
})
export const logSortBySchema = z.enum(['date', 'duration', 'cost', 'status']).default('date')
export const logSortOrderSchema = z.enum(['asc', 'desc']).default('desc')
export const listLogsQuerySchema = logFilterQuerySchema.extend({
cursor: z.string().optional(),
limit: z.coerce.number().int().min(1).max(200).optional().default(100),
sortBy: logSortBySchema,
sortOrder: logSortOrderSchema,
})
export const logDetailQuerySchema = z.object({
workspaceId: z.string().min(1),
})
export const statsQueryParamsSchema = logFilterQuerySchema.extend({
segmentCount: z.coerce.number().optional().default(72),
})
const workflowSummarySchema = z
.object({
id: z.string(),
name: z.string().nullable(),
description: z.string().nullable(),
folderId: z.string().nullable(),
userId: z.string().nullable(),
workspaceId: z.string().nullable(),
createdAt: z.string().nullable(),
updatedAt: z.string().nullable(),
})
.partial()
const tokenBreakdownSchema = z
.object({
total: z.number().optional(),
input: z.number().optional(),
output: z.number().optional(),
prompt: z.number().optional(),
completion: z.number().optional(),
})
.partial()
const modelCostSchema = z
.object({
input: z.number().optional(),
output: z.number().optional(),
total: z.number().optional(),
tokens: tokenBreakdownSchema.optional(),
})
.partial()
const costSummarySchema = z
.object({
total: z.number().optional(),
input: z.number().optional(),
output: z.number().optional(),
tokens: tokenBreakdownSchema.optional(),
models: z.record(z.string(), modelCostSchema).optional(),
pricing: z
.object({
input: z.number(),
output: z.number(),
cachedInput: z.number().optional(),
updatedAt: z.string(),
})
.optional(),
})
.partial()
/**
* Itemized cost breakdown derived from the usage_log ledger (the single source
* of truth) for the detail view. Each item is one billed line (base fee, a
* model, or a tool/integration); the items reconcile to `total`.
*/
const costLedgerItemSchema = z.object({
category: z.enum(['fixed', 'model', 'tool']),
description: z.string(),
cost: z.number(),
inputTokens: z.number().optional(),
outputTokens: z.number().optional(),
})
export const costLedgerSchema = z.object({
total: z.number(),
items: z.array(costLedgerItemSchema),
})
export type CostLedger = z.output<typeof costLedgerSchema>
export type CostLedgerItem = z.output<typeof costLedgerItemSchema>
const pauseSummarySchema = z.object({
status: z.string().nullable(),
total: z.number(),
resumed: z.number(),
})
const blockExecutionSchema = z.object({
id: z.string(),
blockId: z.string(),
blockName: z.string(),
blockType: z.string(),
startedAt: z.string(),
endedAt: z.string(),
durationMs: z.number(),
status: z.enum(['success', 'error', 'skipped']),
errorMessage: z.string().optional(),
errorStackTrace: z.string().optional(),
inputData: z.unknown(),
outputData: z.unknown(),
cost: costSummarySchema.optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
})
const toolCallSchema = z
.object({
id: z.string().optional(),
name: z.string().optional(),
arguments: z.unknown().optional(),
result: z.unknown().optional(),
error: z.string().optional(),
startTime: z.string().optional(),
endTime: z.string().optional(),
duration: z.number().optional(),
})
.passthrough()
type TraceSpan = {
id: string
name: string
type: string
duration?: number
durationMs?: number
startTime?: string
endTime?: string
status?: string
blockId?: string
input?: unknown
output?: unknown
tokens?: number | { total?: number; input?: number; output?: number }
relativeStartMs?: number
toolCalls?: Array<z.output<typeof toolCallSchema>>
children?: TraceSpan[]
}
const traceSpanSchema: z.ZodType<TraceSpan> = z.lazy(() =>
z
.object({
id: z.string(),
name: z.string(),
type: z.string(),
duration: z.number().optional(),
durationMs: z.number().optional(),
startTime: z.string().optional(),
endTime: z.string().optional(),
status: z.string().optional(),
blockId: z.string().optional(),
input: z.unknown().optional(),
output: z.unknown().optional(),
tokens: z
.union([
z.number(),
z
.object({
total: z.number().optional(),
input: z.number().optional(),
output: z.number().optional(),
})
.partial(),
])
.optional(),
relativeStartMs: z.number().optional(),
toolCalls: z.array(toolCallSchema).optional(),
children: z.array(traceSpanSchema).optional(),
})
.passthrough()
)
const executionDataDetailSchema = z
.object({
totalDuration: z.number().nullable().optional(),
enhanced: z.literal(true).optional(),
traceSpans: z.array(traceSpanSchema).optional(),
blockExecutions: z.array(blockExecutionSchema).optional(),
finalOutput: z.unknown().optional(),
workflowInput: z.unknown().optional(),
blockInput: z.record(z.string(), z.unknown()).optional(),
trigger: z.unknown().optional(),
})
.passthrough()
export const workflowLogSummarySchema = z.object({
id: z.string(),
workflowId: z.string().nullable(),
executionId: z.string().nullable(),
deploymentVersionId: z.string().nullable(),
deploymentVersion: z.number().nullable(),
deploymentVersionName: z.string().nullable(),
level: z.string(),
status: z.string().nullable(),
duration: z.string().nullable(),
trigger: z.string().nullable(),
createdAt: z.string(),
workflow: workflowSummarySchema.nullable(),
jobTitle: z.string().nullable(),
// Top-level run cost is the cost_total projection of the usage_log ledger,
// rendered as { total } (dollars). The itemized breakdown lives in costLedger
// (detail only); per-block costs use the richer costSummarySchema elsewhere.
cost: z.object({ total: z.number() }).nullable(),
pauseSummary: pauseSummarySchema,
hasPendingPause: z.boolean(),
})
export const workflowLogDetailSchema = workflowLogSummarySchema.extend({
executionData: executionDataDetailSchema,
files: z.array(userFileSchema).nullable(),
// Itemized, ledger-sourced cost breakdown. Null for legacy/pre-ledger runs,
// where the UI falls back to the (reconciling) cost jsonb.
costLedger: costLedgerSchema.nullable().optional(),
})
export type WorkflowLogSummary = z.output<typeof workflowLogSummarySchema>
export type WorkflowLogDetail = z.output<typeof workflowLogDetailSchema>
/**
* A row that may be either a list-view summary or a fully loaded detail. Used by
* UI surfaces that render the same log before and after its detail query resolves.
*/
export type WorkflowLogRow = WorkflowLogSummary &
Partial<Pick<WorkflowLogDetail, 'executionData' | 'files' | 'costLedger'>>
export const listLogsResponseSchema = z.object({
data: z.array(workflowLogSummarySchema),
nextCursor: z.string().nullable(),
})
export type ListLogsResponse = z.output<typeof listLogsResponseSchema>
export const segmentStatsSchema = z.object({
timestamp: z.string(),
totalExecutions: z.number(),
successfulExecutions: z.number(),
avgDurationMs: z.number(),
})
export const workflowStatsSchema = z.object({
workflowId: z.string(),
workflowName: z.string(),
segments: z.array(segmentStatsSchema),
overallSuccessRate: z.number(),
totalExecutions: z.number(),
totalSuccessful: z.number(),
})
export const dashboardStatsResponseSchema = z.object({
workflows: z.array(workflowStatsSchema),
aggregateSegments: z.array(segmentStatsSchema),
totalRuns: z.number(),
totalErrors: z.number(),
avgLatency: z.number(),
timeBounds: z.object({
start: z.string(),
end: z.string(),
}),
segmentMs: z.number(),
})
export const executionSnapshotDataSchema = z.object({
executionId: z.string(),
workflowId: z.string().nullable(),
workflowState: z.record(z.string(), z.unknown()).nullable(),
childWorkflowSnapshots: z.record(z.string(), z.unknown()).optional(),
executionMetadata: z.object({
trigger: z.string().nullable(),
startedAt: z.string(),
endedAt: z.string().optional(),
totalDurationMs: z.number().nullable().optional(),
cost: z.unknown().nullable(),
totalTokens: z.number().nullable().optional(),
}),
})
export const triggersQuerySchema = z.object({
workspaceId: z.string(),
})
export type TriggersQuery = z.output<typeof triggersQuerySchema>
export const cancelWorkflowExecutionResponseSchema = z.object({
success: z.boolean(),
executionId: z.string(),
redisAvailable: z.boolean(),
durablyRecorded: z.boolean(),
locallyAborted: z.boolean(),
pausedCancelled: z.boolean(),
reason: z.enum(['recorded', 'redis_unavailable', 'redis_write_failed']),
})
export type SegmentStats = z.output<typeof segmentStatsSchema>
export type WorkflowStats = z.output<typeof workflowStatsSchema>
export type DashboardStatsResponse = z.output<typeof dashboardStatsResponseSchema>
export type ExecutionSnapshotData = z.output<typeof executionSnapshotDataSchema>
export type CancelWorkflowExecutionResponse = z.output<typeof cancelWorkflowExecutionResponseSchema>
export const listLogsContract = defineRouteContract({
method: 'GET',
path: '/api/logs',
query: listLogsQuerySchema,
response: {
mode: 'json',
schema: listLogsResponseSchema,
},
})
export const getLogDetailContract = defineRouteContract({
method: 'GET',
path: '/api/logs/[id]',
params: logIdParamsSchema,
query: logDetailQuerySchema,
response: {
mode: 'json',
schema: z.object({
data: workflowLogDetailSchema,
}),
},
})
export const getLogByExecutionIdContract = defineRouteContract({
method: 'GET',
path: '/api/logs/by-execution/[executionId]',
params: executionIdParamsSchema,
query: logDetailQuerySchema,
response: {
mode: 'json',
schema: z.object({
data: workflowLogDetailSchema,
}),
},
})
export const getDashboardStatsContract = defineRouteContract({
method: 'GET',
path: '/api/logs/stats',
query: statsQueryParamsSchema,
response: {
mode: 'json',
schema: dashboardStatsResponseSchema,
},
})
export const getExecutionSnapshotContract = defineRouteContract({
method: 'GET',
path: '/api/logs/execution/[executionId]',
params: executionIdParamsSchema,
response: {
mode: 'json',
schema: executionSnapshotDataSchema,
},
})
export const cancelWorkflowExecutionContract = defineRouteContract({
method: 'POST',
path: '/api/workflows/[id]/executions/[executionId]/cancel',
params: cancelWorkflowExecutionParamsSchema,
response: {
mode: 'json',
schema: cancelWorkflowExecutionResponseSchema,
},
})
+55
View File
@@ -0,0 +1,55 @@
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts/types'
const mcpOauthMetadataQuerySchema = z.record(z.string(), z.string())
export type McpOauthMetadataQuery = z.input<typeof mcpOauthMetadataQuerySchema>
const xSimAuthSchema = z.object({
type: z.literal('api_key'),
header: z.string(),
})
const mcpAuthorizationServerMetadataSchema = z.object({
issuer: z.string(),
authorization_endpoint: z.string(),
token_endpoint: z.string(),
registration_endpoint: z.string(),
jwks_uri: z.string(),
token_endpoint_auth_methods_supported: z.array(z.string()),
grant_types_supported: z.array(z.string()),
response_types_supported: z.array(z.string()),
code_challenge_methods_supported: z.array(z.string()),
scopes_supported: z.array(z.string()),
resource: z.string(),
x_sim_auth: xSimAuthSchema,
})
export type McpAuthorizationServerMetadata = z.output<typeof mcpAuthorizationServerMetadataSchema>
const mcpProtectedResourceMetadataSchema = z.object({
resource: z.string(),
authorization_servers: z.array(z.string()),
bearer_methods_supported: z.array(z.string()),
scopes_supported: z.array(z.string()),
x_sim_auth: xSimAuthSchema,
})
export type McpProtectedResourceMetadata = z.output<typeof mcpProtectedResourceMetadataSchema>
export const mcpOauthAuthorizationServerMetadataContract = defineRouteContract({
method: 'GET',
path: '/api/mcp/copilot/.well-known/oauth-authorization-server',
query: mcpOauthMetadataQuerySchema,
response: {
mode: 'json',
schema: mcpAuthorizationServerMetadataSchema,
},
})
export const mcpOauthProtectedResourceMetadataContract = defineRouteContract({
method: 'GET',
path: '/api/mcp/copilot/.well-known/oauth-protected-resource',
query: mcpOauthMetadataQuerySchema,
response: {
mode: 'json',
schema: mcpProtectedResourceMetadataSchema,
},
})
+501
View File
@@ -0,0 +1,501 @@
import { z } from 'zod'
import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types'
import type { McpToolSchema, McpToolSchemaProperty } from '@/lib/mcp/types'
const MAX_MCP_REFRESH_SERVER_IDS = 100
const dateStringSchema = z.preprocess(
(value) => (value instanceof Date ? value.toISOString() : value),
z.string()
)
const optionalStringFromNullableSchema = z.preprocess(
(value) => (value === null ? undefined : value),
z.string().optional()
)
const optionalDateStringFromNullableSchema = z.preprocess((value) => {
if (value instanceof Date) return value.toISOString()
return value === null ? undefined : value
}, z.string().optional())
const optionalNumberFromNullableSchema = z.preprocess(
(value) => (value === null ? undefined : value),
z.number().optional()
)
const optionalConnectionStatusFromNullableSchema = z.preprocess(
(value) => (value === null ? undefined : value),
z.enum(['connected', 'disconnected', 'error']).optional()
)
const optionalHeadersFromNullableSchema = z.preprocess(
(value) => (value === null ? undefined : value),
z.record(z.string(), z.string()).optional()
)
export const mcpTransportSchema = z.enum(['streamable-http'])
export const mcpAuthTypeSchema = z.enum(['none', 'headers', 'oauth'])
const consecutiveFailuresSchema = z.preprocess(
(value) => (typeof value === 'number' ? value : undefined),
z.number().default(0)
)
export const mcpServerStatusConfigSchema = z
.object({
consecutiveFailures: consecutiveFailuresSchema,
lastSuccessfulDiscovery: z.string().nullable().default(null),
})
.passthrough()
export const mcpToolSchemaPropertySchema: z.ZodType<McpToolSchemaProperty> = z.lazy(() =>
z
.object({
type: z.union([z.string(), z.array(z.string())]).optional(),
description: z.string().optional(),
items: z
.union([mcpToolSchemaPropertySchema, z.array(mcpToolSchemaPropertySchema)])
.optional(),
properties: z.record(z.string(), mcpToolSchemaPropertySchema).optional(),
required: z.array(z.string()).optional(),
enum: z.array(z.unknown()).optional(),
default: z.unknown().optional(),
})
.passthrough()
)
export const mcpToolInputSchema: z.ZodType<McpToolSchema> = z
.object({
type: z.literal('object'),
properties: z.record(z.string(), mcpToolSchemaPropertySchema).optional(),
required: z.array(z.string()).optional(),
description: z.string().optional(),
})
.passthrough()
export const mcpToolSchema = z.object({
name: z.string(),
description: z.string().optional(),
inputSchema: mcpToolInputSchema,
serverId: z.string(),
serverName: z.string(),
})
export const storedMcpToolSchema = z.object({
workflowId: z.string(),
workflowName: z.string(),
serverId: z.string(),
serverUrl: z.string().optional(),
toolName: z.string(),
schema: mcpToolInputSchema.optional(),
})
export const mcpServerSchema = z
.object({
id: z.string(),
workspaceId: z.string(),
name: z.string(),
description: optionalStringFromNullableSchema,
transport: mcpTransportSchema,
authType: mcpAuthTypeSchema.optional(),
url: optionalStringFromNullableSchema,
timeout: optionalNumberFromNullableSchema,
retries: optionalNumberFromNullableSchema,
headers: optionalHeadersFromNullableSchema,
enabled: z.boolean(),
connectionStatus: optionalConnectionStatusFromNullableSchema,
lastError: z.string().nullable().optional(),
statusConfig: z.preprocess(
(value) => (value === null ? undefined : value),
mcpServerStatusConfigSchema.optional()
),
toolCount: optionalNumberFromNullableSchema,
lastToolsRefresh: optionalDateStringFromNullableSchema,
lastConnected: optionalDateStringFromNullableSchema,
createdAt: dateStringSchema,
updatedAt: dateStringSchema,
deletedAt: optionalDateStringFromNullableSchema,
oauthClientId: optionalStringFromNullableSchema,
hasOauthClientSecret: z.boolean().optional(),
})
.passthrough()
export type McpServer = z.output<typeof mcpServerSchema>
export const mcpWorkspaceQuerySchema = z.object({
workspaceId: z.string().min(1),
})
export const mcpServerIdParamsSchema = z.object({
id: z.string().min(1),
})
export const createMcpServerBodySchema = z
.object({
name: z.string().min(1),
description: z.string().optional(),
transport: mcpTransportSchema,
url: z.string().optional(),
authType: mcpAuthTypeSchema.optional(),
headers: z.record(z.string(), z.string()).optional(),
timeout: z.number().optional(),
retries: z.number().optional(),
enabled: z.boolean().optional(),
source: z.string().optional(),
workspaceId: z.string().optional(),
oauthClientId: z.string().nullable().optional(),
oauthClientSecret: z.string().nullable().optional(),
})
.passthrough()
export const updateMcpServerBodySchema = createMcpServerBodySchema.partial()
export const deleteMcpServerQuerySchema = mcpWorkspaceQuerySchema.extend({
serverId: z.string().min(1),
source: z.string().optional(),
})
export const deleteMcpServerByQuerySchema = z.object({
serverId: z.string().optional(),
source: z.string().optional(),
})
export const discoverMcpToolsQuerySchema = mcpWorkspaceQuerySchema.extend({
serverId: z.string().optional(),
refresh: z
.union([z.boolean(), z.enum(['true', 'false']).transform((value) => value === 'true')])
.optional(),
})
export const refreshMcpToolsBodySchema = z.object({
serverIds: z
.array(z.string().min(1))
.transform((serverIds) => [...new Set(serverIds)])
.pipe(
z
.array(z.string())
.max(
MAX_MCP_REFRESH_SERVER_IDS,
`At most ${MAX_MCP_REFRESH_SERVER_IDS} MCP servers can be refreshed at once`
)
),
})
export const mcpEventsQuerySchema = z.object({
workspaceId: z.string().min(1).nullable(),
})
export const mcpServeRouteParamsSchema = z.object({
serverId: z.string().min(1),
})
export const mcpToolDiscoveryQuerySchema = z.object({
serverId: z.string().optional(),
refresh: z.string().optional(),
})
export const mcpToolExecutionBodySchema = z
.object({
serverId: z.string().min(1),
toolName: z.string().min(1),
arguments: z.record(z.string(), z.unknown()).optional(),
workflowId: z.string().optional(),
})
.passthrough()
export type McpToolExecutionBody = z.input<typeof mcpToolExecutionBodySchema>
export const mcpToolResultSchema = z
.object({
content: z.array(z.unknown()).optional(),
isError: z.boolean().optional(),
structuredContent: z.unknown().optional(),
})
.passthrough()
export const mcpToolExecutionResultSchema = z.object({
success: z.boolean(),
output: mcpToolResultSchema.optional(),
error: z.string().optional(),
})
export type McpToolExecutionResult = z.output<typeof mcpToolExecutionResultSchema>
export const mcpJsonRpcRequestSchema = z
.object({
jsonrpc: z.literal('2.0'),
id: z.union([z.string(), z.number()]),
method: z.string().min(1),
params: z.unknown().optional(),
})
.passthrough()
export const mcpJsonRpcNotificationSchema = z
.object({
jsonrpc: z.literal('2.0'),
method: z.string().min(1),
params: z.unknown().optional(),
})
.passthrough()
export const mcpJsonRpcMessageSchema = z
.object({
jsonrpc: z.literal('2.0'),
})
.passthrough()
export const mcpRequestBodySchema = z.union([
mcpJsonRpcMessageSchema,
z.array(mcpJsonRpcMessageSchema),
])
export const mcpToolCallParamsSchema = z
.object({
name: z.string().min(1),
arguments: z.record(z.string(), z.unknown()).optional(),
})
.passthrough()
export const mcpServerTestBodySchema = z
.object({
name: z.string().min(1),
transport: mcpTransportSchema,
url: z.string().optional(),
headers: z.record(z.string(), z.string()).optional(),
timeout: z.number().optional(),
workspaceId: z.string().optional(),
})
.passthrough()
export type McpServerTestBody = z.input<typeof mcpServerTestBodySchema>
export const mcpServerTestResultSchema = z.object({
success: z.boolean(),
message: z.string().optional(),
error: z.string().optional(),
authRequired: z.boolean().optional(),
authType: mcpAuthTypeSchema.optional(),
serverInfo: z
.object({
name: z.string(),
version: z.string(),
})
.optional(),
negotiatedVersion: z.string().optional(),
supportedCapabilities: z.array(z.string()).optional(),
toolCount: z.number().optional(),
warnings: z.array(z.string()).optional(),
})
export type McpServerTestResult = z.output<typeof mcpServerTestResultSchema>
export const refreshMcpServerResultSchema = z.object({
status: z.enum(['connected', 'disconnected', 'error']),
toolCount: z.number(),
lastConnected: z.string().nullable(),
error: z.string().nullable(),
workflowsUpdated: z.number(),
updatedWorkflowIds: z.array(z.string()),
})
export type RefreshMcpServerResult = z.output<typeof refreshMcpServerResultSchema>
const mcpSuccessResponseSchema = <T extends z.ZodType>(dataSchema: T) =>
z.object({
success: z.literal(true),
data: dataSchema,
})
export const listMcpServersContract = defineRouteContract({
method: 'GET',
path: '/api/mcp/servers',
query: mcpWorkspaceQuerySchema,
response: {
mode: 'json',
schema: mcpSuccessResponseSchema(
z.object({
servers: z.array(mcpServerSchema),
})
),
},
})
export type ListMcpServersResponse = ContractJsonResponse<typeof listMcpServersContract>
export const createMcpServerContract = defineRouteContract({
method: 'POST',
path: '/api/mcp/servers',
body: createMcpServerBodySchema,
response: {
mode: 'json',
schema: mcpSuccessResponseSchema(
z.object({
serverId: z.string(),
updated: z.boolean().optional(),
authType: mcpAuthTypeSchema.optional(),
})
),
},
})
export const deleteMcpServerContract = defineRouteContract({
method: 'DELETE',
path: '/api/mcp/servers',
query: deleteMcpServerQuerySchema,
response: {
mode: 'json',
schema: mcpSuccessResponseSchema(
z.object({
message: z.string(),
})
),
},
})
export const updateMcpServerContract = defineRouteContract({
method: 'PATCH',
path: '/api/mcp/servers/[id]',
params: mcpServerIdParamsSchema,
query: mcpWorkspaceQuerySchema,
body: updateMcpServerBodySchema,
response: {
mode: 'json',
schema: mcpSuccessResponseSchema(
z.object({
server: mcpServerSchema,
})
),
},
})
export const refreshMcpServerContract = defineRouteContract({
method: 'POST',
path: '/api/mcp/servers/[id]/refresh',
params: mcpServerIdParamsSchema,
query: mcpWorkspaceQuerySchema,
response: {
mode: 'json',
schema: mcpSuccessResponseSchema(refreshMcpServerResultSchema),
},
})
export const discoverMcpToolsContract = defineRouteContract({
method: 'GET',
path: '/api/mcp/tools/discover',
query: discoverMcpToolsQuerySchema,
response: {
mode: 'json',
schema: mcpSuccessResponseSchema(
z.object({
tools: z.array(mcpToolSchema),
totalCount: z.number(),
byServer: z.record(z.string(), z.number()),
})
),
},
})
export type DiscoverMcpToolsResponse = ContractJsonResponse<typeof discoverMcpToolsContract>
export const refreshMcpToolsContract = defineRouteContract({
method: 'POST',
path: '/api/mcp/tools/discover',
query: mcpWorkspaceQuerySchema,
body: refreshMcpToolsBodySchema,
response: {
mode: 'json',
schema: mcpSuccessResponseSchema(
z.object({
refreshed: z.array(z.object({ serverId: z.string(), toolCount: z.number() })),
failed: z.array(z.object({ serverId: z.string(), error: z.string() })),
summary: z.object({
total: z.number(),
successful: z.number(),
failed: z.number(),
}),
})
),
},
})
export const listStoredMcpToolsContract = defineRouteContract({
method: 'GET',
path: '/api/mcp/tools/stored',
query: mcpWorkspaceQuerySchema,
response: {
mode: 'json',
schema: mcpSuccessResponseSchema(
z.object({
tools: z.array(storedMcpToolSchema),
})
),
},
})
export const testMcpServerConnectionContract = defineRouteContract({
method: 'POST',
path: '/api/mcp/servers/test-connection',
body: mcpServerTestBodySchema,
response: {
mode: 'json',
schema: mcpSuccessResponseSchema(mcpServerTestResultSchema),
},
})
export const executeMcpToolContract = defineRouteContract({
method: 'POST',
path: '/api/mcp/tools/execute',
body: mcpToolExecutionBodySchema,
response: {
mode: 'json',
schema: mcpSuccessResponseSchema(mcpToolExecutionResultSchema),
},
})
export type ExecuteMcpToolResponse = ContractJsonResponse<typeof executeMcpToolContract>
export const startMcpOauthQuerySchema = z.object({
serverId: z.string().min(1, 'serverId is required'),
workspaceId: z.string().min(1, 'workspaceId is required'),
})
export const startMcpOauthResultSchema = z.discriminatedUnion('status', [
z.object({ status: z.literal('redirect'), authorizationUrl: z.string().url() }),
z.object({ status: z.literal('already_authorized') }),
])
export type StartMcpOauthResult = z.output<typeof startMcpOauthResultSchema>
export const startMcpOauthContract = defineRouteContract({
method: 'GET',
path: '/api/mcp/oauth/start',
query: startMcpOauthQuerySchema,
response: {
mode: 'json',
schema: startMcpOauthResultSchema,
},
})
/**
* Provider can return any subset depending on the outcome:
* - success: `state` + `code`
* - provider error: `error` + optional `error_description` + optional `state`
* - malformed callback: nothing
* All fields are optional so the route can render an HTML error page itself.
*/
export const mcpOauthCallbackQuerySchema = z.object({
state: z.string().optional(),
code: z.string().optional(),
error: z.string().optional(),
error_description: z.string().optional(),
})
export const mcpOauthCallbackContract = defineRouteContract({
method: 'GET',
path: '/api/mcp/oauth/callback',
query: mcpOauthCallbackQuerySchema,
response: { mode: 'text' },
})
export const getAllowedMcpDomainsContract = defineRouteContract({
method: 'GET',
path: '/api/settings/allowed-mcp-domains',
response: {
mode: 'json',
schema: z.object({
allowedMcpDomains: z.array(z.string()).nullable(),
}),
},
})
@@ -0,0 +1,2 @@
export * from '@/lib/api/contracts/media/speech'
export * from '@/lib/api/contracts/media/tts-stream'
@@ -0,0 +1,21 @@
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts/types'
export const speechTokenBodySchema = z
.object({
chatId: z.string().optional(),
/** Editor/workspace voice: the workspace the session user is recording in. */
workspaceId: z.string().optional(),
})
.passthrough()
export const speechTokenResponseSchema = z.object({
token: z.string(),
})
export const speechTokenContract = defineRouteContract({
method: 'POST',
path: '/api/speech/token',
body: speechTokenBodySchema,
response: { mode: 'json', schema: speechTokenResponseSchema },
})
@@ -0,0 +1,18 @@
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts/types'
export const ttsStreamBodySchema = z
.object({
text: z.string().min(1),
voiceId: z.string().min(1),
modelId: z.string().optional().default('eleven_flash_v2_5'),
chatId: z.string().min(1),
})
.passthrough()
export const ttsStreamContract = defineRouteContract({
method: 'POST',
path: '/api/proxy/tts/stream',
body: ttsStreamBodySchema,
response: { mode: 'stream' },
})
+142
View File
@@ -0,0 +1,142 @@
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts/types'
export const memoryIdParamsSchema = z.object({
id: z.string().min(1),
})
export const memoryWorkspaceQuerySchema = z.object({
workspaceId: z.string().uuid('Invalid workspace ID format'),
})
const agentMemoryDataSchema = z.object({
role: z.enum(['user', 'assistant', 'system'], {
error: 'Role must be user, assistant, or system',
}),
content: z.string().min(1, 'Content is required'),
})
const genericMemoryDataSchema = z.record(z.string(), z.unknown())
export const memoryPutBodySchema = z.object({
data: z.union([agentMemoryDataSchema, genericMemoryDataSchema], {
error: 'Invalid memory data structure',
}),
workspaceId: z.string().uuid('Invalid workspace ID format'),
})
export type MemoryPutBody = z.input<typeof memoryPutBodySchema>
export const agentMemoryDataSchemaContract = agentMemoryDataSchema
export const memoryListQuerySchema = z.object({
workspaceId: z.string().optional(),
query: z.string().nullable().optional(),
limit: z
.string()
.optional()
.transform((value) => Number.parseInt(value || '50')),
})
export const memoryMessageSchema = z
.object({
role: z.enum(['user', 'assistant', 'system']),
content: z.unknown().refine((value) => Boolean(value)),
})
.passthrough()
export const memoryPostBodySchema = z
.object({
key: z.string().optional(),
data: z.unknown().optional(),
workspaceId: z.string().optional(),
})
.passthrough()
export type MemoryPostBody = z.input<typeof memoryPostBodySchema>
export const memoryDeleteQuerySchema = z.object({
workspaceId: z.string().optional(),
conversationId: z.string().optional(),
})
const memorySuccessResponseSchema = <T extends z.ZodType>(dataSchema: T) =>
z.object({
success: z.literal(true),
data: dataSchema,
})
const memoryRecordSchema = z.object({
conversationId: z.string(),
data: z.unknown(),
})
export const listMemoriesContract = defineRouteContract({
method: 'GET',
path: '/api/memory',
query: memoryListQuerySchema,
response: {
mode: 'json',
schema: memorySuccessResponseSchema(
z.object({
memories: z.array(memoryRecordSchema),
})
),
},
})
export const createMemoryContract = defineRouteContract({
method: 'POST',
path: '/api/memory',
body: memoryPostBodySchema,
response: {
mode: 'json',
schema: memorySuccessResponseSchema(memoryRecordSchema),
},
})
export const deleteMemoryByQueryContract = defineRouteContract({
method: 'DELETE',
path: '/api/memory',
query: memoryDeleteQuerySchema,
response: {
mode: 'json',
schema: memorySuccessResponseSchema(
z.object({
message: z.string(),
deletedCount: z.number(),
})
),
},
})
export const getMemoryByIdContract = defineRouteContract({
method: 'GET',
path: '/api/memory/[id]',
params: memoryIdParamsSchema,
query: memoryWorkspaceQuerySchema,
response: {
mode: 'json',
schema: memorySuccessResponseSchema(memoryRecordSchema),
},
})
export const deleteMemoryByIdContract = defineRouteContract({
method: 'DELETE',
path: '/api/memory/[id]',
params: memoryIdParamsSchema,
query: memoryWorkspaceQuerySchema,
response: {
mode: 'json',
schema: memorySuccessResponseSchema(z.object({ message: z.string() })),
},
})
export const updateMemoryByIdContract = defineRouteContract({
method: 'PUT',
path: '/api/memory/[id]',
params: memoryIdParamsSchema,
body: memoryPutBodySchema,
response: {
mode: 'json',
schema: memorySuccessResponseSchema(memoryRecordSchema),
},
})
@@ -0,0 +1,359 @@
import { z } from 'zod'
import { scheduleContextSchema } from '@/lib/api/contracts/schedules'
import { defineRouteContract } from '@/lib/api/contracts/types'
const dateStringSchema = z.string().refine((value) => !Number.isNaN(Date.parse(value)), {
message: 'Expected a valid date string',
})
export const listMothershipChatsQuerySchema = z.object({
workspaceId: z.string().min(1),
})
export const mothershipChatParamsSchema = z.object({
chatId: z.string().min(1),
})
export const updateMothershipChatBodySchema = z
.object({
title: z.string().trim().min(1).max(200).optional(),
isUnread: z.boolean().optional(),
pinned: z.boolean().optional(),
})
.refine(
(data) => data.title !== undefined || data.isUnread !== undefined || data.pinned !== undefined,
{
message: 'At least one field must be provided',
}
)
export const createMothershipChatBodySchema = z.object({
workspaceId: z.string().min(1),
})
export type CreateMothershipChatBody = z.input<typeof createMothershipChatBodySchema>
export const markMothershipChatReadBodySchema = z.object({
chatId: z.string().min(1),
})
export type MarkMothershipChatReadBody = z.input<typeof markMothershipChatReadBodySchema>
export const markMothershipChatReadContract = defineRouteContract({
method: 'POST',
path: '/api/mothership/chats/read',
body: markMothershipChatReadBodySchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
}),
},
})
const mothershipExecuteMessageSchema = z.object({
role: z.enum(['system', 'user', 'assistant']),
content: z.string(),
})
const mothershipExecuteFileAttachmentSchema = z
.object({
type: z.enum(['text', 'image', 'document', 'audio', 'video']),
source: z
.object({
type: z.literal('base64'),
media_type: z.string().min(1),
data: z.string().min(1),
})
.optional(),
filename: z.string().optional(),
})
.passthrough()
export const mothershipExecuteBodySchema = z.object({
messages: z.array(mothershipExecuteMessageSchema).min(1, 'At least one message is required'),
responseFormat: z.any().optional(),
workspaceId: z.string().min(1, 'workspaceId is required'),
userId: z.string().min(1, 'userId is required'),
chatId: z.string().optional(),
messageId: z.string().optional(),
requestId: z.string().optional(),
fileAttachments: z.array(mothershipExecuteFileAttachmentSchema).optional(),
/**
* `@`-mentioned resources / `/`-invoked skills to resolve into the agent run,
* mirroring the interactive chat path. Used by scheduled tasks, whose
* captured contexts must reach the run without a live client.
*/
contexts: z.array(scheduleContextSchema).optional(),
workflowId: z.string().optional(),
executionId: z.string().optional(),
userMetadata: z
.object({
name: z.string().optional(),
timezone: z.string().optional(),
})
.optional(),
})
export type MothershipExecuteBody = z.input<typeof mothershipExecuteBodySchema>
export const mothershipEventsQuerySchema = z
.object({
workspaceId: z.string().optional(),
})
.passthrough()
export const mothershipChatGetQuerySchema = z
.object({
workflowId: z.string().optional(),
workspaceId: z.string().optional(),
chatId: z.string().optional(),
})
.passthrough()
export const mothershipChatPostEnvelopeSchema = z
.object({
message: z.string().optional(),
chatId: z.string().optional(),
workflowId: z.string().optional(),
workspaceId: z.string().optional(),
})
.passthrough()
export const mothershipChatStopEnvelopeSchema = z
.object({
chatId: z.string().optional(),
streamId: z.string().optional(),
content: z.string().optional(),
contentBlocks: z.array(z.unknown()).optional(),
requestId: z.string().optional(),
})
.passthrough()
export const mothershipChatAbortEnvelopeSchema = z
.object({
streamId: z.string().optional(),
chatId: z.string().optional(),
})
.passthrough()
export const mothershipChatStreamQuerySchema = z
.object({
streamId: z.string().optional(),
after: z.string().optional(),
batch: z.string().optional(),
})
.passthrough()
export const mothershipChatResourceEnvelopeSchema = z
.object({ chatId: z.string().optional() })
.passthrough()
export const adminMothershipQuerySchema = z
.object({
env: z.preprocess(
(value) => (value === '' || value === undefined ? 'dev' : value),
z.string().min(1).default('dev')
),
endpoint: z.string().min(1, 'endpoint query param required'),
})
.passthrough()
export type AdminMothershipQuery = z.output<typeof adminMothershipQuerySchema>
const mothershipChatResourceItemSchema = z.object({
type: z.string(),
id: z.string(),
title: z.string(),
})
const mothershipChatResourcesResponseSchema = z.object({
success: z.literal(true),
resources: z.array(mothershipChatResourceItemSchema),
})
const addMothershipChatResourceBodySchema = z.object({
chatId: z.string().min(1),
resource: mothershipChatResourceItemSchema,
})
const reorderMothershipChatResourcesBodySchema = z.object({
chatId: z.string().min(1),
resources: z.array(mothershipChatResourceItemSchema),
})
const removeMothershipChatResourceBodySchema = z.object({
chatId: z.string().min(1),
resourceType: z.string().min(1),
resourceId: z.string().min(1),
})
export const addMothershipChatResourceContract = defineRouteContract({
method: 'POST',
path: '/api/mothership/chat/resources',
body: addMothershipChatResourceBodySchema,
response: {
mode: 'json',
schema: mothershipChatResourcesResponseSchema,
},
})
export const reorderMothershipChatResourcesContract = defineRouteContract({
method: 'PATCH',
path: '/api/mothership/chat/resources',
body: reorderMothershipChatResourcesBodySchema,
response: {
mode: 'json',
schema: mothershipChatResourcesResponseSchema,
},
})
export const removeMothershipChatResourceContract = defineRouteContract({
method: 'DELETE',
path: '/api/mothership/chat/resources',
body: removeMothershipChatResourceBodySchema,
response: {
mode: 'json',
schema: mothershipChatResourcesResponseSchema,
},
})
export const mothershipChatSchema = z.object({
id: z.string(),
title: z.string().nullable(),
updatedAt: dateStringSchema,
activeStreamId: z.string().nullable(),
lastSeenAt: dateStringSchema.nullable(),
pinned: z.boolean(),
})
export const listMothershipChatsContract = defineRouteContract({
method: 'GET',
path: '/api/mothership/chats',
query: listMothershipChatsQuerySchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
data: z.array(mothershipChatSchema),
}),
},
})
export const updateMothershipChatContract = defineRouteContract({
method: 'PATCH',
path: '/api/mothership/chats/[chatId]',
params: mothershipChatParamsSchema,
body: updateMothershipChatBodySchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
}),
},
})
export const deleteMothershipChatContract = defineRouteContract({
method: 'DELETE',
path: '/api/mothership/chats/[chatId]',
params: mothershipChatParamsSchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
}),
},
})
export const forkMothershipChatBodySchema = z.object({
upToMessageId: z.string().min(1, 'upToMessageId is required'),
})
export type ForkMothershipChatBody = z.input<typeof forkMothershipChatBodySchema>
export const forkMothershipChatContract = defineRouteContract({
method: 'POST',
path: '/api/mothership/chats/[chatId]/fork',
params: mothershipChatParamsSchema,
body: forkMothershipChatBodySchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
id: z.string(),
}),
},
})
export const createMothershipChatResponseSchema = z.object({
success: z.literal(true),
id: z.string(),
})
export const mothershipExecuteResponseSchema = z
.object({
content: z.string().optional(),
model: z.literal('mothership'),
conversationId: z.string(),
tokens: z
.object({
prompt: z.number().optional(),
completion: z.number().optional(),
total: z.number().optional(),
})
.passthrough(),
cost: z.unknown().optional(),
toolCalls: z.array(z.unknown()).optional(),
})
.passthrough()
const mothershipChatStreamSnapshotSchema = z
.object({
events: z.array(z.unknown()),
previewSessions: z.array(z.unknown()),
status: z.string(),
})
.passthrough()
export const getMothershipChatResponseSchema = z.object({
success: z.literal(true),
chat: z
.object({
id: z.string(),
title: z.string().nullable(),
messages: z.array(z.unknown()),
activeStreamId: z.string().nullable(),
resources: z.array(z.unknown()),
createdAt: z.union([z.string(), z.date()]).nullable().optional(),
updatedAt: z.union([z.string(), z.date()]).nullable().optional(),
streamSnapshot: mothershipChatStreamSnapshotSchema.optional(),
})
.passthrough(),
})
export const createMothershipChatContract = defineRouteContract({
method: 'POST',
path: '/api/mothership/chats',
body: createMothershipChatBodySchema,
response: {
mode: 'json',
schema: createMothershipChatResponseSchema,
},
})
export const mothershipExecuteContract = defineRouteContract({
method: 'POST',
path: '/api/mothership/execute',
body: mothershipExecuteBodySchema,
response: {
mode: 'json',
schema: mothershipExecuteResponseSchema,
},
})
export const getMothershipChatContract = defineRouteContract({
method: 'GET',
path: '/api/mothership/chats/[chatId]',
params: mothershipChatParamsSchema,
response: {
mode: 'json',
schema: getMothershipChatResponseSchema,
},
})
export type MothershipChat = z.infer<typeof mothershipChatSchema>
@@ -0,0 +1,209 @@
import { z } from 'zod'
import { workspaceIdSchema } from '@/lib/api/contracts/primitives'
import type {
ContractBody,
ContractBodyInput,
ContractJsonResponse,
} from '@/lib/api/contracts/types'
import { defineRouteContract } from '@/lib/api/contracts/types'
export const oauthAccountSummarySchema = z.object({
id: z.string(),
name: z.string(),
})
export type OAuthAccountSummary = z.output<typeof oauthAccountSummarySchema>
export const oauthConnectionSchema = z.object({
provider: z.string(),
baseProvider: z.string(),
featureType: z.string(),
isConnected: z.boolean(),
accounts: z.array(oauthAccountSummarySchema),
lastConnected: z.string(),
scopes: z.array(z.string()),
})
export type OAuthConnection = z.output<typeof oauthConnectionSchema>
export const disconnectOAuthBodySchema = z.object({
provider: z.string({ error: 'Provider is required' }).min(1, 'Provider is required'),
providerId: z.string().optional(),
accountId: z.string().optional(),
})
export const connectedAccountsQuerySchema = z.object({
provider: z.string().min(1).optional(),
})
export const connectedAccountSchema = z.object({
id: z.string(),
accountId: z.string(),
providerId: z.string(),
displayName: z.string(),
})
export type ConnectedAccount = z.output<typeof connectedAccountSchema>
export const trelloTokenBodySchema = z.object({
token: z.string().min(1),
state: z.string().min(1, 'state is required'),
})
const emptyTrelloAuthQuerySchema = z.object({}).passthrough()
const trelloCallbackQuerySchema = z
.object({
state: z.string().min(1).optional(),
error: z.string().min(1).optional(),
})
.passthrough()
export const oauthTokenRequestBodySchema = z
.object({
credentialId: z.string().min(1).optional(),
credentialAccountUserId: z.string().min(1).optional(),
providerId: z.string().min(1).optional(),
workflowId: z.string().min(1).nullish(),
scopes: z.array(z.string()).optional(),
impersonateEmail: z.string().email().optional(),
})
.refine(
(data) => data.credentialId || (data.credentialAccountUserId && data.providerId),
'Either credentialId or (credentialAccountUserId + providerId) is required'
)
export const oauthTokenGetQuerySchema = z.object({
credentialId: z
.string({
error: 'Credential ID is required',
})
.min(1, 'Credential ID is required'),
})
export const oauthTokenPostQuerySchema = z.object({
userId: z.string().min(1).optional(),
})
const oauthTokenResponseSchema = z.object({
accessToken: z.string(),
idToken: z.string().optional(),
instanceUrl: z.string().optional(),
cloudId: z.string().optional(),
domain: z.string().optional(),
})
export const oauthTokenGetContract = defineRouteContract({
method: 'GET',
path: '/api/auth/oauth/token',
query: oauthTokenGetQuerySchema,
response: {
mode: 'json',
schema: oauthTokenResponseSchema,
},
})
export const oauthTokenPostContract = defineRouteContract({
method: 'POST',
path: '/api/auth/oauth/token',
query: oauthTokenPostQuerySchema,
body: oauthTokenRequestBodySchema,
response: {
mode: 'json',
schema: oauthTokenResponseSchema,
},
})
export const shopifyAuthorizeQuerySchema = z.object({
shop: z.string().optional(),
returnUrl: z.string().optional(),
})
export const shopifyCallbackQuerySchema = z.object({
code: z.string().optional(),
state: z.string().optional(),
shop: z.string().optional(),
})
export const shopifyStoreCookieSchema = z.object({
accessToken: z.string().min(1),
shopDomain: z.string().min(1),
scope: z.string().optional(),
returnUrl: z.string().optional(),
})
const SHOPIFY_SHOP_DOMAIN_REGEX = /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]\.myshopify\.com$/
export const shopifyShopDomainSchema = z.string().regex(SHOPIFY_SHOP_DOMAIN_REGEX)
export const listOAuthConnectionsContract = defineRouteContract({
method: 'GET',
path: '/api/auth/oauth/connections',
response: {
mode: 'json',
schema: z.object({
connections: z.array(oauthConnectionSchema),
}),
},
})
export const disconnectOAuthContract = defineRouteContract({
method: 'POST',
path: '/api/auth/oauth/disconnect',
body: disconnectOAuthBodySchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
}),
},
})
export const listConnectedAccountsContract = defineRouteContract({
method: 'GET',
path: '/api/auth/accounts',
query: connectedAccountsQuerySchema,
response: {
mode: 'json',
schema: z.object({
accounts: z.array(connectedAccountSchema),
}),
},
})
export const storeTrelloTokenContract = defineRouteContract({
method: 'POST',
path: '/api/auth/trello/store',
body: trelloTokenBodySchema,
response: {
mode: 'json',
schema: z.object({ success: z.boolean(), error: z.string().optional() }),
},
})
export const authorizeTrelloContract = defineRouteContract({
method: 'GET',
path: '/api/auth/trello/authorize',
query: emptyTrelloAuthQuerySchema,
response: { mode: 'redirect' },
})
export const trelloCallbackContract = defineRouteContract({
method: 'GET',
path: '/api/auth/trello/callback',
query: trelloCallbackQuerySchema,
response: { mode: 'text' },
})
export const authorizeOAuth2QuerySchema = z.object({
providerId: z.string().min(1, 'providerId is required'),
workspaceId: workspaceIdSchema,
callbackURL: z.string().min(1).optional(),
})
export const authorizeOAuth2Contract = defineRouteContract({
method: 'GET',
path: '/api/auth/oauth2/authorize',
query: authorizeOAuth2QuerySchema,
response: { mode: 'redirect' },
})
export type StoreTrelloTokenBody = ContractBody<typeof storeTrelloTokenContract>
export type StoreTrelloTokenBodyInput = ContractBodyInput<typeof storeTrelloTokenContract>
export type StoreTrelloTokenResponse = ContractJsonResponse<typeof storeTrelloTokenContract>
+614
View File
@@ -0,0 +1,614 @@
import { z } from 'zod'
import {
type PiiRedactionSettings,
piiRedactionSettingsSchema,
retentionOverridesSchema,
workspaceIdSchema,
} from '@/lib/api/contracts/primitives'
import { organizationBillingDataSchema } from '@/lib/api/contracts/subscription'
import { defineRouteContract } from '@/lib/api/contracts/types'
import { workspacePermissionSchema } from '@/lib/api/contracts/workspaces'
import { HEX_COLOR_REGEX } from '@/lib/branding'
const booleanQueryParamSchema = z
.preprocess((value) => {
if (value === 'true') return true
if (value === 'false') return false
return value
}, z.boolean())
.optional()
const numericResponseSchema = z.preprocess((value) => {
if (typeof value !== 'string') return value
const parsed = Number.parseFloat(value)
return Number.isFinite(parsed) ? parsed : value
}, z.number())
export const organizationRoleSchema = z.enum(['owner', 'admin', 'member'], {
error: 'Invalid role',
})
export const organizationParamsSchema = z.object({
id: z.string().min(1),
})
export const organizationMemberParamsSchema = z.object({
id: z.string().min(1),
memberId: z.string().min(1),
})
export const organizationMemberQuerySchema = z
.object({
include: z.string().optional(),
})
.passthrough()
export const workspaceGrantSchema = z.object({
workspaceId: z.string().min(1),
permission: workspacePermissionSchema,
})
export const createOrganizationBodySchema = z
.object({
name: z.string().optional(),
slug: z.string().optional(),
})
.passthrough()
export const updateOrganizationBodySchema = z.object({
name: z.string().trim().min(1, 'Organization name is required').optional(),
slug: z
.string()
.trim()
.min(1, 'Organization slug is required')
.regex(
/^[a-z0-9-_]+$/,
'Slug can only contain lowercase letters, numbers, hyphens, and underscores'
)
.optional(),
logo: z.string().nullable().optional(),
})
export const createOrganizationInvitationBodySchema = z
.object({
email: z.string().optional(),
emails: z.array(z.string()).optional(),
role: z.enum(['member', 'admin'], { error: 'Invalid role' }).optional(),
workspaceInvitations: z.array(workspaceGrantSchema).optional(),
})
.passthrough()
export const organizationInvitationsQuerySchema = z
.object({
validate: booleanQueryParamSchema,
batch: booleanQueryParamSchema,
})
.passthrough()
export const updateOrganizationMemberRoleBodySchema = z.object({
role: organizationRoleSchema,
})
export const inviteOrganizationMemberBodySchema = z
.object({
email: z.string({ error: 'Email is required' }).min(1, 'Email is required'),
role: z.enum(['admin', 'member'], { error: 'Invalid role' }).optional(),
})
.passthrough()
const organizationDataRetentionHoursSchema = z
.number()
.int()
.min(24)
.max(43800)
.nullable()
.optional()
export type { PiiRedactionSettings }
export const updateOrganizationDataRetentionBodySchema = z.object({
logRetentionHours: organizationDataRetentionHoursSchema,
softDeleteRetentionHours: organizationDataRetentionHoursSchema,
taskCleanupHours: organizationDataRetentionHoursSchema,
piiRedaction: piiRedactionSettingsSchema.optional(),
retentionOverrides: retentionOverridesSchema.optional(),
})
export type UpdateOrganizationDataRetentionBody = z.input<
typeof updateOrganizationDataRetentionBodySchema
>
const organizationRetentionValuesSchema = z.object({
logRetentionHours: z.number().int().nullable(),
softDeleteRetentionHours: z.number().int().nullable(),
taskCleanupHours: z.number().int().nullable(),
piiRedaction: piiRedactionSettingsSchema.nullable(),
retentionOverrides: retentionOverridesSchema.nullable(),
})
export type OrganizationRetentionValues = z.output<typeof organizationRetentionValuesSchema>
const organizationDataRetentionDataSchema = z.object({
isEnterprise: z.boolean(),
defaults: organizationRetentionValuesSchema,
configured: organizationRetentionValuesSchema,
effective: organizationRetentionValuesSchema,
piiRedactionEnabled: z.boolean(),
piiGranularRedactionEnabled: z.boolean(),
})
export type OrganizationDataRetention = z.output<typeof organizationDataRetentionDataSchema>
export const organizationDataRetentionResponseSchema = z.object({
success: z.boolean(),
data: organizationDataRetentionDataSchema,
})
export const updateOrganizationWhitelabelBodySchema = z.object({
brandName: z
.string()
.trim()
.max(64, 'Brand name must be 64 characters or fewer')
.nullable()
.optional(),
logoUrl: z.string().min(1).nullable().optional(),
wordmarkUrl: z.string().min(1).nullable().optional(),
primaryColor: z
.string()
.regex(HEX_COLOR_REGEX, 'Primary color must be a valid hex color (e.g. #33c482)')
.nullable()
.optional(),
primaryHoverColor: z
.string()
.regex(HEX_COLOR_REGEX, 'Primary hover color must be a valid hex color')
.nullable()
.optional(),
accentColor: z
.string()
.regex(HEX_COLOR_REGEX, 'Accent color must be a valid hex color')
.nullable()
.optional(),
accentHoverColor: z
.string()
.regex(HEX_COLOR_REGEX, 'Accent hover color must be a valid hex color')
.nullable()
.optional(),
supportEmail: z
.string()
.email('Support email must be a valid email address')
.nullable()
.optional(),
documentationUrl: z.string().url('Documentation URL must be a valid URL').nullable().optional(),
termsUrl: z.string().url('Terms URL must be a valid URL').nullable().optional(),
privacyUrl: z.string().url('Privacy URL must be a valid URL').nullable().optional(),
hidePoweredBySim: z.boolean().optional(),
})
export const transferOwnershipBodySchema = z.object({
newOwnerUserId: z.string().min(1),
alsoLeave: z.boolean().optional().default(false),
})
export const rosterWorkspaceAccessSchema = z.object({
workspaceId: z.string(),
workspaceName: z.string(),
permission: workspacePermissionSchema,
})
export const rosterMemberSchema = z.object({
memberId: z.string(),
userId: z.string(),
role: z.enum(['owner', 'admin', 'member', 'external']),
createdAt: z.string(),
name: z.string(),
email: z.string(),
image: z.string().nullable(),
workspaces: z.array(rosterWorkspaceAccessSchema),
})
export const rosterPendingInvitationSchema = z.object({
id: z.string(),
email: z.string(),
role: z.string(),
kind: z.enum(['organization', 'workspace']),
membershipIntent: z.enum(['internal', 'external']).optional(),
createdAt: z.string(),
expiresAt: z.string(),
inviteeName: z.string().nullable(),
inviteeImage: z.string().nullable(),
workspaces: z.array(rosterWorkspaceAccessSchema),
})
export const organizationRosterSchema = z.object({
members: z.array(rosterMemberSchema),
pendingInvitations: z.array(rosterPendingInvitationSchema),
workspaces: z.array(z.object({ id: z.string(), name: z.string() })),
})
export const organizationMemberUsageSchema = z
.object({
id: z.string(),
userId: z.string(),
organizationId: z.string(),
role: organizationRoleSchema,
createdAt: z.string(),
userName: z.string().nullable(),
userEmail: z.string().nullable(),
currentPeriodCost: numericResponseSchema.nullable().optional(),
currentUsageLimit: numericResponseSchema.nullable().optional(),
usageLimitUpdatedAt: z.string().nullable().optional(),
billingPeriodStart: z.string().nullable().optional(),
billingPeriodEnd: z.string().nullable().optional(),
})
.passthrough()
export const listOrganizationMembersResponseSchema = z
.object({
success: z.boolean(),
data: z.array(organizationMemberUsageSchema),
total: z.number(),
userRole: organizationRoleSchema,
hasAdminAccess: z.boolean(),
})
.passthrough()
const successResponseSchema = z
.object({
success: z.boolean(),
message: z.string().optional(),
})
.passthrough()
const organizationInvitationValidationResponseSchema = z
.object({
success: z.literal(true),
data: z.unknown(),
validatedBy: z.string(),
validatedAt: z.string(),
})
.passthrough()
export const getOrganizationRosterContract = defineRouteContract({
method: 'GET',
path: '/api/organizations/[id]/roster',
params: organizationParamsSchema,
response: {
mode: 'json',
schema: z.object({
success: z.boolean(),
data: organizationRosterSchema,
}),
},
})
export const listOrganizationMembersContract = defineRouteContract({
method: 'GET',
path: '/api/organizations/[id]/members',
params: organizationParamsSchema,
query: organizationMemberQuerySchema,
response: {
mode: 'json',
schema: listOrganizationMembersResponseSchema,
},
})
export const inviteOrganizationMemberContract = defineRouteContract({
method: 'POST',
path: '/api/organizations/[id]/members',
params: organizationParamsSchema,
body: inviteOrganizationMemberBodySchema,
response: {
mode: 'json',
schema: successResponseSchema.extend({
data: z
.object({
invitationId: z.string(),
email: z.string(),
role: organizationRoleSchema,
})
.passthrough()
.optional(),
}),
},
})
export const inviteOrganizationMembersContract = defineRouteContract({
method: 'POST',
path: '/api/organizations/[id]/invitations',
params: organizationParamsSchema,
query: organizationInvitationsQuerySchema,
body: createOrganizationInvitationBodySchema,
response: {
mode: 'json',
schema: z.union([
organizationInvitationValidationResponseSchema,
successResponseSchema.extend({
error: z.string().optional(),
data: z
.object({
invitationsSent: z.number(),
invitedEmails: z.array(z.string()),
directlyAdded: z.array(z.string()).optional(),
directlyAddedCount: z.number().optional(),
failedInvitations: z.array(z.object({ email: z.string(), error: z.string() })),
existingMembers: z.array(z.string()),
pendingInvitations: z.array(z.string()),
invalidEmails: z.array(z.string()),
workspaceGrantsPerInvite: z.number(),
seatInfo: z
.object({
seatsUsed: z.number(),
maxSeats: z.number(),
availableSeats: z.number(),
})
.passthrough()
.optional(),
})
.passthrough()
.optional(),
}),
]),
},
})
export const updateOrganizationMemberRoleContract = defineRouteContract({
method: 'PUT',
path: '/api/organizations/[id]/members/[memberId]',
params: organizationMemberParamsSchema,
body: updateOrganizationMemberRoleBodySchema,
response: {
mode: 'json',
schema: successResponseSchema.extend({
data: z
.object({
id: z.string(),
userId: z.string(),
role: organizationRoleSchema,
updatedBy: z.string(),
})
.passthrough()
.optional(),
}),
},
})
export const removeOrganizationMemberContract = defineRouteContract({
method: 'DELETE',
path: '/api/organizations/[id]/members/[memberId]',
params: organizationMemberParamsSchema,
response: {
mode: 'json',
schema: successResponseSchema.extend({
data: z.record(z.string(), z.unknown()).optional(),
}),
},
})
/** Per-member credit usage + cap for the Manage Credits modal (values in credits). */
export const organizationMemberUsageLimitDataSchema = z.object({
creditsUsed: z.number(),
creditLimit: z.number().nullable(),
/** Billing cadence of the org's subscription, so the UI can label the usage window. */
billingInterval: z.enum(['month', 'year']),
})
export const getOrganizationMemberUsageLimitContract = defineRouteContract({
method: 'GET',
path: '/api/organizations/[id]/members/[memberId]/usage-limit',
params: organizationMemberParamsSchema,
response: {
mode: 'json',
schema: z.object({
success: z.boolean(),
data: organizationMemberUsageLimitDataSchema,
}),
},
})
export const updateOrganizationMemberUsageLimitBodySchema = z.object({
/** New cap in credits; `null` clears the per-member cap. */
creditLimit: z
.number()
.int('Credit limit must be a whole number of credits')
.min(0, 'Credit limit cannot be negative')
.nullable(),
})
export const updateOrganizationMemberUsageLimitContract = defineRouteContract({
method: 'PUT',
path: '/api/organizations/[id]/members/[memberId]/usage-limit',
params: organizationMemberParamsSchema,
body: updateOrganizationMemberUsageLimitBodySchema,
response: {
mode: 'json',
schema: successResponseSchema.extend({
data: z
.object({
creditLimit: z.number().nullable(),
})
.optional(),
}),
},
})
/**
* Self-service per-member usage for the chat-home credits chip. Values are in
* DOLLARS (the DB unit) so the client's `formatCredits` performs the single
* dollars→credits conversion — returning credits here would double-convert.
* `limitDollars` is null when no per-member cap applies (non-hosted, the
* workspace isn't org-owned, or no cap is set), so the chip falls back to the
* plan-level credits view.
*/
export const myMemberCreditsDataSchema = z.object({
usedDollars: z.number(),
limitDollars: z.number().nullable(),
})
export type MyMemberCreditsData = z.infer<typeof myMemberCreditsDataSchema>
/**
* Own-data-only (no admin gate, unlike the admin route above) and workspace-
* scoped, so the chat-home chip can resolve the acting member's own remaining.
*/
export const getMyMemberCreditsContract = defineRouteContract({
method: 'GET',
path: '/api/billing/member-credits',
query: z.object({ workspaceId: workspaceIdSchema }),
response: {
mode: 'json',
schema: z.object({
success: z.boolean(),
data: myMemberCreditsDataSchema,
}),
},
})
export const transferOwnershipContract = defineRouteContract({
method: 'POST',
path: '/api/organizations/[id]/transfer-ownership',
params: organizationParamsSchema,
body: transferOwnershipBodySchema,
response: {
mode: 'json',
schema: z
.object({
success: z.boolean(),
transferred: z.boolean(),
left: z.boolean(),
warning: z.string().optional(),
details: z.record(z.string(), z.unknown()).optional(),
})
.passthrough(),
},
})
export const updateOrganizationContract = defineRouteContract({
method: 'PUT',
path: '/api/organizations/[id]',
params: organizationParamsSchema,
body: updateOrganizationBodySchema,
response: {
mode: 'json',
schema: successResponseSchema.extend({
data: z
.object({
id: z.string(),
name: z.string(),
slug: z.string().nullable(),
logo: z.string().nullable(),
updatedAt: z.string(),
})
.passthrough()
.optional(),
}),
},
})
export const getOrganizationDataRetentionContract = defineRouteContract({
method: 'GET',
path: '/api/organizations/[id]/data-retention',
params: organizationParamsSchema,
response: {
mode: 'json',
schema: organizationDataRetentionResponseSchema,
},
})
export const updateOrganizationDataRetentionContract = defineRouteContract({
method: 'PUT',
path: '/api/organizations/[id]/data-retention',
params: organizationParamsSchema,
body: updateOrganizationDataRetentionBodySchema,
response: {
mode: 'json',
schema: organizationDataRetentionResponseSchema,
},
})
// Read shape mirrors `OrganizationWhitelabelSettings` from
// `@/lib/branding/types`. All fields are optional (nullable on the way in
// for the PUT contract, but stored without nulls on the way out — the
// route deletes keys that are explicitly cleared).
export const organizationWhitelabelSettingsResponseSchema = z.object({
brandName: z.string().optional(),
logoUrl: z.string().optional(),
wordmarkUrl: z.string().optional(),
primaryColor: z.string().optional(),
primaryHoverColor: z.string().optional(),
accentColor: z.string().optional(),
accentHoverColor: z.string().optional(),
supportEmail: z.string().optional(),
documentationUrl: z.string().optional(),
termsUrl: z.string().optional(),
privacyUrl: z.string().optional(),
hidePoweredBySim: z.boolean().optional(),
})
const organizationWhitelabelEnvelopeResponseSchema = z.object({
success: z.boolean(),
data: organizationWhitelabelSettingsResponseSchema,
})
export const getOrganizationWhitelabelContract = defineRouteContract({
method: 'GET',
path: '/api/organizations/[id]/whitelabel',
params: organizationParamsSchema,
response: {
mode: 'json',
schema: organizationWhitelabelEnvelopeResponseSchema,
},
})
export const updateOrganizationWhitelabelContract = defineRouteContract({
method: 'PUT',
path: '/api/organizations/[id]/whitelabel',
params: organizationParamsSchema,
body: updateOrganizationWhitelabelBodySchema,
response: {
mode: 'json',
schema: organizationWhitelabelEnvelopeResponseSchema,
},
})
export const createOrganizationContract = defineRouteContract({
method: 'POST',
path: '/api/organizations',
body: createOrganizationBodySchema,
response: {
mode: 'json',
schema: z.object({
success: z.boolean(),
organizationId: z.string(),
created: z.boolean(),
}),
},
})
export const updateOrganizationUsageLimitContract = defineRouteContract({
method: 'PUT',
path: '/api/usage',
body: z.object({
context: z.literal('organization'),
organizationId: z.string().min(1),
limit: z.number().min(0, 'Limit must be a non-negative number'),
}),
response: {
mode: 'json',
schema: z
.object({
success: z.boolean(),
context: z.literal('organization'),
userId: z.string(),
organizationId: z.string(),
data: organizationBillingDataSchema.nullable(),
})
.passthrough(),
},
})
export type OrganizationRoster = z.infer<typeof organizationRosterSchema>
export type RosterWorkspaceAccess = z.infer<typeof rosterWorkspaceAccessSchema>
export type RosterMember = z.infer<typeof rosterMemberSchema>
export type RosterPendingInvitation = z.infer<typeof rosterPendingInvitationSchema>
export type OrganizationMembersResponse = z.infer<typeof listOrganizationMembersResponseSchema>
export type OrganizationMemberUsageLimitData = z.infer<
typeof organizationMemberUsageLimitDataSchema
>
@@ -0,0 +1,27 @@
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts/types'
/**
* Boundary contract for listing the organizations the current user belongs to.
* Backs `GET /api/organizations`.
*/
export const creatorOrganizationSchema = z.object({
id: z.string(),
name: z.string(),
role: z.string(),
})
export type CreatorOrganization = z.output<typeof creatorOrganizationSchema>
export const listCreatorOrganizationsContract = defineRouteContract({
method: 'GET',
path: '/api/organizations',
response: {
mode: 'json',
schema: z.object({
organizations: z.array(creatorOrganizationSchema),
isMemberOfAnyOrg: z.boolean(),
}),
},
})
@@ -0,0 +1,82 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
createPermissionGroupBodySchema,
updatePermissionGroupBodySchema,
} from '@/lib/api/contracts/permission-groups'
describe('createPermissionGroupBodySchema', () => {
it('accepts a name-only body (scope is resolved and validated server-side)', () => {
const result = createPermissionGroupBodySchema.safeParse({ name: 'Engineering' })
expect(result.success).toBe(true)
})
it('accepts a default group', () => {
const result = createPermissionGroupBodySchema.safeParse({
name: 'Baseline',
isDefault: true,
})
expect(result.success).toBe(true)
})
it('accepts a specific-scope group with workspaces', () => {
const result = createPermissionGroupBodySchema.safeParse({
name: 'Contractors',
workspaceIds: ['ws-1'],
})
expect(result.success).toBe(true)
})
it('accepts a name + empty workspaces (the create route enforces at least one)', () => {
const result = createPermissionGroupBodySchema.safeParse({
name: 'Contractors',
workspaceIds: [],
})
expect(result.success).toBe(true)
})
it('rejects a default group that targets specific workspaces', () => {
const result = createPermissionGroupBodySchema.safeParse({
name: 'Baseline',
isDefault: true,
workspaceIds: ['ws-1'],
})
expect(result.success).toBe(false)
})
})
describe('updatePermissionGroupBodySchema', () => {
it('accepts an empty update', () => {
expect(updatePermissionGroupBodySchema.safeParse({}).success).toBe(true)
})
it('accepts demoting the default via isDefault:false alone (the route re-scopes it)', () => {
expect(updatePermissionGroupBodySchema.safeParse({ isDefault: false }).success).toBe(true)
})
it('accepts emptying scope (group then governs nothing)', () => {
const result = updatePermissionGroupBodySchema.safeParse({ workspaceIds: [] })
expect(result.success).toBe(true)
})
it('accepts a specific scope with workspaces', () => {
const result = updatePermissionGroupBodySchema.safeParse({
workspaceIds: ['ws-1', 'ws-2'],
})
expect(result.success).toBe(true)
})
it('accepts promoting a group to the default', () => {
expect(updatePermissionGroupBodySchema.safeParse({ isDefault: true }).success).toBe(true)
})
it('rejects promoting to the default while naming workspaces', () => {
const result = updatePermissionGroupBodySchema.safeParse({
isDefault: true,
workspaceIds: ['ws-1'],
})
expect(result.success).toBe(false)
})
})
@@ -0,0 +1,313 @@
import { z } from 'zod'
import { organizationIdSchema } from '@/lib/api/contracts/primitives'
import { shareAuthTypeSchema } from '@/lib/api/contracts/public-shares'
import { defineRouteContract } from '@/lib/api/contracts/types'
import { permissionGroupConfigSchema } from '@/lib/permission-groups/types'
export const permissionGroupFullConfigSchema = z.object({
allowedIntegrations: z.array(z.string()).nullable(),
allowedModelProviders: z.array(z.string()).nullable(),
deniedModels: z.array(z.string()).default([]),
deniedTools: z.array(z.string()).default([]),
hideTraceSpans: z.boolean(),
hideKnowledgeBaseTab: z.boolean(),
hideTablesTab: z.boolean(),
hideCopilot: z.boolean(),
hideIntegrationsTab: z.boolean(),
hideSecretsTab: z.boolean(),
hideApiKeysTab: z.boolean(),
hideInboxTab: z.boolean(),
hideFilesTab: z.boolean(),
disableMcpTools: z.boolean(),
disableCustomTools: z.boolean(),
disableSkills: z.boolean(),
disableInvitations: z.boolean(),
disablePublicApi: z.boolean(),
disablePublicFileSharing: z.boolean(),
allowedFileShareAuthTypes: z.array(shareAuthTypeSchema).nullable(),
hideDeployApi: z.boolean(),
hideDeployMcp: z.boolean(),
hideDeployChatbot: z.boolean(),
hideDeployTemplate: z.boolean(),
})
export const addPermissionGroupMemberBodySchema = z.object({
userId: z.string().min(1),
})
/** Route params for organization-scoped permission-group collection routes (`id` = organizationId). */
export const permissionGroupParamsSchema = z.object({
id: organizationIdSchema,
})
/** Route params for a single permission group (`id` = organizationId, `groupId` = permission group id). */
export const permissionGroupDetailParamsSchema = z.object({
id: organizationIdSchema,
groupId: z.string().min(1),
})
/** A workspace a permission group targets (id + display name). */
export const permissionGroupWorkspaceRefSchema = z.object({
id: z.string(),
name: z.string(),
})
export type PermissionGroupWorkspaceRef = z.output<typeof permissionGroupWorkspaceRefSchema>
export const permissionGroupSchema = z.object({
id: z.string(),
name: z.string(),
description: z.string().nullable(),
config: permissionGroupFullConfigSchema,
createdBy: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
creatorName: z.string().nullable(),
creatorEmail: z.string().nullable(),
memberCount: z.number(),
isDefault: z.boolean(),
/**
* Workspaces this group targets. Empty for the default group (which governs
* every workspace) and for a non-default group scoped to nothing.
*/
workspaces: z.array(permissionGroupWorkspaceRefSchema),
})
export type PermissionGroup = z.output<typeof permissionGroupSchema>
export const permissionGroupWriteSchema = z.object({
id: z.string(),
organizationId: z.string(),
name: z.string(),
description: z.string().nullable(),
config: permissionGroupFullConfigSchema,
createdBy: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
isDefault: z.boolean(),
/** Ids of targeted workspaces (empty for the default group). */
workspaceIds: z.array(z.string()),
})
export type PermissionGroupWrite = z.output<typeof permissionGroupWriteSchema>
export const permissionGroupMemberSchema = z.object({
id: z.string(),
userId: z.string(),
assignedAt: z.string(),
userName: z.string().nullable(),
userEmail: z.string().nullable(),
userImage: z.string().nullable(),
})
export type PermissionGroupMember = z.output<typeof permissionGroupMemberSchema>
export const userPermissionConfigQuerySchema = z.object({
workspaceId: z.string().min(1),
})
export const userPermissionConfigSchema = z.object({
permissionGroupId: z.string().nullable(),
groupName: z.string().nullable(),
config: permissionGroupFullConfigSchema.nullable(),
entitled: z.boolean(),
/** The workspace's owning organization id (null when the workspace has no org). */
organizationId: z.string().nullable(),
/** Whether the caller is an owner/admin of the workspace's owning organization. */
isOrgAdmin: z.boolean(),
})
export type UserPermissionConfig = z.output<typeof userPermissionConfigSchema>
/** Upper bound on how many workspaces a single group can explicitly target. */
export const MAX_PERMISSION_GROUP_WORKSPACES = 500
const workspaceIdsSchema = z.array(z.string().min(1)).max(MAX_PERMISSION_GROUP_WORKSPACES)
/**
* The one cross-field scope rule shared by create and update: the organization
* default group governs every workspace, so it cannot also name specific
* workspaces (they would be silently dropped server-side). "Org-wide" is
* definitionally `isDefault` — there is no separate flag — so a default group
* with no `workspaceIds` is already the all-workspaces case and needs no
* assertion here.
*
* Everything else is left to the routes: a non-default group targets the
* workspaces in `workspaceIds` (empty is allowed on update — the group then
* governs nothing, since the resolver inner-joins the workspace link table), and
* the create route requires at least one workspace up front.
*/
function refineWorkspaceScope(
body: { workspaceIds?: string[]; isDefault?: boolean },
ctx: z.RefinementCtx
) {
if (body.isDefault === true && body.workspaceIds && body.workspaceIds.length > 0) {
ctx.addIssue({
code: 'custom',
path: ['workspaceIds'],
message: 'The default group governs all workspaces and cannot target specific workspaces',
})
}
}
export const createPermissionGroupBodySchema = z
.object({
name: z.string().trim().min(1).max(100),
description: z.string().max(500).optional(),
config: permissionGroupConfigSchema.optional(),
isDefault: z.boolean().optional(),
workspaceIds: workspaceIdsSchema.optional(),
})
.superRefine(refineWorkspaceScope)
export const updatePermissionGroupBodySchema = z
.object({
name: z.string().trim().min(1).max(100).optional(),
description: z.string().max(500).nullable().optional(),
config: permissionGroupConfigSchema.optional(),
isDefault: z.boolean().optional(),
workspaceIds: workspaceIdsSchema.optional(),
})
.superRefine(refineWorkspaceScope)
export const removePermissionGroupMemberQuerySchema = z.object({
memberId: z.string().min(1),
})
export const bulkAddPermissionGroupMembersBodySchema = z.object({
userIds: z.array(z.string()).optional(),
addAllOrganizationMembers: z.boolean().optional(),
})
const successResponseSchema = z.object({
success: z.literal(true),
})
export const listPermissionGroupsContract = defineRouteContract({
method: 'GET',
path: '/api/organizations/[id]/permission-groups',
params: permissionGroupParamsSchema,
response: {
mode: 'json',
schema: z.object({
permissionGroups: z.array(permissionGroupSchema).optional(),
}),
},
})
export const createPermissionGroupContract = defineRouteContract({
method: 'POST',
path: '/api/organizations/[id]/permission-groups',
params: permissionGroupParamsSchema,
body: createPermissionGroupBodySchema,
response: {
mode: 'json',
schema: z.object({
permissionGroup: permissionGroupWriteSchema,
}),
},
})
export const getUserPermissionConfigContract = defineRouteContract({
method: 'GET',
path: '/api/permission-groups/user',
query: userPermissionConfigQuerySchema,
response: {
mode: 'json',
schema: userPermissionConfigSchema,
},
})
export const updatePermissionGroupContract = defineRouteContract({
method: 'PUT',
path: '/api/organizations/[id]/permission-groups/[groupId]',
params: permissionGroupDetailParamsSchema,
body: updatePermissionGroupBodySchema,
response: {
mode: 'json',
schema: z.object({
permissionGroup: permissionGroupWriteSchema,
}),
},
})
export const deletePermissionGroupContract = defineRouteContract({
method: 'DELETE',
path: '/api/organizations/[id]/permission-groups/[groupId]',
params: permissionGroupDetailParamsSchema,
response: {
mode: 'json',
schema: successResponseSchema,
},
})
export const listPermissionGroupMembersContract = defineRouteContract({
method: 'GET',
path: '/api/organizations/[id]/permission-groups/[groupId]/members',
params: permissionGroupDetailParamsSchema,
response: {
mode: 'json',
schema: z.object({
members: z.array(permissionGroupMemberSchema).optional(),
}),
},
})
export const removePermissionGroupMemberContract = defineRouteContract({
method: 'DELETE',
path: '/api/organizations/[id]/permission-groups/[groupId]/members',
params: permissionGroupDetailParamsSchema,
query: removePermissionGroupMemberQuerySchema,
response: {
mode: 'json',
schema: successResponseSchema,
},
})
export const addPermissionGroupMemberContract = defineRouteContract({
method: 'POST',
path: '/api/organizations/[id]/permission-groups/[groupId]/members',
params: permissionGroupDetailParamsSchema,
body: addPermissionGroupMemberBodySchema,
response: {
mode: 'json',
schema: z.object({
member: z.object({
id: z.string(),
permissionGroupId: z.string(),
organizationId: z.string(),
userId: z.string(),
assignedBy: z.string(),
assignedAt: z.string(),
}),
}),
},
})
export const bulkAddPermissionGroupMembersContract = defineRouteContract({
method: 'POST',
path: '/api/organizations/[id]/permission-groups/[groupId]/members/bulk',
params: permissionGroupDetailParamsSchema,
body: bulkAddPermissionGroupMembersBodySchema,
response: {
mode: 'json',
schema: z.object({
added: z.number(),
// Users not added because they were already in this group. A conflicting
// selection fails the whole request (409) rather than being skipped, so
// the add is all-or-nothing for conflicts.
skipped: z.number(),
}),
},
})
/**
* List the workspaces belonging to an organization, used to populate the
* workspace multi-select when scoping a permission group to specific workspaces.
*/
export const listOrganizationWorkspacesContract = defineRouteContract({
method: 'GET',
path: '/api/organizations/[id]/workspaces',
params: permissionGroupParamsSchema,
response: {
mode: 'json',
schema: z.object({
workspaces: z.array(permissionGroupWorkspaceRefSchema),
}),
},
})
+238
View File
@@ -0,0 +1,238 @@
import { z } from 'zod'
import { PII_LANGUAGE_CODES } from '@/lib/guardrails/pii-entities'
export const unknownRecordSchema = z.record(z.string(), z.unknown())
export function flattenFieldErrors<TFields extends string>(
error: z.ZodError
): Partial<Record<TFields, string>> {
const result: Partial<Record<TFields, string>> = {}
for (const issue of error.issues) {
const field = issue.path[0]
if (typeof field !== 'string') continue
if (result[field as TFields] === undefined) {
result[field as TFields] = issue.message
}
}
return result
}
export const noInputSchema = z.object({}).strict()
export type NoInput = z.output<typeof noInputSchema>
export const jobIdParamsSchema = z.object({
jobId: z.string().min(1),
})
/**
* Non-empty string identifier (used for workspace, workflow, user, table, etc.).
* Prefer this over inline `z.string().min(1)` so error wording stays consistent
* and refactors can centralize ID validation in one place.
*/
export const nonEmptyIdSchema = z.string().min(1)
/**
* Non-empty `workspaceId` field. Same constraint as `nonEmptyIdSchema` with a
* stable, human-readable message. Use to deduplicate the
* `z.string().min(1, 'Workspace ID is required')` pattern across contracts.
*/
export const workspaceIdSchema = z.string().min(1, 'Workspace ID is required')
/**
* Non-empty `organizationId` field. Same constraint as `nonEmptyIdSchema` with a
* stable, human-readable message.
*/
export const organizationIdSchema = z.string().min(1, 'Organization ID is required')
/**
* Non-empty `workflowId` field. Same constraint as `nonEmptyIdSchema` with a
* stable, human-readable message.
*/
export const workflowIdSchema = z.string().min(1, 'Workflow ID is required')
/**
* A `workspace_files.id` value. The column is a free-form `text` primary key, so
* ids come in two shapes: UUID v4 (legacy rows and the `insertFileMetadata`
* default) and the current `wf_<shortId>` form minted by the workspace upload
* path. Both are drawn from `[A-Za-z0-9_-]`, so accept that charset rather than a
* UUID-only schema — a `.uuid()` constraint here silently 400s every `wf_` file.
*/
export const workspaceFileIdSchema = z
.string()
.min(1, 'File ID is required')
.max(128, 'File ID is too long')
.regex(/^[A-Za-z0-9_-]+$/, 'Invalid file id')
/**
* Reference to an image embedded in a document: either a workspace storage `key`
* (serve-URL embeds) or a workspace file `id` (view-URL embeds) — exactly one. Shared
* by the in-app and public inline-image routes, which resolve it within a workspace.
*/
export const inlineFileRefQuerySchema = z
.object({
key: z.string().min(1).max(512).optional(),
fileId: workspaceFileIdSchema.optional(),
})
.refine((q) => (q.key ? 1 : 0) + (q.fileId ? 1 : 0) === 1, {
message: 'Provide exactly one of `key` or `fileId`',
})
/**
* Boolean query-string primitive that correctly handles the literal strings
* `"true"` / `"false"` (case-insensitive) in addition to real booleans.
*
* Do NOT use `z.coerce.boolean()` for query parameters: it coerces any
* non-empty string to `true`, so `?flag=false` resolves to `true`. This
* primitive treats `"false"` / `"0"` / `""` as `false` and `"true"` / `"1"`
* as `true`, mirroring how query strings are commonly serialized by
* frontends and CLIs.
*
* Real boolean inputs (e.g. when `requestJson` serializes a JS `true`) pass
* through unchanged. Anything else fails validation with a clear message.
*
* Use `.optional()` / `.default(...)` at the call site, not here, so each
* query field controls its own omission/default semantics.
*/
/**
* Canonical boundary schema for `UserFile` (`apps/sim/executor/types.ts`) — the
* shape produced by the executor and persisted in `workflowExecutionLogs.files`,
* forwarded through tool inputs, and rendered in the logs UI. `.passthrough()`
* tolerates legacy/extra fields on stored rows (e.g. `uploadedAt`, `expiresAt`,
* `storageProvider`) without rejecting the whole payload.
*/
export const userFileSchema = z
.object({
id: z.string().optional().default(''),
name: z.string().min(1),
url: z.string().optional().default(''),
size: z.coerce.number().nonnegative(),
type: z.string().optional().default('application/octet-stream'),
key: z.string().min(1),
context: z.string().optional(),
base64: z.string().optional(),
})
.passthrough()
/**
* Per-stage redaction policy: which entity types to mask, in which language. An
* enabled stage must name at least one entity type — "redact all" is not an
* expressible policy, so `enabled: true` with an empty list (which would resolve
* to off and silently skip masking) is rejected at the boundary.
*/
export const piiStagePolicySchema = z
.object({
enabled: z.boolean(),
/** Presidio entity types to mask. Disabled stages may be empty. */
entityTypes: z.array(z.string().min(1, 'Entity type cannot be empty')).max(100),
/** Language whose Presidio recognizers apply; defaults to English. */
language: z.enum(PII_LANGUAGE_CODES).optional(),
})
.refine((stage) => !stage.enabled || stage.entityTypes.length > 0, {
message: 'An enabled redaction stage must select at least one entity type.',
path: ['entityTypes'],
})
export type PiiStagePolicy = z.output<typeof piiStagePolicySchema>
/** The three redaction stages, each independently configured. */
export const piiStagesSchema = z.object({
input: piiStagePolicySchema,
blockOutputs: piiStagePolicySchema,
logs: piiStagePolicySchema,
})
export type PiiStages = z.output<typeof piiStagesSchema>
/**
* A single PII redaction rule targeting one scope (all workspaces, or one).
* New rules carry per-stage `stages`; legacy rows carry only the flat
* `entityTypes`/`language` (resolved as logs-only). At least one must be present.
*/
export const piiRedactionRuleSchema = z
.object({
id: z.string().min(1),
name: z.string().max(100).optional(),
/** null = all workspaces; otherwise the single targeted workspace. */
workspaceId: z.string().min(1).nullable(),
/** Per-stage policy (input / blockOutputs / logs). */
stages: piiStagesSchema.optional(),
/** Legacy flat policy (pre-stages). Retained for back-compat parse + migration. */
entityTypes: z.array(z.string().min(1, 'Entity type cannot be empty')).max(100).optional(),
language: z.enum(PII_LANGUAGE_CODES).optional(),
})
.refine((rule) => rule.stages !== undefined || rule.entityTypes !== undefined, {
message: 'A PII redaction rule must define either stages or entityTypes.',
})
export type PiiRedactionRule = z.output<typeof piiRedactionRuleSchema>
/**
* Enterprise PII redaction policy applied to workflow logs on persist. Each
* scope is unique: at most one all-workspaces rule (`workspaceId: null`) and at
* most one rule per workspace — resolution is most-specific-wins, so duplicate
* scopes would make masking depend on array order.
*/
export const piiRedactionSettingsSchema = z.object({
rules: z
.array(piiRedactionRuleSchema)
.max(1000)
.refine(
(rules) => {
const scopes = rules.map((r) => r.workspaceId ?? '__all__')
return new Set(scopes).size === scopes.length
},
{
message:
'Each workspace (and the all-workspaces default) may have at most one PII redaction rule.',
}
),
})
export type PiiRedactionSettings = z.output<typeof piiRedactionSettingsSchema>
/** Retention hours bound: 1 day to ~5 years, in hours. */
const retentionOverrideHoursSchema = z.number().int().min(24).max(43800).nullable().optional()
/**
* A per-workspace override of the org retention hours. Each field is tri-state:
* omitted = inherit the org value; a number = that workspace's retention in
* hours; `null` = forever (never delete).
*/
export const retentionOverrideSchema = z.object({
workspaceId: workspaceIdSchema,
logRetentionHours: retentionOverrideHoursSchema,
softDeleteRetentionHours: retentionOverrideHoursSchema,
taskCleanupHours: retentionOverrideHoursSchema,
})
export type RetentionOverride = z.output<typeof retentionOverrideSchema>
/**
* Per-workspace retention overrides. Each workspace appears at most once —
* resolution is workspace-override-then-org-default, so duplicate workspaces
* would make the effective value depend on array order.
*/
export const retentionOverridesSchema = z
.array(retentionOverrideSchema)
.max(1000)
.refine(
(overrides) => {
const ids = overrides.map((o) => o.workspaceId)
return new Set(ids).size === ids.length
},
{ message: 'Each workspace may have at most one retention override.' }
)
export type RetentionOverrides = z.output<typeof retentionOverridesSchema>
export const booleanQueryFlagSchema = z.preprocess(
(value) => {
if (typeof value === 'boolean') return value
if (typeof value !== 'string') return value
const normalized = value.trim().toLowerCase()
if (normalized === 'true' || normalized === '1') return true
if (normalized === 'false' || normalized === '0' || normalized === '') return false
return value
},
z.boolean({ error: 'must be a boolean (true/false)' })
)
+337
View File
@@ -0,0 +1,337 @@
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts/types'
export const openRouterModelInfoSchema = z.object({
id: z.string(),
contextLength: z.number().optional(),
supportsStructuredOutputs: z.boolean().optional(),
supportsTools: z.boolean().optional(),
pricing: z
.object({
input: z.number(),
output: z.number(),
})
.optional(),
})
export const providerModelsResponseSchema = z.object({
models: z.array(z.string()),
modelInfo: z.record(z.string(), openRouterModelInfoSchema).optional(),
})
export type ProviderModelsResponse = z.output<typeof providerModelsResponseSchema>
export const fireworksProviderModelsQuerySchema = z.object({
workspaceId: z.string().min(1).optional(),
})
export const ollamaCloudProviderModelsQuerySchema = z.object({
workspaceId: z.string().min(1).optional(),
})
export const togetherProviderModelsQuerySchema = z.object({
workspaceId: z.string().min(1).optional(),
})
export const basetenProviderModelsQuerySchema = z.object({
workspaceId: z.string().min(1).optional(),
})
export const openRouterUpstreamResponseSchema = z.object({
data: z
.array(
z
.object({
id: z.string(),
context_length: z.number().optional(),
supported_parameters: z.array(z.string()).optional(),
pricing: z
.object({
prompt: z.string().optional(),
completion: z.string().optional(),
})
.passthrough()
.optional(),
})
.passthrough()
)
.default([]),
})
export const vllmUpstreamResponseSchema = z.object({
data: z
.array(
z
.object({
id: z.string(),
})
.passthrough()
)
.default([]),
})
export const fireworksUpstreamResponseSchema = z.object({
data: z
.array(
z
.object({
id: z.string(),
object: z.string().optional(),
created: z.number().optional(),
owned_by: z.string().optional(),
})
.passthrough()
)
.default([]),
object: z.string().optional(),
})
const togetherModelObjectSchema = z
.object({
id: z.string(),
object: z.string().optional(),
created: z.number().optional(),
type: z.string().optional(),
display_name: z.string().optional(),
organization: z.string().optional(),
context_length: z.number().optional(),
})
.passthrough()
/** Together's `GET /v1/models` returns a bare top-level array of model objects. */
export const togetherUpstreamResponseSchema = z.array(togetherModelObjectSchema)
export const basetenUpstreamResponseSchema = z.object({
data: z
.array(
z
.object({
id: z.string(),
object: z.string().optional(),
created: z.number().optional(),
owned_by: z.string().optional(),
})
.passthrough()
)
.default([]),
object: z.string().optional(),
})
// Shared by the local Ollama and Ollama Cloud /api/tags endpoints — same `{ models: [{ name }] }` shape.
export const ollamaUpstreamResponseSchema = z.object({
models: z
.array(
z
.object({
name: z.string(),
})
.passthrough()
)
.default([]),
})
const providerToolSchema = z
.object({
id: z.string(),
name: z.string(),
description: z.string(),
params: z.record(z.string(), z.unknown()),
parameters: z
.object({
type: z.string(),
properties: z.record(z.string(), z.unknown()),
required: z.array(z.string()),
})
.passthrough(),
usageControl: z.enum(['auto', 'force', 'none']).optional(),
})
.passthrough()
const providerMessageSchema = z
.object({
role: z.enum(['system', 'user', 'assistant', 'function', 'tool']),
content: z.string().nullable(),
name: z.string().optional(),
function_call: z
.object({
name: z.string(),
arguments: z.string(),
})
.optional(),
tool_calls: z
.array(
z.object({
id: z.string(),
type: z.literal('function'),
function: z.object({
name: z.string(),
arguments: z.string(),
}),
})
)
.optional(),
tool_call_id: z.string().optional(),
})
.passthrough()
const providerResponseFormatSchema = z
.object({
name: z.string(),
// untyped-response: caller-supplied JSON Schema (request body field, not a route response)
schema: z.unknown(),
strict: z.boolean().optional(),
})
.passthrough()
export const providerApiRequestBodySchema = z
.object({
provider: z.string().min(1),
model: z.string().min(1),
systemPrompt: z.string().optional(),
context: z.string().optional(),
tools: z.array(providerToolSchema).optional(),
temperature: z.number().optional(),
maxTokens: z.number().optional(),
apiKey: z.string().optional(),
azureEndpoint: z.string().optional(),
azureApiVersion: z.string().optional(),
vertexProject: z.string().optional(),
vertexLocation: z.string().optional(),
vertexCredential: z.string().optional(),
bedrockAccessKeyId: z.string().optional(),
bedrockSecretKey: z.string().optional(),
bedrockRegion: z.string().optional(),
responseFormat: providerResponseFormatSchema.optional(),
workflowId: z.string().optional(),
workspaceId: z.string().optional(),
stream: z.boolean().optional(),
messages: z.array(providerMessageSchema).optional(),
environmentVariables: z.record(z.string(), z.string()).optional(),
workflowVariables: z.record(z.string(), z.unknown()).optional(),
blockData: z.record(z.string(), z.unknown()).optional(),
blockNameMapping: z.record(z.string(), z.string()).optional(),
reasoningEffort: z.string().optional(),
verbosity: z.string().optional(),
})
.passthrough()
export type ProviderApiRequestBody = z.input<typeof providerApiRequestBodySchema>
export const getBaseProviderModelsContract = defineRouteContract({
method: 'GET',
path: '/api/providers/base/models',
response: {
mode: 'json',
schema: providerModelsResponseSchema,
},
})
export const getOllamaProviderModelsContract = defineRouteContract({
method: 'GET',
path: '/api/providers/ollama/models',
response: {
mode: 'json',
schema: providerModelsResponseSchema,
},
})
export const getVllmProviderModelsContract = defineRouteContract({
method: 'GET',
path: '/api/providers/vllm/models',
response: {
mode: 'json',
schema: providerModelsResponseSchema,
},
})
export const getOpenRouterProviderModelsContract = defineRouteContract({
method: 'GET',
path: '/api/providers/openrouter/models',
response: {
mode: 'json',
schema: providerModelsResponseSchema,
},
})
export const getLitellmProviderModelsContract = defineRouteContract({
method: 'GET',
path: '/api/providers/litellm/models',
response: {
mode: 'json',
schema: providerModelsResponseSchema,
},
})
export const getFireworksProviderModelsContract = defineRouteContract({
method: 'GET',
path: '/api/providers/fireworks/models',
query: fireworksProviderModelsQuerySchema,
response: {
mode: 'json',
schema: providerModelsResponseSchema,
},
})
export const getOllamaCloudProviderModelsContract = defineRouteContract({
method: 'GET',
path: '/api/providers/ollama-cloud/models',
query: ollamaCloudProviderModelsQuerySchema,
response: {
mode: 'json',
schema: providerModelsResponseSchema,
},
})
export const getTogetherProviderModelsContract = defineRouteContract({
method: 'GET',
path: '/api/providers/together/models',
query: togetherProviderModelsQuerySchema,
response: {
mode: 'json',
schema: providerModelsResponseSchema,
},
})
export const getBasetenProviderModelsContract = defineRouteContract({
method: 'GET',
path: '/api/providers/baseten/models',
query: basetenProviderModelsQuerySchema,
response: {
mode: 'json',
schema: providerModelsResponseSchema,
},
})
/**
* `POST /api/providers` returns either a streamed response (handled at the
* runtime level — this contract models only the JSON case) or a JSON provider
* payload. The JSON case mirrors the canonical `ProviderResponse` shape from
* `@/providers/types`, but provider-specific fields are tolerated via
* passthrough so raw provider output flows through without contract drift.
*/
const executeProviderResponseSchema = z
.object({
content: z.string(),
model: z.string(),
tokens: z
.object({
input: z.number().optional(),
output: z.number().optional(),
total: z.number().optional(),
})
.optional(),
toolCalls: z.array(z.record(z.string(), z.unknown())).optional(),
toolResults: z.array(z.record(z.string(), z.unknown())).optional(),
timing: z.record(z.string(), z.unknown()).optional(),
cost: z.record(z.string(), z.unknown()).optional(),
interactionId: z.string().optional(),
})
.passthrough()
export const executeProviderContract = defineRouteContract({
method: 'POST',
path: '/api/providers',
body: providerApiRequestBodySchema,
response: {
mode: 'json',
schema: executeProviderResponseSchema,
},
})
+240
View File
@@ -0,0 +1,240 @@
import { z } from 'zod'
import { inlineFileRefQuerySchema, workspaceIdSchema } from '@/lib/api/contracts/primitives'
import { defineRouteContract } from '@/lib/api/contracts/types'
export const shareResourceTypeSchema = z.enum(['file', 'folder'])
/** How a public share is gated. */
export const shareAuthTypeSchema = z.enum(['public', 'password', 'email', 'sso'])
export type ShareAuthType = z.output<typeof shareAuthTypeSchema>
/** An allowed email address or `@domain` pattern for email/SSO shares. */
const allowedEmailSchema = z.string().min(1).max(320)
/**
* Public-safe representation of a `public_share` row. Never carries the
* underlying storage key or the (encrypted) password — `hasPassword` is the
* only password signal exposed to clients. `allowedEmails` is the allow-list for
* email/SSO shares (visible only to workspace members via the authed share route).
*/
export const shareRecordSchema = z.object({
id: z.string(),
token: z.string(),
url: z.string(),
isActive: z.boolean(),
resourceType: shareResourceTypeSchema,
resourceId: z.string(),
authType: shareAuthTypeSchema,
hasPassword: z.boolean(),
allowedEmails: z.array(allowedEmailSchema),
})
export type ShareRecord = z.output<typeof shareRecordSchema>
const fileShareParamsSchema = z.object({
id: workspaceIdSchema,
fileId: z.string().min(1, 'File ID is required'),
})
export const upsertFileShareBodySchema = z.object({
isActive: z.boolean(),
authType: shareAuthTypeSchema.optional(),
password: z
.string()
.min(1, 'Password cannot be empty')
.max(1024, 'Password is too long')
.optional(),
allowedEmails: z.array(allowedEmailSchema).max(200, 'Too many allowed emails').optional(),
// Client-reserved token shown as the link before saving; persisted on first
// enable so a copied link resolves. Ignored once the share row already exists.
token: z
.string()
.regex(/^[A-Za-z0-9_-]+$/, 'Invalid token')
.min(16, 'Token is too short')
.max(64, 'Token is too long')
.optional(),
})
export type UpsertFileShareBody = z.input<typeof upsertFileShareBodySchema>
const getFileShareResponseSchema = z.object({
share: shareRecordSchema.nullable(),
})
export type GetFileShareResponse = z.output<typeof getFileShareResponseSchema>
export const getFileShareContract = defineRouteContract({
method: 'GET',
path: '/api/workspaces/[id]/files/[fileId]/share',
params: fileShareParamsSchema,
response: {
mode: 'json',
schema: getFileShareResponseSchema,
},
})
const upsertFileShareResponseSchema = z.object({
share: shareRecordSchema,
})
export type UpsertFileShareResponse = z.output<typeof upsertFileShareResponseSchema>
export const upsertFileShareContract = defineRouteContract({
method: 'PUT',
path: '/api/workspaces/[id]/files/[fileId]/share',
params: fileShareParamsSchema,
body: upsertFileShareBodySchema,
response: {
mode: 'json',
schema: upsertFileShareResponseSchema,
},
})
export const publicFileTokenParamsSchema = z.object({
token: z.string().min(1, 'Token is required'),
})
const publicFileMetadataSchema = z.object({
token: z.string(),
name: z.string(),
type: z.string(),
size: z.number(),
workspaceName: z.string().nullable(),
ownerName: z.string().nullable(),
})
export type PublicFileMetadata = z.output<typeof publicFileMetadataSchema>
export const getPublicFileContract = defineRouteContract({
method: 'GET',
path: '/api/files/public/[token]',
params: publicFileTokenParamsSchema,
response: {
mode: 'json',
schema: publicFileMetadataSchema,
},
})
/** Binary stream of the shared file's bytes. Authorized solely by an active token. */
export const getPublicFileContentContract = defineRouteContract({
method: 'GET',
path: '/api/files/public/[token]/content',
params: publicFileTokenParamsSchema,
response: {
mode: 'binary',
},
})
/**
* Binary stream of an image embedded in a shared document. Authorized by the parent
* document's active share — the route serves the bytes only when the reference is
* actually embedded in the shared document AND the file lives in the same workspace,
* and only when the bytes are a renderable raster image.
*/
export const getPublicInlineFileContract = defineRouteContract({
method: 'GET',
path: '/api/files/public/[token]/inline',
params: publicFileTokenParamsSchema,
query: inlineFileRefQuerySchema,
response: {
mode: 'binary',
},
})
const authenticatePublicFileBodySchema = z.object({
password: z.string().min(1, 'Password is required').max(1024, 'Password is too long'),
})
export type AuthenticatePublicFileBody = z.input<typeof authenticatePublicFileBodySchema>
const authenticatePublicFileResponseSchema = z.object({
authType: shareAuthTypeSchema,
})
export type AuthenticatePublicFileResponse = z.output<typeof authenticatePublicFileResponseSchema>
/**
* Exchanges a share password for a `file_auth_{shareId}` cookie. IP rate-limited;
* returns 401 (`Invalid password`) on mismatch and 429 when throttled.
*/
export const authenticatePublicFileContract = defineRouteContract({
method: 'POST',
path: '/api/files/public/[token]',
params: publicFileTokenParamsSchema,
body: authenticatePublicFileBodySchema,
response: {
mode: 'json',
schema: authenticatePublicFileResponseSchema,
},
})
const requestPublicFileOtpBodySchema = z.object({
email: z.string().email('Invalid email address'),
})
export type RequestPublicFileOtpBody = z.input<typeof requestPublicFileOtpBodySchema>
const requestPublicFileOtpResponseSchema = z.object({
message: z.string(),
})
/** Sends a 6-digit verification code to an allow-listed email for an email-gated share. */
export const requestPublicFileOtpContract = defineRouteContract({
method: 'POST',
path: '/api/files/public/[token]/otp',
params: publicFileTokenParamsSchema,
body: requestPublicFileOtpBodySchema,
response: {
mode: 'json',
schema: requestPublicFileOtpResponseSchema,
},
})
const verifyPublicFileOtpBodySchema = requestPublicFileOtpBodySchema.extend({
otp: z.string().length(6, 'Verification code must be 6 digits'),
})
export type VerifyPublicFileOtpBody = z.input<typeof verifyPublicFileOtpBodySchema>
const verifyPublicFileOtpResponseSchema = z.object({
authType: shareAuthTypeSchema,
})
export type VerifyPublicFileOtpResponse = z.output<typeof verifyPublicFileOtpResponseSchema>
/** Verifies the OTP and, on success, sets the `file_auth_{shareId}` cookie. */
export const verifyPublicFileOtpContract = defineRouteContract({
method: 'PUT',
path: '/api/files/public/[token]/otp',
params: publicFileTokenParamsSchema,
body: verifyPublicFileOtpBodySchema,
response: {
mode: 'json',
schema: verifyPublicFileOtpResponseSchema,
},
})
const publicFileSSOBodySchema = z.object({
email: z.string().email('Invalid email address'),
})
export type PublicFileSSOBody = z.input<typeof publicFileSSOBodySchema>
const publicFileSSOResponseSchema = z.object({
eligible: z.boolean(),
})
export type PublicFileSSOResponse = z.output<typeof publicFileSSOResponseSchema>
/** Reports whether an email is on the allow-list for an SSO-gated share. */
export const publicFileSSOContract = defineRouteContract({
method: 'POST',
path: '/api/files/public/[token]/sso',
params: publicFileTokenParamsSchema,
body: publicFileSSOBodySchema,
response: {
mode: 'json',
schema: publicFileSSOResponseSchema,
},
})
+324
View File
@@ -0,0 +1,324 @@
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts/types'
export const scheduleStatusSchema = z.enum(['active', 'disabled', 'completed'])
export type ScheduleStatus = z.output<typeof scheduleStatusSchema>
export const scheduleSourceTypeSchema = z.enum(['workflow', 'job'])
export type ScheduleSourceType = z.output<typeof scheduleSourceTypeSchema>
export const scheduleLifecycleSchema = z.enum(['persistent', 'until_complete'])
export type ScheduleLifecycle = z.output<typeof scheduleLifecycleSchema>
/**
* A `@`-mentioned resource or `/`-invoked skill captured with a scheduled
* task's prompt. `kind` discriminates the variant and the remaining keys carry
* the variant-specific identifiers (`workflowId`, `tableId`, `skillId`, …), so
* the shape is intentionally open beyond the always-present `kind`/`label`.
*/
export const scheduleContextSchema = z
.object({ kind: z.string().min(1), label: z.string() })
.passthrough()
export type ScheduleContext = z.output<typeof scheduleContextSchema>
export const scheduleIdParamsSchema = z.object({
id: z.string().min(1, 'Invalid schedule ID'),
})
export const scheduleQuerySchema = z.object({
workflowId: z.string().optional(),
workspaceId: z.string().optional(),
blockId: z.string().optional(),
})
const workflowScheduleQuerySchema = z.object({
workflowId: z.string().min(1).optional(),
blockId: z.string().min(1).optional(),
})
/**
* Mirrors a full `workflow_schedule` row as it appears on the wire after
* `NextResponse.json` serialization. Single-schedule and workspace-list
* responses both spread the full row, so the schema describes every column
* (with NOT NULL columns required and timestamps as ISO strings).
*/
export const workflowScheduleRowSchema = z.object({
id: z.string(),
workflowId: z.string().nullable(),
deploymentVersionId: z.string().nullable(),
blockId: z.string().nullable(),
cronExpression: z.string().nullable(),
nextRunAt: z.string().nullable(),
lastRanAt: z.string().nullable(),
lastQueuedAt: z.string().nullable(),
triggerType: z.string(),
timezone: z.string(),
failedCount: z.number(),
infraRetryCount: z.number(),
status: scheduleStatusSchema,
lastFailedAt: z.string().nullable(),
/**
* Legacy rows pre-dating the `sourceType` column can still surface in
* workspace listings via `isNull(sourceType)` filters, so the wire may emit
* `null` even though the column is `notNull` for new rows.
*/
sourceType: scheduleSourceTypeSchema.nullable(),
jobTitle: z.string().nullable(),
prompt: z.string().nullable(),
lifecycle: scheduleLifecycleSchema,
successCondition: z.string().nullable(),
maxRuns: z.number().nullable(),
runCount: z.number(),
sourceChatId: z.string().nullable(),
sourceTaskName: z.string().nullable(),
sourceUserId: z.string().nullable(),
sourceWorkspaceId: z.string().nullable(),
jobHistory: z.array(z.object({ timestamp: z.string(), summary: z.string() })).nullable(),
contexts: z.array(scheduleContextSchema).nullable(),
excludedDates: z.array(z.string()).nullable(),
endsAt: z.string().nullable(),
archivedAt: z.string().nullable(),
createdAt: z.string(),
updatedAt: z.string(),
})
export type WorkflowScheduleRow = z.output<typeof workflowScheduleRowSchema>
/**
* Workspace-scope listing extends the row with synthesized join fields.
* Workflow-backed rows carry the workflow name from `workflow` (NOT NULL
* column); job-backed rows synthesize `null` server-side.
*/
export const workspaceScheduleRowSchema = workflowScheduleRowSchema.extend({
workflowName: z.string().nullable(),
})
export type WorkspaceScheduleRow = z.output<typeof workspaceScheduleRowSchema>
export const createScheduleBodySchema = z
.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
title: z.string().min(1, 'Title is required'),
prompt: z.string().min(1, 'Prompt is required'),
/** Recurring cadence. Omit (with `time` set) for a one-time task. */
cronExpression: z.string().min(1).optional(),
/** One-time launch instant (ISO 8601). Omit (with `cronExpression` set) for a recurring task. */
time: z.string().min(1).optional(),
timezone: z.string().optional().default('UTC'),
lifecycle: scheduleLifecycleSchema.optional().default('persistent'),
/** Recurrence end after N runs (gcal "ends after N occurrences"). */
maxRuns: z.number().int().positive().optional(),
/** Recurrence end on a date (ISO 8601; gcal "ends on date"). */
endsAt: z.string().optional(),
startDate: z.string().optional(),
contexts: z.array(scheduleContextSchema).optional(),
})
.superRefine((body, ctx) => {
if (!body.cronExpression && !body.time) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['time'],
message: 'Provide a cron expression for a recurring task or a time for a one-time task',
})
}
})
export type CreateScheduleBody = z.input<typeof createScheduleBodySchema>
export const reactivateScheduleBodySchema = z.object({
action: z.literal('reactivate'),
})
export type ReactivateScheduleBody = z.input<typeof reactivateScheduleBodySchema>
export const disableScheduleBodySchema = z.object({
action: z.literal('disable'),
})
export type DisableScheduleBody = z.input<typeof disableScheduleBodySchema>
export const updateScheduleBodySchema = z.object({
action: z.literal('update'),
title: z.string().min(1).optional(),
prompt: z.string().min(1).optional(),
cronExpression: z.string().nullable().optional(),
/** One-time launch instant (ISO 8601). Switches a task to one-time when set alongside a null `cronExpression`. */
time: z.string().min(1).optional(),
timezone: z.string().optional(),
lifecycle: scheduleLifecycleSchema.optional(),
maxRuns: z.number().int().positive().nullable().optional(),
endsAt: z.string().nullable().optional(),
contexts: z.array(scheduleContextSchema).optional(),
})
export type UpdateScheduleBody = z.input<typeof updateScheduleBodySchema>
/**
* Deletes a single occurrence of a recurring task (gcal "this event"): the
* occurrence's instant is added to the schedule's exclusion list and the next
* run advances past it. Deleting the whole series uses {@link deleteScheduleContract}.
*/
export const excludeOccurrenceBodySchema = z.object({
action: z.literal('exclude_occurrence'),
occurrence: z.string().min(1, 'Occurrence timestamp is required'),
})
export type ExcludeOccurrenceBody = z.input<typeof excludeOccurrenceBodySchema>
export const scheduleUpdateSchema = z.discriminatedUnion('action', [
reactivateScheduleBodySchema,
disableScheduleBodySchema,
updateScheduleBodySchema,
excludeOccurrenceBodySchema,
])
export type ScheduleUpdate = z.input<typeof scheduleUpdateSchema>
const messageResponseSchema = z.object({
message: z.string(),
nextRunAt: z.string().optional(),
})
export const executeSchedulesResponseSchema = z.object({
message: z.string(),
status: z.literal('started'),
})
export type ExecuteSchedulesResponse = z.output<typeof executeSchedulesResponseSchema>
export const getScheduleContract = defineRouteContract({
method: 'GET',
path: '/api/schedules',
query: workflowScheduleQuerySchema,
response: {
mode: 'json',
schema: z.object({
schedule: workflowScheduleRowSchema.nullable(),
isDisabled: z.boolean().optional(),
hasFailures: z.boolean().optional(),
canBeReactivated: z.boolean().optional(),
}),
},
})
export const listWorkspaceSchedulesContract = defineRouteContract({
method: 'GET',
path: '/api/schedules',
query: z.object({
workspaceId: z.string().min(1),
}),
response: {
mode: 'json',
schema: z.object({
schedules: z.array(workspaceScheduleRowSchema),
}),
},
})
/**
* Single-schedule read by id. Used by the mothership resource viewer so opening
* a scheduled-task artifact does a lightweight by-id fetch instead of pulling
* the entire workspace schedule list (which contended with the chat stream).
*/
export const getScheduleByIdContract = defineRouteContract({
method: 'GET',
path: '/api/schedules/[id]',
params: scheduleIdParamsSchema,
response: {
mode: 'json',
schema: z.object({
schedule: workflowScheduleRowSchema,
}),
},
})
/**
* Newly-created job schedules emit a partial summary with the canonical fields
* the route synthesizes server-side; everything else is filled in on
* subsequent reads.
*/
export const createScheduleResponseSchema = z.object({
schedule: z.object({
id: z.string(),
status: scheduleStatusSchema,
/** Null for one-time tasks, which carry no recurring cadence. */
cronExpression: z.string().nullable(),
nextRunAt: z.string(),
}),
})
export type CreateScheduleResponse = z.output<typeof createScheduleResponseSchema>
export const createScheduleContract = defineRouteContract({
method: 'POST',
path: '/api/schedules',
body: createScheduleBodySchema,
response: {
mode: 'json',
schema: createScheduleResponseSchema,
},
})
export const reactivateScheduleContract = defineRouteContract({
method: 'PUT',
path: '/api/schedules/[id]',
params: scheduleIdParamsSchema,
body: reactivateScheduleBodySchema,
response: {
mode: 'json',
schema: messageResponseSchema,
},
})
export const disableScheduleContract = defineRouteContract({
method: 'PUT',
path: '/api/schedules/[id]',
params: scheduleIdParamsSchema,
body: disableScheduleBodySchema,
response: {
mode: 'json',
schema: messageResponseSchema,
},
})
export const updateScheduleContract = defineRouteContract({
method: 'PUT',
path: '/api/schedules/[id]',
params: scheduleIdParamsSchema,
body: scheduleUpdateSchema,
response: {
mode: 'json',
schema: messageResponseSchema,
},
})
export const excludeOccurrenceContract = defineRouteContract({
method: 'PUT',
path: '/api/schedules/[id]',
params: scheduleIdParamsSchema,
body: excludeOccurrenceBodySchema,
response: {
mode: 'json',
schema: messageResponseSchema,
},
})
export const deleteScheduleContract = defineRouteContract({
method: 'DELETE',
path: '/api/schedules/[id]',
params: scheduleIdParamsSchema,
response: {
mode: 'json',
schema: messageResponseSchema,
},
})
export const executeSchedulesContract = defineRouteContract({
method: 'GET',
path: '/api/schedules/execute',
response: {
mode: 'json',
schema: executeSchedulesResponseSchema,
},
})
@@ -0,0 +1,33 @@
import { z } from 'zod'
import {
credentialWorkflowBodySchema,
definePostSelector,
idNameSchema,
} from '@/lib/api/contracts/selectors/shared'
import type { ContractJsonResponse } from '@/lib/api/contracts/types'
const airtableBaseSchema = idNameSchema
const airtableTableSchema = idNameSchema
export const airtableTablesBodySchema = credentialWorkflowBodySchema.extend({
baseId: z.string().min(1, 'Base ID is required'),
})
export const airtableBasesSelectorContract = definePostSelector(
'/api/tools/airtable/bases',
credentialWorkflowBodySchema.passthrough(),
z.object({ bases: z.array(airtableBaseSchema) })
)
export const airtableTablesSelectorContract = definePostSelector(
'/api/tools/airtable/tables',
airtableTablesBodySchema,
z.object({ tables: z.array(airtableTableSchema) })
)
export type AirtableBasesSelectorResponse = ContractJsonResponse<
typeof airtableBasesSelectorContract
>
export type AirtableTablesSelectorResponse = ContractJsonResponse<
typeof airtableTablesSelectorContract
>
@@ -0,0 +1,19 @@
import { z } from 'zod'
import {
credentialWorkflowBodySchema,
definePostSelector,
idNameSchema,
} from '@/lib/api/contracts/selectors/shared'
import type { ContractJsonResponse } from '@/lib/api/contracts/types'
const asanaWorkspaceSchema = idNameSchema
export const asanaWorkspacesSelectorContract = definePostSelector(
'/api/tools/asana/workspaces',
credentialWorkflowBodySchema,
z.object({ workspaces: z.array(asanaWorkspaceSchema) })
)
export type AsanaWorkspacesSelectorResponse = ContractJsonResponse<
typeof asanaWorkspacesSelectorContract
>
@@ -0,0 +1,25 @@
import { z } from 'zod'
import {
credentialWorkflowBodySchema,
definePostSelector,
idNameSchema,
} from '@/lib/api/contracts/selectors/shared'
import type { ContractJsonResponse } from '@/lib/api/contracts/types'
const attioObjectSchema = idNameSchema
const attioListSchema = idNameSchema
export const attioObjectsSelectorContract = definePostSelector(
'/api/tools/attio/objects',
credentialWorkflowBodySchema,
z.object({ objects: z.array(attioObjectSchema) })
)
export const attioListsSelectorContract = definePostSelector(
'/api/tools/attio/lists',
credentialWorkflowBodySchema,
z.object({ lists: z.array(attioListSchema) })
)
export type AttioObjectsSelectorResponse = ContractJsonResponse<typeof attioObjectsSelectorContract>
export type AttioListsSelectorResponse = ContractJsonResponse<typeof attioListsSelectorContract>
@@ -0,0 +1,57 @@
import { z } from 'zod'
import {
credentialWorkflowImpersonateBodySchema,
definePostSelector,
} from '@/lib/api/contracts/selectors/shared'
import type { ContractBodyInput, ContractJsonResponse } from '@/lib/api/contracts/types'
const bigQueryDatasetSchema = z
.object({
datasetReference: z
.object({
datasetId: z.string(),
projectId: z.string(),
})
.passthrough(),
friendlyName: z.string().optional(),
})
.passthrough()
const bigQueryTableSchema = z
.object({
tableReference: z.object({ tableId: z.string() }).passthrough(),
friendlyName: z.string().optional(),
})
.passthrough()
export const bigQueryDatasetsBodySchema = credentialWorkflowImpersonateBodySchema.extend({
projectId: z.string().min(1),
})
export const bigQueryTablesBodySchema = bigQueryDatasetsBodySchema.extend({
datasetId: z.string().min(1),
})
export const bigQueryDatasetsSelectorContract = definePostSelector(
'/api/tools/google_bigquery/datasets',
bigQueryDatasetsBodySchema,
z.object({ datasets: z.array(bigQueryDatasetSchema) })
)
export const bigQueryTablesSelectorContract = definePostSelector(
'/api/tools/google_bigquery/tables',
bigQueryTablesBodySchema,
z.object({ tables: z.array(bigQueryTableSchema) })
)
export type BigQueryDatasetsSelectorBody = ContractBodyInput<
typeof bigQueryDatasetsSelectorContract
>
export type BigQueryTablesSelectorBody = ContractBodyInput<typeof bigQueryTablesSelectorContract>
export type BigQueryDatasetsSelectorResponse = ContractJsonResponse<
typeof bigQueryDatasetsSelectorContract
>
export type BigQueryTablesSelectorResponse = ContractJsonResponse<
typeof bigQueryTablesSelectorContract
>
@@ -0,0 +1,30 @@
import { z } from 'zod'
import {
credentialWorkflowBodySchema,
definePostSelector,
idNameSchema,
} from '@/lib/api/contracts/selectors/shared'
import type { ContractJsonResponse } from '@/lib/api/contracts/types'
const calcomEventTypeSchema = z
.object({ id: z.string(), title: z.string(), slug: z.string() })
.passthrough()
export const calcomEventTypesSelectorContract = definePostSelector(
'/api/tools/calcom/event-types',
credentialWorkflowBodySchema,
z.object({ eventTypes: z.array(calcomEventTypeSchema) })
)
export const calcomSchedulesSelectorContract = definePostSelector(
'/api/tools/calcom/schedules',
credentialWorkflowBodySchema,
z.object({ schedules: z.array(idNameSchema) })
)
export type CalcomEventTypesSelectorResponse = ContractJsonResponse<
typeof calcomEventTypesSelectorContract
>
export type CalcomSchedulesSelectorResponse = ContractJsonResponse<
typeof calcomSchedulesSelectorContract
>
@@ -0,0 +1,84 @@
import { z } from 'zod'
import { definePostSelector, optionalString } from '@/lib/api/contracts/selectors/shared'
import type {
ContractBody,
ContractBodyInput,
ContractJsonResponse,
} from '@/lib/api/contracts/types'
import { validateAwsRegion } from '@/lib/core/security/input-validation'
const cloudwatchLogGroupSchema = z.object({ logGroupName: z.string() }).passthrough()
const cloudwatchLogStreamSchema = z.object({ logStreamName: z.string() }).passthrough()
/**
* AWS region with format validation. Matches the route-level check via
* `validateAwsRegion` (e.g. `us-east-1`, `eu-west-2`).
*/
const awsRegionSchema = z
.string()
.min(1, 'AWS region is required')
.refine((value) => validateAwsRegion(value).isValid, {
message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)',
})
/**
* Optional integer limit that accepts numbers, numeric strings, empty strings,
* and null. Empty/null/undefined → undefined (no limit).
*/
const optionalLimitSchema = z.preprocess(
(value) => (value === '' || value === undefined || value === null ? undefined : value),
z.coerce.number().int().positive().optional()
)
export const cloudwatchLogGroupsBodySchema = z.object({
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
region: awsRegionSchema,
prefix: optionalString,
limit: optionalLimitSchema.optional(),
})
export const cloudwatchLogStreamsBodySchema = cloudwatchLogGroupsBodySchema.extend({
logGroupName: z.string().min(1, 'Log group name is required'),
})
export const cloudwatchLogGroupsSelectorContract = definePostSelector(
'/api/tools/cloudwatch/describe-log-groups',
cloudwatchLogGroupsBodySchema,
z
.object({
success: z.boolean().optional(),
output: z.object({ logGroups: z.array(cloudwatchLogGroupSchema) }),
})
.passthrough()
)
export const cloudwatchLogStreamsSelectorContract = definePostSelector(
'/api/tools/cloudwatch/describe-log-streams',
cloudwatchLogStreamsBodySchema,
z
.object({
success: z.boolean().optional(),
output: z.object({ logStreams: z.array(cloudwatchLogStreamSchema) }),
})
.passthrough()
)
export type CloudwatchLogGroupsSelectorResponse = ContractJsonResponse<
typeof cloudwatchLogGroupsSelectorContract
>
export type CloudwatchLogStreamsSelectorResponse = ContractJsonResponse<
typeof cloudwatchLogStreamsSelectorContract
>
export type CloudwatchLogGroupsSelectorRequest = ContractBodyInput<
typeof cloudwatchLogGroupsSelectorContract
>
export type CloudwatchLogGroupsSelectorBody = ContractBody<
typeof cloudwatchLogGroupsSelectorContract
>
export type CloudwatchLogStreamsSelectorRequest = ContractBodyInput<
typeof cloudwatchLogStreamsSelectorContract
>
export type CloudwatchLogStreamsSelectorBody = ContractBody<
typeof cloudwatchLogStreamsSelectorContract
>
@@ -0,0 +1,613 @@
import { z } from 'zod'
import {
credentialWorkflowDomainBodySchema,
definePostSelector,
fileOptionSchema,
optionalString,
} from '@/lib/api/contracts/selectors/shared'
import type { ContractBody, ContractJsonResponse, ContractQuery } from '@/lib/api/contracts/types'
import { defineRouteContract } from '@/lib/api/contracts/types'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
const confluenceSpaceSchema = z
.object({
id: z.string(),
name: z.string(),
key: z.string(),
status: z.string().optional(),
})
.passthrough()
export const confluencePagesBodySchema = z.object({
domain: z.string().min(1, 'Domain is required'),
accessToken: z.string().min(1, 'Access token is required'),
cloudId: optionalString,
title: optionalString,
limit: z.number().int().positive().optional().default(50),
})
/**
* Refines a `pageId` field to match Confluence's alphanumeric format
* (max 255 chars). Used as a `superRefine` so multiple
* methods (POST/PUT/DELETE) on `/api/tools/confluence/page` can share it.
*/
export function refineConfluencePageId(data: { pageId: string }, ctx: z.RefinementCtx): void {
const validation = validateAlphanumericId(data.pageId, 'pageId', 255)
if (!validation.isValid) {
ctx.addIssue({
code: 'custom',
message: validation.error || 'Invalid page ID',
path: ['pageId'],
})
}
}
export const confluencePageBaseSchema = z.object({
domain: z.string().min(1, 'Domain is required'),
accessToken: z.string().min(1, 'Access token is required'),
cloudId: optionalString,
pageId: z.string().min(1, 'Page ID is required'),
})
export const confluencePageBodySchema = confluencePageBaseSchema.superRefine(refineConfluencePageId)
/** Body schema for `PUT /api/tools/confluence/page`. */
export const confluenceUpdatePageBodySchema = confluencePageBaseSchema
.extend({
title: optionalString,
body: z.object({ value: optionalString }).optional(),
version: z.object({ message: optionalString }).optional(),
})
.superRefine(refineConfluencePageId)
/** Body schema for `DELETE /api/tools/confluence/page`. */
export const confluenceDeletePageBodySchema = confluencePageBaseSchema
.extend({
purge: z.boolean().optional(),
})
.superRefine(refineConfluencePageId)
const confluenceBaseSchema = z.object({
domain: z.string({ error: 'Domain is required' }).min(1, 'Domain is required'),
accessToken: z.string({ error: 'Access token is required' }).min(1, 'Access token is required'),
cloudId: z.string().optional(),
})
const confluencePageScopedSchema = confluenceBaseSchema.extend({
pageId: z.string({ error: 'Page ID is required' }).min(1, 'Page ID is required'),
})
export const confluenceSpaceScopedSchema = confluenceBaseSchema.extend({
spaceId: z.string({ error: 'Space ID is required' }).min(1, 'Space ID is required'),
})
function addAlphanumericIdIssue(
data: Record<string, unknown>,
field: string,
label: string,
ctx: z.RefinementCtx
): void {
const value = data[field]
if (typeof value !== 'string') return
const validation = validateAlphanumericId(value, field, 255)
if (!validation.isValid) {
ctx.addIssue({
code: 'custom',
message: validation.error || `Invalid ${label}`,
path: [field],
})
}
}
// Keep the un-superRefined base separate so downstream schemas can .extend it.
// .superRefine returns a ZodEffects which has no .extend method, so extending
// the refined schema directly throws at module-init time (caught by bundlers
// like esbuild/Trigger.dev that eagerly evaluate; Next.js lazy-loads per-route
// and hides the issue).
const confluenceCommentScopedBaseSchema = confluenceBaseSchema.extend({
commentId: z.string().min(1, 'Comment ID is required'),
})
export const confluenceCommentScopedSchema = confluenceCommentScopedBaseSchema.superRefine(
(data, ctx) => addAlphanumericIdIssue(data, 'commentId', 'comment ID', ctx)
)
const confluenceBlogPostScopedBaseSchema = confluenceBaseSchema.extend({
blogPostId: z.string({ error: 'Blog post ID is required' }).min(1, 'Blog post ID is required'),
})
export const confluenceBlogPostScopedSchema = confluenceBlogPostScopedBaseSchema.superRefine(
(data, ctx) => addAlphanumericIdIssue(data, 'blogPostId', 'blog post ID', ctx)
)
export const confluenceDeleteAttachmentBodySchema = confluenceBaseSchema.extend({
attachmentId: z
.string({ error: 'Attachment ID is required' })
.min(1, 'Attachment ID is required'),
})
export const confluenceListAttachmentsQuerySchema = confluencePageScopedSchema.extend({
limit: z.string().optional().default('50'),
cursor: z.string().optional(),
})
export const confluenceCreateCommentBodySchema = confluencePageScopedSchema.extend({
comment: z.string({ error: 'Comment is required' }).min(1, 'Comment is required'),
})
export const confluenceListCommentsQuerySchema = confluencePageScopedSchema.extend({
limit: z.string().optional().default('25'),
bodyFormat: z.string().optional().default('storage'),
cursor: z.string().optional(),
})
export const confluenceUpdateCommentBodySchema = confluenceCommentScopedBaseSchema
.extend({
comment: z.string().min(1, 'Comment is required'),
})
.superRefine((data, ctx) => addAlphanumericIdIssue(data, 'commentId', 'comment ID', ctx))
export const confluenceCreatePageBodySchema = confluenceSpaceScopedSchema.extend({
title: z.string({ error: 'Title is required' }).min(1, 'Title is required'),
content: z.string({ error: 'Content is required' }).min(1, 'Content is required'),
parentId: z.string().optional(),
})
export const confluenceLabelMutationBodySchema = confluencePageScopedSchema.extend({
labelName: z.string({ error: 'Label name is required' }).min(1, 'Label name is required'),
prefix: z.string().optional(),
})
export const confluenceListLabelsQuerySchema = confluencePageScopedSchema.extend({
limit: z.string().optional().default('25'),
cursor: z.string().optional(),
})
export const confluenceListPagePropertiesQuerySchema = confluencePageScopedSchema.extend({
limit: z.string().optional().default('50'),
cursor: z.string().optional(),
})
export const confluenceCreatePagePropertyBodySchema = confluencePageScopedSchema.extend({
key: z.string({ error: 'Property key is required' }).min(1, 'Property key is required'),
value: z.unknown(),
})
export const confluenceUpdatePagePropertyBodySchema = confluenceCreatePagePropertyBodySchema.extend(
{
propertyId: z.string({ error: 'Property ID is required' }).min(1, 'Property ID is required'),
versionNumber: z.number().min(1).optional(),
}
)
export const confluenceDeletePagePropertyBodySchema = confluencePageScopedSchema.extend({
propertyId: z.string({ error: 'Property ID is required' }).min(1, 'Property ID is required'),
})
export const confluenceGetSpaceQuerySchema = confluenceSpaceScopedSchema
export const confluenceCreateSpaceBodySchema = confluenceBaseSchema.extend({
name: z.string({ error: 'Space name is required' }).min(1, 'Space name is required'),
key: z.string({ error: 'Space key is required' }).min(1, 'Space key is required'),
description: z.string().optional(),
})
export const confluenceUpdateSpaceBodySchema = confluenceSpaceScopedSchema.extend({
name: z.string().optional(),
description: z.string().optional(),
})
export const confluencePageChildrenBodySchema = confluencePageScopedSchema.extend({
limit: z.number().optional().default(50),
cursor: z.string().optional(),
})
export const confluencePageAncestorsBodySchema = confluencePageScopedSchema.extend({
limit: z.number().optional().default(25),
})
export const confluencePageVersionsBodySchema = confluencePageScopedSchema.extend({
versionNumber: z.union([z.string(), z.number()]).optional(),
limit: z.number().optional().default(50),
cursor: z.string().optional(),
})
export const confluencePagesByLabelQuerySchema = confluenceBaseSchema.extend({
labelId: z.string({ error: 'Label ID is required' }).min(1, 'Label ID is required'),
limit: z.string().optional().default('50'),
cursor: z.string().optional(),
})
export const confluenceSearchBodySchema = confluenceBaseSchema.extend({
query: z.string({ error: 'Search query is required' }).min(1, 'Search query is required'),
limit: z.number().optional().default(25),
})
export const confluenceSearchInSpaceBodySchema = confluenceBaseSchema.extend({
spaceKey: z.string({ error: 'Space key is required' }).min(1, 'Space key is required'),
query: z.string().optional(),
limit: z.number().optional().default(25),
contentType: z.string().optional(),
})
export const confluenceSpaceBlogPostsBodySchema = confluenceSpaceScopedSchema.extend({
limit: z.number().optional().default(25),
status: z.string().optional(),
bodyFormat: z.string().optional(),
cursor: z.string().optional(),
})
export const confluenceSpaceLabelsQuerySchema = confluenceSpaceScopedSchema.extend({
limit: z.string().optional().default('25'),
cursor: z.string().optional(),
})
export const confluenceSpacePagesBodySchema = confluenceSpaceScopedSchema.extend({
limit: z.number().optional().default(50),
status: z.string().optional(),
bodyFormat: z.string().optional(),
cursor: z.string().optional(),
})
export const confluenceSpacePermissionsBodySchema = confluenceSpaceScopedSchema.extend({
limit: z.number().optional().default(50),
cursor: z.string().optional(),
})
export const confluenceSpacePropertiesBodySchema = confluenceSpaceScopedSchema.extend({
action: z.string().optional(),
key: z.string().optional(),
value: z.unknown().optional(),
propertyId: z.string().optional(),
limit: z.number().optional().default(50),
cursor: z.string().optional(),
})
export const confluenceListSpacesQuerySchema = confluenceBaseSchema.extend({
limit: z.string().optional().default('25'),
cursor: z.string().optional(),
})
export const confluenceTasksBodySchema = confluenceBaseSchema.extend({
action: z.string().optional(),
taskId: z.string().optional(),
status: z.string().optional(),
pageId: z.string().optional(),
spaceId: z.string().optional(),
assignedTo: z.string().optional(),
limit: z.number().optional().default(50),
cursor: z.string().optional(),
})
export const confluenceUploadAttachmentBodySchema = confluencePageScopedSchema.extend({
file: z.unknown().refine((value) => Boolean(value), { message: 'File is required' }),
fileName: z.string().optional(),
comment: z.string().optional(),
})
export const confluenceUserBodySchema = confluenceBaseSchema.extend({
accountId: z.string({ error: 'Account ID is required' }).min(1, 'Account ID is required'),
})
export const confluenceGetBlogPostBodySchema = confluenceBlogPostScopedBaseSchema
.extend({
bodyFormat: z.string().optional(),
})
.superRefine((data, ctx) => addAlphanumericIdIssue(data, 'blogPostId', 'blog post ID', ctx))
export const confluenceCreateBlogPostBodySchema = confluenceSpaceScopedSchema.extend({
title: z.string({ error: 'Title is required' }).min(1, 'Title is required'),
content: z.string({ error: 'Content is required' }).min(1, 'Content is required'),
status: z.enum(['current', 'draft']).optional(),
})
export const confluenceBlogPostOperationBodySchema = z.union([
confluenceCreateBlogPostBodySchema,
confluenceGetBlogPostBodySchema,
])
export const confluenceListBlogPostsQuerySchema = confluenceBaseSchema.extend({
limit: z.string().optional().default('25'),
status: z.string().optional(),
sort: z.string().optional(),
cursor: z.string().optional(),
})
export const confluenceUpdateBlogPostBodySchema = confluenceBlogPostScopedBaseSchema
.extend({
title: z.string().optional(),
content: z.string().optional(),
})
.superRefine((data, ctx) => addAlphanumericIdIssue(data, 'blogPostId', 'blog post ID', ctx))
const defineConfluencePostContract = <TBody extends z.ZodType>(path: string, body: TBody) =>
defineRouteContract({
method: 'POST',
path,
body,
response: {
mode: 'json',
// untyped-response: shared helper for ~16 confluence POST routes, each forwarding a different Atlassian Confluence v2 payload (pages, comments, labels, blogposts, page properties, search, etc.) whose shapes diverge per resource and are version-dependent
schema: z.unknown(),
},
})
const defineConfluencePutContract = <TBody extends z.ZodType>(path: string, body: TBody) =>
defineRouteContract({
method: 'PUT',
path,
body,
response: {
mode: 'json',
// untyped-response: shared helper for confluence PUT routes (page, comment, blogpost, space, page-properties) that proxy raw Atlassian Confluence v2 update responses whose shape varies per resource
schema: z.unknown(),
},
})
const defineConfluenceDeleteContract = <TBody extends z.ZodType>(path: string, body: TBody) =>
defineRouteContract({
method: 'DELETE',
path,
body,
response: {
mode: 'json',
// untyped-response: shared helper for confluence DELETE routes returning either a normalized deleted marker, an empty body, or a forwarded Atlassian Confluence v2 response depending on the resource
schema: z.unknown(),
},
})
const defineConfluenceGetContract = <TQuery extends z.ZodType>(path: string, query: TQuery) =>
defineRouteContract({
method: 'GET',
path,
query,
response: {
mode: 'json',
// untyped-response: shared helper for confluence GET listing routes (attachments, blogposts, comments, labels, page-properties, space, spaces, space-labels, pages-by-label) each returning a different paginated Atlassian Confluence v2 shape
schema: z.unknown(),
},
})
export const confluenceSpacesSelectorBodySchema = credentialWorkflowDomainBodySchema.extend({
cursor: optionalString,
})
export const confluenceSpacesSelectorContract = definePostSelector(
'/api/tools/confluence/selector-spaces',
confluenceSpacesSelectorBodySchema,
z.object({
spaces: z.array(confluenceSpaceSchema),
nextCursor: optionalString,
})
)
export const confluencePagesSelectorContract = definePostSelector(
'/api/tools/confluence/pages',
confluencePagesBodySchema,
z.object({ files: z.array(fileOptionSchema) })
)
export const confluencePageSelectorContract = definePostSelector(
'/api/tools/confluence/page',
confluencePageBodySchema,
z.object({ id: z.string(), title: z.string() }).passthrough()
)
export const confluenceUpdatePageContract = defineConfluencePutContract(
'/api/tools/confluence/page',
confluenceUpdatePageBodySchema
)
export const confluenceDeletePageContract = defineConfluenceDeleteContract(
'/api/tools/confluence/page',
confluenceDeletePageBodySchema
)
export const confluenceDeleteAttachmentContract = defineConfluenceDeleteContract(
'/api/tools/confluence/attachment',
confluenceDeleteAttachmentBodySchema
)
export const confluenceListAttachmentsContract = defineConfluenceGetContract(
'/api/tools/confluence/attachments',
confluenceListAttachmentsQuerySchema
)
export const confluenceListBlogPostsContract = defineConfluenceGetContract(
'/api/tools/confluence/blogposts',
confluenceListBlogPostsQuerySchema
)
export const confluenceBlogPostOperationContract = defineConfluencePostContract(
'/api/tools/confluence/blogposts',
confluenceBlogPostOperationBodySchema
)
export const confluenceUpdateBlogPostContract = defineConfluencePutContract(
'/api/tools/confluence/blogposts',
confluenceUpdateBlogPostBodySchema
)
export const confluenceDeleteBlogPostContract = defineConfluenceDeleteContract(
'/api/tools/confluence/blogposts',
confluenceBlogPostScopedSchema
)
export const confluenceCreateCommentContract = defineConfluencePostContract(
'/api/tools/confluence/comments',
confluenceCreateCommentBodySchema
)
export const confluenceListCommentsContract = defineConfluenceGetContract(
'/api/tools/confluence/comments',
confluenceListCommentsQuerySchema
)
export const confluenceUpdateCommentContract = defineConfluencePutContract(
'/api/tools/confluence/comment',
confluenceUpdateCommentBodySchema
)
export const confluenceDeleteCommentContract = defineConfluenceDeleteContract(
'/api/tools/confluence/comment',
confluenceCommentScopedSchema
)
export const confluenceCreatePageContract = defineConfluencePostContract(
'/api/tools/confluence/create-page',
confluenceCreatePageBodySchema
)
export const confluenceLabelMutationContract = defineConfluencePostContract(
'/api/tools/confluence/labels',
confluenceLabelMutationBodySchema
)
export const confluenceListLabelsContract = defineConfluenceGetContract(
'/api/tools/confluence/labels',
confluenceListLabelsQuerySchema
)
export const confluenceDeleteLabelContract = defineConfluenceDeleteContract(
'/api/tools/confluence/labels',
confluenceLabelMutationBodySchema
)
export const confluenceListPagePropertiesContract = defineConfluenceGetContract(
'/api/tools/confluence/page-properties',
confluenceListPagePropertiesQuerySchema
)
export const confluenceCreatePagePropertyContract = defineConfluencePostContract(
'/api/tools/confluence/page-properties',
confluenceCreatePagePropertyBodySchema
)
export const confluenceUpdatePagePropertyContract = defineConfluencePutContract(
'/api/tools/confluence/page-properties',
confluenceUpdatePagePropertyBodySchema
)
export const confluenceDeletePagePropertyContract = defineConfluenceDeleteContract(
'/api/tools/confluence/page-properties',
confluenceDeletePagePropertyBodySchema
)
export const confluenceGetSpaceContract = defineConfluenceGetContract(
'/api/tools/confluence/space',
confluenceGetSpaceQuerySchema
)
export const confluenceCreateSpaceContract = defineConfluencePostContract(
'/api/tools/confluence/space',
confluenceCreateSpaceBodySchema
)
export const confluenceUpdateSpaceContract = defineConfluencePutContract(
'/api/tools/confluence/space',
confluenceUpdateSpaceBodySchema
)
export const confluenceDeleteSpaceContract = defineConfluenceDeleteContract(
'/api/tools/confluence/space',
confluenceSpaceScopedSchema
)
export const confluencePageChildrenContract = defineConfluencePostContract(
'/api/tools/confluence/page-children',
confluencePageChildrenBodySchema
)
export const confluencePageDescendantsContract = defineConfluencePostContract(
'/api/tools/confluence/page-descendants',
confluencePageChildrenBodySchema
)
export const confluencePageAncestorsContract = defineConfluencePostContract(
'/api/tools/confluence/page-ancestors',
confluencePageAncestorsBodySchema
)
export const confluencePageVersionsContract = defineConfluencePostContract(
'/api/tools/confluence/page-versions',
confluencePageVersionsBodySchema
)
export const confluencePagesByLabelContract = defineConfluenceGetContract(
'/api/tools/confluence/pages-by-label',
confluencePagesByLabelQuerySchema
)
export const confluenceSearchContract = defineConfluencePostContract(
'/api/tools/confluence/search',
confluenceSearchBodySchema
)
export const confluenceSearchInSpaceContract = defineConfluencePostContract(
'/api/tools/confluence/search-in-space',
confluenceSearchInSpaceBodySchema
)
export const confluenceSpaceBlogPostsContract = defineConfluencePostContract(
'/api/tools/confluence/space-blogposts',
confluenceSpaceBlogPostsBodySchema
)
export const confluenceSpaceLabelsContract = defineConfluenceGetContract(
'/api/tools/confluence/space-labels',
confluenceSpaceLabelsQuerySchema
)
export const confluenceSpacePagesContract = defineConfluencePostContract(
'/api/tools/confluence/space-pages',
confluenceSpacePagesBodySchema
)
export const confluenceSpacePermissionsContract = defineConfluencePostContract(
'/api/tools/confluence/space-permissions',
confluenceSpacePermissionsBodySchema
)
export const confluenceSpacePropertiesContract = defineConfluencePostContract(
'/api/tools/confluence/space-properties',
confluenceSpacePropertiesBodySchema
)
export const confluenceListSpacesContract = defineConfluenceGetContract(
'/api/tools/confluence/spaces',
confluenceListSpacesQuerySchema
)
export const confluenceTasksContract = defineConfluencePostContract(
'/api/tools/confluence/tasks',
confluenceTasksBodySchema
)
export const confluenceUploadAttachmentContract = defineConfluencePostContract(
'/api/tools/confluence/upload-attachment',
confluenceUploadAttachmentBodySchema
)
export const confluenceUserContract = defineConfluencePostContract(
'/api/tools/confluence/user',
confluenceUserBodySchema
)
export type ConfluencePagesBody = ContractBody<typeof confluencePagesSelectorContract>
export type ConfluencePageBody = ContractBody<typeof confluencePageSelectorContract>
export type ConfluenceUpdatePageBody = ContractBody<typeof confluenceUpdatePageContract>
export type ConfluenceDeletePageBody = ContractBody<typeof confluenceDeletePageContract>
export type ConfluenceDeleteAttachmentBody = ContractBody<typeof confluenceDeleteAttachmentContract>
export type ConfluenceListAttachmentsQuery = ContractQuery<typeof confluenceListAttachmentsContract>
export type ConfluenceListBlogPostsQuery = ContractQuery<typeof confluenceListBlogPostsContract>
export type ConfluenceBlogPostOperationBody = ContractBody<
typeof confluenceBlogPostOperationContract
>
export type ConfluenceUpdateBlogPostBody = ContractBody<typeof confluenceUpdateBlogPostContract>
export type ConfluenceDeleteBlogPostBody = ContractBody<typeof confluenceDeleteBlogPostContract>
export type ConfluenceCreateCommentBody = ContractBody<typeof confluenceCreateCommentContract>
export type ConfluenceListCommentsQuery = ContractQuery<typeof confluenceListCommentsContract>
export type ConfluenceUpdateCommentBody = ContractBody<typeof confluenceUpdateCommentContract>
export type ConfluenceDeleteCommentBody = ContractBody<typeof confluenceDeleteCommentContract>
export type ConfluenceCreatePageBody = ContractBody<typeof confluenceCreatePageContract>
export type ConfluenceLabelMutationBody = ContractBody<typeof confluenceLabelMutationContract>
export type ConfluenceListLabelsQuery = ContractQuery<typeof confluenceListLabelsContract>
export type ConfluenceDeleteLabelBody = ContractBody<typeof confluenceDeleteLabelContract>
export type ConfluenceListPagePropertiesQuery = ContractQuery<
typeof confluenceListPagePropertiesContract
>
export type ConfluenceCreatePagePropertyBody = ContractBody<
typeof confluenceCreatePagePropertyContract
>
export type ConfluenceUpdatePagePropertyBody = ContractBody<
typeof confluenceUpdatePagePropertyContract
>
export type ConfluenceDeletePagePropertyBody = ContractBody<
typeof confluenceDeletePagePropertyContract
>
export type ConfluenceGetSpaceQuery = ContractQuery<typeof confluenceGetSpaceContract>
export type ConfluenceCreateSpaceBody = ContractBody<typeof confluenceCreateSpaceContract>
export type ConfluenceUpdateSpaceBody = ContractBody<typeof confluenceUpdateSpaceContract>
export type ConfluenceDeleteSpaceBody = ContractBody<typeof confluenceDeleteSpaceContract>
export type ConfluencePageChildrenBody = ContractBody<typeof confluencePageChildrenContract>
export type ConfluencePageDescendantsBody = ContractBody<typeof confluencePageDescendantsContract>
export type ConfluencePageAncestorsBody = ContractBody<typeof confluencePageAncestorsContract>
export type ConfluencePageVersionsBody = ContractBody<typeof confluencePageVersionsContract>
export type ConfluencePagesByLabelQuery = ContractQuery<typeof confluencePagesByLabelContract>
export type ConfluenceSearchBody = ContractBody<typeof confluenceSearchContract>
export type ConfluenceSearchInSpaceBody = ContractBody<typeof confluenceSearchInSpaceContract>
export type ConfluenceSpaceBlogPostsBody = ContractBody<typeof confluenceSpaceBlogPostsContract>
export type ConfluenceSpaceLabelsQuery = ContractQuery<typeof confluenceSpaceLabelsContract>
export type ConfluenceSpacePagesBody = ContractBody<typeof confluenceSpacePagesContract>
export type ConfluenceSpacePermissionsBody = ContractBody<typeof confluenceSpacePermissionsContract>
export type ConfluenceSpacePropertiesBody = ContractBody<typeof confluenceSpacePropertiesContract>
export type ConfluenceListSpacesQuery = ContractQuery<typeof confluenceListSpacesContract>
export type ConfluenceTasksBody = ContractBody<typeof confluenceTasksContract>
export type ConfluenceUploadAttachmentBody = ContractBody<typeof confluenceUploadAttachmentContract>
export type ConfluenceUserBody = ContractBody<typeof confluenceUserContract>
export type ConfluenceSpacesSelectorResponse = ContractJsonResponse<
typeof confluenceSpacesSelectorContract
>
export type ConfluencePagesSelectorResponse = ContractJsonResponse<
typeof confluencePagesSelectorContract
>
export type ConfluencePageSelectorResponse = ContractJsonResponse<
typeof confluencePageSelectorContract
>
@@ -0,0 +1,136 @@
import { z } from 'zod'
import {
credentialIdQuerySchema,
credentialWorkflowImpersonateBodySchema,
defineGetSelector,
definePostSelector,
fileOptionSchema,
folderOptionSchema,
idNameSchema,
idTitleSchema,
optionalString,
} from '@/lib/api/contracts/selectors/shared'
import type {
ContractBodyInput,
ContractJsonResponse,
ContractQueryInput,
} from '@/lib/api/contracts/types'
const googleCalendarSchema = z.object({ id: z.string(), summary: z.string() }).passthrough()
const gmailLabelSchema = z
.object({
id: z.string(),
name: z.string(),
type: z.string().optional(),
messagesTotal: z.number().optional(),
messagesUnread: z.number().optional(),
})
.passthrough()
export const labelsQuerySchema = credentialIdQuerySchema.extend({
query: optionalString,
impersonateEmail: optionalString,
})
export const gmailLabelQuerySchema = credentialIdQuerySchema.extend({
labelId: z.string().min(1),
impersonateEmail: optionalString,
})
export const googleCalendarQuerySchema = credentialIdQuerySchema.extend({
workflowId: optionalString,
impersonateEmail: optionalString,
})
export const googleDriveFilesQuerySchema = credentialIdQuerySchema.extend({
mimeType: optionalString,
folderId: optionalString,
parentId: optionalString,
query: optionalString,
workflowId: optionalString,
impersonateEmail: optionalString,
})
export const googleDriveFileQuerySchema = credentialIdQuerySchema.extend({
fileId: z.string().min(1, 'File ID is required'),
workflowId: optionalString,
impersonateEmail: optionalString,
})
export const googleSheetsQuerySchema = credentialIdQuerySchema.extend({
spreadsheetId: z.string().min(1, 'Spreadsheet ID is required'),
workflowId: optionalString,
impersonateEmail: optionalString,
})
export const gmailLabelsSelectorContract = defineGetSelector(
'/api/tools/gmail/labels',
labelsQuerySchema,
z.object({ labels: z.array(folderOptionSchema) })
)
export const gmailLabelSelectorContract = defineGetSelector(
'/api/tools/gmail/label',
gmailLabelQuerySchema,
z.object({ label: gmailLabelSchema })
)
export const googleCalendarSelectorContract = defineGetSelector(
'/api/tools/google_calendar/calendars',
googleCalendarQuerySchema,
z.object({ calendars: z.array(googleCalendarSchema) })
)
export const googleTasksTaskListsSelectorContract = definePostSelector(
'/api/tools/google_tasks/task-lists',
credentialWorkflowImpersonateBodySchema,
z.object({ taskLists: z.array(idTitleSchema) })
)
export const googleDriveFilesSelectorContract = defineGetSelector(
'/api/tools/drive/files',
googleDriveFilesQuerySchema,
z.object({ files: z.array(fileOptionSchema) })
)
export const googleDriveFileSelectorContract = defineGetSelector(
'/api/tools/drive/file',
googleDriveFileQuerySchema,
z.object({ file: fileOptionSchema.optional() }).passthrough()
)
export const googleSheetsSelectorContract = defineGetSelector(
'/api/tools/google_sheets/sheets',
googleSheetsQuerySchema,
z.object({ sheets: z.array(idNameSchema) })
)
export type GmailLabelsSelectorQuery = ContractQueryInput<typeof gmailLabelsSelectorContract>
export type GmailLabelSelectorQuery = ContractQueryInput<typeof gmailLabelSelectorContract>
export type GoogleCalendarSelectorQuery = ContractQueryInput<typeof googleCalendarSelectorContract>
export type GoogleTasksTaskListsSelectorBody = ContractBodyInput<
typeof googleTasksTaskListsSelectorContract
>
export type GoogleDriveFilesSelectorQuery = ContractQueryInput<
typeof googleDriveFilesSelectorContract
>
export type GoogleDriveFileSelectorQuery = ContractQueryInput<
typeof googleDriveFileSelectorContract
>
export type GoogleSheetsSelectorQuery = ContractQueryInput<typeof googleSheetsSelectorContract>
export type GmailLabelsSelectorResponse = ContractJsonResponse<typeof gmailLabelsSelectorContract>
export type GmailLabelSelectorResponse = ContractJsonResponse<typeof gmailLabelSelectorContract>
export type GoogleCalendarSelectorResponse = ContractJsonResponse<
typeof googleCalendarSelectorContract
>
export type GoogleTasksTaskListsSelectorResponse = ContractJsonResponse<
typeof googleTasksTaskListsSelectorContract
>
export type GoogleDriveFilesSelectorResponse = ContractJsonResponse<
typeof googleDriveFilesSelectorContract
>
export type GoogleDriveFileSelectorResponse = ContractJsonResponse<
typeof googleDriveFileSelectorContract
>
export type GoogleSheetsSelectorResponse = ContractJsonResponse<typeof googleSheetsSelectorContract>
@@ -0,0 +1,110 @@
import { z } from 'zod'
import {
credentialIdQuerySchema,
defineGetSelector,
optionalString,
} from '@/lib/api/contracts/selectors/shared'
import type { ContractJsonResponse, ContractQueryInput } from '@/lib/api/contracts/types'
const hubspotPropertySchema = z
.object({
id: z.string(),
name: z.string(),
type: z.string().optional(),
fieldType: z.string().optional(),
groupName: z.string().optional(),
})
.passthrough()
const hubspotListSchema = z
.object({
id: z.string(),
name: z.string(),
objectType: z.string().optional(),
processingType: z.string().optional(),
})
.passthrough()
const hubspotPipelineSchema = z
.object({
id: z.string(),
name: z.string(),
stages: z.array(z.object({ id: z.string(), label: z.string() }).passthrough()).optional(),
})
.passthrough()
const hubspotOwnerSchema = z
.object({
id: z.string(),
name: z.string(),
email: z.string().optional(),
})
.passthrough()
const hubspotPropertiesQuerySchema = credentialIdQuerySchema.extend({
objectType: z
.string()
.min(1, 'objectType is required')
.describe('Built-in slug or custom object type id'),
query: optionalString,
})
const hubspotListsQuerySchema = credentialIdQuerySchema.extend({
objectTypeId: optionalString.describe('Limit to lists targeting this object type'),
query: optionalString,
})
const hubspotPipelinesQuerySchema = credentialIdQuerySchema.extend({
objectType: z
.string()
.min(1, 'objectType is required')
.describe("Object type for which to fetch pipelines (e.g., 'deal' or 'ticket')"),
})
const hubspotOwnersQuerySchema = credentialIdQuerySchema.extend({
query: optionalString,
})
export const hubspotPropertiesSelectorContract = defineGetSelector(
'/api/tools/hubspot/properties',
hubspotPropertiesQuerySchema,
z.object({ properties: z.array(hubspotPropertySchema) })
)
export const hubspotListsSelectorContract = defineGetSelector(
'/api/tools/hubspot/lists',
hubspotListsQuerySchema,
z.object({ lists: z.array(hubspotListSchema) })
)
export const hubspotPipelinesSelectorContract = defineGetSelector(
'/api/tools/hubspot/pipelines',
hubspotPipelinesQuerySchema,
z.object({ pipelines: z.array(hubspotPipelineSchema) })
)
export const hubspotOwnersSelectorContract = defineGetSelector(
'/api/tools/hubspot/owners',
hubspotOwnersQuerySchema,
z.object({ owners: z.array(hubspotOwnerSchema) })
)
export type HubspotPropertiesSelectorQuery = ContractQueryInput<
typeof hubspotPropertiesSelectorContract
>
export type HubspotListsSelectorQuery = ContractQueryInput<typeof hubspotListsSelectorContract>
export type HubspotPipelinesSelectorQuery = ContractQueryInput<
typeof hubspotPipelinesSelectorContract
>
export type HubspotOwnersSelectorQuery = ContractQueryInput<typeof hubspotOwnersSelectorContract>
export type HubspotPropertiesSelectorResponse = ContractJsonResponse<
typeof hubspotPropertiesSelectorContract
>
export type HubspotListsSelectorResponse = ContractJsonResponse<typeof hubspotListsSelectorContract>
export type HubspotPipelinesSelectorResponse = ContractJsonResponse<
typeof hubspotPipelinesSelectorContract
>
export type HubspotOwnersSelectorResponse = ContractJsonResponse<
typeof hubspotOwnersSelectorContract
>
@@ -0,0 +1,200 @@
import {
airtableBasesSelectorContract,
airtableTablesSelectorContract,
} from '@/lib/api/contracts/selectors/airtable'
import { asanaWorkspacesSelectorContract } from '@/lib/api/contracts/selectors/asana'
import {
attioListsSelectorContract,
attioObjectsSelectorContract,
} from '@/lib/api/contracts/selectors/attio'
import {
bigQueryDatasetsSelectorContract,
bigQueryTablesSelectorContract,
} from '@/lib/api/contracts/selectors/bigquery'
import {
calcomEventTypesSelectorContract,
calcomSchedulesSelectorContract,
} from '@/lib/api/contracts/selectors/calcom'
import {
cloudwatchLogGroupsSelectorContract,
cloudwatchLogStreamsSelectorContract,
} from '@/lib/api/contracts/selectors/cloudwatch'
import {
confluencePageSelectorContract,
confluencePagesSelectorContract,
confluenceSpacesSelectorContract,
} from '@/lib/api/contracts/selectors/confluence'
import {
gmailLabelSelectorContract,
gmailLabelsSelectorContract,
googleCalendarSelectorContract,
googleDriveFileSelectorContract,
googleDriveFilesSelectorContract,
googleSheetsSelectorContract,
googleTasksTaskListsSelectorContract,
} from '@/lib/api/contracts/selectors/google'
import {
hubspotListsSelectorContract,
hubspotOwnersSelectorContract,
hubspotPipelinesSelectorContract,
hubspotPropertiesSelectorContract,
} from '@/lib/api/contracts/selectors/hubspot'
import {
jiraIssueSelectorContract,
jiraIssuesSelectorContract,
jiraProjectSelectorContract,
jiraProjectsSelectorContract,
} from '@/lib/api/contracts/selectors/jira'
import {
jsmRequestTypesSelectorContract,
jsmServiceDesksSelectorContract,
} from '@/lib/api/contracts/selectors/jsm'
import {
linearProjectsSelectorContract,
linearTeamsSelectorContract,
} from '@/lib/api/contracts/selectors/linear'
import {
microsoftChannelsSelectorContract,
microsoftChatsSelectorContract,
microsoftExcelDriveSelectorContract,
microsoftExcelDrivesSelectorContract,
microsoftExcelSheetsSelectorContract,
microsoftFileSelectorContract,
microsoftFilesSelectorContract,
microsoftPlannerPlansSelectorContract,
microsoftPlannerTasksSelectorContract,
microsoftTeamsSelectorContract,
onedriveFilesSelectorContract,
onedriveFolderSelectorContract,
onedriveFoldersSelectorContract,
outlookFoldersSelectorContract,
} from '@/lib/api/contracts/selectors/microsoft'
import {
mondayBoardsSelectorContract,
mondayGroupsSelectorContract,
} from '@/lib/api/contracts/selectors/monday'
import {
notionDatabasesSelectorContract,
notionPagesSelectorContract,
} from '@/lib/api/contracts/selectors/notion'
import { pipedrivePipelinesSelectorContract } from '@/lib/api/contracts/selectors/pipedrive'
import {
sharepointListsSelectorContract,
sharepointSiteSelectorContract,
sharepointSitesSelectorContract,
} from '@/lib/api/contracts/selectors/sharepoint'
import {
slackChannelsSelectorContract,
slackUserSelectorContract,
slackUsersSelectorContract,
} from '@/lib/api/contracts/selectors/slack'
import { trelloBoardsSelectorContract } from '@/lib/api/contracts/selectors/trello'
import {
wealthboxItemContract,
wealthboxItemsSelectorContract,
wealthboxOAuthItemContract,
wealthboxOAuthItemsContract,
} from '@/lib/api/contracts/selectors/wealthbox'
import {
webflowCollectionsSelectorContract,
webflowItemsSelectorContract,
webflowSitesSelectorContract,
} from '@/lib/api/contracts/selectors/webflow'
import { zoomMeetingsSelectorContract } from '@/lib/api/contracts/selectors/zoom'
export * from '@/lib/api/contracts/selectors/airtable'
export * from '@/lib/api/contracts/selectors/asana'
export * from '@/lib/api/contracts/selectors/attio'
export * from '@/lib/api/contracts/selectors/bigquery'
export * from '@/lib/api/contracts/selectors/calcom'
export * from '@/lib/api/contracts/selectors/cloudwatch'
export * from '@/lib/api/contracts/selectors/confluence'
export * from '@/lib/api/contracts/selectors/google'
export * from '@/lib/api/contracts/selectors/hubspot'
export * from '@/lib/api/contracts/selectors/jira'
export * from '@/lib/api/contracts/selectors/jsm'
export * from '@/lib/api/contracts/selectors/knowledge'
export * from '@/lib/api/contracts/selectors/linear'
export * from '@/lib/api/contracts/selectors/microsoft'
export * from '@/lib/api/contracts/selectors/monday'
export * from '@/lib/api/contracts/selectors/notion'
export * from '@/lib/api/contracts/selectors/oauth'
export * from '@/lib/api/contracts/selectors/pipedrive'
export * from '@/lib/api/contracts/selectors/sharepoint'
export * from '@/lib/api/contracts/selectors/slack'
export * from '@/lib/api/contracts/selectors/trello'
export * from '@/lib/api/contracts/selectors/wealthbox'
export * from '@/lib/api/contracts/selectors/webflow'
export * from '@/lib/api/contracts/selectors/zoom'
export const selectorContractsByPath = {
'/api/tools/airtable/bases': airtableBasesSelectorContract,
'/api/tools/airtable/tables': airtableTablesSelectorContract,
'/api/tools/asana/workspaces': asanaWorkspacesSelectorContract,
'/api/tools/attio/objects': attioObjectsSelectorContract,
'/api/tools/attio/lists': attioListsSelectorContract,
'/api/tools/google_bigquery/datasets': bigQueryDatasetsSelectorContract,
'/api/tools/google_bigquery/tables': bigQueryTablesSelectorContract,
'/api/tools/calcom/event-types': calcomEventTypesSelectorContract,
'/api/tools/calcom/schedules': calcomSchedulesSelectorContract,
'/api/tools/confluence/selector-spaces': confluenceSpacesSelectorContract,
'/api/tools/jsm/selector-servicedesks': jsmServiceDesksSelectorContract,
'/api/tools/jsm/selector-requesttypes': jsmRequestTypesSelectorContract,
'/api/tools/google_tasks/task-lists': googleTasksTaskListsSelectorContract,
'/api/tools/microsoft_planner/plans': microsoftPlannerPlansSelectorContract,
'/api/tools/microsoft_planner/tasks': microsoftPlannerTasksSelectorContract,
'/api/tools/notion/databases': notionDatabasesSelectorContract,
'/api/tools/notion/pages': notionPagesSelectorContract,
'/api/tools/pipedrive/pipelines': pipedrivePipelinesSelectorContract,
'/api/tools/sharepoint/lists': sharepointListsSelectorContract,
'/api/tools/sharepoint/site': sharepointSiteSelectorContract,
'/api/tools/sharepoint/sites': sharepointSitesSelectorContract,
'/api/tools/trello/boards': trelloBoardsSelectorContract,
'/api/tools/zoom/meetings': zoomMeetingsSelectorContract,
'/api/tools/slack/channels': slackChannelsSelectorContract,
'/api/tools/slack/users': slackUsersSelectorContract,
'/api/tools/slack/users:detail': slackUserSelectorContract,
'/api/tools/gmail/labels': gmailLabelsSelectorContract,
'/api/tools/gmail/label': gmailLabelSelectorContract,
'/api/tools/hubspot/properties': hubspotPropertiesSelectorContract,
'/api/tools/hubspot/lists': hubspotListsSelectorContract,
'/api/tools/hubspot/pipelines': hubspotPipelinesSelectorContract,
'/api/tools/hubspot/owners': hubspotOwnersSelectorContract,
'/api/tools/outlook/folders': outlookFoldersSelectorContract,
'/api/tools/google_calendar/calendars': googleCalendarSelectorContract,
'/api/tools/microsoft-teams/teams': microsoftTeamsSelectorContract,
'/api/tools/microsoft-teams/chats': microsoftChatsSelectorContract,
'/api/tools/microsoft-teams/channels': microsoftChannelsSelectorContract,
'/api/tools/wealthbox/items': wealthboxItemsSelectorContract,
'/api/tools/wealthbox/item': wealthboxItemContract,
'/api/auth/oauth/wealthbox/items': wealthboxOAuthItemsContract,
'/api/auth/oauth/wealthbox/item': wealthboxOAuthItemContract,
'/api/tools/jira/projects': jiraProjectsSelectorContract,
'/api/tools/jira/projects:POST': jiraProjectSelectorContract,
'/api/tools/jira/issues': jiraIssuesSelectorContract,
'/api/tools/jira/issues:POST': jiraIssueSelectorContract,
'/api/tools/monday/boards': mondayBoardsSelectorContract,
'/api/tools/monday/groups': mondayGroupsSelectorContract,
'/api/tools/linear/teams': linearTeamsSelectorContract,
'/api/tools/linear/projects': linearProjectsSelectorContract,
'/api/tools/confluence/pages': confluencePagesSelectorContract,
'/api/tools/confluence/page': confluencePageSelectorContract,
'/api/tools/onedrive/files': onedriveFilesSelectorContract,
'/api/tools/onedrive/folder': onedriveFolderSelectorContract,
'/api/tools/onedrive/folders': onedriveFoldersSelectorContract,
'/api/tools/drive/files': googleDriveFilesSelectorContract,
'/api/tools/drive/file': googleDriveFileSelectorContract,
'/api/tools/google_sheets/sheets': googleSheetsSelectorContract,
'/api/tools/microsoft_excel/sheets': microsoftExcelSheetsSelectorContract,
'/api/tools/microsoft_excel/drives': microsoftExcelDrivesSelectorContract,
'/api/tools/microsoft_excel/drives:detail': microsoftExcelDriveSelectorContract,
'/api/auth/oauth/microsoft/file': microsoftFileSelectorContract,
'/api/auth/oauth/microsoft/files': microsoftFilesSelectorContract,
'/api/tools/webflow/sites': webflowSitesSelectorContract,
'/api/tools/webflow/collections': webflowCollectionsSelectorContract,
'/api/tools/webflow/items': webflowItemsSelectorContract,
'/api/tools/cloudwatch/describe-log-groups': cloudwatchLogGroupsSelectorContract,
'/api/tools/cloudwatch/describe-log-streams': cloudwatchLogStreamsSelectorContract,
} as const
export type SelectorContractPath = keyof typeof selectorContractsByPath
@@ -0,0 +1,258 @@
import { z } from 'zod'
import { idNameSchema, optionalString } from '@/lib/api/contracts/selectors/shared'
import type { ContractBody, ContractJsonResponse, ContractQuery } from '@/lib/api/contracts/types'
import { defineRouteContract } from '@/lib/api/contracts/types'
import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas'
const jiraIssueSectionSchema = z
.object({
issues: z.array(
z
.object({
id: z.string().optional(),
key: z.string().optional(),
summary: z.string().optional(),
})
.passthrough()
),
})
.passthrough()
export const jiraProjectsQuerySchema = z.object({
domain: z.string().trim().min(1, 'Domain is required'),
accessToken: z.string().min(1, 'Access token is required'),
cloudId: optionalString,
query: optionalString,
})
export const jiraProjectBodySchema = z.object({
domain: z.string().min(1, 'Domain is required'),
accessToken: z.string().min(1, 'Access token is required'),
cloudId: optionalString,
projectId: z.string().min(1, 'Project ID is required'),
})
/**
* GET `/api/tools/jira/issues` query.
*/
export const jiraIssuesQuerySchema = z.object({
domain: z.string().trim().min(1, 'Domain is required'),
accessToken: z.string().min(1, 'Access token is required'),
cloudId: optionalString,
projectId: optionalString,
manualProjectId: optionalString,
query: optionalString,
all: z
.preprocess(
(value) => (typeof value === 'string' ? value.toLowerCase() === 'true' : value),
z.boolean()
)
.default(false),
limit: z
.preprocess((value) => {
const parsed = typeof value === 'string' ? Number.parseInt(value, 10) : value
return typeof parsed === 'number' && Number.isFinite(parsed) && parsed > 0 ? parsed : 0
}, z.number())
.default(0),
})
export const jiraIssuesBodySchema = z.object({
domain: z.string().min(1, 'Domain is required'),
accessToken: z.string().min(1, 'Access token is required'),
cloudId: optionalString,
issueKeys: z.array(z.string().min(1)).default([]),
})
export const jiraParentReferenceSchema = z.union([
z.string().min(1),
z.object({ key: z.string().min(1) }).passthrough(),
z.object({ id: z.string().min(1) }).passthrough(),
])
export type JiraParentReference = z.input<typeof jiraParentReferenceSchema>
export const jiraWriteBodySchema = z.object({
domain: z.string({ error: 'Domain is required' }).min(1, 'Domain is required'),
accessToken: z.string({ error: 'Access token is required' }).min(1, 'Access token is required'),
projectId: z.string({ error: 'Project ID is required' }).min(1, 'Project ID is required'),
summary: z.string({ error: 'Summary is required' }).min(1, 'Summary is required'),
description: z.union([z.string(), z.record(z.string(), z.unknown())]).optional(),
priority: z.string().optional(),
assignee: z.string().optional(),
cloudId: z.string().optional(),
issueType: z.string().optional(),
parent: jiraParentReferenceSchema.optional(),
labels: z.array(z.string()).optional(),
duedate: z.string().optional(),
reporter: z.string().optional(),
environment: z.union([z.string(), z.record(z.string(), z.unknown())]).optional(),
customFieldId: z.string().optional(),
customFieldValue: z.string().optional(),
components: z.array(z.string()).optional(),
fixVersions: z.array(z.string()).optional(),
})
export const jiraUpdateBodySchema = z.object({
domain: z.string().min(1, 'Domain is required'),
accessToken: z.string().min(1, 'Access token is required'),
issueKey: z.string().min(1, 'Issue key is required'),
summary: z.string().optional(),
title: z.string().optional(),
description: z.union([z.string(), z.record(z.string(), z.unknown())]).optional(),
priority: z.string().optional(),
assignee: z.string().optional(),
labels: z.array(z.string()).optional(),
components: z.array(z.string()).optional(),
duedate: z.string().optional(),
fixVersions: z.array(z.string()).optional(),
environment: z.union([z.string(), z.record(z.string(), z.unknown())]).optional(),
customFieldId: z.string().optional(),
customFieldValue: z.string().optional(),
notifyUsers: z.boolean().optional(),
cloudId: z.string().optional(),
})
export const jiraAddAttachmentBodySchema = z.object({
accessToken: z.string().min(1, 'Access token is required'),
domain: z.string().min(1, 'Domain is required'),
issueKey: z.string().min(1, 'Issue key is required'),
files: RawFileInputArraySchema,
cloudId: z.string().optional().nullable(),
})
export const jiraProjectsSelectorContract = defineRouteContract({
method: 'GET',
path: '/api/tools/jira/projects',
query: jiraProjectsQuerySchema,
response: {
mode: 'json',
schema: z
.object({ projects: z.array(idNameSchema), cloudId: z.string().optional() })
.passthrough(),
},
})
export const jiraProjectSelectorContract = defineRouteContract({
method: 'POST',
path: '/api/tools/jira/projects',
body: jiraProjectBodySchema,
response: {
mode: 'json',
schema: z
.object({ project: idNameSchema.optional(), cloudId: z.string().optional() })
.passthrough(),
},
})
export const jiraIssuesSelectorContract = defineRouteContract({
method: 'GET',
path: '/api/tools/jira/issues',
query: jiraIssuesQuerySchema,
response: {
mode: 'json',
schema: z
.object({
sections: z.array(jiraIssueSectionSchema).optional(),
cloudId: z.string().optional(),
})
.passthrough(),
},
})
export const jiraIssueSelectorContract = defineRouteContract({
method: 'POST',
path: '/api/tools/jira/issues',
body: jiraIssuesBodySchema,
response: {
mode: 'json',
schema: z
.object({ issues: z.array(idNameSchema).optional(), cloudId: z.string().optional() })
.passthrough(),
},
})
const jiraWriteResponseSchema = z.object({
success: z.literal(true),
output: z.object({
ts: z.string(),
id: z.string(),
issueKey: z.string(),
self: z.string(),
summary: z.string(),
success: z.literal(true),
url: z.string(),
assigneeId: z.string().optional(),
}),
})
const jiraUpdateResponseSchema = z.object({
success: z.literal(true),
output: z.object({
ts: z.string(),
issueKey: z.string(),
summary: z.string(),
success: z.literal(true),
}),
})
const jiraAttachmentSchema = z.object({
id: z.string(),
filename: z.string(),
mimeType: z.string(),
size: z.number(),
content: z.string(),
})
const jiraAddAttachmentUserFileSchema = z
.object({
id: z.string().optional(),
name: z.string(),
url: z.string().optional(),
size: z.number(),
type: z.string().optional(),
key: z.string(),
})
.passthrough()
const jiraAddAttachmentResponseSchema = z.object({
success: z.literal(true),
output: z.object({
ts: z.string(),
issueKey: z.string(),
attachments: z.array(jiraAttachmentSchema),
attachmentIds: z.array(z.string()),
files: z.array(jiraAddAttachmentUserFileSchema),
}),
})
export const jiraWriteContract = defineRouteContract({
method: 'POST',
path: '/api/tools/jira/write',
body: jiraWriteBodySchema,
response: { mode: 'json', schema: jiraWriteResponseSchema },
})
export const jiraUpdateContract = defineRouteContract({
method: 'PUT',
path: '/api/tools/jira/update',
body: jiraUpdateBodySchema,
response: { mode: 'json', schema: jiraUpdateResponseSchema },
})
export const jiraAddAttachmentContract = defineRouteContract({
method: 'POST',
path: '/api/tools/jira/add-attachment',
body: jiraAddAttachmentBodySchema,
response: { mode: 'json', schema: jiraAddAttachmentResponseSchema },
})
export type JiraProjectsQuery = ContractQuery<typeof jiraProjectsSelectorContract>
export type JiraProjectBody = ContractBody<typeof jiraProjectSelectorContract>
export type JiraIssuesQuery = ContractQuery<typeof jiraIssuesSelectorContract>
export type JiraIssuesBody = ContractBody<typeof jiraIssueSelectorContract>
export type JiraWriteBody = ContractBody<typeof jiraWriteContract>
export type JiraUpdateBody = ContractBody<typeof jiraUpdateContract>
export type JiraAddAttachmentBody = ContractBody<typeof jiraAddAttachmentContract>
export type JiraProjectsSelectorResponse = ContractJsonResponse<typeof jiraProjectsSelectorContract>
export type JiraProjectSelectorResponse = ContractJsonResponse<typeof jiraProjectSelectorContract>
export type JiraIssuesSelectorResponse = ContractJsonResponse<typeof jiraIssuesSelectorContract>
export type JiraIssueSelectorResponse = ContractJsonResponse<typeof jiraIssueSelectorContract>
+462
View File
@@ -0,0 +1,462 @@
import { isRecordLike } from '@sim/utils/object'
import { z } from 'zod'
import {
credentialWorkflowDomainBodySchema,
definePostSelector,
idNameSchema,
} from '@/lib/api/contracts/selectors/shared'
import type { ContractBody, ContractJsonResponse } from '@/lib/api/contracts/types'
const jsmBaseBodySchema = z.object({
domain: z.string({ error: 'Domain is required' }).min(1, 'Domain is required'),
accessToken: z.string({ error: 'Access token is required' }).min(1, 'Access token is required'),
cloudId: z.string().optional(),
})
const jsmIssueIdOrKeyField = z
.string({ error: 'Issue ID or key is required' })
.min(1, 'Issue ID or key is required')
const jsmServiceDeskIdField = z
.string({ error: 'Service Desk ID is required' })
.min(1, 'Service Desk ID is required')
const jsmFormIdField = z.string({ error: 'Form ID is required' }).min(1, 'Form ID is required')
const jsmIdListSchema = z.union([z.string(), z.array(z.string())]).optional()
export const jsmRequestTypesBodySchema = credentialWorkflowDomainBodySchema.extend({
serviceDeskId: z.string().min(1),
})
export const jsmServiceDesksBodySchema = jsmBaseBodySchema.extend({
expand: z.string().optional(),
start: z.string().optional(),
limit: z.string().optional(),
})
export const jsmServiceDeskScopedBodySchema = jsmBaseBodySchema.extend({
serviceDeskId: jsmServiceDeskIdField,
start: z.string().optional(),
limit: z.string().optional(),
})
export const jsmQueuesBodySchema = jsmServiceDeskScopedBodySchema.extend({
includeCount: z.string().optional(),
})
export const jsmRequestTypesToolBodySchema = jsmServiceDeskScopedBodySchema.extend({
searchQuery: z.string().optional(),
groupId: z.string().optional(),
expand: z.string().optional(),
})
export const jsmRequestTypeFieldsBodySchema = jsmBaseBodySchema.extend({
serviceDeskId: jsmServiceDeskIdField,
requestTypeId: z
.string({ error: 'Request Type ID is required' })
.min(1, 'Request Type ID is required'),
})
export const jsmRequestsBodySchema = jsmBaseBodySchema.extend({
serviceDeskId: z.string().optional(),
requestOwnership: z.string().optional(),
requestStatus: z.string().optional(),
requestTypeId: z.string().optional(),
searchTerm: z.string().optional(),
expand: z.string().optional(),
start: z.string().optional(),
limit: z.string().optional(),
})
export const jsmRequestBodySchema = jsmBaseBodySchema.extend({
issueIdOrKey: z.string().optional(),
serviceDeskId: z.string().optional(),
requestTypeId: z.string().optional(),
summary: z.string().optional(),
description: z.string().optional(),
raiseOnBehalfOf: z.string().optional(),
requestFieldValues: z.record(z.string(), z.unknown()).optional(),
formAnswers: z.record(z.string(), z.unknown()).optional(),
requestParticipants: z.union([z.string(), z.array(z.string())]).optional(),
channel: z.string().optional(),
expand: z.string().optional(),
})
export const jsmCommentBodySchema = jsmBaseBodySchema.extend({
issueIdOrKey: jsmIssueIdOrKeyField,
body: z.string({ error: 'Comment body is required' }).min(1, 'Comment body is required'),
isPublic: z.boolean().optional(),
})
export const jsmCommentsBodySchema = jsmBaseBodySchema.extend({
issueIdOrKey: jsmIssueIdOrKeyField,
isPublic: z.boolean().optional(),
internal: z.boolean().optional(),
expand: z.string().optional(),
start: z.string().optional(),
limit: z.string().optional(),
})
export const jsmTransitionBodySchema = jsmBaseBodySchema.extend({
issueIdOrKey: jsmIssueIdOrKeyField,
transitionId: z
.string({ error: 'Transition ID is required' })
.min(1, 'Transition ID is required'),
comment: z.string().optional(),
})
export const jsmIssuePaginationBodySchema = jsmBaseBodySchema.extend({
issueIdOrKey: jsmIssueIdOrKeyField,
start: z.string().optional(),
limit: z.string().optional(),
})
export const jsmApprovalsBodySchema = jsmBaseBodySchema.extend({
action: z.string({ error: 'Action is required' }).min(1, 'Action is required'),
issueIdOrKey: jsmIssueIdOrKeyField,
approvalId: z.string().optional(),
decision: z.string().optional(),
start: z.string().optional(),
limit: z.string().optional(),
})
export const jsmParticipantsBodySchema = jsmBaseBodySchema.extend({
action: z.string({ error: 'Action is required' }).min(1, 'Action is required'),
issueIdOrKey: jsmIssueIdOrKeyField,
accountIds: z.union([z.string(), z.array(z.string())]).optional(),
start: z.string().optional(),
limit: z.string().optional(),
})
export const jsmCustomersBodySchema = jsmBaseBodySchema.extend({
serviceDeskId: jsmServiceDeskIdField,
query: z.string().optional(),
start: z.string().optional(),
limit: z.string().optional(),
accountIds: jsmIdListSchema,
emails: jsmIdListSchema,
})
export const jsmOrganizationBodySchema = jsmBaseBodySchema.extend({
action: z.string({ error: 'Action is required' }).min(1, 'Action is required'),
name: z.string().optional(),
serviceDeskId: z.string().optional(),
organizationId: z.string().optional(),
})
export const jsmIssueFormsBodySchema = jsmBaseBodySchema.extend({
issueIdOrKey: jsmIssueIdOrKeyField,
})
export const jsmIssueFormBodySchema = jsmIssueFormsBodySchema.extend({
formId: jsmFormIdField,
})
export const jsmAttachFormBodySchema = jsmIssueFormsBodySchema.extend({
formTemplateId: z
.string({ error: 'Form template ID is required' })
.min(1, 'Form template ID is required'),
})
export const jsmSaveFormAnswersBodySchema = jsmIssueFormBodySchema.extend({
answers: z.custom<Record<string, unknown>>(isRecordLike, {
message: 'Answers object is required',
}),
})
export const jsmProjectFormTemplatesBodySchema = jsmBaseBodySchema.extend({
projectIdOrKey: z
.string({ error: 'Project ID or key is required' })
.min(1, 'Project ID or key is required'),
})
export const jsmProjectFormStructureBodySchema = jsmProjectFormTemplatesBodySchema.extend({
formId: jsmFormIdField,
})
export const jsmCopyFormsBodySchema = jsmBaseBodySchema.extend({
sourceIssueIdOrKey: z
.string({ error: 'Source issue ID or key is required' })
.min(1, 'Source issue ID or key is required'),
targetIssueIdOrKey: z
.string({ error: 'Target issue ID or key is required' })
.min(1, 'Target issue ID or key is required'),
formIds: z.array(z.string(), { error: 'formIds must be an array of form UUIDs' }).optional(),
})
const jsmAssetsBaseBodySchema = jsmBaseBodySchema.extend({
workspaceId: z.string().optional(),
})
const jsmAssetsPaginationField = z.union([z.string(), z.number()]).optional()
export const jsmListObjectSchemasBodySchema = jsmAssetsBaseBodySchema.extend({
startAt: jsmAssetsPaginationField,
maxResults: jsmAssetsPaginationField,
includeCounts: z.union([z.string(), z.boolean()]).optional(),
})
export const jsmObjectSchemaBodySchema = jsmAssetsBaseBodySchema.extend({
schemaId: z.string({ error: 'Schema ID is required' }).min(1, 'Schema ID is required'),
})
export const jsmObjectTypesBodySchema = jsmAssetsBaseBodySchema.extend({
schemaId: z.string({ error: 'Schema ID is required' }).min(1, 'Schema ID is required'),
excludeAbstract: z.union([z.string(), z.boolean()]).optional(),
})
export const jsmObjectTypeAttributesBodySchema = jsmAssetsBaseBodySchema.extend({
objectTypeId: z
.string({ error: 'Object type ID is required' })
.min(1, 'Object type ID is required'),
onlyValueEditable: z.union([z.string(), z.boolean()]).optional(),
query: z.string().optional(),
})
export const jsmSearchObjectsAqlBodySchema = jsmAssetsBaseBodySchema.extend({
qlQuery: z.string({ error: 'AQL query is required' }).min(1, 'AQL query is required'),
page: jsmAssetsPaginationField,
resultsPerPage: jsmAssetsPaginationField,
includeAttributes: z.union([z.string(), z.boolean()]).optional(),
objectTypeId: z.string().optional(),
objectSchemaId: z.string().optional(),
})
export const jsmGetObjectBodySchema = jsmAssetsBaseBodySchema.extend({
objectId: z.string({ error: 'Object ID is required' }).min(1, 'Object ID is required'),
})
const jsmAssetAttributeInputSchema = z.object({
objectTypeAttributeId: z
.string({ error: 'objectTypeAttributeId is required' })
.min(1, 'objectTypeAttributeId is required'),
objectAttributeValues: z
.array(z.object({ value: z.unknown() }), {
error: 'objectAttributeValues must be an array of { value } entries',
})
.min(1, 'Each attribute needs at least one value'),
})
export const jsmCreateObjectBodySchema = jsmAssetsBaseBodySchema.extend({
objectTypeId: z
.string({ error: 'Object type ID is required' })
.min(1, 'Object type ID is required'),
attributes: z
.array(jsmAssetAttributeInputSchema, { error: 'attributes is required' })
.min(1, 'At least one attribute is required'),
})
export const jsmUpdateObjectBodySchema = jsmAssetsBaseBodySchema.extend({
objectId: z.string({ error: 'Object ID is required' }).min(1, 'Object ID is required'),
objectTypeId: z.string().optional(),
attributes: z
.array(jsmAssetAttributeInputSchema, { error: 'attributes is required' })
.min(1, 'At least one attribute is required'),
})
export const jsmDeleteObjectBodySchema = jsmAssetsBaseBodySchema.extend({
objectId: z.string({ error: 'Object ID is required' }).min(1, 'Object ID is required'),
})
export const defineJsmToolContract = <TBody extends z.ZodType>(path: string, body: TBody) =>
definePostSelector(path, body, z.unknown())
export const jsmServiceDesksSelectorContract = definePostSelector(
'/api/tools/jsm/selector-servicedesks',
credentialWorkflowDomainBodySchema,
z.object({ serviceDesks: z.array(idNameSchema) })
)
export const jsmRequestTypesSelectorContract = definePostSelector(
'/api/tools/jsm/selector-requesttypes',
jsmRequestTypesBodySchema,
z.object({ requestTypes: z.array(idNameSchema) })
)
export const jsmServiceDesksContract = defineJsmToolContract(
'/api/tools/jsm/servicedesks',
jsmServiceDesksBodySchema
)
export const jsmQueuesContract = defineJsmToolContract('/api/tools/jsm/queues', jsmQueuesBodySchema)
export const jsmRequestTypesContract = defineJsmToolContract(
'/api/tools/jsm/requesttypes',
jsmRequestTypesToolBodySchema
)
export const jsmRequestTypeFieldsContract = defineJsmToolContract(
'/api/tools/jsm/requesttypefields',
jsmRequestTypeFieldsBodySchema
)
export const jsmRequestsContract = defineJsmToolContract(
'/api/tools/jsm/requests',
jsmRequestsBodySchema
)
export const jsmRequestContract = defineJsmToolContract(
'/api/tools/jsm/request',
jsmRequestBodySchema
)
export const jsmCommentContract = defineJsmToolContract(
'/api/tools/jsm/comment',
jsmCommentBodySchema
)
export const jsmCommentsContract = defineJsmToolContract(
'/api/tools/jsm/comments',
jsmCommentsBodySchema
)
export const jsmTransitionContract = defineJsmToolContract(
'/api/tools/jsm/transition',
jsmTransitionBodySchema
)
export const jsmSlaContract = defineJsmToolContract(
'/api/tools/jsm/sla',
jsmIssuePaginationBodySchema
)
export const jsmTransitionsContract = defineJsmToolContract(
'/api/tools/jsm/transitions',
jsmIssuePaginationBodySchema
)
export const jsmApprovalsContract = defineJsmToolContract(
'/api/tools/jsm/approvals',
jsmApprovalsBodySchema
)
export const jsmParticipantsContract = defineJsmToolContract(
'/api/tools/jsm/participants',
jsmParticipantsBodySchema
)
export const jsmCustomersContract = defineJsmToolContract(
'/api/tools/jsm/customers',
jsmCustomersBodySchema
)
export const jsmOrganizationsContract = defineJsmToolContract(
'/api/tools/jsm/organizations',
jsmServiceDeskScopedBodySchema
)
export const jsmOrganizationContract = defineJsmToolContract(
'/api/tools/jsm/organization',
jsmOrganizationBodySchema
)
export const jsmIssueFormsContract = defineJsmToolContract(
'/api/tools/jsm/forms/issue',
jsmIssueFormsBodySchema
)
export const jsmAttachFormContract = defineJsmToolContract(
'/api/tools/jsm/forms/attach',
jsmAttachFormBodySchema
)
export const jsmGetFormContract = defineJsmToolContract(
'/api/tools/jsm/forms/get',
jsmIssueFormBodySchema
)
export const jsmSubmitFormContract = defineJsmToolContract(
'/api/tools/jsm/forms/submit',
jsmIssueFormBodySchema
)
export const jsmDeleteFormContract = defineJsmToolContract(
'/api/tools/jsm/forms/delete',
jsmIssueFormBodySchema
)
export const jsmExternaliseFormContract = defineJsmToolContract(
'/api/tools/jsm/forms/externalise',
jsmIssueFormBodySchema
)
export const jsmInternaliseFormContract = defineJsmToolContract(
'/api/tools/jsm/forms/internalise',
jsmIssueFormBodySchema
)
export const jsmReopenFormContract = defineJsmToolContract(
'/api/tools/jsm/forms/reopen',
jsmIssueFormBodySchema
)
export const jsmSaveFormAnswersContract = defineJsmToolContract(
'/api/tools/jsm/forms/save',
jsmSaveFormAnswersBodySchema
)
export const jsmFormAnswersContract = defineJsmToolContract(
'/api/tools/jsm/forms/answers',
jsmIssueFormBodySchema
)
export const jsmProjectFormTemplatesContract = defineJsmToolContract(
'/api/tools/jsm/forms/templates',
jsmProjectFormTemplatesBodySchema
)
export const jsmProjectFormStructureContract = defineJsmToolContract(
'/api/tools/jsm/forms/structure',
jsmProjectFormStructureBodySchema
)
export const jsmCopyFormsContract = defineJsmToolContract(
'/api/tools/jsm/forms/copy',
jsmCopyFormsBodySchema
)
export const jsmListObjectSchemasContract = defineJsmToolContract(
'/api/tools/jsm/assets/schemas',
jsmListObjectSchemasBodySchema
)
export const jsmGetObjectSchemaContract = defineJsmToolContract(
'/api/tools/jsm/assets/schema',
jsmObjectSchemaBodySchema
)
export const jsmListObjectTypesContract = defineJsmToolContract(
'/api/tools/jsm/assets/object-types',
jsmObjectTypesBodySchema
)
export const jsmObjectTypeAttributesContract = defineJsmToolContract(
'/api/tools/jsm/assets/attributes',
jsmObjectTypeAttributesBodySchema
)
export const jsmSearchObjectsAqlContract = defineJsmToolContract(
'/api/tools/jsm/assets/search',
jsmSearchObjectsAqlBodySchema
)
export const jsmGetObjectContract = defineJsmToolContract(
'/api/tools/jsm/assets/object/get',
jsmGetObjectBodySchema
)
export const jsmCreateObjectContract = defineJsmToolContract(
'/api/tools/jsm/assets/object/create',
jsmCreateObjectBodySchema
)
export const jsmUpdateObjectContract = defineJsmToolContract(
'/api/tools/jsm/assets/object/update',
jsmUpdateObjectBodySchema
)
export const jsmDeleteObjectContract = defineJsmToolContract(
'/api/tools/jsm/assets/object/delete',
jsmDeleteObjectBodySchema
)
export type JsmServiceDesksBody = ContractBody<typeof jsmServiceDesksContract>
export type JsmQueuesBody = ContractBody<typeof jsmQueuesContract>
export type JsmRequestTypesBody = ContractBody<typeof jsmRequestTypesContract>
export type JsmRequestTypeFieldsBody = ContractBody<typeof jsmRequestTypeFieldsContract>
export type JsmRequestsBody = ContractBody<typeof jsmRequestsContract>
export type JsmRequestBody = ContractBody<typeof jsmRequestContract>
export type JsmCommentBody = ContractBody<typeof jsmCommentContract>
export type JsmCommentsBody = ContractBody<typeof jsmCommentsContract>
export type JsmTransitionBody = ContractBody<typeof jsmTransitionContract>
export type JsmSlaBody = ContractBody<typeof jsmSlaContract>
export type JsmTransitionsBody = ContractBody<typeof jsmTransitionsContract>
export type JsmApprovalsBody = ContractBody<typeof jsmApprovalsContract>
export type JsmParticipantsBody = ContractBody<typeof jsmParticipantsContract>
export type JsmCustomersBody = ContractBody<typeof jsmCustomersContract>
export type JsmOrganizationsBody = ContractBody<typeof jsmOrganizationsContract>
export type JsmOrganizationBody = ContractBody<typeof jsmOrganizationContract>
export type JsmIssueFormsBody = ContractBody<typeof jsmIssueFormsContract>
export type JsmAttachFormBody = ContractBody<typeof jsmAttachFormContract>
export type JsmGetFormBody = ContractBody<typeof jsmGetFormContract>
export type JsmSubmitFormBody = ContractBody<typeof jsmSubmitFormContract>
export type JsmDeleteFormBody = ContractBody<typeof jsmDeleteFormContract>
export type JsmExternaliseFormBody = ContractBody<typeof jsmExternaliseFormContract>
export type JsmInternaliseFormBody = ContractBody<typeof jsmInternaliseFormContract>
export type JsmReopenFormBody = ContractBody<typeof jsmReopenFormContract>
export type JsmSaveFormAnswersBody = ContractBody<typeof jsmSaveFormAnswersContract>
export type JsmFormAnswersBody = ContractBody<typeof jsmFormAnswersContract>
export type JsmProjectFormTemplatesBody = ContractBody<typeof jsmProjectFormTemplatesContract>
export type JsmProjectFormStructureBody = ContractBody<typeof jsmProjectFormStructureContract>
export type JsmCopyFormsBody = ContractBody<typeof jsmCopyFormsContract>
export type JsmServiceDesksSelectorResponse = ContractJsonResponse<
typeof jsmServiceDesksSelectorContract
>
export type JsmRequestTypesSelectorResponse = ContractJsonResponse<
typeof jsmRequestTypesSelectorContract
>
@@ -0,0 +1,69 @@
import { z } from 'zod'
import { optionalString } from '@/lib/api/contracts/selectors/shared'
import type { ContractJsonResponse } from '@/lib/api/contracts/types'
import { defineRouteContract } from '@/lib/api/contracts/types'
const knowledgeDocumentsParamsSchema = z.object({ id: z.string().min(1) })
const knowledgeDocumentParamsSchema = knowledgeDocumentsParamsSchema.extend({
documentId: z.string().min(1),
})
const knowledgeDocumentsQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(100).optional(),
offset: z.coerce.number().int().min(0).optional(),
search: optionalString,
})
const knowledgeDocumentQuerySchema = z.object({
includeDisabled: optionalString,
})
const knowledgeDocumentSchema = z.object({ id: z.string(), filename: z.string() }).passthrough()
export const listKnowledgeSelectorDocumentsContract = defineRouteContract({
method: 'GET',
path: '/api/knowledge/[id]/documents',
params: knowledgeDocumentsParamsSchema,
query: knowledgeDocumentsQuerySchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
data: z
.object({
documents: z.array(knowledgeDocumentSchema),
pagination: z
.object({
total: z.number(),
limit: z.number(),
offset: z.number(),
hasMore: z.boolean(),
})
.passthrough(),
})
.passthrough(),
}),
},
})
export const getKnowledgeSelectorDocumentContract = defineRouteContract({
method: 'GET',
path: '/api/knowledge/[id]/documents/[documentId]',
params: knowledgeDocumentParamsSchema,
query: knowledgeDocumentQuerySchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
data: knowledgeDocumentSchema,
}),
},
})
export type ListKnowledgeSelectorDocumentsResponse = ContractJsonResponse<
typeof listKnowledgeSelectorDocumentsContract
>
export type GetKnowledgeSelectorDocumentResponse = ContractJsonResponse<
typeof getKnowledgeSelectorDocumentContract
>
@@ -0,0 +1,28 @@
import { z } from 'zod'
import {
credentialWorkflowBodySchema,
definePostSelector,
idNameSchema,
} from '@/lib/api/contracts/selectors/shared'
import type { ContractJsonResponse } from '@/lib/api/contracts/types'
export const linearProjectsBodySchema = credentialWorkflowBodySchema.extend({
teamId: z.string().min(1),
})
export const linearTeamsSelectorContract = definePostSelector(
'/api/tools/linear/teams',
credentialWorkflowBodySchema,
z.object({ teams: z.array(idNameSchema) })
)
export const linearProjectsSelectorContract = definePostSelector(
'/api/tools/linear/projects',
linearProjectsBodySchema,
z.object({ projects: z.array(idNameSchema) })
)
export type LinearTeamsSelectorResponse = ContractJsonResponse<typeof linearTeamsSelectorContract>
export type LinearProjectsSelectorResponse = ContractJsonResponse<
typeof linearProjectsSelectorContract
>
@@ -0,0 +1,238 @@
import { z } from 'zod'
import {
credentialIdQuerySchema,
credentialIdQueryWithSearchSchema,
credentialWorkflowBodySchema,
defineGetSelector,
definePostSelector,
fileOptionSchema,
idDisplayNameSchema,
idNameSchema,
idTitleSchema,
optionalString,
} from '@/lib/api/contracts/selectors/shared'
import type { ContractBody, ContractJsonResponse, ContractQuery } from '@/lib/api/contracts/types'
export const teamsChannelsBodySchema = credentialWorkflowBodySchema.extend({
teamId: z.string().min(1),
})
export const plannerTasksBodySchema = credentialWorkflowBodySchema.extend({
planId: z.string().min(1),
})
export const microsoftExcelSheetsQuerySchema = credentialIdQuerySchema.extend({
spreadsheetId: z.string().min(1, 'Spreadsheet ID is required'),
driveId: optionalString,
workflowId: optionalString,
})
/**
* Body for `POST /api/tools/microsoft_excel/drives`. The route serves both
* list-drives (no `driveId`) and single-drive lookup (`driveId` provided),
* dispatching at runtime. The contract permits the optional `driveId` so a
* single body schema covers both flows.
*/
export const microsoftExcelDrivesBodySchema = credentialWorkflowBodySchema.extend({
siteId: z.string().min(1, 'Site ID is required'),
driveId: optionalString,
})
/**
* The `/api/auth/oauth/microsoft/files` route is shared by the
* `microsoft.excel` and `microsoft.word` selectors. `fileType` lets the route
* search for and filter to the correct Office document type; it is optional and
* defaults to `excel` on the server for backward compatibility.
*/
export const microsoftFilesQuerySchema = credentialIdQuerySchema.extend({
query: optionalString,
driveId: optionalString,
workflowId: optionalString,
fileType: z.enum(['excel', 'word']).optional(),
})
export const microsoftFileQuerySchema = credentialIdQuerySchema.extend({
fileId: z.string({ error: 'File ID is required' }).min(1, 'File ID is required'),
workflowId: optionalString,
})
export const onedriveFolderQuerySchema = z.object({
credentialId: z.preprocess(
(value) => value ?? '',
z.string().min(1, 'Credential ID and File ID are required')
),
fileId: z.preprocess(
(value) => value ?? '',
z.string().min(1, 'Credential ID and File ID are required')
),
})
export const onedriveFilesQuerySchema = credentialIdQueryWithSearchSchema
export const onedriveFoldersQuerySchema = credentialIdQueryWithSearchSchema
export const outlookFoldersQuerySchema = credentialIdQuerySchema
export const outlookFoldersSelectorContract = defineGetSelector(
'/api/tools/outlook/folders',
outlookFoldersQuerySchema,
z.object({ folders: z.array(z.object({ id: z.string(), name: z.string() }).passthrough()) })
)
export const microsoftTeamsSelectorContract = definePostSelector(
'/api/tools/microsoft-teams/teams',
credentialWorkflowBodySchema,
z.object({ teams: z.array(idDisplayNameSchema) })
)
export const microsoftChatsSelectorContract = definePostSelector(
'/api/tools/microsoft-teams/chats',
credentialWorkflowBodySchema,
z.object({ chats: z.array(idDisplayNameSchema) })
)
export const microsoftChannelsSelectorContract = definePostSelector(
'/api/tools/microsoft-teams/channels',
teamsChannelsBodySchema,
z.object({ channels: z.array(idDisplayNameSchema) })
)
export const microsoftPlannerPlansSelectorContract = definePostSelector(
'/api/tools/microsoft_planner/plans',
credentialWorkflowBodySchema,
z.object({ plans: z.array(idTitleSchema) })
)
export const microsoftPlannerTasksSelectorContract = definePostSelector(
'/api/tools/microsoft_planner/tasks',
plannerTasksBodySchema,
z
.object({
tasks: z.array(idTitleSchema),
metadata: z
.object({
planId: z.string(),
planUrl: z.string(),
})
.passthrough()
.optional(),
})
.passthrough()
)
export const onedriveFilesSelectorContract = defineGetSelector(
'/api/tools/onedrive/files',
onedriveFilesQuerySchema,
z.object({ files: z.array(fileOptionSchema) })
)
export const onedriveFoldersSelectorContract = defineGetSelector(
'/api/tools/onedrive/folders',
onedriveFoldersQuerySchema,
z.object({ files: z.array(fileOptionSchema) })
)
export const onedriveFolderSelectorContract = defineGetSelector(
'/api/tools/onedrive/folder',
onedriveFolderQuerySchema,
z.object({ file: fileOptionSchema.optional() }).passthrough()
)
export const microsoftExcelSheetsSelectorContract = defineGetSelector(
'/api/tools/microsoft_excel/sheets',
microsoftExcelSheetsQuerySchema,
z.object({ sheets: z.array(idNameSchema) })
)
export const microsoftExcelDrivesSelectorContract = definePostSelector(
'/api/tools/microsoft_excel/drives',
microsoftExcelDrivesBodySchema,
z.object({ drives: z.array(idNameSchema) })
)
/**
* Single-drive variant. Same body schema as the list contract; the `driveId`
* is what discriminates the response shape at the route layer.
*/
export const microsoftExcelDriveSelectorContract = definePostSelector(
'/api/tools/microsoft_excel/drives',
microsoftExcelDrivesBodySchema,
z.object({ drive: idNameSchema.optional() })
)
export const microsoftFilesSelectorContract = defineGetSelector(
'/api/auth/oauth/microsoft/files',
microsoftFilesQuerySchema,
z.object({ files: z.array(fileOptionSchema) })
)
export const microsoftFileSelectorContract = defineGetSelector(
'/api/auth/oauth/microsoft/file',
microsoftFileQuerySchema,
z.object({ file: fileOptionSchema.optional() }).passthrough()
)
export type OutlookFoldersSelectorResponse = ContractJsonResponse<
typeof outlookFoldersSelectorContract
>
export type OutlookFoldersSelectorQuery = ContractQuery<typeof outlookFoldersSelectorContract>
export type MicrosoftTeamsSelectorResponse = ContractJsonResponse<
typeof microsoftTeamsSelectorContract
>
export type MicrosoftTeamsSelectorBody = ContractBody<typeof microsoftTeamsSelectorContract>
export type MicrosoftChatsSelectorResponse = ContractJsonResponse<
typeof microsoftChatsSelectorContract
>
export type MicrosoftChatsSelectorBody = ContractBody<typeof microsoftChatsSelectorContract>
export type MicrosoftChannelsSelectorResponse = ContractJsonResponse<
typeof microsoftChannelsSelectorContract
>
export type MicrosoftChannelsSelectorBody = ContractBody<typeof microsoftChannelsSelectorContract>
export type MicrosoftPlannerPlansSelectorResponse = ContractJsonResponse<
typeof microsoftPlannerPlansSelectorContract
>
export type MicrosoftPlannerPlansSelectorBody = ContractBody<
typeof microsoftPlannerPlansSelectorContract
>
export type MicrosoftPlannerTasksSelectorResponse = ContractJsonResponse<
typeof microsoftPlannerTasksSelectorContract
>
export type MicrosoftPlannerTasksSelectorBody = ContractBody<
typeof microsoftPlannerTasksSelectorContract
>
export type OnedriveFilesSelectorResponse = ContractJsonResponse<
typeof onedriveFilesSelectorContract
>
export type OnedriveFilesSelectorQuery = ContractQuery<typeof onedriveFilesSelectorContract>
export type OnedriveFoldersSelectorResponse = ContractJsonResponse<
typeof onedriveFoldersSelectorContract
>
export type OnedriveFoldersSelectorQuery = ContractQuery<typeof onedriveFoldersSelectorContract>
export type OnedriveFolderSelectorResponse = ContractJsonResponse<
typeof onedriveFolderSelectorContract
>
export type OnedriveFolderSelectorQuery = ContractQuery<typeof onedriveFolderSelectorContract>
export type MicrosoftExcelSheetsSelectorResponse = ContractJsonResponse<
typeof microsoftExcelSheetsSelectorContract
>
export type MicrosoftExcelSheetsSelectorQuery = ContractQuery<
typeof microsoftExcelSheetsSelectorContract
>
export type MicrosoftExcelDrivesSelectorResponse = ContractJsonResponse<
typeof microsoftExcelDrivesSelectorContract
>
export type MicrosoftExcelDrivesSelectorBody = ContractBody<
typeof microsoftExcelDrivesSelectorContract
>
export type MicrosoftExcelDriveSelectorResponse = ContractJsonResponse<
typeof microsoftExcelDriveSelectorContract
>
export type MicrosoftExcelDriveSelectorBody = ContractBody<
typeof microsoftExcelDriveSelectorContract
>
export type MicrosoftFilesSelectorResponse = ContractJsonResponse<
typeof microsoftFilesSelectorContract
>
export type MicrosoftFilesSelectorQuery = ContractQuery<typeof microsoftFilesSelectorContract>
export type MicrosoftFileSelectorResponse = ContractJsonResponse<
typeof microsoftFileSelectorContract
>
export type MicrosoftFileSelectorQuery = ContractQuery<typeof microsoftFileSelectorContract>
@@ -0,0 +1,32 @@
import { z } from 'zod'
import {
credentialWorkflowBodySchema,
definePostSelector,
idNameSchema,
} from '@/lib/api/contracts/selectors/shared'
import type { ContractJsonResponse } from '@/lib/api/contracts/types'
/**
* Monday board IDs are numeric in the API (e.g. `123456789`). Clients
* sometimes pass them as numbers and sometimes as strings, so we accept
* `string | number` and let the route's `validateMondayNumericId` enforce
* the actual numeric format.
*/
export const mondayGroupsBodySchema = credentialWorkflowBodySchema.extend({
boardId: z.union([z.string().min(1), z.number()]),
})
export const mondayBoardsSelectorContract = definePostSelector(
'/api/tools/monday/boards',
credentialWorkflowBodySchema,
z.object({ boards: z.array(idNameSchema) })
)
export const mondayGroupsSelectorContract = definePostSelector(
'/api/tools/monday/groups',
mondayGroupsBodySchema,
z.object({ groups: z.array(idNameSchema) })
)
export type MondayBoardsSelectorResponse = ContractJsonResponse<typeof mondayBoardsSelectorContract>
export type MondayGroupsSelectorResponse = ContractJsonResponse<typeof mondayGroupsSelectorContract>
@@ -0,0 +1,24 @@
import { z } from 'zod'
import {
credentialWorkflowBodySchema,
definePostSelector,
idNameSchema,
} from '@/lib/api/contracts/selectors/shared'
import type { ContractJsonResponse } from '@/lib/api/contracts/types'
export const notionDatabasesSelectorContract = definePostSelector(
'/api/tools/notion/databases',
credentialWorkflowBodySchema,
z.object({ databases: z.array(idNameSchema) })
)
export const notionPagesSelectorContract = definePostSelector(
'/api/tools/notion/pages',
credentialWorkflowBodySchema,
z.object({ pages: z.array(idNameSchema) })
)
export type NotionDatabasesSelectorResponse = ContractJsonResponse<
typeof notionDatabasesSelectorContract
>
export type NotionPagesSelectorResponse = ContractJsonResponse<typeof notionPagesSelectorContract>
@@ -0,0 +1,22 @@
import { z } from 'zod'
import { oauthTokenRequestBodySchema } from '@/lib/api/contracts/oauth-connections'
import { definePostSelector } from '@/lib/api/contracts/selectors/shared'
import type { ContractJsonResponse } from '@/lib/api/contracts/types'
const oauthTokenResponseSchema = z
.object({
accessToken: z.string().optional(),
idToken: z.string().optional(),
instanceUrl: z.string().optional(),
cloudId: z.string().optional(),
domain: z.string().optional(),
})
.passthrough()
export const oauthTokenContract = definePostSelector(
'/api/auth/oauth/token',
oauthTokenRequestBodySchema,
oauthTokenResponseSchema
)
export type OauthTokenResponse = ContractJsonResponse<typeof oauthTokenContract>
@@ -0,0 +1,17 @@
import { z } from 'zod'
import {
credentialWorkflowBodySchema,
definePostSelector,
idNameSchema,
} from '@/lib/api/contracts/selectors/shared'
import type { ContractJsonResponse } from '@/lib/api/contracts/types'
export const pipedrivePipelinesSelectorContract = definePostSelector(
'/api/tools/pipedrive/pipelines',
credentialWorkflowBodySchema,
z.object({ pipelines: z.array(idNameSchema) })
)
export type PipedrivePipelinesSelectorResponse = ContractJsonResponse<
typeof pipedrivePipelinesSelectorContract
>
@@ -0,0 +1,70 @@
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts/types'
export const optionalString = z.string().optional()
/**
* Accepts `string | null | undefined` on the wire and outputs `string |
* undefined` (null collapsed to undefined). Used for fields like `workflowId`
* where the wire is permissive but server-side handlers expect an optional
* string.
*/
export const nullableOptionalString = z
.string()
.nullish()
.transform((value) => value ?? undefined)
export const credentialWorkflowBodySchema = z.object({
credential: z.string().min(1),
workflowId: nullableOptionalString,
})
export const credentialWorkflowDomainBodySchema = credentialWorkflowBodySchema.extend({
domain: z.string().min(1),
})
export const credentialWorkflowImpersonateBodySchema = credentialWorkflowBodySchema.extend({
impersonateEmail: optionalString,
})
export const credentialIdQuerySchema = z.object({
credentialId: z
.string({ error: 'Credential ID is required' })
.min(1, 'Credential ID is required'),
})
export const credentialIdQueryWithSearchSchema = credentialIdQuerySchema.extend({
query: optionalString,
})
export const idNameSchema = z.object({ id: z.string(), name: z.string() }).passthrough()
export const idTitleSchema = z.object({ id: z.string(), title: z.string() }).passthrough()
export const idDisplayNameSchema = z
.object({ id: z.string(), displayName: z.string() })
.passthrough()
export const fileOptionSchema = z.object({ id: z.string(), name: z.string() }).passthrough()
export const folderOptionSchema = z.object({ id: z.string(), name: z.string() }).passthrough()
export const definePostSelector = <TBody extends z.ZodType, TResponse extends z.ZodType>(
path: string,
body: TBody,
response: TResponse
) =>
defineRouteContract({
method: 'POST',
path,
body,
response: { mode: 'json', schema: response },
})
export const defineGetSelector = <TQuery extends z.ZodType, TResponse extends z.ZodType>(
path: string,
query: TQuery,
response: TResponse
) =>
defineRouteContract({
method: 'GET',
path,
query,
response: { mode: 'json', schema: response },
})
@@ -0,0 +1,60 @@
import { z } from 'zod'
import {
credentialWorkflowBodySchema,
defineGetSelector,
definePostSelector,
fileOptionSchema,
idDisplayNameSchema,
optionalString,
} from '@/lib/api/contracts/selectors/shared'
import type { ContractBody, ContractJsonResponse, ContractQuery } from '@/lib/api/contracts/types'
export const sharepointListsBodySchema = credentialWorkflowBodySchema.extend({
siteId: z.string().min(1),
})
export const sharepointSitesBodySchema = credentialWorkflowBodySchema.extend({
query: optionalString,
})
export const sharepointSiteQuerySchema = z.object({
credentialId: z.preprocess(
(value) => value ?? '',
z.string().min(1, 'Credential ID and Site ID are required')
),
siteId: z.preprocess(
(value) => value ?? '',
z.string().min(1, 'Credential ID and Site ID are required')
),
})
export const sharepointListsSelectorContract = definePostSelector(
'/api/tools/sharepoint/lists',
sharepointListsBodySchema,
z.object({ lists: z.array(idDisplayNameSchema) })
)
export const sharepointSitesSelectorContract = definePostSelector(
'/api/tools/sharepoint/sites',
sharepointSitesBodySchema,
z.object({ files: z.array(fileOptionSchema) })
)
export const sharepointSiteSelectorContract = defineGetSelector(
'/api/tools/sharepoint/site',
sharepointSiteQuerySchema,
z.object({ site: fileOptionSchema.optional() }).passthrough()
)
export type SharepointListsSelectorResponse = ContractJsonResponse<
typeof sharepointListsSelectorContract
>
export type SharepointListsSelectorBody = ContractBody<typeof sharepointListsSelectorContract>
export type SharepointSitesSelectorResponse = ContractJsonResponse<
typeof sharepointSitesSelectorContract
>
export type SharepointSitesSelectorBody = ContractBody<typeof sharepointSitesSelectorContract>
export type SharepointSiteSelectorResponse = ContractJsonResponse<
typeof sharepointSiteSelectorContract
>
export type SharepointSiteSelectorQuery = ContractQuery<typeof sharepointSiteSelectorContract>
@@ -0,0 +1,49 @@
import { z } from 'zod'
import {
credentialWorkflowBodySchema,
definePostSelector,
} from '@/lib/api/contracts/selectors/shared'
import type { ContractJsonResponse } from '@/lib/api/contracts/types'
const slackChannelSchema = z.object({
id: z.string(),
name: z.string(),
isPrivate: z.boolean(),
})
const slackUserSchema = z
.object({ id: z.string(), name: z.string(), real_name: z.string() })
.passthrough()
export const slackUsersBodySchema = credentialWorkflowBodySchema.extend({
userId: z.string().optional(),
})
export const slackChannelsSelectorContract = definePostSelector(
'/api/tools/slack/channels',
credentialWorkflowBodySchema,
z.object({ channels: z.array(slackChannelSchema) })
)
export const slackUsersSelectorContract = definePostSelector(
'/api/tools/slack/users',
credentialWorkflowBodySchema,
z.object({ users: z.array(slackUserSchema) })
)
export const slackUserSelectorContract = definePostSelector(
'/api/tools/slack/users',
credentialWorkflowBodySchema.extend({ userId: z.string().min(1) }),
z.object({ user: slackUserSchema })
)
export const slackUsersListOrDetailContract = definePostSelector(
'/api/tools/slack/users',
slackUsersBodySchema,
z.union([z.object({ user: slackUserSchema }), z.object({ users: z.array(slackUserSchema) })])
)
export type SlackChannelsSelectorResponse = ContractJsonResponse<
typeof slackChannelsSelectorContract
>
export type SlackUsersSelectorResponse = ContractJsonResponse<typeof slackUsersSelectorContract>
export type SlackUserSelectorResponse = ContractJsonResponse<typeof slackUserSelectorContract>
@@ -0,0 +1,18 @@
import { z } from 'zod'
import {
credentialWorkflowBodySchema,
definePostSelector,
} from '@/lib/api/contracts/selectors/shared'
import type { ContractJsonResponse } from '@/lib/api/contracts/types'
const trelloBoardSchema = z
.object({ id: z.string(), name: z.string(), closed: z.boolean().optional() })
.passthrough()
export const trelloBoardsSelectorContract = definePostSelector(
'/api/tools/trello/boards',
credentialWorkflowBodySchema,
z.object({ boards: z.array(trelloBoardSchema) })
)
export type TrelloBoardsSelectorResponse = ContractJsonResponse<typeof trelloBoardsSelectorContract>
@@ -0,0 +1,77 @@
import { z } from 'zod'
import { defineGetSelector, optionalString } from '@/lib/api/contracts/selectors/shared'
import type { ContractJsonResponse } from '@/lib/api/contracts/types'
export const WEALTHBOX_ITEM_TYPES = ['note', 'contact', 'task'] as const
const wealthboxItemSchema = z.object({
id: z.string(),
name: z.string(),
type: z.string(),
content: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
})
const wealthboxItemsResponseSchema = z.object({
items: z.array(wealthboxItemSchema),
})
const wealthboxItemResponseSchema = z.object({
item: wealthboxItemSchema.optional(),
})
export const wealthboxItemsQuerySchema = z.object({
credentialId: z.string().min(1),
type: z.preprocess(
(value) => (value === '' || value === undefined ? 'contact' : value),
z.literal('contact').default('contact')
),
query: z.preprocess(
(value) => (value === undefined || value === null ? '' : value),
optionalString.default('')
),
})
export const wealthboxItemQuerySchema = z.object({
credentialId: z.preprocess(
(value) => value ?? '',
z.string().min(1, 'Credential ID is required')
),
itemId: z.preprocess((value) => value ?? '', z.string().min(1, 'Item ID is required')),
type: z.preprocess(
(value) => value || 'note',
z.enum(WEALTHBOX_ITEM_TYPES, { error: 'type must be one of: note, contact, task' })
),
})
export const wealthboxItemsSelectorContract = defineGetSelector(
'/api/tools/wealthbox/items',
wealthboxItemsQuerySchema,
wealthboxItemsResponseSchema
)
export const wealthboxItemContract = defineGetSelector(
'/api/tools/wealthbox/item',
wealthboxItemQuerySchema,
wealthboxItemResponseSchema
)
export const wealthboxOAuthItemsContract = defineGetSelector(
'/api/auth/oauth/wealthbox/items',
wealthboxItemsQuerySchema,
wealthboxItemsResponseSchema
)
export const wealthboxOAuthItemContract = defineGetSelector(
'/api/auth/oauth/wealthbox/item',
wealthboxItemQuerySchema,
wealthboxItemResponseSchema
)
export type WealthboxItemsSelectorResponse = ContractJsonResponse<
typeof wealthboxItemsSelectorContract
>
export type WealthboxItemResponse = ContractJsonResponse<typeof wealthboxItemContract>
export type WealthboxOAuthItemsResponse = ContractJsonResponse<typeof wealthboxOAuthItemsContract>
export type WealthboxOAuthItemResponse = ContractJsonResponse<typeof wealthboxOAuthItemContract>
@@ -0,0 +1,49 @@
import { z } from 'zod'
import {
credentialWorkflowBodySchema,
definePostSelector,
idNameSchema,
optionalString,
} from '@/lib/api/contracts/selectors/shared'
import type { ContractJsonResponse } from '@/lib/api/contracts/types'
/**
* Webflow `/sites` accepts an optional `siteId`. When provided, the route
* dispatches to the single-site detail endpoint instead of the list endpoint.
*/
export const webflowSitesBodySchema = credentialWorkflowBodySchema.extend({
siteId: optionalString,
})
export const webflowCollectionsBodySchema = credentialWorkflowBodySchema.extend({
siteId: z.string().min(1, 'Site ID is required'),
})
export const webflowItemsBodySchema = credentialWorkflowBodySchema.extend({
collectionId: z.string().min(1, 'Collection ID is required'),
search: optionalString,
})
export const webflowSitesSelectorContract = definePostSelector(
'/api/tools/webflow/sites',
webflowSitesBodySchema,
z.object({ sites: z.array(idNameSchema) })
)
export const webflowCollectionsSelectorContract = definePostSelector(
'/api/tools/webflow/collections',
webflowCollectionsBodySchema,
z.object({ collections: z.array(idNameSchema) })
)
export const webflowItemsSelectorContract = definePostSelector(
'/api/tools/webflow/items',
webflowItemsBodySchema,
z.object({ items: z.array(idNameSchema) })
)
export type WebflowSitesSelectorResponse = ContractJsonResponse<typeof webflowSitesSelectorContract>
export type WebflowCollectionsSelectorResponse = ContractJsonResponse<
typeof webflowCollectionsSelectorContract
>
export type WebflowItemsSelectorResponse = ContractJsonResponse<typeof webflowItemsSelectorContract>
@@ -0,0 +1,15 @@
import { z } from 'zod'
import {
credentialWorkflowBodySchema,
definePostSelector,
idNameSchema,
} from '@/lib/api/contracts/selectors/shared'
import type { ContractJsonResponse } from '@/lib/api/contracts/types'
export const zoomMeetingsSelectorContract = definePostSelector(
'/api/tools/zoom/meetings',
credentialWorkflowBodySchema,
z.object({ meetings: z.array(idNameSchema) })
)
export type ZoomMeetingsSelectorResponse = ContractJsonResponse<typeof zoomMeetingsSelectorContract>
+97
View File
@@ -0,0 +1,97 @@
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts/types'
export const skillSchema = z.object({
id: z.string(),
workspaceId: z.string().nullable(),
userId: z.string().nullable(),
name: z.string(),
description: z.string(),
content: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
/** True for built-in template skills, which are read-only and not stored in the DB. */
readOnly: z.boolean().optional(),
})
export type Skill = z.output<typeof skillSchema>
export const skillUpsertItemSchema = z.object({
id: z.string().optional(),
name: z
.string()
.min(1, 'Skill name is required')
.max(64)
.regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, 'Name must be kebab-case (e.g. my-skill)'),
description: z.string().min(1, 'Description is required').max(1024),
content: z.string().min(1, 'Content is required').max(50_000, 'Content is too large'),
})
export const listSkillsQuerySchema = z.object({
workspaceId: z.string().min(1),
})
export const upsertSkillsBodySchema = z.object({
skills: z.array(skillUpsertItemSchema),
workspaceId: z.string().min(1),
source: z.enum(['settings', 'tool_input']).optional(),
})
export const deleteSkillQuerySchema = z.object({
id: z.string().min(1),
workspaceId: z.string().min(1),
source: z.enum(['settings', 'tool_input']).optional(),
})
export const importSkillBodySchema = z.object({
url: z.string().url('A valid URL is required'),
})
export const listSkillsContract = defineRouteContract({
method: 'GET',
path: '/api/skills',
query: listSkillsQuerySchema,
response: {
mode: 'json',
schema: z.object({
data: z.array(skillSchema),
}),
},
})
export const upsertSkillsContract = defineRouteContract({
method: 'POST',
path: '/api/skills',
body: upsertSkillsBodySchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
data: z.array(skillSchema),
}),
},
})
export const deleteSkillContract = defineRouteContract({
method: 'DELETE',
path: '/api/skills',
query: deleteSkillQuerySchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
}),
},
})
export const importSkillContract = defineRouteContract({
method: 'POST',
path: '/api/skills/import',
body: importSkillBodySchema,
response: {
mode: 'json',
schema: z.object({
content: z.string(),
}),
},
})
@@ -0,0 +1,792 @@
import { z } from 'zod'
import {
batchPresignedUploadResponseSchema,
presignedUploadResponseSchema,
} from '@/lib/api/contracts/file-uploads'
import { workspaceFileIdSchema } from '@/lib/api/contracts/primitives'
import {
type ContractBodyInput,
type ContractJsonResponse,
type ContractParamsInput,
type ContractQueryInput,
defineRouteContract,
} from '@/lib/api/contracts/types'
import {
FileInputSchema,
RawFileInputArraySchema,
RawFileInputSchema,
} from '@/lib/uploads/utils/file-schemas'
const jsonResponseSchema = z.unknown()
function formatFileSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1)
const value = bytes / k ** i
return `${value.toFixed(value >= 100 || i === 0 ? 0 : 1)} ${sizes[i]}`
}
const multipartPartUrlSchema = z.object({
partNumber: z.number(),
url: z.string(),
blockId: z.string().optional(),
})
const multipartCompletedUploadSchema = z.object({
success: z.literal(true),
location: z.string(),
path: z.string(),
key: z.string(),
})
export const initiateMultipartResponseSchema = z.object({
uploadId: z.string(),
key: z.string(),
uploadToken: z.string(),
})
export const getMultipartPartUrlsResponseSchema = z.object({
presignedUrls: z.array(multipartPartUrlSchema),
})
export const completeMultipartResponseSchema = z.union([
multipartCompletedUploadSchema,
z.object({
results: z.array(multipartCompletedUploadSchema),
}),
])
export const abortMultipartResponseSchema = z.object({
success: z.literal(true),
})
export const multipartUploadResponseSchema = z.union([
initiateMultipartResponseSchema,
getMultipartPartUrlsResponseSchema,
completeMultipartResponseSchema,
abortMultipartResponseSchema,
])
const connectionFields = {
host: z.string().min(1, 'Host is required'),
port: z.coerce.number().int().positive().default(22),
username: z.string().min(1, 'Username is required'),
password: z.string().nullish(),
privateKey: z.string().nullish(),
passphrase: z.string().nullish(),
}
export function requirePasswordOrPrivateKey<S extends z.ZodType>(schema: S): S {
return schema.refine(
(value) => {
const connection = value as { password?: string | null; privateKey?: string | null }
return Boolean(connection.password || connection.privateKey)
},
{ message: 'Either password or privateKey must be provided' }
) as S
}
export const boxUploadBodySchema = z.object({
accessToken: z.string().min(1, 'Access token is required'),
parentFolderId: z.string().min(1, 'Parent folder ID is required'),
file: FileInputSchema.optional().nullable(),
fileContent: z.string().optional().nullable(),
fileName: z.string().optional().nullable(),
})
export const dropboxUploadBodySchema = z.object({
accessToken: z.string().min(1, 'Access token is required'),
path: z.string().min(1, 'Destination path is required'),
file: FileInputSchema.optional().nullable(),
fileContent: z.string().optional().nullable(),
fileName: z.string().optional().nullable(),
mode: z.enum(['add', 'overwrite']).optional().nullable(),
autorename: z.boolean().optional().nullable(),
mute: z.boolean().optional().nullable(),
})
export const jupyterUploadBodySchema = z.object({
serverUrl: z.string().min(1, 'Server URL is required'),
token: z.string().min(1, 'Token is required'),
directory: z.string().optional().nullable(),
file: FileInputSchema.optional().nullable(),
fileContent: z.string().optional().nullable(),
fileName: z.string().optional().nullable(),
})
export const wordpressUploadBodySchema = z.object({
accessToken: z.string().min(1, 'Access token is required'),
siteId: z.string().min(1, 'Site ID is required'),
file: RawFileInputSchema.optional().nullable(),
filename: z.string().optional().nullable(),
title: z.string().optional().nullable(),
caption: z.string().optional().nullable(),
altText: z.string().optional().nullable(),
description: z.string().optional().nullable(),
})
export const sftpListBodySchema = requirePasswordOrPrivateKey(
z.object({
...connectionFields,
remotePath: z.string().min(1, 'Remote path is required'),
detailed: z.boolean().default(false),
})
)
export const sftpDeleteBodySchema = requirePasswordOrPrivateKey(
z.object({
...connectionFields,
remotePath: z.string().min(1, 'Remote path is required'),
recursive: z.boolean().default(false),
})
)
export const sftpMkdirBodySchema = requirePasswordOrPrivateKey(
z.object({
...connectionFields,
remotePath: z.string().min(1, 'Remote path is required'),
recursive: z.boolean().default(false),
})
)
export const sftpDownloadBodySchema = requirePasswordOrPrivateKey(
z.object({
...connectionFields,
remotePath: z.string().min(1, 'Remote path is required'),
encoding: z.enum(['utf-8', 'base64']).default('utf-8'),
})
)
export const sftpUploadBodySchema = requirePasswordOrPrivateKey(
z.object({
...connectionFields,
remotePath: z.string().min(1, 'Remote path is required'),
files: RawFileInputArraySchema.optional().nullable(),
fileContent: z.string().nullish(),
fileName: z.string().nullish(),
overwrite: z.boolean().default(true),
permissions: z.string().nullish(),
})
)
export const sshCheckCommandExistsBodySchema = requirePasswordOrPrivateKey(
z.object({
...connectionFields,
commandName: z.string().min(1, 'Command name is required'),
})
)
export const sshCheckFileExistsBodySchema = requirePasswordOrPrivateKey(
z.object({
...connectionFields,
path: z.string().min(1, 'Path is required'),
type: z.enum(['file', 'directory', 'any']).default('any'),
})
)
export const sshCreateDirectoryBodySchema = requirePasswordOrPrivateKey(
z.object({
...connectionFields,
path: z.string().min(1, 'Path is required'),
recursive: z.boolean().default(true),
permissions: z.string().default('0755'),
})
)
export const sshDeleteFileBodySchema = requirePasswordOrPrivateKey(
z.object({
...connectionFields,
path: z.string().min(1, 'Path is required'),
recursive: z.boolean().default(false),
force: z.boolean().default(false),
})
)
export const sshDownloadFileBodySchema = requirePasswordOrPrivateKey(
z.object({
...connectionFields,
remotePath: z.string().min(1, 'Remote path is required'),
})
)
export const sshExecuteCommandBodySchema = requirePasswordOrPrivateKey(
z.object({
...connectionFields,
command: z.string().min(1, 'Command is required'),
workingDirectory: z.string().nullish(),
})
)
export const sshExecuteScriptBodySchema = requirePasswordOrPrivateKey(
z.object({
...connectionFields,
script: z.string().min(1, 'Script content is required'),
interpreter: z.string().default('/bin/bash'),
workingDirectory: z.string().nullish(),
})
)
export const sshGetSystemInfoBodySchema = requirePasswordOrPrivateKey(z.object(connectionFields))
export const sshListDirectoryBodySchema = requirePasswordOrPrivateKey(
z.object({
...connectionFields,
path: z.string().min(1, 'Path is required'),
detailed: z.boolean().default(true),
recursive: z.boolean().default(false),
})
)
export const sshMoveRenameBodySchema = requirePasswordOrPrivateKey(
z.object({
...connectionFields,
sourcePath: z.string().min(1, 'Source path is required'),
destinationPath: z.string().min(1, 'Destination path is required'),
overwrite: z.boolean().default(false),
})
)
export const sshReadFileContentBodySchema = requirePasswordOrPrivateKey(
z.object({
...connectionFields,
path: z.string().min(1, 'Path is required'),
encoding: z.string().default('utf-8'),
maxSize: z.coerce.number().min(0.01).max(50).default(10),
})
)
export const sshUploadFileBodySchema = requirePasswordOrPrivateKey(
z.object({
...connectionFields,
fileContent: z.string().min(1, 'File content is required'),
fileName: z.string().min(1, 'File name is required'),
remotePath: z.string().min(1, 'Remote path is required'),
permissions: z.string().nullish(),
overwrite: z.boolean().default(true),
})
)
export const sshWriteFileContentBodySchema = requirePasswordOrPrivateKey(
z.object({
...connectionFields,
path: z.string().min(1, 'Path is required'),
content: z.string(),
mode: z.enum(['overwrite', 'append', 'create']).default('overwrite'),
permissions: z.string().nullish(),
})
)
export const storageContextSchema = z.enum([
'knowledge-base',
'chat',
'copilot',
'mothership',
'execution',
'workspace',
'profile-pictures',
'og-images',
'logs',
'workspace-logos',
])
export const fileDownloadBodySchema = z
.object({
key: z.string().optional(),
name: z.string().optional(),
url: z
.string()
.url()
.refine((value) => ['http:', 'https:'].includes(new URL(value).protocol), {
message: 'URL must use http or https',
})
.optional(),
})
.passthrough()
export const fileParseBodySchema = z
.object({
filePath: z
.union([z.string(), z.array(z.string()).max(10, 'At most 10 files can be parsed at once')])
.optional(),
fileType: z.string().optional().default(''),
headers: z.record(z.string(), z.string()).optional(),
workspaceId: z.string().optional().default(''),
workflowId: z.string().optional(),
executionId: z.string().optional(),
})
.passthrough()
export const fileDeleteBodySchema = z
.object({
filePath: z.string().optional(),
context: storageContextSchema.optional(),
})
.passthrough()
const MAX_FILE_SIZE = 100 * 1024 * 1024
export const validUploadTypes = [
'knowledge-base',
'chat',
'copilot',
'profile-pictures',
'mothership',
'workspace-logos',
'execution',
] as const
export const uploadTypeSchema = z.enum(validUploadTypes)
export const presignedUploadQuerySchema = z.object({
type: uploadTypeSchema,
})
export const presignedUrlBodySchema = z
.object({
fileName: z
.string({ error: 'fileName is required and cannot be empty' })
.refine((value) => value.trim().length > 0, {
message: 'fileName is required and cannot be empty',
}),
contentType: z
.string({ error: 'contentType is required and cannot be empty' })
.refine((value) => value.trim().length > 0, {
message: 'contentType is required and cannot be empty',
}),
fileSize: z
.number({ error: 'fileSize must be a positive number' })
.positive('fileSize must be a positive number')
.superRefine((val, ctx) => {
if (val > MAX_FILE_SIZE) {
ctx.addIssue({
code: 'custom',
message: `File size ${formatFileSize(val)} exceeds maximum allowed size of ${formatFileSize(MAX_FILE_SIZE)}`,
})
}
}),
userId: z.string().optional(),
chatId: z.string().optional(),
})
.passthrough()
export const batchPresignedUrlBodySchema = z
.object({
files: z
.array(
z
.object({
fileName: z.string().refine((value) => value.trim().length > 0, {
message: 'fileName is required for all files',
}),
contentType: z.string().refine((value) => value.trim().length > 0, {
message: 'contentType is required for all files',
}),
fileSize: z.number(),
})
.passthrough()
.superRefine((file, ctx) => {
const name = typeof file.fileName === 'string' ? file.fileName : 'file'
if (!Number.isFinite(file.fileSize) || file.fileSize <= 0) {
ctx.addIssue({
code: 'custom',
path: ['fileSize'],
message: `${name} is empty (fileSize must be greater than 0)`,
})
} else if (file.fileSize > MAX_FILE_SIZE) {
ctx.addIssue({
code: 'custom',
path: ['fileSize'],
message: `${name} (${formatFileSize(file.fileSize)}) exceeds maximum allowed size of ${formatFileSize(MAX_FILE_SIZE)}`,
})
}
})
)
.min(1, 'files array is required and cannot be empty')
.max(100, 'Cannot process more than 100 files at once'),
})
.passthrough()
export const multipartActionSchema = z.enum(['initiate', 'get-part-urls', 'complete', 'abort'])
export const initiateMultipartBodySchema = z
.object({
fileName: z.string(),
contentType: z.string(),
fileSize: z.number(),
workspaceId: z.string({ error: 'workspaceId is required' }).min(1, 'workspaceId is required'),
context: z.string().optional(),
})
.passthrough()
export const tokenBoundMultipartBodySchema = z
.object({
uploadToken: z.string().optional(),
})
.passthrough()
export const getMultipartPartUrlsBodySchema = tokenBoundMultipartBodySchema.extend({
partNumbers: z.array(z.number()),
})
export const completeMultipartBodySchema = z
.object({
uploadToken: z.string().optional(),
parts: z.unknown().optional(),
uploads: z
.array(
z
.object({
uploadToken: z.string().optional(),
parts: z.unknown().optional(),
})
.passthrough()
)
.optional(),
})
.passthrough()
export type CompleteMultipartBody = z.output<typeof completeMultipartBodySchema>
export const uploadFilesFormFilesSchema = z.preprocess(
(value) => (Array.isArray(value) ? value.filter((entry) => entry instanceof File) : value),
z.array(z.custom<File>((value) => value instanceof File)).min(1, 'No files provided')
)
export const uploadFilesFormFieldsSchema = z.object({
workflowId: z.string().nullable(),
executionId: z.string().nullable(),
workspaceId: z.string().nullable(),
context: z.string().nullable(),
})
export const fileServeParamsSchema = z.object({
path: z.array(z.string()).min(1),
})
export const fileServeQuerySchema = z.object({
raw: z.string().nullish(),
/** Content version (the file record's `updatedAt`). Present => the URL is content-immutable and may be cached indefinitely by the browser. */
v: z.string().nullish(),
})
export const fileViewParamsSchema = z.object({
id: workspaceFileIdSchema,
})
export const fileExportParamsSchema = z.object({
id: workspaceFileIdSchema,
})
export const boxUploadContract = defineRouteContract({
method: 'POST',
path: '/api/tools/box/upload',
body: boxUploadBodySchema,
response: { mode: 'json', schema: jsonResponseSchema },
})
export const dropboxUploadContract = defineRouteContract({
method: 'POST',
path: '/api/tools/dropbox/upload',
body: dropboxUploadBodySchema,
response: { mode: 'json', schema: jsonResponseSchema },
})
export const jupyterUploadContract = defineRouteContract({
method: 'POST',
path: '/api/tools/jupyter/upload',
body: jupyterUploadBodySchema,
response: { mode: 'json', schema: jsonResponseSchema },
})
export const wordpressUploadContract = defineRouteContract({
method: 'POST',
path: '/api/tools/wordpress/upload',
body: wordpressUploadBodySchema,
response: { mode: 'json', schema: jsonResponseSchema },
})
export const sftpListContract = defineRouteContract({
method: 'POST',
path: '/api/tools/sftp/list',
body: sftpListBodySchema,
response: { mode: 'json', schema: jsonResponseSchema },
})
export const sftpDeleteContract = defineRouteContract({
method: 'POST',
path: '/api/tools/sftp/delete',
body: sftpDeleteBodySchema,
response: { mode: 'json', schema: jsonResponseSchema },
})
export const sftpMkdirContract = defineRouteContract({
method: 'POST',
path: '/api/tools/sftp/mkdir',
body: sftpMkdirBodySchema,
response: { mode: 'json', schema: jsonResponseSchema },
})
export const sftpDownloadContract = defineRouteContract({
method: 'POST',
path: '/api/tools/sftp/download',
body: sftpDownloadBodySchema,
response: { mode: 'json', schema: jsonResponseSchema },
})
export const sftpUploadContract = defineRouteContract({
method: 'POST',
path: '/api/tools/sftp/upload',
body: sftpUploadBodySchema,
response: { mode: 'json', schema: jsonResponseSchema },
})
export const sshCheckCommandExistsContract = defineRouteContract({
method: 'POST',
path: '/api/tools/ssh/check-command-exists',
body: sshCheckCommandExistsBodySchema,
response: { mode: 'json', schema: jsonResponseSchema },
})
export const sshCheckFileExistsContract = defineRouteContract({
method: 'POST',
path: '/api/tools/ssh/check-file-exists',
body: sshCheckFileExistsBodySchema,
response: { mode: 'json', schema: jsonResponseSchema },
})
export const sshCreateDirectoryContract = defineRouteContract({
method: 'POST',
path: '/api/tools/ssh/create-directory',
body: sshCreateDirectoryBodySchema,
response: { mode: 'json', schema: jsonResponseSchema },
})
export const sshDeleteFileContract = defineRouteContract({
method: 'POST',
path: '/api/tools/ssh/delete-file',
body: sshDeleteFileBodySchema,
response: { mode: 'json', schema: jsonResponseSchema },
})
export const sshDownloadFileContract = defineRouteContract({
method: 'POST',
path: '/api/tools/ssh/download-file',
body: sshDownloadFileBodySchema,
response: { mode: 'json', schema: jsonResponseSchema },
})
export const sshExecuteCommandContract = defineRouteContract({
method: 'POST',
path: '/api/tools/ssh/execute-command',
body: sshExecuteCommandBodySchema,
response: { mode: 'json', schema: jsonResponseSchema },
})
export const sshExecuteScriptContract = defineRouteContract({
method: 'POST',
path: '/api/tools/ssh/execute-script',
body: sshExecuteScriptBodySchema,
response: { mode: 'json', schema: jsonResponseSchema },
})
export const sshGetSystemInfoContract = defineRouteContract({
method: 'POST',
path: '/api/tools/ssh/get-system-info',
body: sshGetSystemInfoBodySchema,
response: { mode: 'json', schema: jsonResponseSchema },
})
export const sshListDirectoryContract = defineRouteContract({
method: 'POST',
path: '/api/tools/ssh/list-directory',
body: sshListDirectoryBodySchema,
response: { mode: 'json', schema: jsonResponseSchema },
})
export const sshMoveRenameContract = defineRouteContract({
method: 'POST',
path: '/api/tools/ssh/move-rename',
body: sshMoveRenameBodySchema,
response: { mode: 'json', schema: jsonResponseSchema },
})
export const sshReadFileContentContract = defineRouteContract({
method: 'POST',
path: '/api/tools/ssh/read-file-content',
body: sshReadFileContentBodySchema,
response: { mode: 'json', schema: jsonResponseSchema },
})
export const sshUploadFileContract = defineRouteContract({
method: 'POST',
path: '/api/tools/ssh/upload-file',
body: sshUploadFileBodySchema,
response: { mode: 'json', schema: jsonResponseSchema },
})
export const sshWriteFileContentContract = defineRouteContract({
method: 'POST',
path: '/api/tools/ssh/write-file-content',
body: sshWriteFileContentBodySchema,
response: { mode: 'json', schema: jsonResponseSchema },
})
export const fileDownloadContract = defineRouteContract({
method: 'POST',
path: '/api/files/download',
body: fileDownloadBodySchema,
response: { mode: 'json', schema: jsonResponseSchema },
})
export const fileParseContract = defineRouteContract({
method: 'POST',
path: '/api/files/parse',
body: fileParseBodySchema,
response: { mode: 'json', schema: jsonResponseSchema },
})
export const fileDeleteContract = defineRouteContract({
method: 'POST',
path: '/api/files/delete',
body: fileDeleteBodySchema,
response: { mode: 'json', schema: jsonResponseSchema },
})
export const fileUploadContract = defineRouteContract({
method: 'POST',
path: '/api/files/upload',
response: { mode: 'json', schema: jsonResponseSchema },
})
export const presignedUploadContract = defineRouteContract({
method: 'POST',
path: '/api/files/presigned',
query: presignedUploadQuerySchema,
body: presignedUrlBodySchema,
response: { mode: 'json', schema: presignedUploadResponseSchema },
})
export const presignedUploadBodyContract = defineRouteContract({
method: 'POST',
path: '/api/files/presigned',
body: presignedUrlBodySchema,
response: { mode: 'json', schema: presignedUploadResponseSchema },
})
export const batchPresignedUploadContract = defineRouteContract({
method: 'POST',
path: '/api/files/presigned/batch',
query: presignedUploadQuerySchema,
body: batchPresignedUrlBodySchema,
response: { mode: 'json', schema: batchPresignedUploadResponseSchema },
})
export const batchPresignedUploadBodyContract = defineRouteContract({
method: 'POST',
path: '/api/files/presigned/batch',
body: batchPresignedUrlBodySchema,
response: { mode: 'json', schema: batchPresignedUploadResponseSchema },
})
export const multipartUploadContract = defineRouteContract({
method: 'POST',
path: '/api/files/multipart',
response: { mode: 'json', schema: multipartUploadResponseSchema },
})
export const initiateMultipartUploadContract = defineRouteContract({
method: 'POST',
path: '/api/files/multipart',
body: initiateMultipartBodySchema,
response: { mode: 'json', schema: initiateMultipartResponseSchema },
})
export const getMultipartPartUrlsContract = defineRouteContract({
method: 'POST',
path: '/api/files/multipart',
body: getMultipartPartUrlsBodySchema,
response: { mode: 'json', schema: getMultipartPartUrlsResponseSchema },
})
export const completeMultipartUploadContract = defineRouteContract({
method: 'POST',
path: '/api/files/multipart',
body: completeMultipartBodySchema,
response: { mode: 'json', schema: completeMultipartResponseSchema },
})
export const abortMultipartUploadContract = defineRouteContract({
method: 'POST',
path: '/api/files/multipart',
body: tokenBoundMultipartBodySchema,
response: { mode: 'json', schema: abortMultipartResponseSchema },
})
export const fileServeContract = defineRouteContract({
method: 'GET',
path: '/api/files/serve/[...path]',
params: fileServeParamsSchema,
query: fileServeQuerySchema,
response: { mode: 'binary' },
})
export const fileViewContract = defineRouteContract({
method: 'GET',
path: '/api/files/view/[id]',
params: fileViewParamsSchema,
response: { mode: 'binary' },
})
export const fileExportContract = defineRouteContract({
method: 'GET',
path: '/api/files/export/[id]',
params: fileExportParamsSchema,
response: { mode: 'binary' },
})
export type BoxUploadBody = ContractBodyInput<typeof boxUploadContract>
export type BoxUploadResponse = ContractJsonResponse<typeof boxUploadContract>
export type DropboxUploadBody = ContractBodyInput<typeof dropboxUploadContract>
export type DropboxUploadResponse = ContractJsonResponse<typeof dropboxUploadContract>
export type JupyterUploadBody = ContractBodyInput<typeof jupyterUploadContract>
export type JupyterUploadResponse = ContractJsonResponse<typeof jupyterUploadContract>
export type WordPressUploadBody = ContractBodyInput<typeof wordpressUploadContract>
export type WordPressUploadResponse = ContractJsonResponse<typeof wordpressUploadContract>
export type SftpDownloadBody = ContractBodyInput<typeof sftpDownloadContract>
export type SftpUploadBody = ContractBodyInput<typeof sftpUploadContract>
export type SftpDeleteBody = ContractBodyInput<typeof sftpDeleteContract>
export type SftpMkdirBody = ContractBodyInput<typeof sftpMkdirContract>
export type SshCheckCommandExistsBody = ContractBodyInput<typeof sshCheckCommandExistsContract>
export type SshCheckFileExistsBody = ContractBodyInput<typeof sshCheckFileExistsContract>
export type SshCreateDirectoryBody = ContractBodyInput<typeof sshCreateDirectoryContract>
export type SshDeleteFileBody = ContractBodyInput<typeof sshDeleteFileContract>
export type SshDownloadFileBody = ContractBodyInput<typeof sshDownloadFileContract>
export type SshExecuteCommandBody = ContractBodyInput<typeof sshExecuteCommandContract>
export type SshExecuteScriptBody = ContractBodyInput<typeof sshExecuteScriptContract>
export type SshGetSystemInfoBody = ContractBodyInput<typeof sshGetSystemInfoContract>
export type SshListDirectoryBody = ContractBodyInput<typeof sshListDirectoryContract>
export type SshMoveRenameBody = ContractBodyInput<typeof sshMoveRenameContract>
export type SshReadFileContentBody = ContractBodyInput<typeof sshReadFileContentContract>
export type SshUploadFileBody = ContractBodyInput<typeof sshUploadFileContract>
export type SshWriteFileContentBody = ContractBodyInput<typeof sshWriteFileContentContract>
export type FileDownloadBody = ContractBodyInput<typeof fileDownloadContract>
export type FileDownloadResponse = ContractJsonResponse<typeof fileDownloadContract>
export type FileParseBody = ContractBodyInput<typeof fileParseContract>
export type FileParseResponse = ContractJsonResponse<typeof fileParseContract>
export type FileDeleteBody = ContractBodyInput<typeof fileDeleteContract>
export type FileDeleteResponse = ContractJsonResponse<typeof fileDeleteContract>
export type PresignedUploadQuery = ContractQueryInput<typeof presignedUploadContract>
export type PresignedUploadBody = ContractBodyInput<typeof presignedUploadContract>
export type PresignedUploadResponse = ContractJsonResponse<typeof presignedUploadContract>
export type BatchPresignedUploadQuery = ContractQueryInput<typeof batchPresignedUploadContract>
export type BatchPresignedUploadBody = ContractBodyInput<typeof batchPresignedUploadContract>
export type BatchPresignedUploadResponse = ContractJsonResponse<typeof batchPresignedUploadContract>
export type MultipartAction = z.output<typeof multipartActionSchema>
export type InitiateMultipartBody = z.output<typeof initiateMultipartBodySchema>
export type TokenBoundMultipartBody = z.output<typeof tokenBoundMultipartBodySchema>
export type GetMultipartPartUrlsBody = z.output<typeof getMultipartPartUrlsBodySchema>
export type FileServeParams = ContractParamsInput<typeof fileServeContract>
export type FileServeQuery = ContractQueryInput<typeof fileServeContract>
export type FileViewParams = ContractParamsInput<typeof fileViewContract>
export type FileExportParams = ContractParamsInput<typeof fileExportContract>
+387
View File
@@ -0,0 +1,387 @@
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts/types'
const booleanQueryParamSchema = z
.preprocess((value) => {
if (value === 'true') return true
if (value === 'false') return false
return value
}, z.boolean())
.optional()
.default(false)
export const billingUpdateCostBodySchema = z.object({
userId: z.string().min(1, 'User ID is required'),
cost: z.number().min(0, 'Cost must be a non-negative number'),
model: z.string().min(1, 'Model is required'),
inputTokens: z.number().min(0).default(0),
outputTokens: z.number().min(0).default(0),
source: z
.enum(['copilot', 'workspace-chat', 'mcp_copilot', 'mothership_block'])
.default('copilot'),
idempotencyKey: z.string().min(1).optional(),
/**
* Originating workspace, used for org-workspace cost attribution on hosted
* Sim. Best-effort by design: self-hosted and headless clients bill through
* this endpoint with workspace IDs that exist only in their own deployment
* (or with none at all — the Go client omits the field when empty), so the
* value is optional and the route only stamps it onto the ledger when it
* resolves to a workspace in this deployment. Billing is keyed on the
* user's billing entity and must never fail over attribution metadata.
*/
workspaceId: z.string().min(1).optional(),
})
export type BillingUpdateCostBody = z.input<typeof billingUpdateCostBodySchema>
export const billingSwitchPlanBodySchema = z.object({
targetPlanName: z.string(),
interval: z.enum(['month', 'year']).optional(),
})
export type BillingSwitchPlanBody = z.input<typeof billingSwitchPlanBodySchema>
export const billingQuerySchema = z.object({
context: z.enum(['user', 'organization']).optional().default('user'),
id: z.string().min(1).optional(),
includeOrg: booleanQueryParamSchema,
})
export const billingUsageDataSchema = z
.object({
current: z.number(),
limit: z.number(),
percentUsed: z.number(),
isWarning: z.boolean(),
isExceeded: z.boolean(),
billingPeriodStart: z.string().nullable(),
billingPeriodEnd: z.string().nullable(),
lastPeriodCost: z.number(),
lastPeriodCopilotCost: z.number(),
daysRemaining: z.number(),
copilotCost: z.number(),
})
.passthrough()
export const subscriptionBillingDataSchema = z
.object({
type: z.enum(['individual', 'organization']),
plan: z.string(),
currentUsage: z.number(),
usageLimit: z.number(),
percentUsed: z.number(),
isWarning: z.boolean(),
isExceeded: z.boolean(),
daysRemaining: z.number(),
creditBalance: z.number(),
billingInterval: z.enum(['month', 'year']),
isPaid: z.boolean(),
isPro: z.boolean(),
isTeam: z.boolean(),
isEnterprise: z.boolean(),
isOrgScoped: z.boolean(),
organizationId: z.string().nullable(),
status: z.string().nullable(),
seats: z.number().nullable(),
metadata: z.unknown().nullable(),
stripeSubscriptionId: z.string().nullable(),
periodEnd: z.string().nullable(),
cancelAtPeriodEnd: z.boolean().optional(),
usage: billingUsageDataSchema,
billingBlocked: z.boolean().optional(),
billingBlockedReason: z.enum(['payment_failed', 'dispute']).nullable().optional(),
blockedByOrgOwner: z.boolean().optional(),
organization: z
.object({
id: z.string(),
role: z.enum(['owner', 'admin', 'member']),
})
.optional(),
})
.passthrough()
export const subscriptionApiResponseSchema = z
.object({
success: z.boolean(),
context: z.string(),
data: subscriptionBillingDataSchema,
})
.passthrough()
export const organizationBillingMemberSchema = z
.object({
id: z.string().optional(),
userId: z.string().optional(),
userName: z.string().nullable().optional(),
userEmail: z.string().nullable().optional(),
joinedAt: z.string().nullable().optional(),
})
.passthrough()
export const organizationBillingDataSchema = z
.object({
organizationId: z.string(),
organizationName: z.string(),
subscriptionPlan: z.string(),
subscriptionStatus: z.string().nullable(),
totalSeats: z.number(),
usedSeats: z.number(),
seatsCount: z.number(),
totalCurrentUsage: z.number(),
totalUsageLimit: z.number(),
minimumBillingAmount: z.number(),
averageUsagePerMember: z.number(),
billingPeriodStart: z.string().nullable(),
billingPeriodEnd: z.string().nullable(),
members: z.array(organizationBillingMemberSchema),
billingBlocked: z.boolean().optional(),
billingBlockedReason: z.enum(['payment_failed', 'dispute']).nullable().optional(),
blockedByOrgOwner: z.boolean().optional(),
})
.passthrough()
export const organizationBillingApiResponseSchema = z
.object({
success: z.boolean(),
context: z.literal('organization'),
data: organizationBillingDataSchema,
userRole: z.enum(['owner', 'admin', 'member']),
billingBlocked: z.boolean().optional(),
billingBlockedReason: z.enum(['payment_failed', 'dispute']).nullable().optional(),
blockedByOrgOwner: z.boolean().optional(),
})
.passthrough()
export const usageLimitDataSchema = z
.object({
currentLimit: z.number(),
canEdit: z.boolean(),
minimumLimit: z.number(),
plan: z.string(),
updatedAt: z.string().nullable(),
scope: z.enum(['user', 'organization']),
organizationId: z.string().nullable(),
})
.passthrough()
export const usageQuerySchema = z.object({
context: z.enum(['user', 'organization']).optional().default('user'),
userId: z.string().optional(),
organizationId: z.string().optional(),
})
export const updateUsageLimitBodySchema = z
.object({
limit: z.number().min(0, 'Limit must be a non-negative number'),
context: z.enum(['user', 'organization']).optional().default('user'),
organizationId: z.string().optional(),
})
.refine((data) => data.context !== 'organization' || data.organizationId, {
message: 'Organization ID is required when context is organization',
})
export const usageLimitApiResponseSchema = z
.object({
success: z.boolean(),
context: z.string(),
userId: z.string(),
organizationId: z.string().nullable(),
data: usageLimitDataSchema,
})
.passthrough()
export const organizationUsageLimitApiResponseSchema = z
.object({
success: z.boolean(),
context: z.literal('organization'),
userId: z.string(),
organizationId: z.string(),
data: organizationBillingDataSchema.nullable(),
})
.passthrough()
export const purchaseCreditsBodySchema = z.object({
amount: z.number().min(10).max(1000),
requestId: z.string().uuid(),
})
export const billingPortalBodySchema = z.object({
context: z.enum(['user', 'organization']).optional().default('user'),
organizationId: z.string().min(1).optional(),
returnUrl: z.string().min(1).optional(),
})
export const invoicesQuerySchema = z.object({
context: z.enum(['user', 'organization']).optional().default('user'),
organizationId: z.string().min(1).optional(),
})
export const invoiceItemSchema = z.object({
id: z.string(),
number: z.string().nullable(),
/** Invoice creation time as a Unix timestamp in seconds. */
created: z.number(),
/** Invoice total in the currency's minor units (e.g. cents). */
total: z.number(),
/** Amount paid in the currency's minor units (e.g. cents). */
amountPaid: z.number(),
currency: z.string(),
status: z.string().nullable(),
hostedInvoiceUrl: z.string().nullable(),
invoicePdf: z.string().nullable(),
})
export const invoicesApiResponseSchema = z.object({
success: z.boolean(),
invoices: z.array(invoiceItemSchema),
/** True when Stripe has more invoices than the returned page (overflow). */
hasMore: z.boolean(),
})
const successResponseSchema = z.object({
success: z.boolean(),
})
export const getBillingContract = defineRouteContract({
method: 'GET',
path: '/api/billing',
query: billingQuerySchema,
response: {
mode: 'json',
schema: z.union([subscriptionApiResponseSchema, organizationBillingApiResponseSchema]),
},
})
export const getUserBillingContract = defineRouteContract({
method: 'GET',
path: '/api/billing',
query: billingQuerySchema.extend({
context: z.literal('user').optional().default('user'),
}),
response: {
mode: 'json',
schema: subscriptionApiResponseSchema,
},
})
export const getOrganizationBillingContract = defineRouteContract({
method: 'GET',
path: '/api/billing',
query: billingQuerySchema.extend({
context: z.literal('organization'),
id: z.string().min(1),
}),
response: {
mode: 'json',
schema: organizationBillingApiResponseSchema,
},
})
export const getUsageLimitContract = defineRouteContract({
method: 'GET',
path: '/api/usage',
query: usageQuerySchema,
response: {
mode: 'json',
schema: z.union([usageLimitApiResponseSchema, organizationUsageLimitApiResponseSchema]),
},
})
export const getUserUsageLimitContract = defineRouteContract({
method: 'GET',
path: '/api/usage',
query: usageQuerySchema.extend({
context: z.literal('user').optional().default('user'),
}),
response: {
mode: 'json',
schema: usageLimitApiResponseSchema,
},
})
export const updateUsageLimitContract = defineRouteContract({
method: 'PUT',
path: '/api/usage',
body: updateUsageLimitBodySchema,
response: {
mode: 'json',
schema: z.union([usageLimitApiResponseSchema, organizationUsageLimitApiResponseSchema]),
},
})
export const purchaseCreditsContract = defineRouteContract({
method: 'POST',
path: '/api/billing/credits',
body: purchaseCreditsBodySchema,
response: {
mode: 'json',
schema: successResponseSchema,
},
})
export const createBillingPortalContract = defineRouteContract({
method: 'POST',
path: '/api/billing/portal',
body: billingPortalBodySchema,
response: {
mode: 'json',
schema: z.object({
url: z.string().min(1),
}),
},
})
export const getInvoicesContract = defineRouteContract({
method: 'GET',
path: '/api/billing/invoices',
query: invoicesQuerySchema,
response: {
mode: 'json',
schema: invoicesApiResponseSchema,
},
})
export const billingSwitchPlanResponseSchema = z.object({
success: z.literal(true),
plan: z.string().optional(),
interval: z.enum(['month', 'year']).optional(),
message: z.string().optional(),
})
export const billingUpdateCostResponseSchema = z.object({
success: z.literal(true),
message: z.string().optional(),
data: z.object({
userId: z.string().optional(),
cost: z.number().optional(),
billingEnabled: z.boolean().optional(),
processedAt: z.string(),
requestId: z.string(),
}),
})
export const billingSwitchPlanContract = defineRouteContract({
method: 'POST',
path: '/api/billing/switch-plan',
body: billingSwitchPlanBodySchema,
response: {
mode: 'json',
schema: billingSwitchPlanResponseSchema,
},
})
export const billingUpdateCostContract = defineRouteContract({
method: 'POST',
path: '/api/billing/update-cost',
body: billingUpdateCostBodySchema,
response: {
mode: 'json',
schema: billingUpdateCostResponseSchema,
},
})
export type BillingUsageData = z.infer<typeof billingUsageDataSchema>
export type SubscriptionBillingData = z.infer<typeof subscriptionBillingDataSchema>
export type SubscriptionApiResponse = z.infer<typeof subscriptionApiResponseSchema>
export type OrganizationBillingApiResponse = z.infer<typeof organizationBillingApiResponseSchema>
export type UsageLimitApiResponse = z.infer<typeof usageLimitApiResponseSchema>
export type InvoiceItem = z.infer<typeof invoiceItemSchema>
export type InvoicesApiResponse = z.infer<typeof invoicesApiResponseSchema>
+24
View File
@@ -0,0 +1,24 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { tableEventStreamQuerySchema } from '@/lib/api/contracts/tables'
describe('tableEventStreamQuerySchema', () => {
it('parses an explicit cursor', () => {
expect(tableEventStreamQuerySchema.parse({ from: '7' })).toEqual({ from: 7 })
})
it('keeps 0 as an explicit replay-from-start cursor', () => {
expect(tableEventStreamQuerySchema.parse({ from: '0' })).toEqual({ from: 0 })
})
it('yields undefined when absent — the tail-from-latest signal', () => {
expect(tableEventStreamQuerySchema.parse({})).toEqual({ from: undefined })
})
it('yields undefined for invalid values instead of coercing to a full replay', () => {
expect(tableEventStreamQuerySchema.parse({ from: 'abc' })).toEqual({ from: undefined })
expect(tableEventStreamQuerySchema.parse({ from: '-4' })).toEqual({ from: undefined })
})
})
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts/types'
const ALLOWED_TELEMETRY_CATEGORIES = [
'page_view',
'feature_usage',
'performance',
'error',
'workflow',
'consent',
'batch',
] as const
export const telemetryCategorySchema = z.enum([...ALLOWED_TELEMETRY_CATEGORIES] as [
string,
...string[],
])
export type TelemetryCategory = z.output<typeof telemetryCategorySchema>
const SENSITIVE_PATTERNS = [/password/, /token/, /secret/, /key/, /auth/, /credential/, /private/]
export const telemetryEventSchema = z
.object({
category: telemetryCategorySchema,
action: z.string().min(1),
})
.passthrough()
.refine(
(data) => {
const jsonStr = JSON.stringify(data).toLowerCase()
return !SENSITIVE_PATTERNS.some((pattern) => pattern.test(jsonStr))
},
{ message: 'Telemetry data contains sensitive information' }
)
export type TelemetryEvent = z.output<typeof telemetryEventSchema>
export const telemetryContract = defineRouteContract({
method: 'POST',
path: '/api/telemetry',
body: telemetryEventSchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
forwarded: z.boolean(),
}),
},
})
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest'
import { tiktokPublishVideoBodySchema } from '@/lib/api/contracts/tiktok-tools'
const file = {
key: 'workspace/test/video.mp4',
name: 'video.mp4',
size: 1024,
type: 'video/mp4',
}
describe('tiktokPublishVideoBodySchema', () => {
it('accepts a draft without direct-post metadata', () => {
expect(
tiktokPublishVideoBodySchema.safeParse({
accessToken: 'token',
mode: 'draft',
file,
}).success
).toBe(true)
})
it('requires direct-post privacy and commercial-content disclosure', () => {
expect(
tiktokPublishVideoBodySchema.safeParse({
accessToken: 'token',
mode: 'direct',
file,
postInfo: { privacy_level: 'SELF_ONLY' },
}).success
).toBe(false)
})
it('accepts documented direct-post metadata', () => {
expect(
tiktokPublishVideoBodySchema.safeParse({
accessToken: 'token',
mode: 'direct',
file,
musicUsageConsent: 'accepted',
postInfo: {
title: 'A test video',
privacy_level: 'SELF_ONLY',
disable_duet: true,
disable_stitch: true,
disable_comment: true,
brand_content_toggle: false,
},
}).success
).toBe(true)
})
it('rejects direct posting without explicit music usage consent', () => {
expect(
tiktokPublishVideoBodySchema.safeParse({
accessToken: 'token',
mode: 'direct',
file,
postInfo: {
privacy_level: 'SELF_ONLY',
disable_duet: true,
disable_stitch: true,
disable_comment: true,
brand_content_toggle: false,
},
}).success
).toBe(false)
})
it('rejects an undocumented privacy value', () => {
expect(
tiktokPublishVideoBodySchema.safeParse({
accessToken: 'token',
mode: 'direct',
file,
musicUsageConsent: 'accepted',
postInfo: {
privacy_level: 'PRIVATE',
disable_duet: true,
disable_stitch: true,
disable_comment: true,
brand_content_toggle: false,
},
}).success
).toBe(false)
})
})

Some files were not shown because too many files have changed in this diff Show More