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

213 lines
7.0 KiB
TypeScript

import { db } from '@sim/db'
import { skill, skillMember } from '@sim/db/schema'
import { and, eq, inArray } from 'drizzle-orm'
import type { DbOrTx } from '@/lib/db/types'
import {
getUsersWithPermissions,
resolveWorkspaceAccess,
type WorkspaceAccess,
} from '@/lib/workspaces/permissions/utils'
type SkillRecord = typeof skill.$inferSelect
export interface SkillActorContext {
skill: SkillRecord | null
/** Whether the actor can see and use the skill — plain workspace access. */
hasWorkspaceAccess: boolean
/**
* Whether the actor can edit, delete, and share the skill: an explicit
* `skill_member` editor row, or derived workspace admin (always, undemotable).
*/
canEdit: boolean
}
/**
* Resolves the acting user's context for a single skill. Everyone with
* workspace access sees and uses every skill; editing is gated by the editors
* list. Builtin skills are code-only and have no editors; callers guard with
* `isBuiltinSkillId` before reaching this.
*/
export async function getSkillActorContext(
skillId: string,
userId: string
): Promise<SkillActorContext> {
const [skillRow] = await db.select().from(skill).where(eq(skill.id, skillId)).limit(1)
if (!skillRow?.workspaceId) {
return { skill: skillRow ?? null, hasWorkspaceAccess: false, canEdit: false }
}
const [workspaceAccess, [editorRow]] = await Promise.all([
resolveWorkspaceAccess(skillRow.workspaceId, userId),
db
.select({ id: skillMember.id })
.from(skillMember)
.where(and(eq(skillMember.skillId, skillId), eq(skillMember.userId, userId)))
.limit(1),
])
return {
skill: skillRow,
hasWorkspaceAccess: workspaceAccess.hasAccess,
canEdit: workspaceAccess.hasAccess && (workspaceAccess.canAdmin || !!editorRow),
}
}
export interface EditableSkillIds {
/** Workspace admins are derived editors of every skill in the workspace. */
canAdminWorkspace: boolean
/** Skills where the user holds an explicit editor row. */
editorSkillIds: Set<string>
}
/**
* Batch edit-access surface for tagging many skills at once (list routes,
* upsert authorization): one workspace-access lookup plus one editor-row scan
* scoped to the workspace. A skill is editable when `canAdminWorkspace` or its
* id is in `editorSkillIds`.
*
* Pass `workspaceAccess` when the caller already resolved it to skip a
* redundant lookup.
*/
export async function getEditableSkillIds(
workspaceId: string,
userId: string,
options?: { workspaceAccess?: WorkspaceAccess }
): Promise<EditableSkillIds> {
const [workspaceAccess, editorRows] = await Promise.all([
resolveWorkspaceAccess(workspaceId, userId, options?.workspaceAccess),
db
.select({ skillId: skillMember.skillId })
.from(skillMember)
.innerJoin(skill, eq(skillMember.skillId, skill.id))
.where(and(eq(skill.workspaceId, workspaceId), eq(skillMember.userId, userId))),
])
if (!workspaceAccess.hasAccess) {
return { canAdminWorkspace: false, editorSkillIds: new Set() }
}
return {
canAdminWorkspace: workspaceAccess.canAdmin,
editorSkillIds: new Set(editorRows.map((row) => row.skillId)),
}
}
export interface SkillEditor {
/** Explicit row id, or a synthetic `workspace-admin-<userId>` id for derived admins without rows. */
id: string
userId: string
userName: string | null
userEmail: string | null
userImage: string | null
/** Derived editors — always present, cannot be removed from the list. */
isWorkspaceAdmin: boolean
}
/**
* The editor roster for a skill: every workspace admin (derived, undemotable)
* plus every explicit-row user still in the workspace roster. Rows for users
* who left the workspace are ignored, exactly as edit enforcement ignores them.
*/
export async function listSkillEditors(skillRow: {
id: string
workspaceId: string
}): Promise<SkillEditor[]> {
const [explicitRows, workspaceMembers] = await Promise.all([
db
.select({ id: skillMember.id, userId: skillMember.userId })
.from(skillMember)
.where(eq(skillMember.skillId, skillRow.id)),
getUsersWithPermissions(skillRow.workspaceId),
])
const rowByUser = new Map(explicitRows.map((row) => [row.userId, row]))
const editors: SkillEditor[] = []
for (const wsMember of workspaceMembers) {
const row = rowByUser.get(wsMember.userId)
const isWorkspaceAdmin = wsMember.permissionType === 'admin'
if (!row && !isWorkspaceAdmin) continue
editors.push({
id: row?.id ?? `workspace-admin-${wsMember.userId}`,
userId: wsMember.userId,
userName: wsMember.name,
userEmail: wsMember.email,
userImage: wsMember.image ?? null,
isWorkspaceAdmin,
})
}
return editors
}
export interface SkillsUpdateAccess {
/** Ids from the request that resolve to existing skills in the workspace. */
existingIds: Set<string>
/** Existing skills the user may not update (not an editor, not a workspace admin). */
denied: Array<{ id: string; name: string }>
}
/**
* Partitions an upsert request's skill ids for authorization: ids that resolve
* to existing workspace skills require skill editor access; unresolved ids are
* creates, gated by workspace write permission instead.
*/
export async function checkSkillsUpdateAccess(params: {
workspaceId: string
userId: string
skillIds: string[]
workspaceAccess?: WorkspaceAccess
}): Promise<SkillsUpdateAccess> {
if (params.skillIds.length === 0) return { existingIds: new Set(), denied: [] }
const rows = await db
.select({ id: skill.id, name: skill.name })
.from(skill)
.where(and(eq(skill.workspaceId, params.workspaceId), inArray(skill.id, params.skillIds)))
const existingIds = new Set(rows.map((row) => row.id))
if (rows.length === 0) return { existingIds, denied: [] }
const access = await getEditableSkillIds(params.workspaceId, params.userId, {
workspaceAccess: params.workspaceAccess,
})
const denied = access.canAdminWorkspace
? []
: rows.filter((row) => !access.editorSkillIds.has(row.id))
return { existingIds, denied }
}
/**
* Removes a user's skill editor grants across one or more workspaces when they
* leave (workspace removal, org removal/transfer). Rows are editor grants
* only — everyone in the workspace already sees and uses every skill — so a
* later re-invite lands them with no edit rights until re-added. Workspace
* admins are derived editors, so no promotion is needed to avoid orphaning a
* skill. Returns the number of grants removed.
*/
export async function removeWorkspaceSkillMembershipsTx(
tx: DbOrTx,
workspaceId: string | string[],
userId: string
): Promise<number> {
const workspaceIds = Array.isArray(workspaceId) ? workspaceId : [workspaceId]
if (workspaceIds.length === 0) return 0
const removed = await tx
.delete(skillMember)
.where(
and(
eq(skillMember.userId, userId),
inArray(
skillMember.skillId,
tx.select({ id: skill.id }).from(skill).where(inArray(skill.workspaceId, workspaceIds))
)
)
)
.returning({ id: skillMember.id })
return removed.length
}