@x12i/static-memorix-explorer-api 1.3.0 → 1.3.1

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.
package/README.md CHANGED
@@ -121,7 +121,9 @@ The mock server accepts the following identity keys across fetch/write engines:
121
121
  - `memoryId` — memory
122
122
 
123
123
  Endpoints like `/records/full` and `/records/content` require exactly one identity key
124
- per request. Memory collections are keyed by `memoryId` and stored in `./mocks/data/memory/memory.json`.
124
+ per request. Memory collections are keyed by `memoryId`; the unprefixed
125
+ `./mocks/data/memory/memory.json` is the seed and routed writes use the active
126
+ Catalox-prefixed filename.
125
127
 
126
128
  ## Snapshot alias translation
127
129
 
@@ -184,7 +186,7 @@ Full implementation walkthrough: [guides/demo-app.md](./guides/demo-app.md).
184
186
  |-----|---------|-------|
185
187
  | `PORT` | `5030` | Listen port |
186
188
  | `HOST` | `0.0.0.0` | Listen host |
187
- | `MOCKS_DIR` | `<project>/mocks` | Fixture root |
189
+ | `MOCKS_DIR` | bundled `<package>/mocks` | Fixture root |
188
190
  | `DISK_FLUSH_DEBOUNCE_MS` | `300` | Debounce before disk flush |
189
191
  | `MEMORIX_ORG_ID` | `memorix` | Organization prefix for DB routing |
190
192
  | `MEMORIX_AGENT_ID` | `default-agent` | Agent prefix for catalox DB routing |
@@ -193,6 +195,11 @@ Full implementation walkthrough: [guides/demo-app.md](./guides/demo-app.md).
193
195
  | `MEMORIX_EXPLORER_ENABLE_PIPELINE_WRITES` | `false` | Allow pipeline writes |
194
196
  | `MEMORIX_EXPLORER_ENABLE_REGISTRY_WRITES` | `false` | Allow registry writes |
195
197
 
198
+ For mutation-capable installed deployments, set `MOCKS_DIR` to a writable,
199
+ persistent directory. Routing configuration is process-scoped and resolved on
200
+ first import; use one process per organization/agent context and one writer per
201
+ prefix.
202
+
196
203
  ## DB Router Simulation
197
204
 
198
205
  The mock server does not connect to MongoDB, but it **tricks the frontend** into
@@ -253,6 +260,8 @@ The mock's querying and writing engines now support `target=memory` and
253
260
  and `knowledgeId`.
254
261
 
255
262
  - Folder structure: `./mocks/data/memory/memory.json` (seeded with example memories).
263
+ - Runtime memory writes persist to the active
264
+ `<agent>-memorix-catalox--memory.json` route.
256
265
  - Inventory engine accepts `target=memory` and includes memory collections in
257
266
  the simulated `inventorySummary`.
258
267
  - Records engine accepts `target=memory` and supports `memoryId` for fetching.
@@ -269,6 +278,16 @@ and `knowledgeId`.
269
278
 
270
279
  ## Release history
271
280
 
281
+ ### v1.3.1
282
+ - Rejected unsafe routing IDs, object types, and metadata keys before path use.
283
+ - Changed malformed and empty JSON handling from silent fallback to explicit
284
+ startup failure, preventing accidental data loss.
285
+ - Made JSON persistence atomic through same-directory temporary-file renames.
286
+ - Made list deletion restart-safe with routed tombstones.
287
+ - Fixed installed npm CLI execution through symlink-aware entry detection.
288
+ - Added packed-install, restart, traversal, corruption, and atomic-write checks.
289
+ - Documented writable `MOCKS_DIR`, process isolation, and single-writer limits.
290
+
272
291
  ### v1.3.0
273
292
  - Added database-routing parity through deterministic JSON filename prefixes.
274
293
  - Added separate entity, event, and Catalox/metadata prefix resolution.
package/dist/config.d.ts CHANGED
@@ -5,6 +5,7 @@ export declare const DATA_DIR: string;
5
5
  export declare const PORT: number;
