Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .ai.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,22 @@ server-side (60 req/min default), campaign-scoped, optional
device-fingerprint lock. See also `chronicle/.ai/conventions.md` security
section and CLAUDE.md → "Working with this project" (tenets T-B1, T-B4).

**Module version header:** REST requests through `api-client.mjs` (`fetch`
and `uploadMedia`) send `X-Chronicle-Module-Version` from the manifest version
(`scripts/_module-version.mjs`); omitted if unreadable. Caller headers win. An
older Chronicle's CORS refuses the header, so a network TypeError on a request
that carried it retries once without it and drops it for the session. The
dashboard's raw connection probes never send it.
`tools/test-module-version-header.mjs`.

**Connect line:** the client-scoped `connectLine` setting takes
`chronicle://…/c/<campaignId>?key=…` (`chronicle+http://` for plain http),
parsed by `scripts/_connect-line.mjs`. `applyConnectLine` in `settings.mjs`
(GM only) writes `apiUrl`, `campaignId` and the client-scoped `apiKey`, then
clears the line; nothing changes on an invalid line. `SyncManager` reads the
settings in `start()` and is not restarted, so the GM reloads. Format in
API-CONTRACT.md → "Connect Line". `tools/test-connect-line.mjs`.

## Known API Field Discrepancies

API-CONTRACT.md defines canonical names; code uses some accepted aliases.
Expand Down
51 changes: 42 additions & 9 deletions API-CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ All REST requests include a Bearer token:
Authorization: Bearer <api-key>
```

REST requests through the API client also carry the module's manifest version, so Chronicle
can tell an owner when the module is out of date:
```
X-Chronicle-Module-Version: <module.json version>
```
The header is omitted when the version cannot be read. A Chronicle that
records it lists it in its CORS `Access-Control-Allow-Headers`; an older one
doesn't, so the browser refuses the preflight. The module then retries that
request once without the header and stops sending it for the session
(`scripts/_module-version.mjs`), so an older server keeps syncing. WebSocket
connections do not send it.

WebSocket connections authenticate via query parameter at connection time:
```
wss://chronicle.example.com/ws?token=<api-key>
Expand All @@ -20,6 +32,27 @@ API keys are scoped to a single campaign. The key determines:
- A `sync`-level key covers read + write + sync
- Rate limit: 60 requests/minute (default)

## Connect Line

Chronicle shows campaign owners a one-paste line that carries everything the
module needs:
```
chronicle://chronicle.example.net/c/<campaignId>?key=<apiKey>
chronicle+http://192.168.1.5:8080/sub/c/<campaignId>?key=<apiKey>
```
- Scheme `chronicle:` means `https://<host[:port]><path>`; `chronicle+http:`
means `http://<host[:port]><path>` (plain-http instances).
- `<path>` is everything before the final `/c/<campaignId>` segment; it is
empty unless Chronicle is served under a sub-path. The result is the `apiUrl`
setting.
- `key` is the URL-decoded query parameter and becomes the API key.
- Any other scheme, a missing key or campaign id, or userinfo in the line is
invalid and changes nothing.

The module parses it in `scripts/_connect-line.mjs` (`parseConnectLine`); a GM
pastes it into the "Connect line" module setting, which fills the URL,
campaign ID and client-scoped API key and then clears itself.

## Base URL Pattern

