run-dmcp 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +51 -13
  2. package/dist/bin/run-dmcp.js +1 -1
  3. package/dist/db/schema.js +170 -18
  4. package/dist/http/server.js +22 -1
  5. package/dist/index.d.ts +34 -2
  6. package/dist/index.js +86 -2
  7. package/dist/mcp-server.d.ts +1 -1
  8. package/dist/mcp-server.js +1 -1
  9. package/dist/register/resources.js +2 -2
  10. package/dist/rpg/index.d.ts +0 -16
  11. package/dist/rpg/index.js +4 -22
  12. package/dist/rpg/server.d.ts +16 -0
  13. package/dist/rpg/server.js +22 -0
  14. package/dist/schemas/index.d.ts +62 -62
  15. package/dist/server.d.ts +1 -0
  16. package/dist/server.js +20 -0
  17. package/dist/timeline/changes.d.ts +8 -0
  18. package/dist/timeline/changes.js +8 -0
  19. package/dist/timeline/export.d.ts +10 -0
  20. package/dist/timeline/export.js +10 -0
  21. package/dist/timeline/irreversible.d.ts +2 -0
  22. package/dist/timeline/replay.d.ts +22 -0
  23. package/dist/timeline/replay.js +22 -0
  24. package/dist/timeline/schema.js +2 -0
  25. package/dist/tools/audio.js +13 -9
  26. package/dist/tools/game.js +33 -1
  27. package/dist/tools/images.js +17 -10
  28. package/dist/tools/resource.d.ts +2 -2
  29. package/dist/tools/time.js +18 -3
  30. package/dist/types/index.d.ts +1 -1
  31. package/dist/utils/media-path.d.ts +52 -0
  32. package/dist/utils/media-path.js +106 -0
  33. package/dist/utils/output-schemas.d.ts +63 -63
  34. package/dist/utils/output-schemas.js +1 -1
  35. package/package.json +14 -2
  36. package/dist/register/abilities.d.ts +0 -2
  37. package/dist/register/abilities.js +0 -165
  38. package/dist/register/combat.d.ts +0 -2
  39. package/dist/register/combat.js +0 -207
  40. package/dist/register/mcp-prompts.d.ts +0 -2
  41. package/dist/register/mcp-prompts.js +0 -684
  42. package/dist/register/quests.d.ts +0 -2
  43. package/dist/register/quests.js +0 -118
  44. package/dist/register/status.d.ts +0 -2
  45. package/dist/register/status.js +0 -130
  46. package/dist/register/tables.d.ts +0 -2
  47. package/dist/register/tables.js +0 -146
  48. package/dist/tools/ability.d.ts +0 -48
  49. package/dist/tools/ability.js +0 -238
  50. package/dist/tools/combat.d.ts +0 -13
  51. package/dist/tools/combat.js +0 -195
  52. package/dist/tools/dice.d.ts +0 -23
  53. package/dist/tools/dice.js +0 -111
  54. package/dist/tools/quest.d.ts +0 -34
  55. package/dist/tools/quest.js +0 -164
  56. package/dist/tools/status.d.ts +0 -36
  57. package/dist/tools/status.js +0 -218
  58. package/dist/tools/tables.d.ts +0 -33
  59. package/dist/tools/tables.js +0 -209