6
6
  export declare const HOST: string;
7
7
  export declare const DISK_FLUSH_DEBOUNCE_MS: number;
8
+ export declare function validateRouteId(value: string, variableName?: string): string;
8
9
  export declare const MEMORIX_ORG_ID: string;
9
10
  export declare const MEMORIX_AGENT_ID: string;
10
11
  export declare const MEMORIX_DEPLOYMENT_PROFILE: string;
package/dist/config.js CHANGED
@@ -20,13 +20,23 @@ function resolveOrgId() {
20
20
  if ((process.env.MEMORIX_DEPLOYMENT_PROFILE || "").toLowerCase() === "ebook") {
21
21
  return "ebooks";
22
22
  }
23
- return process.env.MEMORIX_ORG_ID || "memorix";
23
+ return validateRouteId(process.env.MEMORIX_ORG_ID || "memorix", "MEMORIX_ORG_ID");
24
24
  }
25
25
  function resolveAgentId() {
26
26
  if ((process.env.MEMORIX_DEPLOYMENT_PROFILE || "").toLowerCase() === "ebook") {
27
27
  return "ebooks";
28
28
  }
29
- return process.env.MEMORIX_AGENT_ID || "default-agent";
29
+ return validateRouteId(process.env.MEMORIX_AGENT_ID || "default-agent", "MEMORIX_AGENT_ID");
30
+ }
31
+ export function validateRouteId(value, variableName = "route ID") {
32
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value) ||
33
+ value.includes("--") ||
34
+ value === "." ||
35
+ value === "..") {
36
+ throw Object.assign(new Error(`${variableName} must contain only letters, numbers, dot, underscore, or hyphen; ` +
37
+ `it must not contain path separators or the reserved "--" delimiter`), { statusCode: 400 });
38
+ }
39
+ return value;
30
40
  }
31
41
  export const MEMORIX_ORG_ID = resolveOrgId();
32
42
  export const MEMORIX_AGENT_ID = resolveAgentId();
@@ -62,6 +72,12 @@ export function buildMetadataFilePrefix() {
62
72
  return buildCataloxDbString();
63
73
  }
64
74
  export function prefixedJsonFilename(prefix, basename) {
75
+ validateRouteId(prefix, "file routing prefix");
76
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(basename) || basename.includes("--")) {
77
+ throw Object.assign(new Error(`Unsafe JSON fixture basename: ${basename}`), {
78
+ statusCode: 400,
79
+ });
80
+ }
65
81
  return `${prefix}--${basename}.json`;
66
82
  }
67
83
  // ---------------------------------------------------------------------------
@@ -228,7 +228,8 @@ export async function registerRoutes(app) {
228
228
  return { ok: false, error: "Metadata writes are disabled" };
229
229
  }
230
230
  const { listId } = request.params;
231
- store.setMeta(`lists/${listId}`, undefined, false);
231
+ store.deleteMeta(`lists/${listId}`);
232
+ await store.flushAll();
232
233
  return { ok: true, deleted: listId };
233
234
  });
234
235
  // ---------- F. Narratives Engine ----------
package/dist/server.d.ts CHANGED
@@ -6,6 +6,7 @@ export interface StartServerOptions {
6
6
  installSignalHandlers?: boolean;
7
7
  }
8
8
  export declare function parseCliArgs(args: string[]): StartServerOptions;
9
+ export declare function isCliEntrypoint(argvPath: string | undefined, moduleUrl: string): boolean;
9
10
  /** Build a loaded Fastify instance without opening a network port. */
10
11
  export declare function buildServer(options?: Pick<StartServerOptions, "logger">): Promise<import("fastify").FastifyInstance<import("http").Server<typeof import("http").IncomingMessage, typeof import("http").ServerResponse>, import("http").IncomingMessage, import("http").ServerResponse<import("http").IncomingMessage>, import("fastify").FastifyBaseLogger, import("fastify").FastifyTypeProviderDefault>>;
