Skip to content

Foundry: Calendaria import, follow wizard-made calendars, one-paste connect line - #954

Merged
keyxmakerx merged 8 commits into
mainfrom
claude/foundry-calendar-sync-ayr0rv
Oct 3, 2026
Merged

keyxmakerx merged 8 commits into
mainfrom
claude/foundry-calendar-sync-ayr0rv

Conversation

@keyxmakerx

@keyxmakerx keyxmakerx commented Oct 3, 2026 •

Copy link
Copy Markdown
Owner

Requested by Key Maker · project thread

Fixes #869
Fixes #893
Security implication: The import is Owner-only (a DM grant without membership doesn't count, same as the web wizard). It is scoped to the URL campaign, which RequireCampaignMatch pins to the key's campaign; the payload carries no campaign id. The body is capped at 1 MiB. The primary-calendar fallback picks the same calendar for every viewer, then gates it, so a player never gets a different or hidden calendar.
Consumer-verified: Payload shape from Chronicle-Foundry-Module/scripts/sync-calendar-import-from-calendaria.mjs (transformCalendariaCalendar). The module's POST and its 404/405 handling are at scripts/sync-calendar.mjs:1214-1229. The module calls only /calendar, /calendar/date(/confirm), /calendar/events(/:id) and /calendar/weather (checked by grepping scripts/).
Mockup: The calendar import is backend only. The Foundry row follows the approved Sign-offs card "Before and after: the Foundry row on Apps & game system" (from the signed #851 Manage mockup).

What this changes

Before: The Foundry module's "Import into Chronicle" button got a 404 ("Chronicle doesn't have the create endpoint yet"). A calendar made in the V5 wizard was never marked default, so Foundry couldn't find it at all: every sync read 404'd and the module offered to import. Sixteen other old calendar routes answered 503 "rebuilding".

After: The Import button creates the campaign's first calendar from the Calendaria calendar. That covers months, weekdays, seasons, moons, eras, today's date and time, and the description. The calendar is made the default, and dates and events then sync. Foundry follows the default calendar, or the first one when none is marked default, so wizard-made calendars sync too. The 16 old routes answer 410 with what to use instead.

Why

#869, under #778. The Calendaria import is the one remaining route the module calls. The other 16 are called by nothing, and V5 edits calendar structure only in Chronicle's calendar, so each is retired with a reason rather than ported:

  • structure and settings writes: edit them in Chronicle's calendar;
  • event categories: replaced by event kinds;
  • world state: GET /calendar/date carries season, moons and weather;
  • advance and advance-time: use PUT /calendar/date;
  • export: in the campaign export;
  • import onto an existing calendar: use the wizard, or POST /calendar for a first calendar.

Part 2: the Foundry row on Apps & game system (#893)

Before: The Foundry row said "Connection keys and sync status" and linked to Settings › Integrations. There you copied a key and the address into Foundry separately.

After:

  • The row shows a status dot with "Connected now", "Last seen … ago" (amber after a day) or "Never connected", plus the module version.
  • It shows the start of the newest key and a note.
  • Make a new connect line mints a new key (read, write and sync; old keys keep working, as the owner chose) and shows the full chronicle:// line once with a Copy button. All keys links to the key list.

Load-bearing lines:

  • campaigns/foundry_connect.go:
  • campaigns/extensions_hub_handler.go NewFoundryConnectLine:
    • Owner only, on the route POST /campaigns/:id/extensions/foundry/connect-line.
    • The response is Cache-Control: no-store and the line is never logged. The base URL is checked before a key is minted.
    • The GET page only ever carries the key prefix.
  • app/routes.go foundryConnectorAdapter: reads the campaign's active keys and skips ones labelled "custom" (other tools). Hub presence comes from the websocket hub.
  • syncapi/middleware.go and repository.go:
    • X-Chronicle-Module-Version is stored on the key when it matches ^[0-9A-Za-z.+\-]{1,32}$.
    • COALESCE keeps the stored value when the header is absent, and the session door's synthetic key is never written.
  • syncapi/migrations/008_api_key_module_version: ADD COLUMN IF NOT EXISTS module_version VARCHAR(32). Additive and idempotent; the migration-safety check passed.
  • middleware/cors.go: the header is added to the allow-list so a cross-origin Foundry's preflight passes. The module drops the header for older servers that don't allow it.

Live check after deploy: #966. Before deploying, confirm BASE_URL is the public https address. The connect line is built from it, and the default http://localhost:8080 would give Foundry a line pointing at localhost.

Load-bearing lines (calendar)

  • calendar/import_foundry.go ParseFoundryImport:
    • Maps the module's payload onto ImportResult, reusing the Calendaria file parser's season and moon arithmetic.
    • Season months are 0-based, as Calendaria's live API is; the module adds 1 to the current month for the same reason.
    • leap_extra_days is Calendaria's total leap-month length, read the same way as the file parser's leapDays.
    • A time past a 24-hour day is clamped with a warning, because the payload carries no day length.
  • calendar/service.go ImportFoundryCalendar:
    • 409 if the campaign has any calendar.
    • Creates with MakeDefault, so the idx_one_default_per_campaign unique index refuses a racing second insert. That Duplicate entry becomes 409. No duplicate copies.
  • calendar/service.go GetPrimaryCalendarForViewer: the default, else the first by sort order. A hidden default gives 404 and does not fall back. The campaign export adapter already had the same fallback.
  • syncapi/calendar_api_handler.go CreateCalendar: requireOwner, then the body cap, then 201 {created, warnings}.
  • syncapi/routes.go: the 16 routes now use retiredCalendarRoute(...). calendarRebuilding and its test are gone.
  • CreateCalendarFromImport now also carries Today.Hour/Minute (range-checked) and Settings.Description. Only the Foundry parser sets them; every other import path sends 0 and nil, as before.

Honest deviations and follow-ups

  • The module discards the 201 body, so the server's warnings never reach the GM. The warnings are "day length assumed 24h", "leap rule not sent" and "random moon phases made steady". The module also sends whole-number moon cycles (29.53 becomes 29) and no day length or leap rule. Module issue to follow.
  • The 409 shows in the module as a generic "post failed" with Chronicle's message. That's acceptable: the module's next probe finds the existing calendar.
  • docs/api/openapi.yaml still describes the calendar routes as 503; it was already stale on main. Issue to follow.
  • Found while doing this, filed as Calendar import: names with a full stop lose everything before it #953: the file importers drop text before a full stop in names ("D.R." becomes empty). The Foundry import uses a stricter rule.

Test plan

  • go build ./... && go vet ./internal/plugins/calendar/ ./internal/plugins/syncapi/: clean.
  • go test -count=1 ./internal/plugins/calendar/ ./internal/plugins/syncapi/ ./internal/plugins/campaigns/ ./internal/app/ ./internal/middleware/ ./internal/wire/ ./internal/systems/: all ok. A review also ran make verify: go test ./... -short green and make test-js 291/291.
  • Manage: one-paste Foundry connect line with last seen and module version #893 tests: connect-line builder table, status table (connected, under 24h, exactly 24h, never, hub against key), module-version header acceptance, recordKeyUsage, route tests through the real router (Owner 200; Scribe and Player 403 with no key minted; the POST shows the line once with no-store; the GET never contains a raw key), and adapter tests (inactive, expired and custom keys ignored). The calendar and syncapi packages have 1,361 test runs: 1,335 pass, and the rest are DB integration tests skipped locally.
  • Every tools/check-*.sh guard exits 0, including plugin-isolation and migration-immutability.
  • New tests:
    • TestParseFoundryImport_*: structure, seasons, moons, eras, names, warnings, time clamp, 0-based seasons, errors.
    • TestImportFoundryCalendar: 409 with an existing or hidden calendar; duplicate-default race gives 409; 400 on bad payloads; a failed apply leaves no calendar.
    • TestGetPrimaryCalendarForViewer.
    • TestCalendarAPI_CreateCalendar: Owner 201; Scribe and Player 403; 409 passes through; body cap.
    • TestRetiredCalendarRoute, TestCalendarRoutes_RetiredAreRegisteredRetired.
  • CI passes.
  • After deploy (operator), on a test campaign with no calendar, with Calendaria in Foundry:
    1. Open Chronicle Sync › Sync Calendar.
    2. Press Import on a Calendaria calendar.
    3. Working: Foundry says it succeeded, and Chronicle's Calendars page shows the calendar with the same months and today's date.
    4. Press Import again. Working: Foundry shows "already has a calendar", and there is still only one calendar.

Tenet self-check

  • T-B1 security: Owner gate, path-campaign scoping, body cap, and the unique index guards against duplicates.
  • T-B2 plugin isolation: no new plugin-name literals (guard passes); syncapi reaches calendar only through CalendarService.
  • T-B3 production UI: n/a, no UI.
  • T-B4 docs: syncapi/.ai.md, calendar/.ai.md, .ai/architecture.md and .ai/data-model.md now describe the routes as they are.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UexL55BkZcztyC1eezXKfn


Generated by Claude Code


Generated by Claude Code

claude added 2 commits October 3, 2026 01:35
…retire old routes

POST /calendar creates a campaign's first calendar from the Foundry
module's Calendaria payload (Owner only, 409 when the campaign already has
a calendar, created as the default so a racing second import is refused
by the one-default index). The sync routes follow the default calendar or,
when none is marked default, the first, since the V5 wizard never marks
one. The 16 pre-V5 structure/advance/export/import routes the module never
called answer 410 calendar_route_retired with what to use instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UexL55BkZcztyC1eezXKfn
Calendaria's live API counts months from 0, so the Foundry import uses that
base instead of guessing it from the smallest season start. A time of day
past a 24-hour day is clamped with a warning rather than refusing the
import, since the payload carries no day length. The 201 body names the
calendar "created" (the plugin-isolation guard reads a bare "calendar"
literal outside its plugin as a cross-plugin reference). Moves a
misplaced doc comment and drops a stale world-state route comment.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UexL55BkZcztyC1eezXKfn
claude added 3 commits October 3, 2026 03:28
The Foundry row on Apps & game system now shows a status dot (connected
now / last seen / never), the module version, a prefix-only preview of the
connect line, and a button that mints a new key and shows the full line once.

- campaigns: FoundryConnector interface, ComputeFoundryStatus,
  BuildFoundryConnectLine (single definition of the wire format), owner-only
  POST /extensions/foundry/connect-line, response is Cache-Control: no-store
- app: foundryConnectorAdapter over the sync key service, websocket hub and
  BaseURL
- syncapi: api_keys.module_version (migration 008) stored from
  X-Chronicle-Module-Version when it matches the allowed pattern; absent or
  invalid leaves the stored value alone; the session door's synthetic key is
  never written
- cors: allow X-Chronicle-Module-Version on preflight so a browser-hosted
  Foundry is not blocked by the new header

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UexL55BkZcztyC1eezXKfn
A key with the "custom" label belongs to a bot or script, so its last use
and missing module version no longer show as Foundry's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UexL55BkZcztyC1eezXKfn
@keyxmakerx keyxmakerx changed the title Foundry calendar sync: Calendaria import, follow wizard-made calendars, retire unused routes Foundry: Calendaria import, follow wizard-made calendars, one-paste connect line Oct 3, 2026
claude added 2 commits October 3, 2026 04:24
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UexL55BkZcztyC1eezXKfn
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UexL55BkZcztyC1eezXKfn

keyxmakerx commented Oct 3, 2026 •

Copy link
Copy Markdown
Owner Author

Build & Test and Fresh-DB Migration Replay were red because main had two migrations numbered 36. Fixed: #977 merged, main was merged into this branch, and every check is green on 1bbf76b.


Generated by Claude Code

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UexL55BkZcztyC1eezXKfn
@keyxmakerx
keyxmakerx merged commit e8c03eb into main Oct 3, 2026
10 checks passed
@keyxmakerx
keyxmakerx deleted the claude/foundry-calendar-sync-ayr0rv branch October 3, 2026 15:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants