Skip to content

Commit f7cd718

Browse files
authored
Merge pull request #67 from keyxmakerx/claude/gallant-johnson-kd574n
fix: per-player permission sync — push + member-mapping UI + match warnings + DM-only clarity
2 parents d7fa2cb + ca4e748 commit f7cd718

10 files changed

Lines changed: 1044 additions & 66 deletions

File tree

.ai.md

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,8 @@ All sync modules use a `_syncing` boolean flag to prevent infinite loops:
7575
| `scripts/item-sync.mjs` | Actor inventory ↔ Chronicle "Has Item" relations. System data sync (quantity, equipped) via relation metadata |
7676
| `scripts/note-sync.mjs` | Chronicle Notes ↔ JournalEntries in "Chronicle Notes" folder tree. Case normalization (camelCase ↔ snake_case). Sharing → ownership mapping |
7777
| `scripts/adapters/generic-adapter.mjs` | API-driven adapter: fetches field defs from `/systems/:id/character-fields` API, auto-generates mappings from `foundry_path` annotations in system manifests. Works for all installed systems |
78-
| `scripts/sync-dashboard.mjs` | 8-tab ApplicationV2 dashboard (Entities, Shops, Maps, Characters, Calendar, Status). Pull/push actions, health metrics, error log, search/filter. Status tab also surfaces a **Copy Calendar Diagnostics** button (`_buildCalendarDiagnosticsInput()` + `buildCalendarDiagnostics()`) so GMs can export a Calendaria diagnostic report without opening the Sync Calendar editor. |
78+
| `scripts/sync-dashboard.mjs` | 9-tab ApplicationV2 dashboard (Config, Entities, Shops, Maps, Characters, **Members**, Notes, Calendar, Status). Pull/push actions, health metrics, error log, search/filter. **Members tab** (FM-PERM-SYNC): per-member Foundry-user mapping dropdown + matched/UNMATCHED badge, wired to `setUserMappings`; row view-model in `_member-mapping.mjs`. Status tab surfaces a **Copy Calendar Diagnostics** button and a **DM-only-hidden counter** (audit §1A — clarifies that private entities synced but landed hidden-from-players). |
79+
| `scripts/_member-mapping.mjs` | Pure view-model `buildMemberRows({members, mappings, foundryUsers, keyOf})` for the dashboard Members tab — one row per Chronicle member with current mapping + matched/UNMATCHED badge (stale mappings → UNMATCHED). No Foundry globals; unit-tested at `tools/test-permission-sync.mjs`. |
7980
| `templates/sync-dashboard.hbs` | Handlebars template for the sync dashboard tabs and content |
8081
| `templates/shop-window.hbs` | Handlebars template for shop inventory display |
8182
| `styles/chronicle-sync.css` | Status indicator + shop window styles |
@@ -273,6 +274,38 @@ or WS vs REST payload differences. Verify with the Chronicle dev before changing
273274
- Uses `GET/PUT /entities/:eid/permissions` endpoints
274275
- Reverse-map Foundry ownership changes to Chronicle entity permissions
275276