11
12
  /** Build and listen. Options override PORT/HOST environment configuration. */
package/dist/server.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import Fastify from "fastify";
3
+ import fs from "node:fs";
3
4
  import path from "node:path";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import { store } from "./storage/InMemoryStore.js";
@@ -31,6 +32,16 @@ export function parseCliArgs(args) {
31
32
  }
32
33
  return options;
33
34
  }
35
+ export function isCliEntrypoint(argvPath, moduleUrl) {
36
+ if (!argvPath)
37
+ return false;
38
+ try {
39
+ return fs.realpathSync(path.resolve(argvPath)) === fileURLToPath(moduleUrl);
40
+ }
41
+ catch {
42
+ return false;
43
+ }
44
+ }
34
45
  /** Build a loaded Fastify instance without opening a network port. */
35
46
  export async function buildServer(options = {}) {
36
47
  const app = Fastify({ logger: options.logger ?? false });
@@ -74,9 +85,7 @@ export async function startServer(options = {}) {
74
85
  }
75
86
  return app;
76
87
  }
77
- const isCliEntry = process.argv[1]
78
- ? path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
79
- : false;
88
+ const isCliEntry = isCliEntrypoint(process.argv[1], import.meta.url);
80
89
  if (isCliEntry) {
81
90
  Promise.resolve().then(() => startServer(parseCliArgs(process.argv.slice(2)))).catch((err) => {
82
91
  // eslint-disable-next-line no-console
@@ -12,6 +12,8 @@ export declare class InMemoryStore {
12
12
  private loadDataCollections;
13
13
  getMeta<T = any>(key: string): T | undefined;
14
14
  setMeta(key: string, data: any, persist?: boolean): void;
15
+ /** Persist a routed tombstone so an unprefixed seed does not reappear. */
16
+ deleteMeta(key: string): void;
15
17
  getMetaKeys(): string[];
16
18
  getCollection(objectType: string, ct: ContentType): any[];
17
19
  setCollection(objectType: string, ct: ContentType, data: any[]): void;
@@ -1,11 +1,28 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { CONTENT_TYPES } from "../types.js";
4
- import { MOCKS_DIR, METADATA_DIR, DATA_DIR, buildDataFilePrefix, buildMetadataFilePrefix, prefixedJsonFilename, } from "../config.js";
4
+ import { MOCKS_DIR, METADATA_DIR, DATA_DIR, buildDataFilePrefix, buildMetadataFilePrefix, prefixedJsonFilename, validateRouteId, } from "../config.js";
5
5
  import { validateRecordArray } from "../engine/identity.js";
6
6
  import { isDir, listJsonFiles, readJson, writeJson, basenameNoExt, } from "./fs.js";
7
7
  import { DISK_FLUSH_DEBOUNCE_MS } from "../config.js";
8
8
  const DIRTY = Symbol("dirty");
9
+ function routedMetadataPath(key) {
10
+ const parts = key.split("/");
11
+ if (parts.length === 1 && parts[0] === "agents") {
12
+ return path.join(METADATA_DIR, prefixedJsonFilename(buildMetadataFilePrefix(), "agents"));
13
+ }
14
+ if (parts.length !== 2) {
15
+ throw Object.assign(new Error(`Unsafe metadata key: ${key}`), { statusCode: 400 });
16
+ }
17
+ const [section, name] = parts;
18
+ if (!["object-types", "lists", "write-descriptors", "narratives"].includes(section)) {
19
+ throw Object.assign(new Error(`Unsupported metadata section: ${section}`), {
20
+ statusCode: 400,
21
+ });
22
+ }
23
+ validateRouteId(name, "metadata fixture name");
24
+ return path.join(METADATA_DIR, section, prefixedJsonFilename(buildMetadataFilePrefix(), name));
25
+ }
9
26
  export class InMemoryStore {
10
27
  collections = new Map(); // key: `${objectType}/${contentType}`
11
28
  metadata = new Map();
@@ -108,7 +125,7 @@ export class InMemoryStore {
108
125
  }
109
126
  else {
110
127
  this.metadata.set(key, {
111
- path: path.join(METADATA_DIR, `${path.dirname(key)}/${prefixedJsonFilename(buildMetadataFilePrefix(), path.basename(key))}`),
128
+ path: routedMetadataPath(key),
112
129
  data,
113
130
  [DIRTY]: true,
114
131
  });
@@ -116,11 +133,28 @@ export class InMemoryStore {
116
133
  if (persist)
117
134
  this.scheduleMetaFlush(key);
118
135
  }
136
+ /** Persist a routed tombstone so an unprefixed seed does not reappear. */
137
+ deleteMeta(key) {
138
+ const existing = this.metadata.get(key);
139
+ if (existing) {
140
+ existing.data = null;
141
+ existing[DIRTY] = true;
142
+ }
143
+ else {
144
+ this.metadata.set(key, {
145
+ path: routedMetadataPath(key),
146
+ data: null,
147
+ [DIRTY]: true,
148
+ });
149
+ }
150
+ this.scheduleMetaFlush(key);
151
+ }
119
152
  getMetaKeys() {
120
153
  return [...this.metadata.keys()];
121
154
  }
122
155
  // ---- Collection access ----
123
156
  getCollection(objectType, ct) {
157
+ validateRouteId(objectType, "object type");
124
158
  const key = `${objectType}/${ct}`;
125
159
  const c = this.collections.get(key);
126
160
  if (!c) {
@@ -134,6 +168,7 @@ export class InMemoryStore {
134
168
  return c.data;
135
169
  }
136
170
  setCollection(objectType, ct, data) {
171
+ validateRouteId(objectType, "object type");
137
172
  const key = `${objectType}/${ct}`;
138
173
  const c = this.collections.get(key);
139
174
  if (c) {
@@ -15,16 +15,31 @@ export function readJson(p, fallback) {
15
15
  try {
16
16
  const raw = fs.readFileSync(p, "utf8");
17
17
  if (!raw.trim())
18
- return fallback;
18
+ throw new Error("file is empty");
19
19
  return JSON.parse(raw);
20
20
  }
21
- catch {
22
- return fallback;
21
+ catch (error) {
22
+ if (error.code === "ENOENT")
23
+ return fallback;
24
+ const message = error instanceof Error ? error.message : String(error);
25
+ throw new Error(`Failed to read JSON fixture ${p}: ${message}`);
23
26
  }
24
27
  }
25
28
  export function writeJson(p, data) {
26
29
  ensureDir(path.dirname(p));
27
- fs.writeFileSync(p, JSON.stringify(data, null, 2) + "\n", "utf8");
30
+ const serialized = JSON.stringify(data, null, 2);
31
+ if (serialized === undefined) {
32
+ throw new Error(`Cannot serialize undefined JSON fixture: ${p}`);
33
+ }
34
+ const temp = path.join(path.dirname(p), `.${path.basename(p)}.${process.pid}.tmp`);
35
+ try {
36
+ fs.writeFileSync(temp, serialized + "\n", "utf8");
37
+ fs.renameSync(temp, p);
38
+ }
39
+ finally {
40
+ if (fs.existsSync(temp))
41
+ fs.unlinkSync(temp);
42
+ }
28
43
  }
29
44
  export function listJsonFiles(dir) {
30
45
  if (!isDir(dir))
@@ -324,7 +324,9 @@ with fields for title, area, status, priority, and assignee. On submit:
324
324
  descriptor for the chosen area (`product-task-write`, etc.).
325
325
  3. The server validates the payload against the descriptor's JSON schema.
326
326
  4. On success, the record is appended to the in-memory collection and the
327
- debounced flush writes it to `mocks/data/<area>/snapshots.json` within 300 ms.
327
+ flush writes it to
328
+ `mocks/data/<area>/<org>-memorix-entities--snapshots.json`. If that routed
329
+ file does not exist yet, the unprefixed `snapshots.json` is used as seed data.
328
330
  5. The UI unshifts the returned record into `state.tasks`, selects it, and
329
331
  re-renders.
330
332
 
@@ -385,8 +387,9 @@ scale. For higher volume you'd model comments as `events` records keyed by
385
387
  ## Why each area is a separate collection
386
388
 
387
389
  We could have put all tasks in one `tasks` collection with an `area` field.
388
- The demo deliberately splits them across `product/snapshots.json`,
389
- `marketing/snapshots.json`, and `admin/snapshots.json` to demonstrate:
390
+ The demo deliberately splits the seed fixtures across `product/snapshots.json`,
391
+ `marketing/snapshots.json`, and `admin/snapshots.json`; runtime mutations go to
392
+ their routed, prefixed counterparts. This demonstrates:
390
393
 
391
394
  1. **Multi-entity applications** — typical real apps have more than one object
392
395
  type. Showing 3 object types is more representative than 1.
@@ -29,6 +29,10 @@ the routing matrix is:
29
29
  4. Unprefixed seed files are never overwritten while routed mode is active.
30
30
  5. Files belonging to a different prefix are ignored by the active process.
31
31
 
32
+ Deleting a metadata document writes JSON `null` as a routed tombstone. This is
33
+ intentional: removing only the routed file would cause the unprefixed seed to
34
+ reappear on the next restart.
35
+
32
36
  This makes onboarding migration-safe: existing files such as
33
37
  `product/snapshots.json` continue to seed a new tenant, while the first write
34
38
  creates `product/neo-memorix-entities--snapshots.json`. Starting with another
@@ -37,6 +41,20 @@ organization ID selects a different file and therefore different state.
37
41
  The delimiter `--` is reserved. Do not use it inside ordinary, unprefixed
38
42
  fixture basenames.
39
43
 
44
+ Routing IDs are validated before any files are opened. Organization IDs,
45
+ agent IDs, object types, and metadata names may contain letters, numbers,
46
+ dot, underscore, and hyphen, but cannot contain path separators, `..`, or the
47
+ reserved `--` delimiter.
48
+
49
+ Malformed or empty JSON stops startup with the affected path in the error;
50
+ it is never silently converted to an empty collection. Writes use a temporary
51
+ file and same-directory rename so readers never observe a partially written
52
+ JSON document.
53
+
54
+ Routing is process-scoped. Run one server process per organization/agent route,
55
+ and use only one writer process for a given prefix. Two processes writing the
56
+ same routed file concurrently use last-writer-wins semantics.
57
+
40
58
  ### Inspect the active route
41
59
 
42
60
  ```bash
@@ -104,7 +104,19 @@ All configuration is via environment variables. Defaults work out of the box.
104
104
  | --------- | ----------- | ------------------------------ |
105
105
  | `PORT` | `5030` | HTTP listen port |
106
106
  | `HOST` | `0.0.0.0` | HTTP listen host |
107
- | `MOCKS_DIR` | `<project>/mocks` | Root directory for JSON fixtures |
107
+ | `MOCKS_DIR` | bundled `<package>/mocks` | Root directory for JSON fixtures |
108
+
109
+ `MOCKS_DIR` must be writable if you use mutation endpoints. For installed or
110
+ containerized deployments, point it at a persistent application-owned volume
111
+ rather than relying on the package's bundled seed directory:
112
+
113
+ ```bash
114
+ MOCKS_DIR=/var/lib/my-app/memorix-mocks static-memorix-explorer-api --port 5030
115
+ ```
116
+
117
+ Configuration is resolved when the package is imported. In programmatic usage,
118
+ set `process.env.MOCKS_DIR`, `MEMORIX_ORG_ID`, and `MEMORIX_AGENT_ID` before the
119
+ first dynamic import, and run separate processes for separate routing contexts.
108
120
 
109
121
  ### DB Router Simulation
110
122
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@x12i/static-memorix-explorer-api",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "Static mock server providing full API parity for the Memorix Explorer API.",
5
5
  "type": "module",
6
6
  "main": "dist/server.js",