Skip to content
3 changes: 3 additions & 0 deletions .ai.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ Calendaria back-catalog note mappings are per-GM-user, not per-world (#95).
| `scripts/actor-sync.mjs` | Actor ↔ character entity sync, adapter loading. `is_private` ↔ `prototypeToken.hidden` NPC visibility syncs bidirectionally. Deleting from Chronicle unlinks the Actor (data preserved); deleting the Actor in Foundry asks before deleting the Chronicle entity (`_remote-deletes.mjs`). Player Character Claiming addon-aware: PC sub-type routing, `owner_user_id` gated on addon state, one-time hint when addon is off but player-owned actors exist |
| `scripts/item-sync.mjs` | Actor inventory ↔ Chronicle "Has Item" relations (quantity, equipped). Chronicle → Foundry reconciles a whole character per relation event or feed entry |
| `scripts/_inventory-plan.mjs` | Pure: the create/adopt/update/unlink plan that brings an actor's linked items in line with its relations |
| `scripts/_inline-pictures.mjs` | Pictures inside Chronicle page text (`<figure class="ce-img …"><img src="/media/<uuid>">`) ↔ Foundry: on pull a shared picture's src points at its local copy and a GM-only one (`ce-img--gm`) goes inside a native `<section class="secret">`, never copied, which the pull then stores as a placeholder like any GM-only text (`_gm-secrets.mjs`); on push every src goes back to `/media/<uuid>` and every Chronicle picture inside a secret block goes back GM-only, whatever shape the editor left it in. A copy made while a picture was shared stays in the world's files if it later turns GM-only (no longer shown; Foundry has no file delete for modules). Pure. `tools/test-inline-pictures.mjs`, bench "pictures inside page text" |
| `scripts/picture-store.mjs` | Local copies of those pictures in `worlds/<world>/chronicle-media/<uuid>.<ext>` (Chronicle's signed links expire in minutes, so a journal can't point at Chronicle). GM-only: `GET /media/:id` for the signed link, a cookieless fetch on the `apiUrl` host only, PNG/JPEG/GIF/WebP/AVIF only, 25 MB cap, served type must match, `FilePicker.upload`. Copied once per id. `watchGMPictures` shows GM-only pictures on the GM's screen from a fresh signed link, as `gm-secret-view.mjs` fills their placeholders, outside editors. `tools/test-inline-pictures.mjs` |
| `scripts/player-notebook.mjs` | Notebook window and bottom-right "Jot notes" tab for every user: frames of Chronicle's own Journal and Jot pages (`/embed/campaigns/:id/notes/journal\|jots`), fed the player's notes grant over `postMessage`. First use opens Chronicle's Allow window. Tracks the newest open Chronicle-linked sheet so jots follow the page in view |
| `scripts/_notes-grant.mjs` | Pure checks for the notebook: Allow/frame URLs, the grant message (Chronicle origin, `cnt_` token, campaign, and the GM's member matching for this Foundry login), per-Foundry-user client storage, frame message source/origin, `PageTracker`. `tools/test-notes-grant.mjs` |
| `scripts/_scene-controls.mjs` | Builds the Chronicle scene-control group: Dashboard + Sync Calendar for GMs only, Notebook for everyone when connected. `tools/test-scene-controls.mjs` |
Expand Down Expand Up @@ -295,6 +297,7 @@ break-out, or a leaked bearer token.
| Class | What we trust | What we validate | Where | Pin |
|---|---|---|---|---|
| HTML for `JournalEntry.text.content` | Chronicle's bluemonday UGCPolicy at write | Pre-sanitize via `TextEditor.cleanHTML` at ingress | `scripts/_html-sanitizer.mjs` | `tools/test-html-sanitizer.mjs` |
| Pictures inside page text | Chronicle's own media path | Only `/media/<uuid>` (UUID-checked, so never a path) is copied; the signed link must be on the `apiUrl` host; raster MIME only, declared and served types must match; GM-only pictures are never copied | `scripts/picture-store.mjs`, `scripts/_inline-pictures.mjs` | `tools/test-inline-pictures.mjs` |
| Image URLs from map sub-resources | Operator-configured `apiUrl` host | `_isAllowedImageHost` rejects full-URL `image_url`/token `image` whose hostname doesn't match `apiUrl`, in `map-sync.mjs` + `map-viewer.mjs`; rejection → blank `<img>` + warning | `scripts/_url-validation.mjs` | `tools/test-url-validation.mjs` |
| User-visible DOM interpolation (e.g. dashboard test results) | Nothing | `replaceChildren` + `createTextNode`, never `innerHTML` of Chronicle data | `scripts/sync-dashboard.mjs::_renderTestResults` | `tools/test-sync-dashboard-xss.mjs` |
| Sync history rows (names, messages, people) | Nothing | Built as node descriptions; `toDom` sets only text and attributes | `scripts/_history-view.mjs::toDom` | `tools/test-history-view.mjs` |
Expand Down
5 changes: 4 additions & 1 deletion API-CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -999,7 +999,10 @@ Uploads a media file (image, etc.).
```

#### GET /media/:mediaId
Returns media metadata.
Returns media metadata: `mime_type`, `file_size`, and `url`, a signed
`/media/<id>?expires=…&sig=…` link valid for about 15 minutes. Journal sync
uses it to copy pictures inside page text into the world's files
(`scripts/picture-store.mjs`); a saved signed link would expire.

#### DELETE /media/:mediaId
Deletes a media file.
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ data flow, file index and feature details. Entry point: `scripts/module.mjs`
- `module.json` (Foundry manifest, v12–v14), `chronicle-package.json`
(serving descriptor, schema v1) — cross-validated by
`tools/check-package-descriptor.mjs`.
- `scripts/*.mjs`: sync (`journal-sync`, `map-sync`+`map-viewer`+`map-sheet-items`,
- `scripts/*.mjs`: sync (`journal-sync`+`picture-store`, `map-sync`+`map-viewer`+`map-sheet-items`,
`calendar-sync`+`sync-calendar`+`sync-calendar-*`, `actor-sync`,
`item-sync`, `stash-sync`+`stash-client`), UI (`sync-dashboard`, `npc-presence`,
`sync-diagnostic-bundle`, `update-info`, `gm-secret-view`, `character-claim-indicator`,
Expand Down
24 changes: 24 additions & 0 deletions bench/fake-foundry.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,7 @@ export function installFoundry({ settings = {}, systemId = 'dnd5e' } = {}) {
items: new Collection(),
scenes: new Collection(),
system: { id: systemId, version: '5.0.0' },
world: { id: 'bench-world' },
version: '14.300',
modules: { get: () => null },
time: { worldTime: 0, components: {} },
Expand Down Expand Up @@ -424,6 +425,28 @@ export function installFoundry({ settings = {}, systemId = 'dnd5e' } = {}) {
world.game = game;
world.settingsValues = values;

// The world's user-data files, in memory: path → { size, type }.
const files = new Map();
world.files = files;
const FilePicker = {
async browse(source, dir) {
const prefix = `${dir}/`;
const listed = [...files.keys()].filter((p) => p.startsWith(prefix));
if (listed.length === 0 && ![...files.keys()].some((p) => p === dir)) throw new Error(`ENOENT: ${dir}`);
return { files: listed, dirs: [] };
},
async createDirectory(source, dir) {
if (files.has(dir)) throw new Error(`EEXIST: ${dir}`);
files.set(dir, { dir: true });
},
async upload(source, dir, file) {
const path = `${dir}/${file.name}`;
files.set(path, { size: file.size, type: file.type });
log.writes.push({ op: 'upload', type: 'File', id: path });
return { status: 'success', path };
},
};

world.collectionFor = (type) => ({ JournalEntry: journal, Actor: actors, Folder: folders }[type]);

function makeDocClass(type, Impl) {
Expand Down Expand Up @@ -478,6 +501,7 @@ export function installFoundry({ settings = {}, systemId = 'dnd5e' } = {}) {
escapeHTML: (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])),
},
applications: {
apps: { FilePicker: { implementation: FilePicker } },
api: {
ApplicationV2: class { render() { return this; } close() {} },
HandlebarsApplicationMixin: (b) => b,
Expand Down
59 changes: 59 additions & 0 deletions bench/journals.bench.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { CHRONICLE_URL } from './chronicle.mjs';
import { openWorld, closeWorld, settle, recordRequests, waitFor } from './world.mjs';
import { FLAG, linked, byEntity, writes, pageText, chroniclePage, scenario } from './scenario.mjs';

Expand Down Expand Up @@ -268,6 +269,64 @@ test('the same page edited on both sides at once ends the same on both sides', (
assert.equal(world.game.journal.filter((x) => x.name.startsWith('Crossroads')).length, 1);
}));

// Two tiny, different PNGs: Chronicle gives identical bytes one media id.
const PNGS = [
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEElEQVR4nGM4IScHRAwQCgAfJgQRoo8irwAAAABJRU5ErkJggg==',
].map((b) => Buffer.from(b, 'base64'));

async function uploadPicture(seed, name, bytes) {
const form = new FormData();
form.append('file', new Blob([bytes], { type: 'image/png' }), name);
const res = await fetch(`${CHRONICLE_URL}/api/v1/campaigns/${seed.campaignId}/media`, {
method: 'POST', headers: { authorization: `Bearer ${seed.moduleKey}` }, body: form,
});
assert.equal(res.status, 201, `upload ${name}: ${await res.clone().text()}`);
return (await res.json()).id;
}

test('pictures inside page text show in Foundry, GM-only ones stay secret, and an edit sends both back unchanged', (t) => scenario('pictures', async ({ seed, world }) => {
const shared = await uploadPicture(seed, 'mira.png', PNGS[0]);
const secret = await uploadPicture(seed, 'traitor.png', PNGS[1]);
assert.notEqual(shared, secret);
const html = `<p>Mira runs the docks.</p>`
+ `<figure class="ce-img ce-img--w40 ce-img--right"><img src="/media/${shared}" alt="Mira"><figcaption>Mira Kell</figcaption></figure>`
+ `<figure class="ce-img ce-img--w30 ce-img--left ce-img--gm"><img src="/media/${secret}" alt="x"><figcaption>The traitor</figcaption></figure>`
+ `<p>She owes the guild.</p>`;
const e = await chroniclePage(seed, 'Mira Kell', html);
const stored = (await seed.chronicle.get(`/entities/${e.id}`)).entry_html || '';
if (!stored.includes('ce-img--gm')) {
t.skip('this Chronicle does not keep pictures inside page text yet (Chronicle#997)');
return;
}

await openWorld(world);
await JournalSync_resync(world);
const j = byEntity(world, e.id);
const text = pageText(j);
const local = `worlds/bench-world/chronicle-media/${shared}.png`;
assert.ok(text.includes(`src="${local}"`), `shared picture points at its copy: ${text}`);
assert.ok(world.files.has(local), 'the copy is in the world files');
// The GM-only picture is a placeholder in the saved page, like GM-only text.
assert.ok(!text.includes(secret) && !text.includes('ce-img--gm'), `the GM-only picture is not in the saved page: ${text}`);
assert.match(text, /<section class="secret[^"]*"[^>]* id="secret-chrk[0-9a-f]{32}"/);
assert.ok(![...world.files.keys()].some((p) => p.includes(secret)), 'a GM-only picture is never copied');

// A second pull reuses the copy.
await JournalSync_resync(world);
assert.equal([...world.files.keys()].filter((p) => p.includes(shared)).length, 1);

// An edit in Foundry sends the plain Chronicle paths back, GM-only intact.
const page = j.pages.contents.find((p) => p.type === 'text');
await page.update({ 'text.content': page.text.content.replace('She owes the guild.', 'She owes the guild 40 gold.') });
await settle();
const after = (await seed.chronicle.get(`/entities/${e.id}`)).entry_html || '';
assert.match(after, /40 gold/);
assert.ok(after.includes(`src="/media/${shared}"`), `shared path restored: ${after}`);
assert.match(after, new RegExp(`<figure class="[^"]*ce-img--gm[^"]*"><img src="/media/${secret}"`));
assert.ok(!after.includes('chronicle-media') && !after.includes('<section'), `no Foundry-side markup leaks back: ${after}`);
}));

/** The dashboard's Resync, used where a scenario needs every page linked up front. */
async function JournalSync_resync(world) {
const js = world.syncManager._modules.find((m) => m.constructor.name === 'JournalSync');
Expand Down
Loading
Loading