@@ -53,6 +53,8 @@ export function initializeTimelineSchema() {
53
53
  // all (absence is the absence of a fact, never a fact of absence -- hard
54
54
  // rule 3). No ON DELETE CASCADE and no FK to `games`: deleting a game
55
55
  // deletes its live rows, but the timeline of that game survives.
56
+ // DECISION(#21): a property to be declared irreversible needs its own fact key.
57
+ //
56
58
  // `irreversible` is a per-fact flag, not a per-entity or per-value one --
57
59
  // so any property a future consumer wants to declare irreversible has to
58
60
  // live under its own fact key; you cannot flag half a blob.
@@ -2,6 +2,7 @@ import { v4 as uuidv4 } from "uuid";
2
2
  import { getDatabase, getDataDir, withTransaction } from "../db/connection.js";
3
3
  import { writeFileSync, readFileSync, mkdirSync, existsSync, unlinkSync, rmSync, } from "fs";
4
4
  import { dirname, join, extname } from "path";
5
+ import { mediaDirPath, mediaFilePath, mediaPathWithin } from "../utils/media-path.js";
5
6
  import { getCharacter } from "./character.js";
6
7
  import { getLocation } from "./world.js";
7
8
  import { getFaction } from "./faction.js";
@@ -128,10 +129,11 @@ export async function storeAudio(params) {
128
129
  else {
129
130
  throw new Error("Either url or filePath must be provided");
130
131
  }
131
- // Build file path
132
+ // Build file path. Every segment below arrives from the caller, so the
133
+ // composition goes through the media-path choke point, which rejects
134
+ // anything that would land outside the audio directory.
132
135
  const ext = getExtension(mimeType);
133
- const relativePath = join(params.gameId, `${params.entityType}s`, params.entityId, `${id}.${ext}`);
134
- const fullPath = join(getAudioDir(), relativePath);
136
+ const { relativePath, fullPath } = mediaFilePath(getAudioDir(), [params.gameId, `${params.entityType}s`, params.entityId], `${id}.${ext}`);
135
137
  // Ensure directory exists and write file
136
138
  ensureDir(dirname(fullPath));
137
139
  writeFileSync(fullPath, audioBuffer);
@@ -192,7 +194,7 @@ export function getAudioFilePath(audioId) {
192
194
  const audio = getAudio(audioId);
193
195
  if (!audio)
194
196
  return null;
195
- const fullPath = join(getAudioDir(), audio.filePath);
197
+ const fullPath = mediaPathWithin(getAudioDir(), audio.filePath);
196
198
  if (!existsSync(fullPath))
197
199
  return null;
198
200
  return fullPath;
@@ -201,7 +203,7 @@ export function getAudioData(audioId) {
201
203
  const audio = getAudio(audioId);
202
204
  if (!audio)
203
205
  return null;
204
- const fullPath = join(getAudioDir(), audio.filePath);
206
+ const fullPath = mediaPathWithin(getAudioDir(), audio.filePath);
205
207
  if (!existsSync(fullPath))
206
208
  return null;
207
209
  const buffer = readFileSync(fullPath);
@@ -264,7 +266,7 @@ export function getCharacterVoiceReferences(gameId, characterId) {
264
266
  const audioDir = getAudioDir();
265
267
  const filePaths = voiceRefs
266
268
  .map((ref) => {
267
- const fullPath = join(audioDir, ref.filePath);
269
+ const fullPath = mediaPathWithin(audioDir, ref.filePath);
268
270
  return existsSync(fullPath) ? fullPath : null;
269
271
  })
270
272
  .filter((p) => p !== null);
@@ -283,7 +285,7 @@ export function deleteAudio(audioId) {
283
285
  if (!audio)
284
286
  return false;
285
287
  // Delete file
286
- const fullPath = join(getAudioDir(), audio.filePath);
288
+ const fullPath = mediaPathWithin(getAudioDir(), audio.filePath);
287
289
  if (existsSync(fullPath)) {
288
290
  unlinkSync(fullPath);
289
291
  }
@@ -339,8 +341,10 @@ export function updateAudioMetadata(audioId, updates) {
339
341
  // Cleanup helper - delete all audio for a game
340
342
  export function deleteGameAudio(gameId) {
341
343
  const db = getDatabase();
342
- // Delete files
343
- const gameDir = join(getAudioDir(), gameId);
344
+ // Delete files. mediaDirPath rejects before the rmSync below, which is
345
+ // recursive and forced: an unchecked `..` here would take the whole data
346
+ // directory with it.
347
+ const gameDir = mediaDirPath(getAudioDir(), [gameId]);
344
348
  if (existsSync(gameDir)) {
345
349
  rmSync(gameDir, { recursive: true, force: true });
346
350
  }
@@ -1,6 +1,10 @@
1
1
  import { v4 as uuidv4 } from "uuid";
2
2
  import { getDatabase } from "../db/connection.js";
3
3
  import { safeJsonParse } from "../utils/json.js";
4
+ import { createLogger } from "../utils/logger.js";
5
+ import { deleteGameAudio } from "./audio.js";
6
+ import { deleteGameImages } from "./images.js";
7
+ const log = createLogger("game");
4
8
  /**
5
9
  * Validates that a game exists and returns it, or throws an error if not found.
6
10
  * Use this in create operations to prevent orphaned records.
@@ -119,7 +123,35 @@ export function deleteGame(id) {
119
123
  const db = getDatabase();
120
124
  const stmt = db.prepare(`DELETE FROM games WHERE id = ?`);
121
125
  const result = stmt.run(id);
122
- return result.changes > 0;
126
+ if (result.changes === 0)
127
+ return false;
128
+ // The stored_audio / stored_images rows went with the game by foreign key
129
+ // cascade, so nothing is left that knows the files on disk exist. Cleanup
130
+ // therefore runs here, after the delete has committed.
131
+ //
132
+ // A cleanup failure degrades to a log rather than an exception: the row is
133
+ // already gone and throwing cannot un-delete it, so it would trade a stale
134
+ // directory for a failed tool call and lose both. Each cleanup is attempted
135
+ // separately, so one failing does not skip the other.
136
+ try {
137
+ deleteGameAudio(id);
138
+ }
139
+ catch (error) {
140
+ log.error("Failed to remove stored audio for a deleted game; files remain on disk", {
141
+ gameId: id,
142
+ error: error.message,
143
+ });
144
+ }
145
+ try {
146
+ deleteGameImages(id);
147
+ }
148
+ catch (error) {
149
+ log.error("Failed to remove stored images for a deleted game; files remain on disk", {
150
+ gameId: id,
151
+ error: error.message,
152
+ });
153
+ }
154
+ return true;
123
155
  }
124
156
  export function updateGameLocation(gameId, locationId) {
125
157
  const db = getDatabase();
@@ -3,6 +3,7 @@ import { getDatabase, getDataDir, withTransaction } from "../db/connection.js";
3
3
  import { writeFileSync, readFileSync, mkdirSync, existsSync, unlinkSync, rmSync, } from "fs";
4
4
  import { dirname, join } from "path";
5
5
  import sharp from "sharp";
6
+ import { mediaDirPath, mediaFilePath, mediaPathWithin } from "../utils/media-path.js";
6
7
  import { getCharacter } from "./character.js";
7
8
  import { getLocation } from "./world.js";
8
9
  import { getItem } from "./inventory.js";
@@ -122,10 +123,11 @@ export async function storeImage(params) {
122
123
  catch {
123
124
  // If sharp can't parse it, proceed without dimensions
124
125
  }
125
- // Build file path
126
+ // Build file path. Every segment below arrives from the caller, so the
127
+ // composition goes through the media-path choke point, which rejects
128
+ // anything that would land outside the images directory.
126
129
  const ext = getExtension(mimeType);
127
- const relativePath = join(params.gameId, `${params.entityType}s`, params.entityId, `${id}.${ext}`);
128
- const fullPath = join(getImagesDir(), relativePath);
130
+ const { relativePath, fullPath } = mediaFilePath(getImagesDir(), [params.gameId, `${params.entityType}s`, params.entityId], `${id}.${ext}`);
129
131
  // Ensure directory exists and write file
130
132
  ensureDir(dirname(fullPath));
131
133
  writeFileSync(fullPath, imageBuffer);
@@ -177,7 +179,7 @@ export async function getImageData(imageId, options) {
177
179
  const image = getImage(imageId);
178
180
  if (!image)
179
181
  return null;
180
- const fullPath = join(getImagesDir(), image.filePath);
182
+ const fullPath = mediaPathWithin(getImagesDir(), image.filePath);
181
183
  if (!existsSync(fullPath))
182
184
  return null;
183
185
  const originalBuffer = readFileSync(fullPath);
@@ -335,7 +337,7 @@ export function deleteImage(imageId) {
335
337
  if (!image)
336
338
  return false;
337
339
  // Delete file
338
- const fullPath = join(getImagesDir(), image.filePath);
340
+ const fullPath = mediaPathWithin(getImagesDir(), image.filePath);
339
341
  if (existsSync(fullPath)) {
340
342
  unlinkSync(fullPath);
341
343
  }
@@ -380,10 +382,13 @@ export function updateImageMetadata(imageId, updates) {
380
382
  validateEntityExists(newEntityId, newEntityType);
381
383
  // If entity changed, move the file
382
384
  if (newEntityId !== current.entityId || newEntityType !== current.entityType) {
383
- const oldFullPath = join(getImagesDir(), current.filePath);
385
+ const oldFullPath = mediaPathWithin(getImagesDir(), current.filePath);
384
386
  const ext = current.filePath.split(".").pop() || "png";
385
- newFilePath = join(current.gameId, `${newEntityType}s`, newEntityId, `${imageId}.${ext}`);
386
- const newFullPath = join(getImagesDir(), newFilePath);
387
+ // The move recomposes the path from a caller-supplied entity id and
388
+ // type, so it goes through the same choke point as the original write.
389
+ const moved = mediaFilePath(getImagesDir(), [current.gameId, `${newEntityType}s`, newEntityId], `${imageId}.${ext}`);
390
+ newFilePath = moved.relativePath;
391
+ const newFullPath = moved.fullPath;
387
392
  // Ensure new directory exists
388
393
  ensureDir(dirname(newFullPath));
389
394
  // Move the file
@@ -411,8 +416,10 @@ export function updateImageMetadata(imageId, updates) {
411
416
  // Cleanup helper - delete all images for a game
412
417
  export function deleteGameImages(gameId) {
413
418
  const db = getDatabase();
414
- // Delete files
415
- const sessionDir = join(getImagesDir(), gameId);
419
+ // Delete files. mediaDirPath rejects before the rmSync below, which is
420
+ // recursive and forced: an unchecked `..` here would take the whole data
421
+ // directory with it.
422
+ const sessionDir = mediaDirPath(getImagesDir(), [gameId]);
416
423
  if (existsSync(sessionDir)) {
417
424
  rmSync(sessionDir, { recursive: true, force: true });
418
425
  }
@@ -1,7 +1,7 @@
1
1
  import type { Resource, ResourceChange } from "../types/index.js";
2
2
  export declare function createResource(params: {
3
3
  gameId: string;
4
- ownerType: "game" | "character";
4
+ ownerType: "game" | "character" | "faction" | "location";
5
5
  ownerId?: string;
6
6
  name: string;
7
7
  description?: string;
@@ -20,7 +20,7 @@ export declare function updateResource(id: string, updates: {
20
20
  }): Resource | null;
21
21
  export declare function deleteResource(id: string): boolean;
22
22
  export declare function listResources(gameId: string, filter?: {
23
- ownerType?: "game" | "character";
23
+ ownerType?: "game" | "character" | "faction" | "location";
24
24
  ownerId?: string;
25
25
  category?: string;
26
26
  }): Resource[];
@@ -133,9 +133,24 @@ export function advanceTime(gameId, duration) {
133
133
  const consequenceFailures = [];
134
134
  for (const row of events) {
135
135
  const triggerTime = safeJsonParse(row.trigger_time, { year: 1, month: 1, day: 1, hour: 0, minute: 0 });
136
- // Check if event should trigger (trigger time is between previous and new time)
137
- if (compareDateTime(triggerTime, previousTime, calendarConfig) >= 0 &&
138
- compareDateTime(triggerTime, newTime, calendarConfig) <= 0) {
136
+ // Due if the trigger time has been reached -- deliberately NOT also gated
137
+ // on `triggerTime >= previousTime`.
138
+ //
139
+ // That lower bound looks like the right window ("did we cross it on THIS
140
+ // call?") and quietly broke the retry the catch block below promises. When
141
+ // a consequence throws, the transaction rolls back and the row stays
142
+ // pending, exactly as intended -- but the clock has already moved past
143
+ // triggerTime by then, because it is updated unconditionally above. On
144
+ // every subsequent call the lower bound therefore excluded the row, and
145
+ // the event sat pending forever with its consequence never applied. The
146
+ // rollback was correct and unreachable.
147
+ //
148
+ // Without the lower bound, "pending and past due" is the whole condition,
149
+ // which is the same retry semantics timers already have. The `triggered =
150
+ // 0` filter in the query above is what keeps this exactly-once: a
151
+ // successful event is marked (or, if recurring, rescheduled forward) in
152
+ // the same transaction as its consequence, so it cannot come back.
153
+ if (compareDateTime(triggerTime, newTime, calendarConfig) <= 0) {
139
154
  const eventId = row.id;
140
155
  const eventName = row.name;
141
156
  const recurring = row.recurring;
@@ -465,7 +465,7 @@ export interface Resource {
465
465
  id: string;
466
466
  gameId: string;
467
467
  ownerId: string | null;
468
- ownerType: "game" | "character";
468
+ ownerType: "game" | "character" | "faction" | "location";
469
469
  name: string;
470
470
  description: string;
471
471
  category: string | null;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The one place a path beneath a media root is composed or resolved.
3
+ *
4
+ * Media file paths are built from ids that arrive over the wire -- a game id,
5
+ * an entity id, an entity type -- and are then handed to `writeFileSync` and,
6
+ * worse, to a recursive `rmSync`. `join()` resolves `..` happily, so an id
7
+ * carrying one walks out of the data directory before the write. The rule this
8
+ * breaks is the engine's: it writes nothing to the consumer's machine that the
9
+ * consumer did not name.
10
+ *
11
+ * The guard is here, at the composition point, rather than at each call site,
12
+ * because a per-site check is one forgotten site away from being no check. Two
13
+ * things are asserted, and both are literal checks over characters -- never an
14
+ * attempt to read meaning out of a value (root CLAUDE.md hard rule 4):
15
+ *
16
+ * 1. Every path segment matches an allowlist. Every id the engine mints is a
17
+ * UUID and every entity type is an ASCII word, so the allowlist costs
18
+ * nothing the engine actually uses.
19
+ * 2. The resolved result is strictly beneath the resolved root -- a
20
+ * structural backstop that holds even for a path this module did not
21
+ * compose, such as one read back out of a row written before this guard
22
+ * existed.
23
+ *
24
+ * Rejection, never normalisation: an id containing `a/../b` is refused rather
25
+ * than quietly rewritten to `b`, because the rewrite would silently store one
26
+ * caller's media under a different id than the caller named.
27
+ */
28
+ export declare class MediaPathError extends Error {
29
+ constructor(message: string);
30
+ }
31
+ /**
32
+ * Resolve a relative path against a media root, refusing anything that is not
33
+ * strictly beneath it. Use for paths read back from a row; `mediaFilePath` and
34
+ * `mediaDirPath` funnel through it too, so every media path in the codebase
35
+ * passes this check exactly once.
36
+ */
37
+ export declare function mediaPathWithin(root: string, relativePath: string): string;
38
+ /**
39
+ * Compose the path of a media file from segments and a leaf file name,
40
+ * returning both the relative path to store in the row and the full path to
41
+ * write to.
42
+ */
43
+ export declare function mediaFilePath(root: string, segments: string[], filename: string): {
44
+ relativePath: string;
45
+ fullPath: string;
46
+ };
47
+ /**
48
+ * Compose the path of a directory beneath a media root. The caller of this one
49
+ * is a recursive delete, so an empty segment list -- which would resolve to the
50
+ * media root itself -- is refused along with everything else.
51
+ */
52
+ export declare function mediaDirPath(root: string, segments: string[]): string;
@@ -0,0 +1,106 @@
1
+ import { isAbsolute, join, resolve, sep } from "path";
2
+ import { createLogger } from "./logger.js";
3
+ const log = createLogger("media-path");
4
+ /**
5
+ * The one place a path beneath a media root is composed or resolved.
6
+ *
7
+ * Media file paths are built from ids that arrive over the wire -- a game id,
8
+ * an entity id, an entity type -- and are then handed to `writeFileSync` and,
9
+ * worse, to a recursive `rmSync`. `join()` resolves `..` happily, so an id
10
+ * carrying one walks out of the data directory before the write. The rule this
11
+ * breaks is the engine's: it writes nothing to the consumer's machine that the
12
+ * consumer did not name.
13
+ *
14
+ * The guard is here, at the composition point, rather than at each call site,
15
+ * because a per-site check is one forgotten site away from being no check. Two
16
+ * things are asserted, and both are literal checks over characters -- never an
17
+ * attempt to read meaning out of a value (root CLAUDE.md hard rule 4):
18
+ *
19
+ * 1. Every path segment matches an allowlist. Every id the engine mints is a
20
+ * UUID and every entity type is an ASCII word, so the allowlist costs
21
+ * nothing the engine actually uses.
22
+ * 2. The resolved result is strictly beneath the resolved root -- a
23
+ * structural backstop that holds even for a path this module did not
24
+ * compose, such as one read back out of a row written before this guard
25
+ * existed.
26
+ *
27
+ * Rejection, never normalisation: an id containing `a/../b` is refused rather
28
+ * than quietly rewritten to `b`, because the rewrite would silently store one
29
+ * caller's media under a different id than the caller named.
30
+ */
31
+ export class MediaPathError extends Error {
32
+ constructor(message) {
33
+ super(message);
34
+ this.name = "MediaPathError";
35
+ }
36
+ }
37
+ /** Directory names: ids the engine mints, plus pluralised entity types. */
38
+ const SAFE_SEGMENT = /^[A-Za-z0-9_-]+$/;
39
+ /** Leaf file names: a safe stem, one dot, an alphanumeric extension. */
40
+ const SAFE_FILENAME = /^[A-Za-z0-9_-]+\.[A-Za-z0-9]+$/;
41
+ function reject(what, value) {
42
+ log.warn("Refused a media path built from an unsafe value", { what, value });
43
+ throw new MediaPathError(`Unsafe ${what} for a media path: ${JSON.stringify(value)}. ` +
44
+ `Only letters, digits, underscore and hyphen are allowed; a path separator, ` +
45
+ `"." or ".." would escape the media directory.`);
46
+ }
47
+ function assertSafeSegment(value) {
48
+ if (typeof value !== "string" || !SAFE_SEGMENT.test(value)) {
49
+ reject("path segment", String(value));
50
+ }
51
+ }
52
+ /**
53
+ * Resolve a relative path against a media root, refusing anything that is not
54
+ * strictly beneath it. Use for paths read back from a row; `mediaFilePath` and
55
+ * `mediaDirPath` funnel through it too, so every media path in the codebase
56
+ * passes this check exactly once.
57
+ */
58
+ export function mediaPathWithin(root, relativePath) {
59
+ if (typeof relativePath !== "string" || relativePath.length === 0) {
60
+ reject("stored path", String(relativePath));
61
+ }
62
+ if (isAbsolute(relativePath)) {
63
+ reject("stored path", relativePath);
64
+ }
65
+ // Split on both separators regardless of platform: a stored path composed on
66
+ // one and read on another must not sneak a traversal past the check.
67
+ for (const segment of relativePath.split(/[\\/]/)) {
68
+ if (segment.length === 0 || segment === "." || segment === "..") {
69
+ reject("stored path", relativePath);
70
+ }
71
+ }
72
+ const rootResolved = resolve(root);
73
+ const fullPath = resolve(rootResolved, relativePath);
74
+ // Compare with the separator appended, so a sibling directory whose name
75
+ // merely starts with the root's (`<root>-elsewhere`) is not mistaken for a
76
+ // child of it.
77
+ if (!fullPath.startsWith(rootResolved + sep)) {
78
+ reject("stored path", relativePath);
79
+ }
80
+ return fullPath;
81
+ }
82
+ /**
83
+ * Compose the path of a media file from segments and a leaf file name,
84
+ * returning both the relative path to store in the row and the full path to
85
+ * write to.
86
+ */
87
+ export function mediaFilePath(root, segments, filename) {
88
+ segments.forEach(assertSafeSegment);
89
+ if (typeof filename !== "string" || !SAFE_FILENAME.test(filename)) {
90
+ reject("file name", String(filename));
91
+ }
92
+ const relativePath = join(...segments, filename);
93
+ return { relativePath, fullPath: mediaPathWithin(root, relativePath) };
94
+ }
95
+ /**
96
+ * Compose the path of a directory beneath a media root. The caller of this one
97
+ * is a recursive delete, so an empty segment list -- which would resolve to the
98
+ * media root itself -- is refused along with everything else.
99
+ */
100
+ export function mediaDirPath(root, segments) {
101
+ if (segments.length === 0) {
102
+ reject("path segment", "");
103
+ }
104
+ segments.forEach(assertSafeSegment);
105
+ return mediaPathWithin(root, join(...segments));
106
+ }