277+
### FM-PERM-SYNC: Per-player Permission Sync + Mapping UI (DONE)
278+
279+
Closes the per-player gaps from the 2026-06-21 permission-sync audit. Fail-CLOSED
280+
posture preserved throughout — any ambiguity resolves toward LESS player visibility.
281+
282+
- **Per-user push (Foundry→Chronicle).** `journal-sync.mjs::_buildPermissionGrants`
283+
(pure, tested) reverse-maps each Foundry per-user ownership entry → Chronicle
284+
`{subject_type:'user', subject_id, permission}` via `SyncManager.getChronicleUserId`
285+
(level ≥ OWNER → `edit`, else `view`). `_pushPermissions` sends `visibility:'custom'`
286+
only when real user grants exist; unmapped Foundry users are SKIPPED (never
287+
over-shared) and surfaced (notification + dashboard `warning`).
288+
- **Manual mapping UI + match-error surfacing (the operator must-have).** Dashboard
289+
**Members tab** lists every Chronicle member (`getMembers()`) with a Foundry-user
290+
dropdown + matched/UNMATCHED badge; selecting persists via `setUserMappings`. On
291+
connect, `fetchAndCacheMembers` auto-matches by display name then emits a visible
292+
`ui.notifications.warn` + dashboard `warning` listing every member that did NOT
293+
auto-match (previously silently skipped). `SyncManager.memberKey(member)` (exported,
294+
tested) is the single source of truth for the member→mapping key (`user_id`, falls
295+
back to `id`).
296+
- **Silent-failure surfacing (audit §3).** Dropped unmapped grant in `_buildOwnership`,
297+
fail-closed-to-NONE permissions-API error, per-user push failures, and a members-
298+
fetch failure all now log a dashboard `warning`/`error` + notify where user-facing.
299+
- **Perception-gap counter (audit §1A).** `SyncManager.noteDmOnlyHidden()` /
300+
`getDmOnlyHiddenCount()` count private entities that synced but landed hidden-from-
301+
players; the Status tab shows "N DM-only entities synced (hidden per the setting)".
302+
- **Consume `tag_grants` + `public` (audit §5).** `_buildOwnership` maps a `public`
303+
grant → OBSERVER/OWNER default (never lowers a higher grant); `tag_grants` can't be
304+
expanded to Foundry users so they're surfaced as a warning, keeping fail-closed.
305+
- **Tests:** `tools/test-permission-sync.mjs` (push grant-building, memberKey, unmatched
306+
warning, member-row view-model) + new cases in `tools/test-build-ownership.mjs`
307+
(public/tag_grants/dropped-grant/DM-only-count).
308+
276309
### F-3: System Detection & Character Field Templates (DONE)
277310
- `SyncManager._detectSystem()` queries `GET /api/v1/campaigns/:id/systems` on start
278311
- Matches Foundry `game.system.id` to Chronicle system, stores in `detectedSystem` setting

