Files
WeHub Mirror 6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (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 / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
WeHub snapshot of cb28d14c6f2c081de7a0d8729a8c816c9adef67a
2026-08-10 11:17:50 +08:00

382 lines
12 KiB
TypeScript

import { z } from 'zod'
import { nonEmptyIdSchema, requiredFieldSchema } from '@/lib/api/contracts/primitives'
import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types'
export const workspaceScopeSchema = z.enum(['active', 'archived', 'all'])
export const workspaceModeSchema = z.enum(['personal', 'organization', 'grandfathered_shared'])
export const workspacePermissionSchema = z.enum(['admin', 'write', 'read'])
export type WorkspaceMode = z.output<typeof workspaceModeSchema>
export type WorkspacePermission = z.output<typeof workspacePermissionSchema>
export const workspaceSchema = z.object({
id: z.string(),
name: z.string(),
color: z.string().optional(),
logoUrl: z.string().nullable().optional(),
ownerId: z.string(),
organizationId: z.string().nullable(),
workspaceMode: workspaceModeSchema,
role: z.string().optional(),
membershipId: z.string().optional(),
permissions: workspacePermissionSchema.nullable().optional(),
billedAccountUserId: z.string().nullable().optional(),
allowPersonalApiKeys: z.boolean().optional(),
inviteMembersEnabled: z.boolean().optional(),
inviteDisabledReason: z.string().nullable().optional(),
inviteUpgradeRequired: z.boolean().optional(),
// Source workspace id when this was created as a fork (null otherwise). Optional
// because not every workspace response builder includes the column.
forkedFromWorkspaceId: z.string().nullable().optional(),
})
export type Workspace = z.output<typeof workspaceSchema>
export const workspaceCreationPolicySchema = z.object({
canCreate: z.boolean(),
workspaceMode: workspaceModeSchema,
organizationId: z.string().nullable(),
maxWorkspaces: z.number().nullable(),
currentWorkspaceCount: z.number(),
reason: z.string().nullable(),
/**
* Machine-readable discriminant for blocked states whose correct user-facing
* copy the workspace mode alone cannot determine.
*/
blockedReasonCode: z.literal('organization-subscription-inactive').optional(),
})
export type WorkspaceCreationPolicy = z.output<typeof workspaceCreationPolicySchema>
export const listWorkspacesQuerySchema = z.object({
scope: workspaceScopeSchema.default('active'),
})
export type WorkspaceQueryScope = NonNullable<z.input<typeof listWorkspacesQuerySchema>['scope']>
export const createWorkspaceBodySchema = z.object({
name: z.string().trim().min(1, 'Name is required'),
color: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.optional(),
skipDefaultWorkflow: z.boolean().optional().default(false),
})
export const workspaceParamsSchema = z.object({
id: z.string().min(1),
})
export const updateWorkspaceBodySchema = z.object({
name: z.string().trim().min(1).optional(),
color: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.optional(),
logoUrl: z
.string()
.refine((val) => val.startsWith('/') || val.startsWith('https://'), {
message: 'Logo URL must be an absolute path or HTTPS URL',
})
.nullable()
.optional(),
billedAccountUserId: z.string().optional(),
allowPersonalApiKeys: z.boolean().optional(),
})
export const deleteWorkspaceBodySchema = z.object({})
export const workspaceUserSchema = z.object({
userId: z.string(),
email: z.string(),
name: z.string().nullable(),
image: z.string().nullable(),
permissionType: workspacePermissionSchema,
isExternal: z.boolean(),
joinedAt: z.string(),
roleSource: z.enum(['owner', 'explicit', 'org-admin']),
isBilledAccount: z.boolean(),
})
export type WorkspaceUser = z.output<typeof workspaceUserSchema>
export const workspacePermissionsViewerSchema = z.object({
userId: z.string(),
isAdmin: z.boolean(),
permissionType: workspacePermissionSchema,
})
export type WorkspacePermissionsViewer = z.output<typeof workspacePermissionsViewerSchema>
export const workspacePermissionsResponseSchema = z.object({
users: z.array(workspaceUserSchema),
total: z.number().int(),
viewer: workspacePermissionsViewerSchema.optional(),
})
export type WorkspacePermissions = z.output<typeof workspacePermissionsResponseSchema>
/**
* Role changes for users who are **already** workspace members. The route
* rejects any `userId` without an existing workspace permission row — adding a
* collaborator goes through the invitation flow, which owns the plan, seat, and
* consent gates this endpoint has no way to apply.
*/
export const updateWorkspacePermissionsBodySchema = z.object({
updates: z
.array(
z.object({
userId: requiredFieldSchema('User ID is required').max(128, 'User ID is too long'),
permissions: workspacePermissionSchema,
})
)
.min(1, 'updates must contain at least one permission change')
.max(100, 'Cannot update more than 100 permissions at once')
/**
* One entry per user. Repeating a userId made the batch self-contradictory:
* the route's guards inspect the first matching entry while the write loop
* applied every entry in order, so a second entry could carry a role the
* guards had already vetted the first one against.
*/
.superRefine((updates, ctx) => {
const seen = new Set<string>()
for (const [index, update] of updates.entries()) {
if (seen.has(update.userId)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [index, 'userId'],
message: 'Each user may appear only once in updates',
})
return
}
seen.add(update.userId)
}
}),
})
export const workspaceMemberSchema = z.object({
userId: z.string(),
name: z.string(),
image: z.string().nullable(),
})
export type WorkspaceMember = z.output<typeof workspaceMemberSchema>
export const workspacePreviewBodySchema = z
.object({
code: z
.string({ error: 'code is required' })
.refine((code) => code.trim().length > 0, { message: 'code is required' }),
})
.passthrough()
export const workspaceMetricsExecutionsQuerySchema = z.object({
startTime: z.string().optional(),
endTime: z.string().optional(),
segments: z.coerce.number().min(1).max(200).default(72),
workflowIds: z.string().optional(),
folderIds: z.string().optional(),
triggers: z.string().optional(),
level: z.string().optional(),
allTime: z
.enum(['true', 'false'])
.optional()
.transform((value) => value === 'true'),
})
export const listWorkspacesContract = defineRouteContract({
method: 'GET',
path: '/api/workspaces',
query: listWorkspacesQuerySchema,
response: {
mode: 'json',
schema: z.object({
workspaces: z.array(workspaceSchema),
lastActiveWorkspaceId: z.string().nullable(),
/**
* Workspace ids the viewer pinned in the switcher, from `pinned_item`. May
* name workspaces absent from `workspaces` (archived, or access since
* removed); clients look pins up per rendered workspace, so unmatched ids
* are inert and the pin survives if access is restored.
*/
pinnedWorkspaceIds: z.array(z.string()).default([]),
creationPolicy: workspaceCreationPolicySchema.nullable(),
}),
},
})
export const createWorkspaceContract = defineRouteContract({
method: 'POST',
path: '/api/workspaces',
body: createWorkspaceBodySchema,
response: {
mode: 'json',
schema: z.object({
workspace: workspaceSchema,
}),
},
})
export const getWorkspaceContract = defineRouteContract({
method: 'GET',
path: '/api/workspaces/[id]',
params: workspaceParamsSchema,
response: {
mode: 'json',
schema: z.object({
workspace: workspaceSchema,
}),
},
})
/**
* Subscription access fields of the workspace's billed account (its OWNER's
* rolled-up plan) — the workspace-scoped counterpart to the viewer `/api/billing`
* data. Feed to `getSubscriptionAccessState` to gate workspace features on the
* owner's plan instead of the signed-in viewer's. No usage/credit data.
*/
export const workspaceOwnerBillingSchema = z.object({
plan: z.string(),
status: z.string().nullable(),
isPaid: z.boolean(),
isPro: z.boolean(),
isTeam: z.boolean(),
isEnterprise: z.boolean(),
isOrgScoped: z.boolean(),
organizationId: z.string().nullable(),
billingInterval: z.enum(['month', 'year']),
billingBlocked: z.boolean(),
billingBlockedReason: z.enum(['payment_failed', 'dispute']).nullable(),
})
export type WorkspaceOwnerBilling = z.output<typeof workspaceOwnerBillingSchema>
export const workspaceHostContextSchema = z.object({
workspace: z.object({
id: nonEmptyIdSchema,
name: z.string().min(1),
workspaceMode: workspaceModeSchema,
billedAccountUserId: nonEmptyIdSchema,
}),
hostOrganizationId: nonEmptyIdSchema.nullable(),
ownerBilling: workspaceOwnerBillingSchema,
viewer: z.object({
permission: workspacePermissionSchema,
isHostOrganizationMember: z.boolean(),
isHostOrganizationAdmin: z.boolean(),
}),
})
export type WorkspaceHostContext = z.output<typeof workspaceHostContextSchema>
export const getWorkspaceHostContextContract = defineRouteContract({
method: 'GET',
path: '/api/workspaces/[id]/host-context',
params: workspaceParamsSchema,
response: {
mode: 'json',
schema: workspaceHostContextSchema,
},
})
export const workspaceCreditAvailabilitySchema = z.object({
remainingDollars: z.number().nonnegative().nullable(),
scope: z.enum(['payer', 'member', 'effective']),
})
export type WorkspaceCreditAvailability = z.output<typeof workspaceCreditAvailabilitySchema>
export const getWorkspaceCreditAvailabilityContract = defineRouteContract({
method: 'GET',
path: '/api/workspaces/[id]/credit-availability',
params: workspaceParamsSchema,
response: {
mode: 'json',
schema: workspaceCreditAvailabilitySchema,
},
})
export const workspaceUsageGateSchema = z.object({
isExceeded: z.boolean(),
message: z.string().min(1).nullable(),
scope: z.enum(['actor', 'payer', 'member']).nullable(),
})
export type WorkspaceUsageGate = z.output<typeof workspaceUsageGateSchema>
export const getWorkspaceUsageGateContract = defineRouteContract({
method: 'GET',
path: '/api/workspaces/[id]/usage-gate',
params: workspaceParamsSchema,
response: {
mode: 'json',
schema: workspaceUsageGateSchema,
},
})
export const updateWorkspaceContract = defineRouteContract({
method: 'PATCH',
path: '/api/workspaces/[id]',
params: workspaceParamsSchema,
body: updateWorkspaceBodySchema,
response: {
mode: 'json',
schema: z.object({
workspace: workspaceSchema,
}),
},
})
export const deleteWorkspaceContract = defineRouteContract({
method: 'DELETE',
path: '/api/workspaces/[id]',
params: workspaceParamsSchema,
body: deleteWorkspaceBodySchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
}),
},
})
export const getWorkspacePermissionsContract = defineRouteContract({
method: 'GET',
path: '/api/workspaces/[id]/permissions',
params: workspaceParamsSchema,
response: {
mode: 'json',
schema: workspacePermissionsResponseSchema,
},
})
export const updateWorkspacePermissionsContract = defineRouteContract({
method: 'PATCH',
path: '/api/workspaces/[id]/permissions',
params: workspaceParamsSchema,
body: updateWorkspacePermissionsBodySchema,
/**
* Acknowledgement only. The roster this used to echo was discarded by every
* caller — the members list is owned by the GET above and refetched on
* settle — so building it cost three queries per role change and made a
* post-commit read failure able to report an applied change as a 500.
*/
response: {
mode: 'json',
schema: z.object({ message: z.string() }),
},
})
export const getWorkspaceMembersContract = defineRouteContract({
method: 'GET',
path: '/api/workspaces/[id]/members',
params: workspaceParamsSchema,
response: {
mode: 'json',
schema: z.object({
members: z.array(workspaceMemberSchema),
}),
},
})
export type WorkspacesResponse = ContractJsonResponse<typeof listWorkspacesContract>