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
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:
@@ -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' }])
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './errors'
|
||||
export * from './request'
|
||||
@@ -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/
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
@@ -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(),
|
||||
}),
|
||||
},
|
||||
})
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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(),
|
||||
}),
|
||||
},
|
||||
})
|
||||
@@ -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),
|
||||
}),
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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: '1–10' },
|
||||
{ value: '11_50', label: '11–50' },
|
||||
{ value: '51_200', label: '51–200' },
|
||||
{ value: '201_500', label: '201–500' },
|
||||
{ value: '501_1000', label: '501–1,000' },
|
||||
{ value: '1001_10000', label: '1,001–10,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,
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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(),
|
||||
}),
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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'
|
||||
@@ -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)),
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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' },
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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),
|
||||
}),
|
||||
},
|
||||
})
|
||||
@@ -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)' })
|
||||
)
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user