lang/en.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@
8686
"Maps": "Maps",
8787
"Notes": "Notes",
8888
"Characters": "Characters",
89+
"Members": "Members",
8990
"Calendar": "Calendar",
9091
"Status": "Status"
9192
},
@@ -191,6 +192,18 @@
191192
"NoSystem": "Character sync requires a matching game system.",
192193
"NoSystemHint": "Your Foundry system must match a Chronicle game system with character field templates."
193194
},
195+
"Members": {
196+
"Title": "Permission Mapping",
197+
"Intro": "Map each Chronicle campaign member to a Foundry user so per-player permissions sync. Unmatched members are skipped — their per-player grants won't sync in either direction until mapped.",
198+
"FoundryUser": "Foundry user",
199+
"Matched": "Matched",
200+
"Unmatched": "UNMATCHED",
201+
"Refresh": "Refresh Members",
202+
"RefreshHint": "Re-fetch campaign members from Chronicle and re-run auto-match",
203+
"UnmatchedWarning": "{count} Chronicle member(s) are not mapped to a Foundry user. Their per-player permissions will NOT sync until you map them below.",
204+
"NoMembers": "No campaign members loaded.",
205+
"NoMembersHint": "Members load when sync connects. Use Refresh Members to fetch them now."
206+
},
194207
"OfflineWarning": "Sync messages are queued while disconnected, but the queue is limited. Extended offline periods may cause missed updates — use Pull All after reconnecting to ensure consistency.",
195208
"StatusTab": {
196209
"Connection": "Connection",
@@ -207,6 +220,7 @@
207220
"CharacterSyncEnabled": "Enabled",
208221
"CharacterSyncDisabled": "Disabled",
209222
"CharacterSyncNoSystem": "Requires matching game system",
223+
"DmOnlyHiddenNote": "{count} DM-only entit(y/ies) synced this session and were hidden from players (per the \"DM-only hidden\" setting). The GM sees them; players intentionally don't — this is not a sync failure.",
210224
"RecentActivity": "Recent Activity",
211225
"NoActivity": "No recent activity.",
212226
"ClearLog": "Clear Log",

scripts/_member-mapping.mjs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* Chronicle Sync - Member ↔ Foundry-user mapping view-model
3+
*
4+
* Pure builder for the dashboard Members tab. Given the Chronicle campaign
5+
* members, the current `userMappings` table, the available Foundry users, and
6+
* a member-key resolver, it produces one row per member describing the current
7+
* mapping and whether it is matched or UNMATCHED.
8+
*
9+
* Kept side-effect-free (no Foundry globals) so it can be unit-tested without a
10+
* Foundry runtime — the same pattern the sync-calendar pure helpers use.
11+
*
12+
* Security posture: an UNMATCHED member's per-player permission grants are
13+
* silently dropped on both push and pull, so the row's `matched=false` /
14+
* `UNMATCHED` badge is the operator's required signal that a wrong/missing
15+
* mapping is widening or narrowing access without their knowledge (audit §2).
16+
*/
17+
18+
/**
19+
* @typedef {object} MemberRow
20+
* @property {string} key - Chronicle user-id key (mapping table key).
21+
* @property {string} name - Member display name.
22+
* @property {string} [role] - Chronicle role label, if present.
23+
* @property {string|null} foundryUserId - Currently mapped Foundry user id, or null.
24+
* @property {string|null} foundryUserName - Currently mapped Foundry user name, or null.
25+
* @property {boolean} matched - True when mapped to a live Foundry user.
26+
* @property {Array<{id: string, name: string, selected: boolean}>} options - Dropdown options.
27+
*/
28+
29+
/**
30+
* Build the Members-tab rows.
31+
*
32+
* @param {object} params
33+
* @param {Array<object>} params.members - Chronicle members (`GET /members`).
34+
* @param {Object<string,string>} params.mappings - `userMappings` (chronicleId → foundryId).
35+
* @param {Array<{id: string, name: string}>} params.foundryUsers - `game.users` (id + name).
36+
* @param {(member: object) => (string|null)} params.keyOf - Member-key resolver (`memberKey`).
37+
* @returns {{rows: MemberRow[], matchedCount: number, unmatchedCount: number}}
38+
*/
39+
export function buildMemberRows({ members, mappings, foundryUsers, keyOf }) {
40+
const userList = Array.isArray(foundryUsers) ? foundryUsers : [];
41+
const map = mappings || {};
42+
const rows = [];
43+
let matchedCount = 0;
44+
45+
for (const member of members || []) {
46+
const key = keyOf(member);
47+
if (!key) continue;
48+
49+
const mappedId = map[key] || null;
50+
// A mapping only counts as "matched" if it points at a Foundry user that
51+
// still exists — a stale id (user deleted) must surface as UNMATCHED so the
52+
// operator re-maps it rather than trusting a dead link.
53+
const mappedUser = mappedId ? userList.find((u) => u.id === mappedId) : null;
54+
const matched = !!mappedUser;
55+
if (matched) matchedCount++;
56+
57+
const options = [
58+
{ id: '', name: '— Unmapped —', selected: !matched },
59+
...userList.map((u) => ({
60+
id: u.id,
61+
name: u.name,
62+
selected: u.id === mappedId,
63+
})),
64+
];
65+
66+
rows.push({
67+
key,
68+
name: member.display_name || key,
69+
role: member.role || '',
70+
foundryUserId: mappedId,
71+
foundryUserName: mappedUser ? mappedUser.name : null,
72+
matched,
73+
options,
74+
});
75+
}
76+
77+
return {
78+
rows,
79+
matchedCount,
80+
unmatchedCount: rows.length - matchedCount,
81+
};
82+
}

0 commit comments

Comments
 (0)