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'replacingtype: 'allow', defaultread;accepted(default false) plusacceptedAt. Both editions —dbSchema.ts, the pg re-export,db:generate:pg, and amigrations-pg/file, which is what prod runs at boot. Existing rows migrate tolevel: 'read',accepted: true. (Done.0015_red_stephen_strange.sql, generated and then hand-extended with theacceptedbackfill drizzle cannot know to write — without it every pre-existing grant landsfalseand re-prompts a user to accept access they already have.levelis deliberately not backfilled fromtype. The generatedDEFAULT 'read'is the whole migration for that column, which means a v0.42.0type: 'allow'row — whose one meaning was "may impersonate" — comes outreadand 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 inutils/tokenOwnership.tsandroutes/tokenOwnership.tsdrop theirtype = '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. AndgrantCharacterAllowwriteslevel: 'control', notread: what that function has always granted is impersonation, soreadthere would silently revoke every grant made through it. It also writesaccepted: trueuntil 5a exists to clear an unaccepted row with.token_permissionskeepstype— 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 oftypedefault allow" is now 2, with a note saying that going back up is the regression.) - [x] 2b. Add
acceptedtocampaign_members. Same two columns, same defaults; the table already carriesroleandjoinedAtso it extends without reshaping. Existing rows migrate toaccepted: true. (Done.migrations-pg/0016, plus — unlike 2a — the sqlite side ofdb.ts, becausecampaign_membersis one of the tables that file spells out by hand: threeCREATE TABLEcopies (two sqlite, one theLEGACY_PG_DDLfallback) and anALTER TABLEpath 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.sqliteApplyColumnMigrationsruns on every boot, so the obviousUPDATE ... SET accepted = 1 WHERE accepted = 0would have accepted every genuinely-pending invitation on the next restart — quietly, and worse the longer the feature existed. It is guarded by a newsqliteColumnExists()probe instead, so it runs in the one boot that adds the column and never again; that guard has its own test. Nothing readsacceptedyet and no writer sets it, so new memberships landfalsefrom the column default, which is what 5a wants.roleis untouched — campaign permission levels are out of scope.) - [x] 2c. Drop
character_membersand 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 grantedviewer/editornow writesread/edit. (Done. Nine call sites across eight files, all routed through a newutils/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 needsedit—controlis transient state only, and principle 4 says anything unclassified requiresedit. Nothing here got quietly widened tocontrol.shouldUpgradeMemberRoleis 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 toraiseCharacterPermission, applied by all of them. Two things surfaced that were not in the plan. sqlite never hadcharacter_permissionsat all — the table has been indbSchema.tssince v0.42.0, postgres gets it frommigrations-pg/0002, anddb.tsdeclared it in none of its DDL, so every query against it on a sqlite database hit a missing table. That was survivable only becausecharacter_memberswas answering the sharing question in parallel, which is exactly what this commit removes, so the DDL lands here. And the endpoints that still sayrolenow returnlevelbeside it rather than instead of it: the derivation is lossy (controlreports asviewer, an understatement, never an overstatement) and task 6a is what moves the client onto the real field.character_invites.rolekeeps 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 thecontrola campaign owner gains by association is a real row that can be raised toeditor 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'scontrolis 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 tocontrol, not to nothing. Unlinking is what makes removal total. The four route-localgetUserCharacterAccesscopies (characters, equipment, ledger, plususerCanEditCharacterin 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:canEditmeanseditor better, and whatcontrolmay 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
controlproperty allowlist. A named list of character propertiescontrolmay write — current (not max) HP, conditions, the rest of the transient non-Build-mode state. Everything absent from the list requiresedit, and that default is the point: a property added by a later release is locked until somebody decides otherwise. Acontrolcaller 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 themetadatablob, 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 anattributesentry, whosecomponentsare the max. Allowing either column wholesale would allow a level-up. Worse, a PATCH sends whole values rather than diffs —buildCharacterRuntimePatchspreads 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 oflevelorclassesis not a write and is allowed, or the honest client would be the one that broke. The allowlist itself isconditions,metadata.currentHp,metadata.tempHpand an attribute'scurrent— exactly the three homesshared'sreadCharacterRuntimelooks 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.
controlis what impersonation requires. Supersedes the earlier working answer thatreadwould be enough —readis read-only in the full sense.useImpersonatableTokenIdsandImpersonationPermissionGuardread 3a's resolved level. Rolling dice needs nothing; accepting anapplyDamage()needscontrol, because it writes current HP. Moving a token is a token change, governed by the token's own permissions. (Done.canImpersonateCharacteris 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.hasCharacterAllowis renamedcanControlCharacter, 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.0type: 'allow'row migrated toreadin 2a, andreadcannot 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.persistRuntimeToCharacterwas 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 requirescontrol. 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 tocontrol. Both files' docblocks stopped restating a rule they do not own.) - [x] 3d. Refuse token permissions on linked tokens.
token_permissionsstays, 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 ingrantTokenAllow, 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 typedLinkedTokenPermissionErrorand turns it into a 400 that names the character the caller should be granting on instead. Two callers had to go with it.syncCharacterPermissionsToTokencopied 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 sincecanImpersonateTokengrew a runtime character lookup: the copy only ever knew about grants that existed the moment the token was created. And the character PUT'ssyncTokenIdno 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 drivesgrantTokenAllowdirectly, and one of its cases is the shape trap:metadataarrives 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 existingedit. 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 GMcontrolimplicitly, 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 toedit, 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, notset, so re-linking cannot downgrade a GM who already holdsedit; 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, inuseCharacters().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 returnsgrantedControlTo, 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_permissionsandcampaign_membersstartaccepted: false.POST .../acceptfor each, idempotent, caller must be the grantee. The list endpoints return the flag. (Done:POST /api/characters/:characterId/access/acceptandPOST /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 themcontrolimplicitly — 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/acceptalready 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
InvitedToAccesscomponent and oneuseAcceptAccesshook serve both lists, because the question is identical and only the endpoint differs. The component navigates nowhere at all — noLink, nouseNavigate— 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 isaccepted === 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/editwith a one-line explanation of each, not a single "access" toggle. The campaign owner's association-grantedcontrolrow appears here like any other and can be raised toeditor removed. (Done.PermissionsPanelis 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.controlis 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.minLevelis the part that took thought. A campaign GM sits atcontrolhowever the table reads, because 3a treats GM-ness as a floor, so an editor offeringreadfor 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 takeslevel(null to remove) and still accepts the oldallowedboolean, read ascontrol— impersonation is the only thing that toggle ever granted, so reading it asreadwould 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 existingedit, but an owner deliberately editing the table must be able to lower a level too.grantCharacterAllowis retired with that change — it could only ever grant.) - [x] 6b. Best-effort client-side gating.
controlhides or disables the Build-mode unlock buttons;readdisables editing outright. Explicitly a courtesy — every one of these has a server-side counterpart from §3. (Done.GET /api/characters/:idattaches_level, and the sheet gates on it in two places. Build mode is withheld by withholdingonBuildModeChange— 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 forcesbuildModeoff rather than only hiding the control, or a host passing the prop directly would leave the sheet unlocked with nothing left to lock it.readis handled by wrapping the single write path instead of guarding twenty-eightupdateCharactercall 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:_levelon theCharactertype 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 andcharacters.ts.)