Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
666 lines
24 KiB
TypeScript
666 lines
24 KiB
TypeScript
import { createBuilder, type CustomMutatorDefs, type Transaction } from '@rocicorp/zero'
|
|
import {
|
|
assert,
|
|
getIndexBelow,
|
|
IndexKey,
|
|
sortByIndex,
|
|
sortByMaybeIndex,
|
|
uniqueId,
|
|
} from '@tldraw/utils'
|
|
import { MAX_NUMBER_OF_WORKSPACES, MAX_WORKSPACE_NAME_LENGTH } from './constants'
|
|
import { Role, can, isRole } from './roles'
|
|
import { FILE_PREFIX } from './routes'
|
|
import {
|
|
immutableColumns,
|
|
schema,
|
|
TlaFile,
|
|
TlaFilePartial,
|
|
TlaFileState,
|
|
TlaFileStatePartial,
|
|
TlaFlags,
|
|
TlaSchema,
|
|
TlaUser,
|
|
TlaUserPartial,
|
|
} from './tlaSchema'
|
|
import { ZErrorCode } from './types'
|
|
|
|
/** Query builder for mutators - uses Zero's createBuilder API */
|
|
const zql = createBuilder(schema)
|
|
|
|
type Tx = Transaction<TlaSchema>
|
|
|
|
/**
|
|
* Parse a flags string into an array of individual flags.
|
|
* Supports flags separated by commas, spaces, or both.
|
|
* @param flags - The flags string to parse (e.g., "flag1,flag2" or "flag1 flag2")
|
|
* @returns Array of individual flag strings
|
|
*/
|
|
export function parseFlags(flags: string | null | undefined): string[] {
|
|
return flags?.split(/[,\s]+/).filter(Boolean) ?? []
|
|
}
|
|
|
|
/**
|
|
* Check if a flags string contains a specific flag.
|
|
* @param flags - The flags string to check
|
|
* @param flag - The flag to look for
|
|
* @returns true if the flag is present
|
|
*/
|
|
export function userHasFlag(flags: string | null | undefined, flag: TlaFlags): boolean {
|
|
return parseFlags(flags).includes(flag)
|
|
}
|
|
|
|
function disallowImmutableMutations<
|
|
S extends TlaFilePartial | TlaFileStatePartial | TlaUserPartial,
|
|
>(data: S, immutableColumns: Set<keyof S>) {
|
|
for (const immutableColumn of immutableColumns) {
|
|
assert(!(immutableColumn in data), ZErrorCode.forbidden)
|
|
}
|
|
}
|
|
|
|
export type TlaMutators = ReturnType<typeof createMutators>
|
|
|
|
// A client timestamp more than 5s stale, or in the future, is replaced by the server's clock.
|
|
function ensureSensibleTimestamp(time: number) {
|
|
// if a mutation took more than 5 seconds to reach the server, or is in the future, let's use the server's time
|
|
const now = Date.now()
|
|
return time < now - 5000 || time > now ? now : time
|
|
}
|
|
|
|
/**
|
|
* Resolve the user's role in a workspace, or null if they aren't a member. Pair it
|
|
* with `can` from ./roles: `const role = await getRole(...); assert(can(role, 'x'))`.
|
|
*/
|
|
async function getRole(
|
|
tx: Tx,
|
|
userId: string,
|
|
workspaceId: string | null | undefined
|
|
): Promise<Role | null> {
|
|
if (!workspaceId) return null
|
|
// A user's personal/home workspace has an id equal to their userId; they own it.
|
|
if (workspaceId === userId) return 'owner'
|
|
const workspaceUser = await tx.run(
|
|
zql.group_user.where('userId', '=', userId).where('groupId', '=', workspaceId).one()
|
|
)
|
|
return workspaceUser?.role ?? null
|
|
}
|
|
|
|
/**
|
|
* The home workspace (group id === its owner's user id) is private: it can't be
|
|
* invited to, left, deleted, or have its members managed. (It can be renamed.)
|
|
* Throw if `workspaceId` is a home workspace, i.e. a user exists with a matching id.
|
|
*/
|
|
async function assertNotHomeWorkspace(tx: Tx, workspaceId: string) {
|
|
const user = await tx.run(zql.user.where('id', '=', workspaceId).one())
|
|
assert(!user, ZErrorCode.forbidden)
|
|
}
|
|
|
|
function assertValidId(id: string) {
|
|
assert(id.match(/^[a-zA-Z0-9_-]+$/), ZErrorCode.bad_request)
|
|
assert(id.length <= 32, ZErrorCode.bad_request)
|
|
assert(id.length >= 16, ZErrorCode.bad_request)
|
|
}
|
|
|
|
/**
|
|
* Check if a user has the required permissions for a file.
|
|
* @param tx - The transaction
|
|
* @param userId - The user ID to check permissions for
|
|
* @param file - The file to check permissions on
|
|
* @param allowGuestAccess - If true, shared files are accessible even if the user isn't a member
|
|
*/
|
|
async function assertUserCanAccessFileInternal(
|
|
tx: Tx,
|
|
userId: string,
|
|
file: TlaFile,
|
|
allowGuestAccess: boolean
|
|
) {
|
|
assert(file, ZErrorCode.bad_request)
|
|
assert(!file.isDeleted, ZErrorCode.bad_request)
|
|
|
|
// If shared and we allow shared access, grant access immediately
|
|
if (allowGuestAccess && file.shared) return
|
|
|
|
assert(file.owningGroupId, ZErrorCode.bad_request)
|
|
const role = await getRole(tx, userId, file.owningGroupId)
|
|
assert(can(role, 'accessFiles'), ZErrorCode.forbidden)
|
|
}
|
|
|
|
/**
|
|
* Check if a user can access (read) a file.
|
|
* A user can access a file if:
|
|
* - They are a member of the owning workspace (new model: user is in file.owningGroupId)
|
|
* - The file is shared
|
|
*/
|
|
async function assertUserCanAccessFile(tx: Tx, userId: string, file: TlaFile) {
|
|
await assertUserCanAccessFileInternal(tx, userId, file, true)
|
|
}
|
|
|
|
/**
|
|
* Check if a user can update (write to) a file.
|
|
* A user can update a file if:
|
|
* - They are a member of the owning workspace (new model: user is in file.owningGroupId)
|
|
* Note: Sharing only grants read access, not write access
|
|
*/
|
|
async function assertUserCanUpdateFile(tx: Tx, userId: string, file: TlaFile) {
|
|
await assertUserCanAccessFileInternal(tx, userId, file, false)
|
|
}
|
|
|
|
async function assertUserCanAccessFileById(tx: Tx, userId: string, fileId: string) {
|
|
const file = await tx.run(zql.file.where('id', '=', fileId).one())
|
|
await assertUserCanAccessFile(tx, userId, file!)
|
|
}
|
|
|
|
export function createMutators(userId: string) {
|
|
const mutators = {
|
|
user: {
|
|
/** @deprecated */
|
|
insert: async (tx: Tx, user: TlaUser) => {
|
|
assert(userId === user.id, ZErrorCode.forbidden)
|
|
await tx.mutate.user.insert(user)
|
|
},
|
|
update: async (tx: Tx, user: TlaUserPartial) => {
|
|
assert(userId === user.id, ZErrorCode.forbidden)
|
|
disallowImmutableMutations(user, immutableColumns.user)
|
|
await tx.mutate.user.update(user)
|
|
},
|
|
},
|
|
file: {
|
|
update: async (tx: Tx, _file: TlaFilePartial) => {
|
|
disallowImmutableMutations(_file, immutableColumns.file)
|
|
const file = await tx.run(zql.file.where('id', '=', _file.id).one())
|
|
await assertUserCanUpdateFile(tx, userId, file!)
|
|
|
|
await tx.mutate.file.update({
|
|
..._file,
|
|
id: file!.id,
|
|
})
|
|
},
|
|
},
|
|
file_state: {
|
|
/** @deprecated now update creates if not exists */
|
|
insert: async (tx: Tx, fileState: TlaFileState) => {
|
|
assert(fileState.userId === userId, ZErrorCode.forbidden)
|
|
if (tx.location === 'server') {
|
|
// Verify the user has access to this file
|
|
await assertUserCanAccessFileById(tx, userId, fileState.fileId)
|
|
}
|
|
// use upsert under the hood here for a little fault tolerance
|
|
await tx.mutate.file_state.upsert(fileState)
|
|
},
|
|
update: async (tx: Tx, fileState: TlaFileStatePartial) => {
|
|
assert(fileState.userId === userId, ZErrorCode.forbidden)
|
|
disallowImmutableMutations(fileState, immutableColumns.file_state)
|
|
if (tx.location === 'server') {
|
|
// Verify the user has access to this file
|
|
await assertUserCanAccessFileById(tx, userId, fileState.fileId)
|
|
}
|
|
const exists = await tx.run(
|
|
zql.file_state.where('fileId', '=', fileState.fileId).where('userId', '=', userId).one()
|
|
)
|
|
|
|
// if the file state does not exist, do nothing
|
|
if (!exists) return
|
|
|
|
await tx.mutate.file_state.upsert(fileState)
|
|
},
|
|
},
|
|
|
|
comment: {
|
|
/**
|
|
* Mark a comment as read by the current user. Row present = read; readAt is stored so
|
|
* an "edits reset unread" rule can later be added client-side without a migration.
|
|
*/
|
|
markRead: async (tx: Tx, { commentId, readAt }: { commentId: string; readAt: number }) => {
|
|
if (tx.location === 'server') {
|
|
// Verify the comment exists and the user can access its file
|
|
const comment = await tx.run(zql.comment.where('id', '=', commentId).one())
|
|
assert(comment, ZErrorCode.bad_request)
|
|
await assertUserCanAccessFileById(tx, userId, comment.fileId)
|
|
}
|
|
await tx.mutate.comment_read.upsert({
|
|
userId,
|
|
commentId,
|
|
readAt: ensureSensibleTimestamp(readAt),
|
|
})
|
|
},
|
|
/**
|
|
* Mark a batch of comments as read by the current user in one mutation — opening a thread
|
|
* or "mark all read" would otherwise pay a mutation (comment lookup, file lookup, access
|
|
* check) per comment. One access check per distinct fileId covers the whole batch.
|
|
*/
|
|
markManyRead: async (
|
|
tx: Tx,
|
|
{ commentIds, readAt }: { commentIds: string[]; readAt: number }
|
|
) => {
|
|
const uniqueIds = [...new Set(commentIds)]
|
|
if (uniqueIds.length === 0) return
|
|
if (tx.location === 'server') {
|
|
// Verify every comment exists and the user can access each involved file
|
|
const comments = await tx.run(zql.comment.where('id', 'IN', uniqueIds))
|
|
assert(comments.length === uniqueIds.length, ZErrorCode.bad_request)
|
|
for (const fileId of new Set(comments.map((comment) => comment.fileId))) {
|
|
await assertUserCanAccessFileById(tx, userId, fileId)
|
|
}
|
|
}
|
|
const timestamp = ensureSensibleTimestamp(readAt)
|
|
for (const commentId of uniqueIds) {
|
|
await tx.mutate.comment_read.upsert({ userId, commentId, readAt: timestamp })
|
|
}
|
|
},
|
|
/** Mark a comment as unread by deleting the current user's read row. Own-row-only by construction. */
|
|
markUnread: async (tx: Tx, { commentId }: { commentId: string }) => {
|
|
await tx.mutate.comment_read.delete({ userId, commentId })
|
|
},
|
|
},
|
|
|
|
createFile: async (
|
|
tx: Tx,
|
|
{
|
|
fileId,
|
|
workspaceId,
|
|
name,
|
|
time,
|
|
createSource,
|
|
}: {
|
|
fileId: string
|
|
workspaceId: string
|
|
name: string
|
|
time: number
|
|
createSource: string | null
|
|
}
|
|
) => {
|
|
time = ensureSensibleTimestamp(time)
|
|
|
|
// Security: when a new file is seeded from another app file (the Duplicate
|
|
// action sets `createSource` to `${FILE_PREFIX}/${sourceFileId}`), the user
|
|
// must be able to read that source file. The content copy happens later in
|
|
// the worker (handleFileCreateFromSource), which trusts `createSource`
|
|
// verbatim, so the authorization has to happen here at creation time.
|
|
// Without this, a user who can still see a file they've lost access to — or
|
|
// who merely knows its id — could duplicate it and obtain an owned, editable
|
|
// copy of content they cannot read. Checked on the server only, matching the
|
|
// other file-access checks in this file (the optimistic client run may not
|
|
// have the source file synced). Other `createSource` prefixes (published,
|
|
// legacy rooms, local files) are intentionally not gated here.
|
|
if (tx.location === 'server' && createSource) {
|
|
const [prefix, sourceFileId] = createSource.split('/')
|
|
if (prefix === FILE_PREFIX) {
|
|
await assertUserCanAccessFileById(tx, userId, sourceFileId)
|
|
}
|
|
}
|
|
|
|
const file = await tx.run(zql.file.where('id', '=', fileId).one())
|
|
assert(!file, ZErrorCode.bad_request)
|
|
assertValidId(fileId)
|
|
assertValidId(workspaceId)
|
|
assert(name.trim(), ZErrorCode.bad_request)
|
|
// Authorize via getRole, not a raw group_user lookup: the home workspace
|
|
// (id === userId) is implicitly owned and may have no group_user row, yet
|
|
// the user must still be able to create files in it. A raw row check would
|
|
// reject that case, leaving an empty home un-switchable (selecting it from
|
|
// the switcher creates-then-opens a file, which silently fails). Every other
|
|
// workspace op already authorizes through getRole/can for the same reason.
|
|
const role = await getRole(tx, userId, workspaceId)
|
|
assert(can(role, 'addFiles'), ZErrorCode.forbidden)
|
|
|
|
// create file row, group_file row, file_state row
|
|
await tx.mutate.file.insert({
|
|
id: fileId,
|
|
name,
|
|
owningGroupId: workspaceId,
|
|
ownerName: '',
|
|
thumbnail: '',
|
|
shared: true,
|
|
sharedLinkType: 'edit',
|
|
isEmpty: true,
|
|
published: false,
|
|
lastPublished: 0,
|
|
publishedSlug: uniqueId(),
|
|
createdAt: time,
|
|
updatedAt: time,
|
|
isDeleted: false,
|
|
createSource,
|
|
})
|
|
await tx.mutate.group_file.insert({
|
|
fileId,
|
|
groupId: workspaceId,
|
|
createdAt: time,
|
|
updatedAt: time,
|
|
index: null,
|
|
})
|
|
await tx.mutate.file_state.insert({
|
|
fileId,
|
|
userId,
|
|
lastEditAt: null,
|
|
lastVisitAt: null,
|
|
firstVisitAt: null,
|
|
lastSessionState: null,
|
|
})
|
|
},
|
|
|
|
pinFile: async (
|
|
tx: Tx,
|
|
{ fileId, workspaceId, index }: { fileId: string; workspaceId: string; index?: IndexKey }
|
|
) => {
|
|
assert(fileId, ZErrorCode.bad_request)
|
|
assert(typeof index === 'string' || index == null, ZErrorCode.bad_request)
|
|
assert(workspaceId, ZErrorCode.bad_request)
|
|
// Without this any signed-in user who knows a (fileId, workspaceId) pair — a guest who
|
|
// opened a shared link, a removed member — could rewrite that workspace's pin order.
|
|
assert(can(await getRole(tx, userId, workspaceId), 'accessFiles'), ZErrorCode.forbidden)
|
|
|
|
// Pinned files are group_file rows with a non-null index.
|
|
// New pins go above the workspace's current top pinned file.
|
|
let indexToUse = index
|
|
if (indexToUse == null) {
|
|
const allWorkspaceFiles = await tx.run(zql.group_file.where('groupId', '=', workspaceId))
|
|
const otherPinnedFiles = allWorkspaceFiles
|
|
.filter((gf) => gf.index !== null)
|
|
.sort(sortByMaybeIndex)
|
|
indexToUse = getIndexBelow(otherPinnedFiles[0]?.index)
|
|
}
|
|
|
|
await tx.mutate.group_file.update({
|
|
fileId,
|
|
groupId: workspaceId,
|
|
index: indexToUse,
|
|
})
|
|
},
|
|
|
|
unpinFile: async (tx: Tx, { fileId, workspaceId }: { fileId: string; workspaceId: string }) => {
|
|
assert(fileId, ZErrorCode.bad_request)
|
|
assert(workspaceId, ZErrorCode.bad_request)
|
|
// See pinFile.
|
|
assert(can(await getRole(tx, userId, workspaceId), 'accessFiles'), ZErrorCode.forbidden)
|
|
|
|
await tx.mutate.group_file.update({
|
|
fileId,
|
|
groupId: workspaceId,
|
|
index: null,
|
|
})
|
|
},
|
|
|
|
removeFileFromWorkspace: async (
|
|
tx: Tx,
|
|
{ fileId, workspaceId }: { fileId: string; workspaceId: string }
|
|
) => {
|
|
assert(fileId, ZErrorCode.bad_request)
|
|
assert(workspaceId, ZErrorCode.bad_request)
|
|
const role = await getRole(tx, userId, workspaceId)
|
|
assert(can(role, 'removeFiles'), ZErrorCode.forbidden)
|
|
const file = await tx.run(zql.file.where('id', '=', fileId).one())
|
|
assert(file, ZErrorCode.bad_request)
|
|
|
|
await tx.mutate.file_state.delete({ fileId, userId })
|
|
await tx.mutate.group_file.delete({ fileId, groupId: workspaceId })
|
|
if (file.owningGroupId === workspaceId) {
|
|
await tx.mutate.file.update({ id: fileId, isDeleted: true })
|
|
}
|
|
},
|
|
onEnterFile: async (tx: Tx, { fileId, time }: { fileId: string; time: number }) => {
|
|
assert(fileId, ZErrorCode.bad_request)
|
|
time = ensureSensibleTimestamp(time)
|
|
|
|
const file = await tx.run(zql.file.where('id', '=', fileId).one())
|
|
|
|
// Verify the user has permission to access this file
|
|
if (tx.location === 'server') {
|
|
await assertUserCanAccessFile(tx, userId, file!)
|
|
}
|
|
|
|
// If we get here, the user has legitimate access to the file
|
|
await tx.mutate.file_state.upsert({ fileId, userId, firstVisitAt: time })
|
|
|
|
// Add a visited file to the user's home group so it shows as a "guest file" in the
|
|
// sidebar — unless it's already visible to the user somewhere else. A file is listed
|
|
// in a workspace only when that workspace actually OWNS it (getWorkspaceFilesSorted
|
|
// lists a non-home workspace's file only when owningGroupId === workspaceId), so the
|
|
// only thing that should suppress the home link is the file being owned by a
|
|
// workspace the user belongs to.
|
|
//
|
|
// We deliberately do NOT key off "any group_file row in one of my groups": a
|
|
// stale/mislinked row (e.g. a leftover from the removed drag-to-link feature,
|
|
// #9107/#9254, or a create-workspace-race mirror) points at a workspace that does
|
|
// not own the file, so it shows the file nowhere. Counting it here would skip the
|
|
// home link and make the file invisible in the sidebar entirely.
|
|
const alreadyLinkedInHome = await tx.run(
|
|
zql.group_file.where('fileId', '=', fileId).where('groupId', '=', userId).one()
|
|
)
|
|
if (!alreadyLinkedInHome) {
|
|
// getRole returns null when the user isn't a member of the file's owning
|
|
// workspace (and for a null owningGroupId), so this is true only when the file
|
|
// is genuinely owned by a workspace the user belongs to.
|
|
const ownedByOneOfMyWorkspaces = (await getRole(tx, userId, file?.owningGroupId)) !== null
|
|
if (!ownedByOneOfMyWorkspaces) {
|
|
await tx.mutate.group_file.insert({
|
|
fileId,
|
|
groupId: userId,
|
|
createdAt: time,
|
|
updatedAt: time,
|
|
index: null,
|
|
})
|
|
}
|
|
}
|
|
},
|
|
createWorkspace: async (tx: Tx, { id, name }: { id: string; name: string }) => {
|
|
assertValidId(id)
|
|
|
|
const clampedName = name.trim().slice(0, MAX_WORKSPACE_NAME_LENGTH)
|
|
assert(clampedName, ZErrorCode.bad_request)
|
|
|
|
// Enforce the workspace limit before creating anything.
|
|
const existingWorkspaces = await tx.run(zql.group_user.where('userId', '=', userId))
|
|
assert(
|
|
existingWorkspaces.length < MAX_NUMBER_OF_WORKSPACES,
|
|
ZErrorCode.max_workspaces_reached
|
|
)
|
|
|
|
await tx.mutate.group.insert({
|
|
id,
|
|
name: clampedName,
|
|
inviteSecret: tx.location === 'server' ? uniqueId() : null,
|
|
inviteLinkEnabled: true,
|
|
isDeleted: false,
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
})
|
|
|
|
// Use tldraw's fractional indexing to place the new workspace at the top
|
|
// (the workspace list sorts ascending, so the lowest index renders first)
|
|
const lowest = existingWorkspaces.sort(sortByIndex)[0]?.index
|
|
// First workspace gets 'a1'; otherwise generate a new index below the current lowest
|
|
const index = lowest === undefined ? ('a1' as IndexKey) : getIndexBelow(lowest)
|
|
|
|
await tx.mutate.group_user.insert({
|
|
userId,
|
|
groupId: id,
|
|
// these are set by the trigger
|
|
userName: '',
|
|
userColor: '#000000',
|
|
role: 'owner',
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
index,
|
|
})
|
|
},
|
|
updateWorkspace: async (tx: Tx, { id, name }: { id: string; name: string }) => {
|
|
assert(id, ZErrorCode.bad_request)
|
|
assert(name && name.trim(), ZErrorCode.bad_request)
|
|
// The home workspace can be renamed (unlike other home-workspace actions),
|
|
// so it's intentionally not guarded by assertNotHomeWorkspace here.
|
|
const role = await getRole(tx, userId, id)
|
|
assert(can(role, 'manageWorkspace'), ZErrorCode.forbidden)
|
|
|
|
await tx.mutate.group.update({
|
|
id,
|
|
name: name.trim().slice(0, MAX_WORKSPACE_NAME_LENGTH),
|
|
})
|
|
},
|
|
regenerateWorkspaceInviteSecret: async (tx: Tx, { id }: { id: string }) => {
|
|
assert(id, ZErrorCode.bad_request)
|
|
await assertNotHomeWorkspace(tx, id)
|
|
|
|
const role = await getRole(tx, userId, id)
|
|
assert(can(role, 'manageWorkspace'), ZErrorCode.forbidden)
|
|
|
|
if (tx.location === 'server') {
|
|
await tx.mutate.group.update({ id, inviteSecret: uniqueId() })
|
|
}
|
|
},
|
|
setWorkspaceInviteLinkEnabled: async (
|
|
tx: Tx,
|
|
{ id, enabled }: { id: string; enabled: boolean }
|
|
) => {
|
|
assert(id, ZErrorCode.bad_request)
|
|
await assertNotHomeWorkspace(tx, id)
|
|
|
|
const role = await getRole(tx, userId, id)
|
|
assert(can(role, 'manageWorkspace'), ZErrorCode.forbidden)
|
|
|
|
// Flip the flag only; inviteSecret is preserved so re-enabling restores the
|
|
// same link.
|
|
await tx.mutate.group.update({ id, inviteLinkEnabled: enabled })
|
|
},
|
|
setWorkspaceMemberRole: async (
|
|
tx: Tx,
|
|
{
|
|
workspaceId,
|
|
targetUserId,
|
|
role: targetRole,
|
|
}: { workspaceId: string; targetUserId: string; role: Role }
|
|
) => {
|
|
assert(workspaceId, ZErrorCode.bad_request)
|
|
assert(targetUserId, ZErrorCode.bad_request)
|
|
assert(isRole(targetRole), ZErrorCode.bad_request)
|
|
await assertNotHomeWorkspace(tx, workspaceId)
|
|
|
|
const role = await getRole(tx, userId, workspaceId)
|
|
assert(can(role, 'manageWorkspace'), ZErrorCode.forbidden)
|
|
|
|
// Target must be a member
|
|
const targetMembership = await tx.run(
|
|
zql.group_user.where('userId', '=', targetUserId).where('groupId', '=', workspaceId).one()
|
|
)
|
|
assert(targetMembership, ZErrorCode.bad_request)
|
|
|
|
if (targetMembership.role === targetRole) return
|
|
|
|
// Invariant (not a capability): a group must always keep at least one
|
|
// owner, so the last owner can't be demoted away from owner.
|
|
if (targetMembership.role === 'owner' && targetRole !== 'owner') {
|
|
const owners = await tx.run(
|
|
zql.group_user.where('groupId', '=', workspaceId).where('role', '=', 'owner')
|
|
)
|
|
assert(owners.length > 1, ZErrorCode.forbidden)
|
|
}
|
|
|
|
await tx.mutate.group_user.update({
|
|
userId: targetUserId,
|
|
groupId: workspaceId,
|
|
role: targetRole,
|
|
})
|
|
},
|
|
removeWorkspaceMember: async (
|
|
tx: Tx,
|
|
{ workspaceId, targetUserId }: { workspaceId: string; targetUserId: string }
|
|
) => {
|
|
assert(workspaceId, ZErrorCode.bad_request)
|
|
assert(targetUserId, ZErrorCode.bad_request)
|
|
|
|
const role = await getRole(tx, userId, workspaceId)
|
|
assert(can(role, 'manageWorkspace'), ZErrorCode.forbidden)
|
|
|
|
// Target must be a member
|
|
const targetMembership = await tx.run(
|
|
zql.group_user.where('userId', '=', targetUserId).where('groupId', '=', workspaceId).one()
|
|
)
|
|
assert(targetMembership, ZErrorCode.bad_request)
|
|
|
|
// Invariant (not a capability): a group must always keep at least one
|
|
// owner, so the last owner can't be removed.
|
|
if (targetMembership.role === 'owner') {
|
|
const owners = await tx.run(
|
|
zql.group_user.where('groupId', '=', workspaceId).where('role', '=', 'owner')
|
|
)
|
|
assert(owners.length > 1, ZErrorCode.forbidden)
|
|
}
|
|
|
|
await tx.mutate.group_user.delete({ userId: targetUserId, groupId: workspaceId })
|
|
},
|
|
leaveWorkspace: async (tx: Tx, { workspaceId }: { workspaceId: string }) => {
|
|
assert(workspaceId, ZErrorCode.bad_request)
|
|
await assertNotHomeWorkspace(tx, workspaceId)
|
|
const owners = await tx.run(
|
|
zql.group_user.where('groupId', '=', workspaceId).where('role', '=', 'owner')
|
|
)
|
|
const isOnlyOwner = owners.length === 1 && owners[0].userId === userId
|
|
// Invariant (not a capability): a group must always keep at least one
|
|
// owner, so the last owner can't leave — they must delete the group instead.
|
|
assert(!isOnlyOwner, ZErrorCode.forbidden)
|
|
await tx.mutate.group_user.delete({ userId, groupId: workspaceId })
|
|
},
|
|
deleteWorkspace: async (tx: Tx, { id }: { id: string }) => {
|
|
assert(id, ZErrorCode.bad_request)
|
|
await assertNotHomeWorkspace(tx, id)
|
|
const role = await getRole(tx, userId, id)
|
|
assert(can(role, 'manageWorkspace'), ZErrorCode.forbidden)
|
|
|
|
// Delete all workspace files
|
|
const workspaceFileRows = await tx.run(zql.group_file.where('groupId', '=', id))
|
|
for (const workspaceFile of workspaceFileRows) {
|
|
await tx.mutate.group_file.delete({ fileId: workspaceFile.fileId, groupId: id })
|
|
}
|
|
|
|
// Mark all files owned by this workspace as deleted
|
|
const files = await tx.run(zql.file.where('owningGroupId', '=', id))
|
|
for (const file of files) {
|
|
await tx.mutate.file.update({ id: file.id, isDeleted: true })
|
|
}
|
|
|
|
await tx.mutate.group.update({ id: id, isDeleted: true })
|
|
if (tx.location !== 'server') {
|
|
await tx.mutate.group_user.delete({ userId, groupId: id })
|
|
}
|
|
},
|
|
moveFileToWorkspace: async (
|
|
tx: Tx,
|
|
{ fileId, workspaceId }: { fileId: string; workspaceId: string }
|
|
) => {
|
|
assert(fileId, ZErrorCode.bad_request)
|
|
assert(workspaceId, ZErrorCode.bad_request)
|
|
|
|
const file = await tx.run(zql.file.where('id', '=', fileId).one())
|
|
assert(file, ZErrorCode.bad_request)
|
|
|
|
// No-op if file is already in the target workspace
|
|
if (file.owningGroupId === workspaceId) return
|
|
|
|
// User must be allowed to take the file out of its current workspace and
|
|
// to add files to the destination workspace.
|
|
const fromRole = await getRole(tx, userId, file.owningGroupId)
|
|
assert(can(fromRole, 'removeFiles'), ZErrorCode.forbidden)
|
|
const toRole = await getRole(tx, userId, workspaceId)
|
|
assert(can(toRole, 'addFiles'), ZErrorCode.forbidden)
|
|
|
|
// Remove file from current group association if it exists
|
|
if (file.owningGroupId) {
|
|
await tx.mutate.group_file.delete({ fileId, groupId: file.owningGroupId })
|
|
}
|
|
|
|
// Point the file at its new workspace
|
|
await tx.mutate.file.update({
|
|
id: fileId,
|
|
owningGroupId: workspaceId,
|
|
updatedAt: Date.now(),
|
|
})
|
|
await tx.mutate.group_file.insert({
|
|
fileId,
|
|
groupId: workspaceId,
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
index: null,
|
|
})
|
|
},
|
|
} as const satisfies CustomMutatorDefs
|
|
return mutators
|
|
}
|