All REST endpoints are prefixed with:
Expand Down Expand Up @@ -725,6 +758,10 @@ a retryable sync error.
{ "year": 1492, "month": 3, "day": 1, "hour": 8, "minute": 0 }
```

#### POST /calendar
Creates the campaign's calendar. Answers `201 {"created": …, "warnings": […]}`;
`409` when the campaign already has a calendar.

#### POST /calendar/date/confirm
**Optional** (newer Chronicle deployments only). Confirms Foundry *applied* a
date pulled from Chronicle to the active local calendar module (Calendaria or
Expand All @@ -750,15 +787,11 @@ Any other failure is debug-logged and swallowed — a missed confirmation only
leaves the applied-beacon stale, never blocks sync. See
`scripts/_applied-date-confirm.mjs::isConfirmNotSupported`.

#### POST /calendar/advance
Advances the calendar by N days (1-3650).

**Request:** `{ "days": 7 }`

#### POST /calendar/advance-time
Advances time by hours/minutes (rolls over into days).
#### POST /calendar/advance — retired
Answers `410 {"error":"calendar_route_retired"}`.

**Request:** `{ "hours": 2, "minutes": 30 }`
#### POST /calendar/advance-time — retired
Answers `410 {"error":"calendar_route_retired"}`.

---

Expand Down Expand Up @@ -836,7 +869,7 @@ Returns a single event by ID.
| `GET /calendar/weather` | Returns current weather state, or `{}` if none set |
| `PUT /calendar/weather` | Sets current weather state (GM override) |
| `GET /calendar/export` | Exports the full calendar as Chronicle JSON; `?events=true` includes events |
| `POST /calendar/import` | Imports a calendar from JSON (Chronicle, Simple Calendar, Calendaria, Fantasy-Calendar formats) |
| `POST /calendar/import` | Retired: answers `410 {"error":"calendar_route_retired"}`; use `POST /calendar` |

---

Expand Down
7 changes: 4 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,10 @@ Install/update flow: also `.ai.md` → "Chronicle Integration — Install & Upda

## Calendar blackout and date-push pauses

Chronicle's date and event routes are live; some calendar routes (structure
and settings writes, import, export, advance) still answer
`HTTP 503 {"error":"calendar_rebuilding", ...}`. Only a 503 whose body says
Chronicle's date and event routes are live. Current Chronicle answers the
old structure, settings, import, export and advance routes with
`410 {"error":"calendar_route_retired"}` (the module calls none of them); an
older Chronicle answers them with `HTTP 503 {"error":"calendar_rebuilding", ...}`. Only a 503 whose body says
`calendar_rebuilding` arms the blackout (`scripts/_calendar-blackout-guard.mjs`,
`_calendar-probe-state.mjs`); a bare 503 from a proxy or restart is an ordinary
failed request retried next tick. The GM gets one notice when it arms; pushes
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ sends update traffic to GitHub instead of Chronicle.

1. Enable the module in your world's **Module Management**
2. Open **Game Settings → Module Settings → Chronicle Sync**
3. Enter your Chronicle **API URL**, **API Key**, and **Campaign ID**
3. Paste the **Connect line** from Chronicle (your campaign → **Manage → Apps & game system**, Foundry VTT row → **Make a connect line**) and save; it fills in the URL, API key and campaign ID, then reload Foundry. Or enter the **API URL**, **API Key**, and **Campaign ID** by hand
4. Enable the sync categories you want (Journals, Maps, Calendar, Characters)

The module runs sync for the GM only. Players receive updates passively through Foundry.
Expand Down
8 changes: 8 additions & 0 deletions lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@
"Name": "API Key",
"Hint": "API key with 'sync' permission from your Chronicle campaign settings. Stored in THIS browser only and never sent to players; each GM enters it once per browser."
},
"ConnectLine": {
"Name": "Connect line",
"Hint": "Paste the connect line from Chronicle (your campaign → Manage → Apps & game system → Foundry VTT → Make a connect line) and save. It fills in the URL, campaign ID and API key for you, then clears itself. GM only.",
"Success": "Connected to Chronicle. Reload Foundry to start syncing.",
"Invalid": "That is not a valid Chronicle connect line ({reason}). Nothing was changed.",
"GmOnly": "Only a GM can apply a Chronicle connect line. Nothing was changed.",
"Failed": "Could not save the connection settings. See the console for details."
},
"CampaignId": {
"Name": "Campaign ID",
"Hint": "The UUID of your Chronicle campaign"
Expand Down
46 changes: 46 additions & 0 deletions scripts/_connect-line.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* Parses the one-paste connect line Chronicle shows campaign owners:
* chronicle://host[:port][/sub]/c/<campaignId>?key=<apiKey> (https)
* chronicle+http://host[:port][/sub]/c/<campaignId>?key=<apiKey> (plain http)
* The path before the final `/c/<campaignId>` is the instance's sub-path.
* Pure and Foundry-free so it is unit-tested; pinned by
* `tools/test-connect-line.mjs`. Never include the line or key in a reason.
*/

const SCHEMES = Object.freeze({ 'chronicle:': 'https:', 'chronicle+http:': 'http:' });

/**
* @param {unknown} text - Pasted line (surrounding whitespace is ignored).
* @returns {{ok: true, baseUrl: string, campaignId: string, apiKey: string}
* | {ok: false, reason: string}}
*/
export function parseConnectLine(text) {
const fail = (reason) => ({ ok: false, reason });
if (typeof text !== 'string' || !text.trim()) return fail('empty');

let url;
try {
url = new URL(text.trim());
} catch {
return fail('not a valid connect line');
}

const scheme = SCHEMES[url.protocol];
if (!scheme) return fail('unsupported scheme');
// Credentials in the authority would be a different, riskier line shape.
if (url.username || url.password) return fail('userinfo not allowed');
if (!url.host) return fail('missing host');

const m = /^(.*)\/c\/([^/]+)\/?$/.exec(url.pathname);
if (!m) return fail('missing /c/<campaignId> segment');

let campaignId;
try { campaignId = decodeURIComponent(m[2]); } catch { return fail('bad campaign id'); }
if (!campaignId || /[\s/]/.test(campaignId)) return fail('bad campaign id');

const apiKey = url.searchParams.get('key');
if (!apiKey) return fail('missing key');

const subPath = m[1].replace(/\/+$/, '');
return { ok: true, baseUrl: `${scheme}//${url.host}${subPath}`, campaignId, apiKey };
}
57 changes: 57 additions & 0 deletions scripts/_module-version.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* Builds the header that tells Chronicle which module release is calling, so
* the server can show owners a mismatch instead of guessing from behavior.
* The builder is pure; `moduleVersionHeaders()` reads the live manifest version.
* Pinned by `tools/test-module-version-header.mjs`.
*/

import { MODULE_ID } from './constants.mjs';

export const MODULE_VERSION_HEADER = 'X-Chronicle-Module-Version';

/**
* @param {unknown} version - Manifest version, if known.
* @returns {Object<string,string>} One-entry header map, or `{}` when the
* version is unavailable (the header is omitted rather than sent empty).
*/
export function buildModuleVersionHeaders(version) {
if (typeof version !== 'string') return {};
const v = version.trim();
// Header values must be a single visible-ASCII line.
if (!v || !/^[\x21-\x7e]+(?: [\x21-\x7e]+)*$/.test(v)) return {};
return { [MODULE_VERSION_HEADER]: v };
}

// A Chronicle older than the version header lists no such name in its CORS
// allow-list, so the browser refuses every preflight that carries it. Once a
// request fails that way the header is dropped for the rest of the session.
let refusedByServer = false;

/** Stop sending the header for this session (an older Chronicle refused it). */
export function markModuleVersionHeaderRefused() { refusedByServer = true; }

/** Test hook: forget a refusal. */
export function resetModuleVersionHeaderRefusal() { refusedByServer = false; }

/**
* Header map for the running module; `{}` if `game` or the version is
* unavailable, or the server refused the header earlier this session.
*/
export function moduleVersionHeaders() {
if (refusedByServer) return {};
let version;
try { version = globalThis.game?.modules?.get(MODULE_ID)?.version; } catch { /* not ready */ }
return buildModuleVersionHeaders(version);
}

/**
* Whether a fetch failure should be retried once without the version header:
* a network-level TypeError (what a refused CORS preflight looks like) on a
* request that carried it.
*
* @param {unknown} err - What fetch threw.
* @param {Object<string,string>} headers - The headers that request sent.
*/
export function shouldRetryWithoutVersionHeader(err, headers) {
return !refusedByServer && err instanceof TypeError && !!headers && MODULE_VERSION_HEADER in headers;
}
38 changes: 26 additions & 12 deletions scripts/api-client.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@

import { getSetting } from './settings.mjs';
import { describeCampaignIdError } from './_settings-validation.mjs';
import {
MODULE_VERSION_HEADER,
markModuleVersionHeaderRefused,
moduleVersionHeaders,
shouldRetryWithoutVersionHeader,
} from './_module-version.mjs';

/**
* Validate the campaignId setting and abort with a clear notification if
Expand Down Expand Up @@ -227,15 +233,20 @@ export class ChronicleAPI {
const headers = {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
...moduleVersionHeaders(),
...options.headers,
};

let response;
try {
response = await fetch(url, {
...options,
headers,
});
try {
response = await fetch(url, { ...options, headers });
} catch (err) {
if (!shouldRetryWithoutVersionHeader(err, headers)) throw err;
markModuleVersionHeaderRefused();
const { [MODULE_VERSION_HEADER]: _dropped, ...withoutVersion } = headers;
response = await fetch(url, { ...options, headers: withoutVersion });
}
} catch (err) {
// Network error (no response at all).
this.health.restErrorCount++;
Expand Down Expand Up @@ -411,14 +422,17 @@ export class ChronicleAPI {
const formData = new FormData();
formData.append('file', file, filename || file.name);

const response = await fetch(
`${baseUrl}/api/v1/campaigns/${campaignId}/media`,
{
method: 'POST',
headers: { 'Authorization': `Bearer ${apiKey}` },
body: formData,
}
);
const url = `${baseUrl}/api/v1/campaigns/${campaignId}/media`;
const headers = { 'Authorization': `Bearer ${apiKey}`, ...moduleVersionHeaders() };
let response;
try {
response = await fetch(url, { method: 'POST', headers, body: formData });
} catch (err) {
if (!shouldRetryWithoutVersionHeader(err, headers)) throw err;
markModuleVersionHeaderRefused();
const { [MODULE_VERSION_HEADER]: _dropped, ...withoutVersion } = headers;
response = await fetch(url, { method: 'POST', headers: withoutVersion, body: formData });
}

if (!response.ok) {
this.health.restErrorCount++;
Expand Down
56 changes: 56 additions & 0 deletions scripts/settings.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import { MODULE_ID } from './constants.mjs';
import { UpdateInfoApplication } from './update-info.mjs';
import { SyncCalendarApplication } from './sync-calendar.mjs';
import { parseConnectLine } from './_connect-line.mjs';

/**
* Register all Chronicle Sync module settings.
Expand Down Expand Up @@ -41,6 +42,19 @@ export function registerSettings() {
requiresReload: true,
});

// One-paste connect line from Chronicle. Client scope so the pasted key
// never touches a world document; the value is consumed and cleared on
// change, so it is only ever stored for the instant of the write.
game.settings.register(MODULE_ID, 'connectLine', {
name: game.i18n.localize('CHRONICLE.Settings.ConnectLine.Name'),
hint: game.i18n.localize('CHRONICLE.Settings.ConnectLine.Hint'),
scope: 'client',
config: true,
type: String,
default: '',
onChange: (value) => { applyConnectLine(value); },
});

// Campaign UUID.
game.settings.register(MODULE_ID, 'campaignId', {
name: game.i18n.localize('CHRONICLE.Settings.CampaignId.Name'),
Expand Down Expand Up @@ -389,6 +403,43 @@ export async function migrateApiKeyToClientScope() {
return true;
}

/**
* Apply a pasted connect line: write URL, campaign id and the CLIENT-scoped
* API key, then clear the raw line. All-or-nothing: an invalid line or a
* non-GM changes nothing. Neither the line nor the key is logged. The
* running SyncManager is not restarted (it reads these settings in start()),
* so the GM is told to reload.
*
* @param {string} raw - Value of the `connectLine` setting.
* @returns {Promise<boolean>} true if the connection settings were written.
*/
export async function applyConnectLine(raw) {
if (typeof raw !== 'string' || !raw.trim()) return false;
const clear = () => game.settings.set(MODULE_ID, 'connectLine', '').catch(() => {});
try {
if (!game.user?.isGM) {
ui.notifications.warn(game.i18n.localize('CHRONICLE.Settings.ConnectLine.GmOnly'));
return false;
}
const parsed = parseConnectLine(raw);
if (!parsed.ok) {
ui.notifications.warn(game.i18n.format('CHRONICLE.Settings.ConnectLine.Invalid', { reason: parsed.reason }));
return false;
}
await game.settings.set(MODULE_ID, 'apiUrl', parsed.baseUrl);
await game.settings.set(MODULE_ID, 'campaignId', parsed.campaignId);
await game.settings.set(MODULE_ID, 'apiKey', parsed.apiKey);
ui.notifications.info(game.i18n.localize('CHRONICLE.Settings.ConnectLine.Success'));
return true;
} catch (err) {
ui.notifications.error(game.i18n.localize('CHRONICLE.Settings.ConnectLine.Failed'));
console.error('Chronicle Sync | Applying the connect line failed:', err?.message);
return false;
} finally {
await clear();
}
}

/**
* Set a Chronicle Sync setting value.
* @param {string} key - Setting key without module prefix.
Expand Down Expand Up @@ -523,4 +574,9 @@ Hooks.on('renderSettingsConfig', (app, html) => {
keyInput.type = 'password';
keyInput.autocomplete = 'off';
}
const lineInput = root?.querySelector?.(`input[name="${MODULE_ID}.connectLine"]`);
if (lineInput) {
lineInput.type = 'password';
lineInput.autocomplete = 'off';
}
});
Loading
Loading