Skip to content

[pull] main from tldraw:main#581

Merged
pull[bot] merged 2 commits into
code:mainfrom
tldraw:main
Jun 9, 2026
Merged

[pull] main from tldraw:main#581
pull[bot] merged 2 commits into
code:mainfrom
tldraw:main

Conversation

@pull

@pull pull Bot commented Jun 9, 2026

Copy link
Copy Markdown

See Commits and Changes for more details.


Created by pull[bot] (v2.0.0-alpha.4)

Can you help keep this open source service alive? 💖 Please sponsor : )

frolic added 2 commits June 9, 2026 19:24
)

In order to make group roles easy to read and safe to adjust, this PR
refactors the dotcom groups prototype so authorization is a single
predicate — `can(role, 'deleteGroup')` — instead of branching on role
literals (`role === 'owner'` / `role === 'admin'`) or checking group
membership existence directly. Each role is defined as a list of named
capabilities in one `roles` table, so what a role can do reads off one
place, and roles can be added or changed without hunting down scattered
checks.

This came out of renaming `admin` → `member`: it was hard to tell what
that role actually grants. With capabilities, a role's powers are a list
you can read, and the rename becomes a relabel rather than a behavioral
question.

The change is behavior-preserving, with one intentional exception: the
group-name input is now `disabled` for members without the `editGroup`
capability (previously they could type into it and the mutation silently
failed server-side).

### Concepts

| Term | Type | Meaning |
| --- | --- | --- |
| `capabilities` | `as const` array | The list of every capability,
enumerable at runtime (`capabilities.ts`) |
| `Capability` | `(typeof capabilities)[number]` | A single thing a
member can do — derived from the list |
| `roles` | table | The single source of truth: each role → its
capability list (`roles.ts`) |
| `Role` | `keyof typeof roles` | The role string stored in
`group_user.role` (`'admin' \| 'owner'`) — derived from the table keys |
| `can(role, capability)` | function | The one check. `role` is loose
(`string \| null \| undefined`); unknown/null → `false` |
| `isRole(role)` | guard | Whether a string is a known role (used for
validating client input) |
| `getRole(db, …)` | sync-worker helper | Resolves the role from the DB
(mirrors the mutators' `getRole`); compose with `can` |

The role stays a plain string in the DB; capabilities are never
persisted — they're derived from that string in code.

### Usage

```ts
// client — role comes straight from the synced membership
const role = currentUser?.role
can(role, 'editGroup')        // boolean, no wrapper, no narrowing

// mutators — resolve the role, then check it
const role = await getRole(tx, userId, id)
assert(can(role, 'deleteGroup'), ZErrorCode.forbidden)

// sync-worker (Kysely) — getRole resolves from the DB
const role = await getRole(db, userId, groupId)
if (can(role, 'accessFiles')) { /* allow */ }
```

### Where it's enforced

Every group authorization check now goes through `can`, so they all
consult the same table:

- **Mutators** (`@tldraw/dotcom-shared`) — `getRole` + `can(role, …)` on
each group mutation.
- **Sync-worker** — `getRole` + `can(role, 'accessFiles')` for file
access in `getAuth` (`requireWriteAccessToFile`), `worker.ts`, and the
three `TLFileDurableObject` socket/snapshot/prune paths that previously
checked membership existence directly.
- **Client** — `TldrawApp.canUpdateFile` and `useHasFileAdminRights` use
`can(role, 'accessFiles')` instead of bare membership checks.

The "a group must always keep at least one owner" rule (last owner can't
be demoted or leave) is a group **invariant**, not a capability, so it
stays as explicit, labeled logic — it's now the only place that compares
against a role literal.

### Capability → role matrix

These map 1:1 onto current behavior — no role gains or loses anything.

| Capability | Operation | admin | owner |
| --- | --- | :---: | :---: |
| `accessFiles` | open/edit the group's files, reorder own membership |
✓ | ✓ |
| `addFiles` | link/move a file into the group, drag | ✓ | ✓ |
| `removeFiles` | remove a file from the group, move-out | ✓ | ✓ |
| `manageInvites` | regenerate the invite link | ✓ | ✓ |
| `editGroup` | rename the group | | ✓ |
| `editMembers` | change other members' roles (more member actions to
come) | | ✓ |
| `deleteGroup` | delete the group and its files | | ✓ |

### On the rename

Stored role values stay `'admin' | 'owner'` here to keep this separate
from the rename migration. Because no logic branches on the literal
anymore, renaming `admin` → `member` later is just relabeling the key in
the `roles` table (plus a data migration and flipping the hardcoded
`role: 'admin'` in `acceptInvite.ts`).

### Change type

- [x] `improvement`

### Test plan

1. As a group **owner**: rename the group, regenerate the invite, change
a member's role, remove a file, delete the group — all succeed.
2. As a group **admin/member**: regenerate the invite and add/remove
files succeed; the name input is disabled and the role dropdown / delete
group are hidden.
3. Confirm the last owner still can't be demoted or leave.
4. Confirm a non-member is denied file access (socket connect, download)
on a group-owned file.

- [x] Unit tests
- [ ] End to end tests

`roles.test.ts` covers `can`/`isRole` and the role → capability matrix;
existing `mutators.test.ts` group tests cover the mutator gates.

### Code changes

| Section        | LOC change  |
| -------------- | ----------- |
| Core code      | +139 / -67  |
| Tests          | +69 / -0    |
| Apps           | +83 / -92   |
The `update-snapshots` workflow regenerates Playwright snapshots and
commits them via huppy-bot. When a run produces no snapshot changes, the
`git commit` step failed with `nothing to commit, working tree clean`
(exit 1), so the workflow reported a red ✗ even though nothing was wrong
— there was simply nothing to update.

This guards the commit step: if nothing is staged, it logs a message and
exits 0 instead of attempting an empty commit. Runs that do produce
snapshot changes still commit and push as before.

## Code changes

| File | +/- |
| --- | --- |
| `.github/workflows/playwright-update-snapshots.yml` | +4 / −0 |

## Test plan

- A label-triggered run with no snapshot diffs now completes green
instead of failing on the empty commit.
- A run that does produce snapshot changes still commits and pushes (the
guard only short-circuits the empty case).
@pull pull Bot locked and limited conversation to collaborators Jun 9, 2026
@pull pull Bot added the ⤵️ pull label Jun 9, 2026
@pull
pull Bot merged commit 54bb28f into code:main Jun 9, 2026
@pull
pull Bot had a problem deploying to bemo-canary June 9, 2026 21:13 Failure
@pull
pull Bot had a problem deploying to deploy-production June 9, 2026 21:13 Failure
@pull
pull Bot had a problem deploying to bemo-canary June 9, 2026 21:13 Failure
@pull
pull Bot had a problem deploying to deploy-staging June 9, 2026 21:13 Failure
@pull
pull Bot had a problem deploying to deploy-staging June 9, 2026 21:13 Error
@pull
pull Bot had a problem deploying to npm deploy June 9, 2026 21:13 Failure
@pull
pull Bot had a problem deploying to npm deploy June 9, 2026 21:13 Failure
@pull
pull Bot had a problem deploying to vsce publish June 9, 2026 21:13 Failure
@pull
pull Bot had a problem deploying to deploy-staging June 10, 2026 00:57 Failure
@pull
pull Bot temporarily deployed to e2e-dotcom June 10, 2026 02:38 Inactive
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant