Skip to content

v0.61.0 — Character Permissions

One table, one access model. character_permissions becomes the single answer to "what may this user do with this character", gaining a three-valued level (read / control / edit) and an accepted flag; character_members is dropped and its data abandoned.

Spec: 19-character-permissions — read § Why the modal is confusing first for the two-table problem, and § The model we are moving to for why a middle level exists. Tasks are requirements.md, one commit each, in order.

First of the Phase 1 series: v0.62.0 (Notifications) and v0.63.0 (Sharing Buttons) both build on the accepted column and the control level introduced here.

The model

  • [x] 2a. Add the level and the acceptance state to character_permissions. level: 'read' | 'control' | 'edit' replacing type: 'allow', default read; accepted (default false) plus acceptedAt. Both editions — dbSchema.ts, the pg re-export, db:generate:pg, and a migrations-pg/ file, which is what prod runs at boot. Existing rows migrate to level: 'read', accepted: true. (Done. 0015_red_stephen_strange.sql, generated and then hand-extended with the accepted backfill drizzle cannot know to write — without it every pre-existing grant lands false and re-prompts a user to accept access they already have. level is deliberately not backfilled from type. The generated DEFAULT 'read' is the whole migration for that column, which means a v0.42.0 type: 'allow' row — whose one meaning was "may impersonate" — comes out read and can no longer impersonate once 3c lands. That is the requirements' answer ("cannot accidentally widen access") and it costs nothing on data that is stale in prod and absent in alpha, but it is a real revocation, not a no-op rename. The reverse choice was available and rejected. Two things this commit deliberately did not do. The reads in utils/tokenOwnership.ts and routes/tokenOwnership.ts drop their type = 'allow' predicate and now treat any row as a grant — identical to v0.42.0's behaviour, because half of 3c's rule is worse than none of it. And grantCharacterAllow writes level: 'control', not read: what that function has always granted is impersonation, so read there would silently revoke every grant made through it. It also writes accepted: true until 5a exists to clear an unaccepted row with. token_permissions keeps type — it is still a 2-state table, and v0.61.0 only narrows which tokens may have rows (3d). The tokenOwnership schema test's "4 declarations of type default allow" is now 2, with a note saying that going back up is the regression.)
  • [x] 2b. Add accepted to campaign_members. Same two columns, same defaults; the table already carries role and joinedAt so it extends without reshaping. Existing rows migrate to accepted: true. (Done. migrations-pg/0016, plus — unlike 2a — the sqlite side of db.ts, because campaign_members is one of the tables that file spells out by hand: three CREATE TABLE copies (two sqlite, one the LEGACY_PG_DDL fallback) and an ALTER TABLE path for existing dev databases. A test pins all three copies at once, since a column added to one and not the others is exactly how they drift. The one real trap was the sqlite backfill. sqliteApplyColumnMigrations runs on every boot, so the obvious UPDATE ... SET accepted = 1 WHERE accepted = 0 would have accepted every genuinely-pending invitation on the next restart — quietly, and worse the longer the feature existed. It is guarded by a new sqliteColumnExists() probe instead, so it runs in the one boot that adds the column and never again; that guard has its own test. Nothing reads accepted yet and no writer sets it, so new memberships land false from the column default, which is what 5a wants. role is untouched — campaign permission levels are out of scope.)
  • [x] 2c. Drop character_members and abandon its data. A deliberate one-time break — prod's data is stale and its database is being retired, alpha has none. Remove the table, both editions' schema entries, and every read of it; the compile errors are the checklist. Anything that granted viewer / editor now writes read / edit. (Done. Nine call sites across eight files, all routed through a new utils/characterPermissions.ts — deliberately policy-free: it reads and writes rows and knows how the three levels rank, and it does not know that owners and GMs are implicitly permitted. That short-circuit is 3a's, and keeping the split means there is exactly one place a policy decision can be made. Each site had to be classified, not just translated. Equipment, ledger entries, party membership, validation-rule preferences and the roll-request editor set are all persisted character data, so every one of them needs edit — control is transient state only, and principle 4 says anything unclassified requires edit. Nothing here got quietly widened to control. shouldUpgradeMemberRole is deleted with the table. It encoded "viewer may become editor, never the reverse" and was re-spelled at each grant path; with three levels that generalises to raiseCharacterPermission, applied by all of them. Two things surfaced that were not in the plan. sqlite never had character_permissions at all — the table has been in dbSchema.ts since v0.42.0, postgres gets it from migrations-pg/0002, and db.ts declared it in none of its DDL, so every query against it on a sqlite database hit a missing table. That was survivable only because character_members was answering the sharing question in parallel, which is exactly what this commit removes, so the DDL lands here. And the endpoints that still say role now return level beside it rather than instead of it: the derivation is lossy (control reports as viewer, an understatement, never an overstatement) and task 6a is what moves the client onto the real field. character_invites.role keeps its two values for now — an invite is still "view or edit", and widening what a user can offer belongs with the editor in 6a.)

Enforcement

The API is the rule and the client is a courtesy: every level is checked server-side, and the tests drive the routes rather than the UI.

  • [x] 3a. Resolve a caller's level, once, in one place. One resolveCharacterAccess(userId, characterId) returning 'owner' | 'edit' | 'control' | 'read' | null, with owner and campaign-GM short-circuits ahead of the table lookup. Every character route consults it; no route re-derives access from raw rows. (Done, with one thing the spec left genuinely ambiguous and this had to settle. §3a asks for a campaign-GM short-circuit ahead of the table lookup; §4a says the control a campaign owner gains by association is a real row that can be raised to edit or removed. A short-circuit that ends the lookup makes raising it impossible; a row that overrides the short-circuit lets an owner drop a GM below what they need to run their own session. The only reading that satisfies both is that the GM's control is a floor: level = max(explicit row, 'control' if GM, 'owner' if owner). The consequence, which is documented in the module and has its own test: while a character is still linked to a GM's campaign, removing that GM's row drops them back to control, not to nothing. Unlinking is what makes removal total. The four route-local getUserCharacterAccess copies (characters, equipment, ledger, plus userCanEditCharacter in parties and the validation-rule prefs check) are now four lines each — the resolver's answer reshaped into the boolean pair they have always taken. That pair stays coarse on purpose: canEdit means edit or better, and what control may write is a property allowlist, which a boolean cannot express. It arrives in 3b. Side effect worth naming: those helpers never consulted campaign membership at all, so a GM could not previously open the sheet of a character in their own campaign unless someone had shared it with them. They can now — that is the success criterion, not an accident. Tested as a decision table against one shared fixture — owner, GM, each of the three explicit levels, no row, unlinked, wrong campaign, missing character, and the admin — with the db module mocked rather than booted.)
  • [x] 3b. The control property allowlist. A named list of character properties control may write — current (not max) HP, conditions, the rest of the transient non-Build-mode state. Everything absent from the list requires edit, and that default is the point: a property added by a later release is locked until somebody decides otherwise. A control caller sending a disallowed key gets a 403 naming the key, not a silent drop. (Done, and it could not be a list of column names. Two of the three transient values are not columns: current HP is one key of the metadata blob, which also holds AC, speed and every other derived build value, and for a system that models HP as an attribute it is one field of an attributes entry, whose components are the max. Allowing either column wholesale would allow a level-up. Worse, a PATCH sends whole values rather than diffs — buildCharacterRuntimePatch spreads the existing metadata around the one key it changes — so a key filter on the request cannot tell a runtime write from a build write at all. The check is therefore a diff against the stored row, and it runs after every other transform in the handler, so what gets diffed is what is about to be written. Three attacks a naive version would have missed have their own tests: a build value smuggled in beside a legitimate HP change, deleting a metadata key rather than changing it, and renaming an attribute to slip past the positional match. The mirror concern is that the level has to stay usable: a no-op resend of level or classes is not a write and is allowed, or the honest client would be the one that broke. The allowlist itself is conditions, metadata.currentHp, metadata.tempHp and an attribute's current — exactly the three homes shared's readCharacterRuntime looks in, and a test asserts that correspondence so the two cannot drift apart silently. It is also asserted to be exactly that small, so growing it is a deliberate act with a test change attached.)
  • [x] 3c. control is what impersonation requires. Supersedes the earlier working answer that read would be enough — read is read-only in the full sense. useImpersonatableTokenIds and ImpersonationPermissionGuard read 3a's resolved level. Rolling dice needs nothing; accepting an applyDamage() needs control, because it writes current HP. Moving a token is a token change, governed by the token's own permissions. (Done. canImpersonateCharacter is now one line over the resolver: its owner and campaign-GM short-circuits were a second copy of 3a's, which is the exact problem this release exists to remove. hasCharacterAllow is renamed canControlCharacter, because after 3a it is not a row lookup — owner and GM answer it without one. The behaviour change with teeth is not the rule, it is what the rule meets: every pre-v0.61.0 type: 'allow' row migrated to read in 2a, and read cannot impersonate. So anyone whose access predates this release stops being able to, until their owner re-grants. 2a chose that direction deliberately and this is where it lands; the test says so in as many words. applyDamage() needed a check that did not exist. persistRuntimeToCharacter was reached through the TOKEN's permissions — right for moving a token, wrong for a write that lands on the character's current HP — so it now resolves the character's own level and requires control. It skips rather than throws: the token write has already committed, and refusing the character write must not turn a successful damage roll into a 500. The client needed no logic change. It consumes the server-computed set, and its one local decision — the owner/GM fast path that stops a stale set reverting a legitimate switch — already agrees, because a GM resolves to control. Both files' docblocks stopped restating a rule they do not own.)
  • [x] 3d. Refuse token permissions on linked tokens. token_permissions stays, for unlinked tokens only — a token with no character behind it is a legitimate thing to share. Writing one for a linked token is a 400: two tables that can both answer "may this player drive this token" is the ambiguity this project removes. (Done. The refusal lives in grantTokenAllow, not in the route — a route-only check leaves every other caller able to write around it, which is how the two-table problem got created the first time. The route catches the typed LinkedTokenPermissionError and turns it into a 400 that names the character the caller should be granting on instead. Two callers had to go with it. syncCharacterPermissionsToToken copied a character's grants onto a linked token at creation and is deleted, with nothing replacing it — it wrote exactly the rows 3d forbids, and it had been redundant since canImpersonateToken grew a runtime character lookup: the copy only ever knew about grants that existed the moment the token was created. And the character PUT's syncTokenId no longer grants; it only revokes, which is how a legacy row from before this release gets cleared instead of continuing to grant access the owner has just taken away. Removal is deliberately never blocked, on either path. Deleting a row can only reduce the ambiguity; refusing to delete one would strand it. The test drives grantTokenAllow directly, and one of its cases is the shape trap: metadata arrives as a JSON string on some paths and an object on others, so a check that only worked on the parsed shape would be no check at all.)

Association grants control

  • [x] 4a. Linking a character to a campaign grants the campaign owner control. A real row, like any other grant, and the user is advised at link time what it means: the campaign owner can run this character but cannot edit its build. Idempotent — re-linking neither duplicates nor downgrades an existing edit. Unlinking does not revoke; revocation is the owner's explicit act. (Done, and worth being clear that the row changes no permission: 3a already gives a GM control implicitly, so this grants nothing they did not have. It exists to be visible and editable — it shows up in the permission editor like any other grant, can be raised to edit, and can be removed once the character is unlinked. Principle 5 is the whole reason to write a row that makes no difference, and "it makes no difference" is exactly the argument that would delete it. raise, not set, so re-linking cannot downgrade a GM who already holds edit; the character's own owner is skipped, because owners never get rows. Creating a new character VERSION copies its campaign links, so it copies the grants those links carry. The advisory is emitted once, in useCharacters().linkCharacterToCampaign — the helper all five linking surfaces already go through, for the same reason v0.59.0 put the cache invalidation there. Every path says it and none can forget to. The route returns grantedControlTo, and when that is null — an unowned campaign, or a GM who already owns the character — nothing is said at all, because announcing a grant that did not happen is worse than saying nothing.)

Acceptance

  • [x] 5a. Grants land unaccepted, and can be accepted. New rows in character_permissions and campaign_members start accepted: false. POST .../accept for each, idempotent, caller must be the grantee. The list endpoints return the flag. (Done: POST /api/characters/:characterId/access/accept and POST /api/campaigns/:campaignId/members/accept. "Caller must be the grantee" is not a check that could be forgotten — neither route takes a user id at all, so accepting on somebody else's behalf is unaskable rather than refused. Both 404 on a missing grant with a message that does not distinguish "no such entity" from "you weren't invited": these routes are reachable by anyone, and the honest version would be an existence oracle. One exception to "new rows start unaccepted", and it needed deciding: 4a's association grant lands accepted. Nobody invited the GM — they own the campaign the character was added to, and 3a already gives them control implicitly — so offering it to them would be a category error, and would put one "invited to access" row in their character list per party member. Every grant that comes from a person choosing to give another person access still lands unaccepted. Raising an already-accepted grant does not re-open the question either: the grantee accepted having access to the character, not one particular level of it. POST /invites/:token/accept already existed on campaigns and is a different thing despite the name — it redeems an invite LINK, which creates the membership the new route then lets the user accept. Following a link says who you are; accepting says you want it.)
  • [x] 5b. The list surfaces show "invited to access". Unaccepted entries render in their own row with an explicit Accept button rather than a card that loads the thing; accepting moves it into the normal list without a reload. v0.62.0 adds the matching action inside the notification, and both call 5a. (Done. One InvitedToAccess component and one useAcceptAccess hook serve both lists, because the question is identical and only the endpoint differs. The component navigates nowhere at all — no Link, no useNavigate — which is the point of the task rather than an implementation detail: every other entry in these lists is a card you click to load the thing, and an invitation rendered that way makes accepting something that happens on the way to looking. The invited entries are held out of the list proper, not merely styled differently, so the search and the grid never contain something you have not accepted. The trap, and both lists have it: the test is accepted === false, never !accepted. A character the user OWNS carries no flag at all, and so does an owned campaign — testing falsiness would file every one of them as an unanswered invitation and empty the list the user came for. Pending state is per row rather than per section, so answering one invitation does not freeze the others, and the invalidation is awaited so the button's spinner covers the refetch instead of stopping before the row moves.)

UI

The minimum not to regress what exists. The full sharing surfaces are v0.63.0 (21-sharing-buttons).

  • [x] 6a. The permission editor shows all three levels. Wherever character permissions are edited, offer read / control / edit with a one-line explanation of each, not a single "access" toggle. The campaign owner's association-granted control row appears here like any other and can be raised to edit or removed. (Done. PermissionsPanel is now two panels wearing one name, and deliberately so: a character gets a Select over four options — No access / Read-only / Can play / Can edit — each with a line saying what it means, while a token keeps its switch, because since 3d that table only covers unlinked tokens and a token with no character behind it has nothing to read and nothing to build. Allow/deny really is its whole model. The explanations are not decoration. control is the level nobody has a prior intuition for, and an unexplained middle option between "read only" and "edit" is just a mystery — which is the same failure as the "access" toggle this replaces. minLevel is the part that took thought. A campaign GM sits at control however the table reads, because 3a treats GM-ness as a floor, so an editor offering read for them would be offering something that would not happen. The endpoint returns the floor and the Select renders options below it disabled rather than hidden, so the constraint is visible instead of leaving the user hunting for a missing option. The character's owner gets no Select at all — there is no question to ask, and a disabled dropdown implies one. The PUT takes level (null to remove) and still accepts the old allowed boolean, read as control — impersonation is the only thing that toggle ever granted, so reading it as read would silently revoke every grant made by a client that has not been updated. It sets rather than raises: the invite-accept paths raise so that accepting a read invite cannot demote an existing edit, but an owner deliberately editing the table must be able to lower a level too. grantCharacterAllow is retired with that change — it could only ever grant.)
  • [x] 6b. Best-effort client-side gating. control hides or disables the Build-mode unlock buttons; read disables editing outright. Explicitly a courtesy — every one of these has a server-side counterpart from §3. (Done. GET /api/characters/:id attaches _level, and the sheet gates on it in two places. Build mode is withheld by withholding onBuildModeChange — that prop is already "the signal that this sheet can write at all", which the per-section unlock button is gated on, so one derivation covers the header toggle and all fourteen sections rather than fourteen places that could drift apart. It also forces buildMode off rather than only hiding the control, or a host passing the prop directly would leave the sheet unlocked with nothing left to lock it. read is handled by wrapping the single write path instead of guarding twenty-eight updateCharacter call sites — a guard that must be remembered twenty-eight times will be forgotten once. It notifies rather than dropping the write: the PATCH would 403 anyway (3b), so the alternative to a message is a sheet that appears to accept the edit and reverts on reload. Both checks fail open on an absent _level, deliberately. A token-only sheet has no character, and a character fetched by something that does not attach the field is not one the user lacks rights to — failing closed would make an owner's own sheet read-only for the duration of a fetch. That is only a safe choice because §3 enforces the same rules independently, which is the argument for having built the server half first. One thing this turned up: _level on the Character type trips the zod/type parity guard, which is the guard working. It is a fact about who is asking, not about the character, and a client able to send its own access level is the one field this release must never make writable — so it is registered as a deliberate omission with that reason attached, in both the guard and characters.ts.)