@x12i/memorix-client-api 0.17.0 → 0.19.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 (108) hide show
  1. package/HANDOVER.md +14 -3
  2. package/dist/agent-directory.d.ts +25 -0
  3. package/dist/agent-directory.d.ts.map +1 -0
  4. package/dist/agent-directory.js +75 -0
  5. package/dist/agent-directory.js.map +1 -0
  6. package/dist/app.d.ts +31 -6
  7. package/dist/app.d.ts.map +1 -1
  8. package/dist/app.js +220 -33
  9. package/dist/app.js.map +1 -1
  10. package/dist/authix.d.ts +28 -2
  11. package/dist/authix.d.ts.map +1 -1
  12. package/dist/authix.js +55 -4
  13. package/dist/authix.js.map +1 -1
  14. package/dist/catalog.d.ts +33 -1
  15. package/dist/catalog.d.ts.map +1 -1
  16. package/dist/catalog.js +83 -4
  17. package/dist/catalog.js.map +1 -1
  18. package/dist/cli.js +11 -1
  19. package/dist/cli.js.map +1 -1
  20. package/dist/config.d.ts +9 -0
  21. package/dist/config.d.ts.map +1 -1
  22. package/dist/config.js +31 -0
  23. package/dist/config.js.map +1 -1
  24. package/dist/errors.d.ts +16 -2
  25. package/dist/errors.d.ts.map +1 -1
  26. package/dist/errors.js +16 -2
  27. package/dist/errors.js.map +1 -1
  28. package/dist/fetch.d.ts.map +1 -1
  29. package/dist/fetch.js +27 -3
  30. package/dist/fetch.js.map +1 -1
  31. package/dist/peers.d.ts +3 -3
  32. package/dist/peers.d.ts.map +1 -1
  33. package/dist/peers.js +5 -5
  34. package/dist/peers.js.map +1 -1
  35. package/dist/refs.d.ts +7 -1
  36. package/dist/refs.d.ts.map +1 -1
  37. package/dist/refs.js +36 -26
  38. package/dist/refs.js.map +1 -1
  39. package/dist/scope.d.ts +25 -2
  40. package/dist/scope.d.ts.map +1 -1
  41. package/dist/scope.js +71 -4
  42. package/dist/scope.js.map +1 -1
  43. package/dist/scoper-embedded.d.ts +29 -0
  44. package/dist/scoper-embedded.d.ts.map +1 -0
  45. package/dist/scoper-embedded.js +239 -0
  46. package/dist/scoper-embedded.js.map +1 -0
  47. package/dist/scoper.d.ts +23 -31
  48. package/dist/scoper.d.ts.map +1 -1
  49. package/dist/scoper.js +55 -250
  50. package/dist/scoper.js.map +1 -1
  51. package/dist/upstream.d.ts +6 -1
  52. package/dist/upstream.d.ts.map +1 -1
  53. package/dist/upstream.js +22 -4
  54. package/dist/upstream.js.map +1 -1
  55. package/dist/v1/census.d.ts +59 -0
  56. package/dist/v1/census.d.ts.map +1 -0
  57. package/dist/v1/census.js +146 -0
  58. package/dist/v1/census.js.map +1 -0
  59. package/dist/v1/content.d.ts +48 -0
  60. package/dist/v1/content.d.ts.map +1 -0
  61. package/dist/v1/content.js +292 -0
  62. package/dist/v1/content.js.map +1 -0
  63. package/dist/v1/context.d.ts +50 -0
  64. package/dist/v1/context.d.ts.map +1 -0
  65. package/dist/v1/context.js +4 -0
  66. package/dist/v1/context.js.map +1 -0
  67. package/dist/v1/discovery.d.ts +24 -0
  68. package/dist/v1/discovery.d.ts.map +1 -0
  69. package/dist/v1/discovery.js +163 -0
  70. package/dist/v1/discovery.js.map +1 -0
  71. package/dist/v1/gaps.d.ts +18 -0
  72. package/dist/v1/gaps.d.ts.map +1 -0
  73. package/dist/v1/gaps.js +2 -0
  74. package/dist/v1/gaps.js.map +1 -0
  75. package/dist/v1/http.d.ts +18 -0
  76. package/dist/v1/http.d.ts.map +1 -0
  77. package/dist/v1/http.js +105 -0
  78. package/dist/v1/http.js.map +1 -0
  79. package/dist/v1/mapping.d.ts +155 -0
  80. package/dist/v1/mapping.d.ts.map +1 -0
  81. package/dist/v1/mapping.js +380 -0
  82. package/dist/v1/mapping.js.map +1 -0
  83. package/dist/v1/planning-store.d.ts +74 -0
  84. package/dist/v1/planning-store.d.ts.map +1 -0
  85. package/dist/v1/planning-store.js +192 -0
  86. package/dist/v1/planning-store.js.map +1 -0
  87. package/dist/v1/planning.d.ts +5 -0
  88. package/dist/v1/planning.d.ts.map +1 -0
  89. package/dist/v1/planning.js +297 -0
  90. package/dist/v1/planning.js.map +1 -0
  91. package/dist/v1/records.d.ts +18 -0
  92. package/dist/v1/records.d.ts.map +1 -0
  93. package/dist/v1/records.js +42 -0
  94. package/dist/v1/records.js.map +1 -0
  95. package/dist/v1/relationships.d.ts +72 -0
  96. package/dist/v1/relationships.d.ts.map +1 -0
  97. package/dist/v1/relationships.js +436 -0
  98. package/dist/v1/relationships.js.map +1 -0
  99. package/dist/v1/routes.d.ts +5 -0
  100. package/dist/v1/routes.d.ts.map +1 -0
  101. package/dist/v1/routes.js +16 -0
  102. package/dist/v1/routes.js.map +1 -0
  103. package/dist/v1/sources.d.ts +62 -0
  104. package/dist/v1/sources.d.ts.map +1 -0
  105. package/dist/v1/sources.js +218 -0
  106. package/dist/v1/sources.js.map +1 -0
  107. package/openapi/openapi.json +3587 -480
  108. package/package.json +74 -67
package/HANDOVER.md CHANGED
@@ -38,19 +38,20 @@ The listen port comes from `attachCoreFastify` (`MEMORIX_CLIENT_API_PORT`, defau
38
38
  | `MEMORIX_AUTH` | `header` | `header` takes the org from `x-memorix-org-id`. `authix` takes it from `Authorization: Bearer` via `@x12i/authix-sdk`. |
39
39
  | `MGC_MCP_URL` | | MGC MCP endpoint. Wins over the source service URL. |
40
40
  | `MGC_SOURCE_SERVICE_URL` or `MGC_URL` | | MGC source host. This process calls `{url}/mcp` when `MGC_MCP_URL` is unset. When both are unset, it reads `genericConnectors.mcpUrl` from Memorix. |
41
- | `SCOPER_REASONER` | scripted, or ai-dispatcher when `OPENROUTER_API_KEY` is set | Reasoner for the hosted `@x12i/memorix-scoper@2.3.0`. |
41
+ | `SCOPER_REASONER` | scripted, or ai-dispatcher when `OPENROUTER_API_KEY` is set | Reasoner for the hosted `@x12i/memorix-scoper@3.0.0`. |
42
42
  | `SCOPER_AI_MODEL` | `openai/gpt-6-luna` | Model when the reasoner is `ai-dispatcher`. Runs on `@x12i/ai-dispatcher@3.2.0`. |
43
43
  | `SCOPER_FUNCTIONS_DIR` | | Pagx directory of saved query and scope functions, passed to `createScoper({ functions })`. |
44
44
  | `SCOPER_FUNCTIONS_PACKAGE` | | Parent package name for that directory. |
45
45
  | `SCOPER_FUNCTIONS_LEARN` | off | `true` asks the coder to write a function on a miss. Needs `@x12i/coder-dispatcher`. |
46
- | `SCOPER_MODE` | `auto` | `auto` hosts Scoper here unless `SCOPER_URL` is healthy and `2.3.x`. `external` requires that peer. `embedded` does not probe. |
46
+ | `SCOPER_MODE` | `auto` | `auto` hosts Scoper here unless `SCOPER_URL` is healthy and `3.x`. `external` requires that peer and does not load the Scoper packages. `embedded` does not probe. See `docs/scoper-downstream.md`. |
47
47
  | `SCOPER_URL` | | Base URL of a Scoper peer. Proxied for the process lifetime when selected. |
48
48
  | `SKILLS_MODE` | `auto` | `auto` probes skill-api and hosts agent functions here when that probe fails. `external` requires the peer. `embedded` does not probe. |
49
49
  | `SKILL_API_URL` | `http://127.0.0.1:7180` in `auto` | Skill-api base. A selected peer answers list, run, save, and delete. |
50
50
  | `SKILLS_PACK_DIR` | | Pack directory with `functions/catalog.json` and `functions/run.mjs`. Used when functions are hosted here. |
51
51
  | `AGENTS_DIR` | | Agents root. A folder named for the agent, or the first pack with functions, is the catalog. |
52
52
  | `MEMORIX_AGENT_IDS` | | Content agents that use the process pack. `PROCESS_AGENT_ID` is the fallback. |
53
- | `ASSISTANT_MONGO_URI` or `MONGO_URI` | | Assistants database for function overrides. Unset: pack functions run, save returns `503`. Not opened when skill-api is the peer. |
53
+ | `ASSISTANT_MONGO_URI` or `MONGO_URI` | | Assistants database `{orgId}-memorix-assistants`: function overrides (only while functions are hosted here) and planning items (collection `planning_items`). Unset: pack functions run, save returns `503`, and planning routes are `501 CAPABILITY_UNAVAILABLE`. |
54
+ | `PLANNING_ASSIGNEES` | | JSON object of org id to `[{ "id", "name", "kind"? }]`. Planning assignee options. Unset: only unassigned. |
54
55
  | `AUTHIX_APP_ID` | | Required with `MEMORIX_AUTH=authix`. |
55
56
  | `AUTHIX_APP_SECRET_KEY` | | Required with `MEMORIX_AUTH=authix`. |
56
57
  | `AUTHIX_INTROSPECT_URL` | | Optional revocation check, for example `http://127.0.0.1:3100/v1/tokens/introspect`. |
@@ -63,6 +64,16 @@ npm run typecheck
63
64
  npm run build && npm start
64
65
  ```
65
66
 
67
+ The `visualizer` contract (`docs/visualizer-api.md`) is served from `src/v1/`. Every `/api/v1` route checks the requested agents against `GET /api/metadata/agents?scope=org` (reused for 30 seconds): `*` is the org's deployed agents, and an agent the org has not deployed is `403 AGENT_NOT_DEPLOYED`. Against a Memorix without the org agent record, agent ids pass through unchanged.
68
+
69
+ ## Release
70
+
71
+ 1. Bump `package.json` and `package-lock.json` (`npm version <v> --no-git-tag-version`), `openapi/openapi.json` `info.version`, and the version lines in the root `README.md`. The read client also bumps `clientInfo.version` in `src/index.ts`. `memorix-client` adds a `CHANGELOG.md` entry.
72
+ 2. In each package: `npm test`, `npm run typecheck`, `npm run build`. Check that `npm view <package>@<version>` is a 404, then `npm pack --dry-run`.
73
+ 3. Publish `memorix-client` first, then this server (which depends on it), then the read client: `npm publish --access public`.
74
+ 4. Commit and push.
75
+ 5. On the host, stop the listener on 5120 and start `npm run dev` in this folder again. Check `/health`, `/ready`, `/api/openapi.json` (`info.version`), and `/api/v1/capabilities` (401 without a token).
76
+
66
77
  ## What must already be on the Memorix host
67
78
 
68
79
  These stay in the Memorix repo. This API calls them over HTTP.
@@ -0,0 +1,25 @@
1
+ import { type Upstream } from "./upstream.js";
2
+ export type AgentEntry = {
3
+ id: string;
4
+ ok: boolean;
5
+ error: string | null;
6
+ objectTypes: Array<{
7
+ id: string;
8
+ name?: string;
9
+ contentTypes: string[];
10
+ }>;
11
+ };
12
+ export type AgentDirectory = {
13
+ /**
14
+ * Agents recorded for the org (`GET /api/metadata/agents?scope=org`).
15
+ * `null` means Memorix has no org agent record (service older than 3.18.0).
16
+ */
17
+ deployed(orgId: string, caller: string): Promise<AgentEntry[] | null>;
18
+ /** Every pack installed on the platform. */
19
+ installed(orgId: string, caller: string): Promise<AgentEntry[]>;
20
+ };
21
+ export declare function agentsFromListing(body: unknown): AgentEntry[];
22
+ /** A Memorix without an org agent record: agent ids are sent as requested and `*` is not expanded. */
23
+ export declare const NO_ORG_AGENT_RECORD: AgentDirectory;
24
+ export declare function createAgentDirectory(upstream: Upstream, ttlMs?: number): AgentDirectory;
25
+ //# sourceMappingURL=agent-directory.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agent-directory.d.ts","sourceRoot":"","sources":["../src/agent-directory.ts"],"names":[],"mappings":"AAEA,OAAO,EAAY,KAAK,QAAQ,EAAE,MAAM,eAAe,CAAC;AAExD,MAAM,MAAM,UAAU,GAAG;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,EAAE,EAAE,OAAO,CAAC;IACZ,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,WAAW,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,YAAY,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,CAAC;CAC3E,CAAC;AAEF,MAAM,MAAM,cAAc,GAAG;IAC3B;;;OAGG;IACH,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,EAAE,GAAG,IAAI,CAAC,CAAC;IACtE,4CAA4C;IAC5C,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,EAAE,CAAC,CAAC;CACjE,CAAC;AAIF,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,OAAO,GAAG,UAAU,EAAE,CA2B7D;AAED,sGAAsG;AACtG,eAAO,MAAM,mBAAmB,EAAE,cAGjC,CAAC;AAEF,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,QAAQ,EAAE,KAAK,SAAiB,GAAG,cAAc,CAoC/F"}
@@ -0,0 +1,75 @@
1
+ import { UpstreamError } from "./errors.js";
2
+ import { asRecord } from "./upstream.js";
3
+ const DEFAULT_TTL_MS = 30_000;
4
+ export function agentsFromListing(body) {
5
+ const agents = asRecord(body)?.agents;
6
+ if (!Array.isArray(agents))
7
+ return [];
8
+ const out = [];
9
+ for (const item of agents) {
10
+ const row = asRecord(item);
11
+ if (!row || typeof row.id !== "string" || !row.id)
12
+ continue;
13
+ const objectTypes = Array.isArray(row.objectTypes)
14
+ ? row.objectTypes
15
+ .map((entry) => asRecord(entry))
16
+ .filter((entry) => entry !== null && typeof entry.id === "string")
17
+ .map((entry) => ({
18
+ id: entry.id,
19
+ ...(typeof entry.name === "string" ? { name: entry.name } : {}),
20
+ contentTypes: Array.isArray(entry.contentTypes)
21
+ ? entry.contentTypes.filter((ct) => typeof ct === "string")
22
+ : [],
23
+ }))
24
+ : [];
25
+ out.push({
26
+ id: row.id,
27
+ ok: row.ok !== false,
28
+ error: typeof row.error === "string" ? row.error : null,
29
+ objectTypes,
30
+ });
31
+ }
32
+ return out.sort((a, b) => a.id.localeCompare(b.id));
33
+ }
34
+ /** A Memorix without an org agent record: agent ids are sent as requested and `*` is not expanded. */
35
+ export const NO_ORG_AGENT_RECORD = {
36
+ deployed: async () => null,
37
+ installed: async () => [],
38
+ };
39
+ export function createAgentDirectory(upstream, ttlMs = DEFAULT_TTL_MS) {
40
+ const cache = new Map();
41
+ function cached(key, load) {
42
+ const hit = cache.get(key);
43
+ if (hit && Date.now() - hit.at < ttlMs)
44
+ return hit.value;
45
+ const value = load();
46
+ cache.set(key, { at: Date.now(), value });
47
+ value.catch(() => cache.delete(key));
48
+ return value;
49
+ }
50
+ function listingScope(orgId, caller) {
51
+ return { orgId, agentIds: ["*"], caller };
52
+ }
53
+ return {
54
+ deployed(orgId, caller) {
55
+ return cached(`org:${orgId}`, async () => {
56
+ try {
57
+ const body = await upstream.get("/api/metadata/agents?scope=org", listingScope(orgId, caller));
58
+ if (!Array.isArray(asRecord(body)?.agents))
59
+ return null;
60
+ return agentsFromListing(body);
61
+ }
62
+ catch (err) {
63
+ if (err instanceof UpstreamError && (err.statusCode === 404 || err.statusCode === 501))
64
+ return null;
65
+ throw err;
66
+ }
67
+ });
68
+ },
69
+ async installed(orgId, caller) {
70
+ const value = await cached("platform", async () => agentsFromListing(await upstream.get("/api/metadata/agents", listingScope(orgId, caller))));
71
+ return value ?? [];
72
+ },
73
+ };
74
+ }
75
+ //# sourceMappingURL=agent-directory.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agent-directory.js","sourceRoot":"","sources":["../src/agent-directory.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAE5C,OAAO,EAAE,QAAQ,EAAiB,MAAM,eAAe,CAAC;AAmBxD,MAAM,cAAc,GAAG,MAAM,CAAC;AAE9B,MAAM,UAAU,iBAAiB,CAAC,IAAa;IAC7C,MAAM,MAAM,GAAG,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACtC,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC;QAAE,OAAO,EAAE,CAAC;IACtC,MAAM,GAAG,GAAiB,EAAE,CAAC;IAC7B,KAAK,MAAM,IAAI,IAAI,MAAM,EAAE,CAAC;QAC1B,MAAM,GAAG,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAC;QAC3B,IAAI,CAAC,GAAG,IAAI,OAAO,GAAG,CAAC,EAAE,KAAK,QAAQ,IAAI,CAAC,GAAG,CAAC,EAAE;YAAE,SAAS;QAC5D,MAAM,WAAW,GAAG,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,WAAW,CAAC;YAChD,CAAC,CAAC,GAAG,CAAC,WAAW;iBACZ,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;iBAC/B,MAAM,CAAC,CAAC,KAAK,EAAoC,EAAE,CAAC,KAAK,KAAK,IAAI,IAAI,OAAO,KAAK,CAAC,EAAE,KAAK,QAAQ,CAAC;iBACnG,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;gBACf,EAAE,EAAE,KAAK,CAAC,EAAY;gBACtB,GAAG,CAAC,OAAO,KAAK,CAAC,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;gBAC/D,YAAY,EAAE,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,YAAY,CAAC;oBAC7C,CAAC,CAAC,KAAK,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC,EAAE,EAAgB,EAAE,CAAC,OAAO,EAAE,KAAK,QAAQ,CAAC;oBACzE,CAAC,CAAC,EAAE;aACP,CAAC,CAAC;YACP,CAAC,CAAC,EAAE,CAAC;QACP,GAAG,CAAC,IAAI,CAAC;YACP,EAAE,EAAE,GAAG,CAAC,EAAE;YACV,EAAE,EAAE,GAAG,CAAC,EAAE,KAAK,KAAK;YACpB,KAAK,EAAE,OAAO,GAAG,CAAC,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI;YACvD,WAAW;SACZ,CAAC,CAAC;IACL,CAAC;IACD,OAAO,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;AACtD,CAAC;AAED,sGAAsG;AACtG,MAAM,CAAC,MAAM,mBAAmB,GAAmB;IACjD,QAAQ,EAAE,KAAK,IAAI,EAAE,CAAC,IAAI;IAC1B,SAAS,EAAE,KAAK,IAAI,EAAE,CAAC,EAAE;CAC1B,CAAC;AAEF,MAAM,UAAU,oBAAoB,CAAC,QAAkB,EAAE,KAAK,GAAG,cAAc;IAC7E,MAAM,KAAK,GAAG,IAAI,GAAG,EAA+D,CAAC;IAErF,SAAS,MAAM,CAAC,GAAW,EAAE,IAAwC;QACnE,MAAM,GAAG,GAAG,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QAC3B,IAAI,GAAG,IAAI,IAAI,CAAC,GAAG,EAAE,GAAG,GAAG,CAAC,EAAE,GAAG,KAAK;YAAE,OAAO,GAAG,CAAC,KAAK,CAAC;QACzD,MAAM,KAAK,GAAG,IAAI,EAAE,CAAC;QACrB,KAAK,CAAC,GAAG,CAAC,GAAG,EAAE,EAAE,EAAE,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,KAAK,EAAE,CAAC,CAAC;QAC1C,KAAK,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC;QACrC,OAAO,KAAK,CAAC;IACf,CAAC;IAED,SAAS,YAAY,CAAC,KAAa,EAAE,MAAc;QACjD,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,CAAC,GAAG,CAAC,EAAE,MAAM,EAAE,CAAC;IAC5C,CAAC;IAED,OAAO;QACL,QAAQ,CAAC,KAAK,EAAE,MAAM;YACpB,OAAO,MAAM,CAAC,OAAO,KAAK,EAAE,EAAE,KAAK,IAAI,EAAE;gBACvC,IAAI,CAAC;oBACH,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,GAAG,CAAC,gCAAgC,EAAE,YAAY,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,CAAC;oBAC/F,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;wBAAE,OAAO,IAAI,CAAC;oBACxD,OAAO,iBAAiB,CAAC,IAAI,CAAC,CAAC;gBACjC,CAAC;gBAAC,OAAO,GAAG,EAAE,CAAC;oBACb,IAAI,GAAG,YAAY,aAAa,IAAI,CAAC,GAAG,CAAC,UAAU,KAAK,GAAG,IAAI,GAAG,CAAC,UAAU,KAAK,GAAG,CAAC;wBAAE,OAAO,IAAI,CAAC;oBACpG,MAAM,GAAG,CAAC;gBACZ,CAAC;YACH,CAAC,CAAC,CAAC;QACL,CAAC;QACD,KAAK,CAAC,SAAS,CAAC,KAAK,EAAE,MAAM;YAC3B,MAAM,KAAK,GAAG,MAAM,MAAM,CAAC,UAAU,EAAE,KAAK,IAAI,EAAE,CAChD,iBAAiB,CAAC,MAAM,QAAQ,CAAC,GAAG,CAAC,sBAAsB,EAAE,YAAY,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,CAAC,CAC3F,CAAC;YACF,OAAO,KAAK,IAAI,EAAE,CAAC;QACrB,CAAC;KACF,CAAC;AACJ,CAAC"}
package/dist/app.d.ts CHANGED
@@ -1,21 +1,45 @@
1
1
  import { type FastifyInstance } from "fastify";
2
2
  import { type ScoperReasonerKind } from "./config.js";
3
3
  import type { PeerChoice } from "./peers.js";
4
- import { createScoperHost, type ScoperHost } from "./scoper.js";
4
+ import { type ScoperHost } from "./scoper.js";
5
+ import type { createScoperHost } from "./scoper-embedded.js";
5
6
  import { type EmbeddedSkillsOptions, type SkillsHost } from "./skills.js";
7
+ import { type AgentDirectory } from "./agent-directory.js";
8
+ import type { TokenClaims } from "./authix.js";
9
+ import { type PlanningStore } from "./v1/planning-store.js";
6
10
  export type ClientApiAuth = {
7
11
  mode: "header";
8
12
  } | {
9
13
  mode: "authix";
10
- verify: (token: string) => Promise<{
11
- organizationIds: string[];
12
- }>;
14
+ verify: (token: string) => Promise<TokenClaims>;
13
15
  };
14
16
  export type ClientApiOptions = {
15
17
  memorixUrl: string;
16
18
  fetchImpl?: typeof fetch;
17
19
  /** Default `header`: org comes from `x-memorix-org-id`. */
18
20
  auth?: ClientApiAuth;
21
+ /** Upstream timeout. Default 30000 ms. */
22
+ upstreamTimeoutMs?: number;
23
+ /** How long the org's deployed agent list is reused. Default 30000 ms. */
24
+ agentCacheMs?: number;
25
+ /** Deployed and installed agents. Default reads `GET /api/metadata/agents` on Memorix. */
26
+ agentDirectory?: AgentDirectory;
27
+ /** How long a census is reused per org and agent set. Default 60000 ms. */
28
+ censusCacheMs?: number;
29
+ /**
30
+ * Planning items. `store` is a test double. `mongoUri` keeps items in `{orgId}-memorix-assistants.planning_items`.
31
+ * Neither means planning routes are `501 CAPABILITY_UNAVAILABLE`.
32
+ */
33
+ planning?: {
34
+ store?: PlanningStore;
35
+ mongoUri?: string;
36
+ /** Assignee options per org. Absent means only unassigned is valid. */
37
+ assignees?: Record<string, Array<{
38
+ id: string;
39
+ name: string;
40
+ kind?: string;
41
+ }>>;
42
+ };
19
43
  /** Listen port. Default `MEMORIX_CLIENT_API_PORT` or 5120. */
20
44
  port?: number;
21
45
  /**
@@ -24,8 +48,9 @@ export type ClientApiOptions = {
24
48
  */
25
49
  mgcMcpUrl?: string | null;
26
50
  /**
27
- * Scoper 2.3.0 hosted in this process. The library default is the scripted reasoner
28
- * and no saved functions. The CLI passes the process env.
51
+ * Scoper 3.0.0 hosted in this process, or a peer chosen at startup. The library default is the
52
+ * scripted reasoner and no saved functions. The CLI passes the process env.
53
+ * The Scoper packages are loaded only when this process hosts Scoper.
29
54
  */
30
55
  scoper?: {
31
56
  reasoner?: ScoperReasonerKind;
package/dist/app.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"AAGA,OAAgB,EAAE,KAAK,eAAe,EAAuB,MAAM,SAAS,CAAC;AAW7E,OAAO,EAAkB,KAAK,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAStE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAC7C,OAAO,EAAE,gBAAgB,EAAkD,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AAChH,OAAO,EAIL,KAAK,qBAAqB,EAC1B,KAAK,UAAU,EAChB,MAAM,aAAa,CAAC;AAQrB,MAAM,MAAM,aAAa,GACrB;IAAE,IAAI,EAAE,QAAQ,CAAA;CAAE,GAClB;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC;QAAE,eAAe,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,CAAA;CAAE,CAAC;AAE1F,MAAM,MAAM,gBAAgB,GAAG;IAC7B,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB,2DAA2D;IAC3D,IAAI,CAAC,EAAE,aAAa,CAAC;IACrB,8DAA8D;IAC9D,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B;;;OAGG;IACH,MAAM,CAAC,EAAE;QACP,QAAQ,CAAC,EAAE,kBAAkB,CAAC;QAC9B,YAAY,CAAC,EAAE,MAAM,CAAC;QACtB,gBAAgB,CAAC,EAAE,MAAM,CAAC;QAC1B,cAAc,CAAC,EAAE,OAAO,CAAC;QACzB,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;QACxB,2EAA2E;QAC3E,IAAI,CAAC,EAAE,UAAU,CAAC;QAClB,mEAAmE;QACnE,IAAI,CAAC,EAAE,UAAU,CAAC;QAClB,8CAA8C;QAC9C,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;QACzB,0FAA0F;QAC1F,UAAU,CAAC,EAAE,OAAO,gBAAgB,CAAC;KACtC,CAAC;IACF;;;OAGG;IACH,MAAM,CAAC,EAAE;QACP,IAAI,CAAC,EAAE,UAAU,CAAC;QAClB,IAAI,CAAC,EAAE,UAAU,CAAC;QAClB,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;QACzB,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;QAC3B,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,YAAY,CAAC,EAAE,qBAAqB,CAAC,cAAc,CAAC,CAAC;KACtD,CAAC;CACH,CAAC;AAEF,MAAM,MAAM,SAAS,GAAG,eAAe,GAAG;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC;AAsH3D,wBAAsB,eAAe,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,SAAS,CAAC,CAoUnF"}
1
+ {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"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"}
package/dist/app.js CHANGED
@@ -3,7 +3,7 @@ import { dirname, resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import Fastify from "fastify";
5
5
  import { attachCoreFastify } from "@x12i/core-service/fastify";
6
- import { catalogFromKinds, findContentType, findObjectType, } from "./catalog.js";
6
+ import { catalogFromKinds, findContentType, findObjectType, isConflicted, } from "./catalog.js";
7
7
  import { contentTypeIsDerived } from "./derived.js";
8
8
  import { resolveApiPort } from "./config.js";
9
9
  import { ApiError, UpstreamError } from "./errors.js";
@@ -12,14 +12,22 @@ import { clientApiLiveRing, memorixFetch } from "./live.js";
12
12
  import { withClientApiLiveNav } from "./live-ops-nav.js";
13
13
  import { registerProcessedRoutes } from "./processed.js";
14
14
  import { projectContents } from "./project.js";
15
- import { collectReferenceTargets, loadResolvedReferences, mergeReferenceTargets } from "./refs.js";
15
+ import { collectReferenceTargets, loadResolvedReferences, mergeReferenceTargets, } from "./refs.js";
16
16
  import { registerReadsLive } from "./reads-live-routes.js";
17
- import { createScoperHost, createExternalScoperHost, registerScoperRoutes } from "./scoper.js";
17
+ import { createExternalScoperHost, registerScoperRoutes } from "./scoper.js";
18
18
  import { createEmbeddedSkillsHost, createExternalSkillsHost, registerSkillRoutes, } from "./skills.js";
19
19
  import { attachRequestCountex, registerRequestStatsRoute } from "./request-countex.js";
20
20
  import { registerRefRoutes } from "./shaped-refs.js";
21
- import { orgFromToken, scopeBody, scopeFromHeaders } from "./scope.js";
21
+ import { orgFromToken, resolveAgentSelection, scopeBody, scopeFromHeaders, } from "./scope.js";
22
22
  import { asRecord, createUpstream, subjectDirectoryPath } from "./upstream.js";
23
+ import { createAgentDirectory } from "./agent-directory.js";
24
+ import { defaultRetryable } from "./errors.js";
25
+ import { createCensusService } from "./v1/census.js";
26
+ import { stateOf } from "./v1/context.js";
27
+ import { decodeSegments, rawPathAfter, requestIdFrom } from "./v1/http.js";
28
+ import { createMongoPlanningStore } from "./v1/planning-store.js";
29
+ import { registerV1Routes } from "./v1/routes.js";
30
+ import { recordsListExtras, refsModeOf, wrapReferenceStatus } from "./v1/records.js";
23
31
  const openApiPath = resolve(dirname(fileURLToPath(import.meta.url)), "../openapi/openapi.json");
24
32
  function queryOf(req) {
25
33
  return (req.query ?? {});
@@ -62,6 +70,22 @@ function wildcard(req) {
62
70
  const params = req.params;
63
71
  return (params["*"] ?? params.path ?? "").trim();
64
72
  }
73
+ function assertResolved(objectType) {
74
+ if (!isConflicted(objectType))
75
+ return;
76
+ const agents = (objectType.alternates ?? []).map((alt) => alt.definedByAgentId).join(", ");
77
+ throw new ApiError(409, "METADATA_CONFLICT", `Agents ${agents} define object type ${objectType.id} differently. Select one of them, or read the bodies on metadata/kinds/object-types.`);
78
+ }
79
+ function withReferenceStatus(contents, fieldMap, resolved, attempted) {
80
+ const out = {};
81
+ for (const [contentType, list] of Object.entries(contents)) {
82
+ const fields = fieldMap.get(contentType) ?? [];
83
+ out[contentType] = list.map((instance) => instance.json
84
+ ? { ...instance, json: { data: wrapReferenceStatus(instance.json.data, fields, resolved, attempted) } }
85
+ : instance);
86
+ }
87
+ return out;
88
+ }
65
89
  function fieldsByType(objectType, contentTypes) {
66
90
  const map = new Map();
67
91
  for (const contentType of contentTypes) {
@@ -74,9 +98,19 @@ async function projectRead(upstream, scope, catalog, contents, fieldMap, represe
74
98
  const resolved = await loadResolvedReferences(upstream, scope, catalog, targets);
75
99
  return projectContents(contents, fieldMap, representation, shape, resolved);
76
100
  }
77
- async function loadCatalog(upstream, scope) {
101
+ async function loadObjectTypeKinds(upstream, scope) {
78
102
  const body = await upstream.get("/api/metadata/kinds/object-types", scope);
79
- return catalogFromKinds(body);
103
+ const items = asRecord(body)?.items;
104
+ return {
105
+ items: Array.isArray(items)
106
+ ? items.filter((item) => asRecord(item) !== null)
107
+ : [],
108
+ objectTypes: catalogFromKinds(body),
109
+ readAt: new Date().toISOString(),
110
+ };
111
+ }
112
+ async function loadCatalog(upstream, scope) {
113
+ return (await loadObjectTypeKinds(upstream, scope)).objectTypes;
80
114
  }
81
115
  async function loadSubject(upstream, scope, recordId) {
82
116
  const body = asRecord(await upstream.get(subjectDirectoryPath(recordId), scope));
@@ -114,14 +148,27 @@ function bearerToken(headerValue) {
114
148
  const match = /^Bearer\s+(\S+)\s*$/i.exec(raw.trim());
115
149
  return match?.[1] ?? "";
116
150
  }
151
+ async function loadEmbeddedScoper() {
152
+ try {
153
+ return (await import("./scoper-embedded.js")).createScoperHost;
154
+ }
155
+ catch (err) {
156
+ const message = err instanceof Error ? err.message : String(err);
157
+ throw new Error(`Scoper cannot be hosted in this process (${message}). Set SCOPER_URL to a Scoper peer and SCOPER_MODE=external, or install @x12i/memorix-scoper.`);
158
+ }
159
+ }
117
160
  export async function createClientApi(options) {
118
- const upstream = createUpstream(options.memorixUrl, options.fetchImpl ?? memorixFetch);
161
+ const upstream = createUpstream(options.memorixUrl, options.fetchImpl ?? memorixFetch, options.upstreamTimeoutMs);
119
162
  const auth = options.auth ?? { mode: "header" };
163
+ const directory = options.agentDirectory ?? createAgentDirectory(upstream, options.agentCacheMs);
164
+ const census = createCensusService(upstream, options.censusCacheMs);
165
+ const planning = options.planning?.store ??
166
+ (options.planning?.mongoUri ? createMongoPlanningStore(options.planning.mongoUri) : null);
120
167
  const scoperPeer = options.scoper?.peer;
121
168
  const scoperHost = options.scoper?.host ??
122
169
  (scoperPeer?.mode === "external"
123
170
  ? createExternalScoperHost(scoperPeer, options.scoper?.fetchImpl ?? fetch)
124
- : await (options.scoper?.createHost ?? createScoperHost)({
171
+ : await (options.scoper?.createHost ?? (await loadEmbeddedScoper()))({
125
172
  memorixUrl: options.memorixUrl,
126
173
  reasoner: options.scoper?.reasoner ?? "scripted",
127
174
  functionsDir: options.scoper?.functionsDir,
@@ -151,29 +198,35 @@ export async function createClientApi(options) {
151
198
  disableRequestLogging: true,
152
199
  });
153
200
  app.addHook("onRequest", async (req, reply) => {
201
+ const requestId = requestIdFrom(req.headers["x-request-id"]);
202
+ stateOf(req).requestId = requestId;
203
+ reply.header("x-request-id", requestId);
154
204
  const origin = req.headers.origin;
155
205
  if (typeof origin === "string" && origin) {
156
206
  reply.header("access-control-allow-origin", origin);
157
207
  reply.header("vary", "Origin");
158
- reply.header("access-control-allow-headers", "content-type, authorization, x-memorix-org-id, x-memorix-agent-ids, x-memorix-caller, x-trace-id");
159
- reply.header("access-control-allow-methods", "GET, POST, PUT, DELETE, OPTIONS");
208
+ reply.header("access-control-allow-headers", "content-type, authorization, x-memorix-org-id, x-memorix-agent-ids, x-memorix-caller, x-trace-id, x-request-id, if-match, if-none-match");
209
+ reply.header("access-control-allow-methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
210
+ reply.header("access-control-expose-headers", "etag, x-request-id, retry-after");
160
211
  }
161
212
  if (req.method === "OPTIONS") {
162
213
  await reply.code(204).send();
163
214
  }
164
215
  });
165
- async function scopeFor(req) {
216
+ async function verifiedClaims(req) {
217
+ const state = stateOf(req);
218
+ if (state.clientClaims !== undefined)
219
+ return state.clientClaims;
166
220
  if (auth.mode === "header") {
167
- const scope = scopeFromHeaders(req.headers);
168
- req.clientScope = scope;
169
- return scope;
221
+ state.clientClaims = null;
222
+ return null;
170
223
  }
171
224
  const token = bearerToken(req.headers.authorization);
172
225
  if (!token)
173
226
  throw new ApiError(401, "UNAUTHORIZED", "Authorization bearer token is required");
174
- let organizationIds;
227
+ let claims;
175
228
  try {
176
- organizationIds = (await auth.verify(token)).organizationIds;
229
+ claims = await auth.verify(token);
177
230
  }
178
231
  catch (err) {
179
232
  if (err instanceof ApiError)
@@ -181,24 +234,91 @@ export async function createClientApi(options) {
181
234
  const message = err instanceof Error ? err.message : "invalid token";
182
235
  throw new ApiError(401, "UNAUTHORIZED", message);
183
236
  }
184
- const requested = req.headers["x-memorix-org-id"];
185
- const requestedOrg = (Array.isArray(requested) ? requested[0] : requested)?.trim() ?? "";
186
- const scope = scopeFromHeaders(req.headers, orgFromToken(organizationIds, requestedOrg));
187
- req.clientScope = scope;
237
+ state.clientClaims = claims;
238
+ return claims;
239
+ }
240
+ async function authenticate(req) {
241
+ const claims = await verifiedClaims(req);
242
+ const raw = req.headers["x-memorix-caller"];
243
+ const caller = (Array.isArray(raw) ? raw[0] : raw)?.trim() ?? "";
244
+ if (!caller)
245
+ throw new ApiError(400, "SCOPE_INVALID", "x-memorix-caller is required");
246
+ return { claims, caller };
247
+ }
248
+ async function scopeFor(req, scopeOptions = {}) {
249
+ const agents = scopeOptions.agents ?? "required";
250
+ const claims = await verifiedClaims(req);
251
+ let base;
252
+ if (!claims) {
253
+ base = scopeFromHeaders(req.headers, undefined, agents);
254
+ }
255
+ else {
256
+ const requested = req.headers["x-memorix-org-id"];
257
+ const requestedOrg = (Array.isArray(requested) ? requested[0] : requested)?.trim() ?? "";
258
+ base = scopeFromHeaders(req.headers, orgFromToken(claims.organizationIds, requestedOrg), agents);
259
+ }
260
+ const scope = await resolveAgentSelection(base, directory, agents);
261
+ stateOf(req).clientScope = scope;
188
262
  return scope;
189
263
  }
190
- app.setErrorHandler((err, _req, reply) => {
264
+ app.setErrorHandler((err, req, reply) => {
265
+ const requestId = stateOf(req).requestId;
191
266
  if (err instanceof ApiError) {
267
+ for (const [name, value] of Object.entries(err.extra.headers ?? {}))
268
+ reply.header(name, value);
192
269
  return reply.status(err.statusCode).send({
193
- error: { code: err.code, message: err.message },
270
+ error: {
271
+ code: err.code,
272
+ message: err.message,
273
+ requestId,
274
+ retryable: err.extra.retryable ?? defaultRetryable(err.statusCode),
275
+ ...(err.extra.feature ? { feature: err.extra.feature } : {}),
276
+ ...(err.extra.details ? { details: err.extra.details } : {}),
277
+ ...(err.extra.current !== undefined ? { current: err.extra.current } : {}),
278
+ },
194
279
  });
195
280
  }
196
281
  if (err instanceof UpstreamError) {
197
- return reply.status(err.statusCode).send(err.body ?? { error: { code: "UPSTREAM", message: err.message } });
282
+ if (err.statusCode === 429) {
283
+ if (err.retryAfter)
284
+ reply.header("retry-after", err.retryAfter);
285
+ return reply.status(429).send({
286
+ error: { code: "UPSTREAM_RATE_LIMITED", message: "Memorix limited the call", requestId, retryable: true },
287
+ });
288
+ }
289
+ const body = asRecord(err.body);
290
+ const error = asRecord(body?.error);
291
+ if (body && error) {
292
+ return reply.status(err.statusCode).send({
293
+ ...body,
294
+ error: {
295
+ ...error,
296
+ requestId,
297
+ retryable: typeof error.retryable === "boolean" ? error.retryable : defaultRetryable(err.statusCode),
298
+ },
299
+ });
300
+ }
301
+ return reply.status(err.statusCode).send(err.body ?? {
302
+ error: { code: "UPSTREAM", message: err.message, requestId, retryable: defaultRetryable(err.statusCode) },
303
+ });
198
304
  }
305
+ const statusCode = err.statusCode;
199
306
  const message = err instanceof Error ? err.message : String(err);
200
- return reply.status(500).send({ error: { code: "INTERNAL", message } });
307
+ if (typeof statusCode === "number" && statusCode >= 400 && statusCode < 500) {
308
+ return reply.status(statusCode).send({
309
+ error: { code: statusCode === 404 ? "NOT_FOUND" : "VALIDATION", message, requestId, retryable: false },
310
+ });
311
+ }
312
+ return reply.status(500).send({ error: { code: "INTERNAL", message, requestId, retryable: false } });
201
313
  });
314
+ app.setNotFoundHandler((req, reply) => reply.status(404).send({
315
+ error: {
316
+ code: "NOT_FOUND",
317
+ message: `No route ${req.method} ${req.url.split("?")[0]}`,
318
+ requestId: stateOf(req).requestId,
319
+ retryable: false,
320
+ },
321
+ }));
202
322
  const core = await attachCoreFastify(app, {
203
323
  // 5120 is outside the memorix zone, so the published port is passed in.
204
324
  port: options.port ?? resolveApiPort(),
@@ -276,6 +396,7 @@ export async function createClientApi(options) {
276
396
  const objectType = findObjectType(await loadCatalog(upstream, scope), id);
277
397
  if (!objectType)
278
398
  throw new ApiError(404, "READ_NOT_FOUND", `Unknown object type ${id}`);
399
+ assertResolved(objectType);
279
400
  const view = findContentType(objectType, contentType);
280
401
  if (!view)
281
402
  throw new ApiError(404, "READ_NOT_FOUND", `Unknown content type ${contentType}`);
@@ -289,6 +410,7 @@ export async function createClientApi(options) {
289
410
  const objectType = findObjectType(catalog, id);
290
411
  if (!objectType)
291
412
  throw new ApiError(404, "READ_NOT_FOUND", `Unknown object type ${id}`);
413
+ assertResolved(objectType);
292
414
  const leadingContentType = typeof query.leadingContentType === "string" && query.leadingContentType.trim()
293
415
  ? query.leadingContentType.trim()
294
416
  : objectType.leadingContentType;
@@ -316,6 +438,7 @@ export async function createClientApi(options) {
316
438
  const pageInfo = asRecord(listed?.pageInfo);
317
439
  const shape = representation === "json" ? "data" : "envelope";
318
440
  const fieldMap = fieldsByType(objectType, contentTypes);
441
+ const refs = refsModeOf(query.refs, "expand");
319
442
  const rows = items.map((item) => {
320
443
  const row = asRecord(item);
321
444
  return {
@@ -326,7 +449,16 @@ export async function createClientApi(options) {
326
449
  const targets = new Map();
327
450
  for (const row of rows)
328
451
  mergeReferenceTargets(targets, collectReferenceTargets(row.contents, fieldMap, shape));
329
- const resolved = await loadResolvedReferences(upstream, scope, catalog, targets);
452
+ const attempted = new Set();
453
+ const resolved = await loadResolvedReferences(upstream, scope, catalog, targets, attempted);
454
+ const nextCursor = typeof pageInfo?.nextCursor === "string" && pageInfo.nextCursor ? pageInfo.nextCursor : null;
455
+ const extras = recordsListExtras(id, nextCursor, census.peek(scope));
456
+ const projected = rows.map((row) => ({
457
+ recordId: row.recordId,
458
+ contents: refs === "status"
459
+ ? withReferenceStatus(projectContents(row.contents, fieldMap, representation, shape), fieldMap, resolved, attempted)
460
+ : projectContents(row.contents, fieldMap, representation, shape, resolved),
461
+ }));
330
462
  return {
331
463
  scope: scopeBody(scope),
332
464
  objectType: id,
@@ -334,21 +466,29 @@ export async function createClientApi(options) {
334
466
  leadingSource: query.leadingContentType && String(query.leadingContentType).trim()
335
467
  ? "request"
336
468
  : objectType.leadingSource,
337
- items: rows.map((row) => ({
469
+ total: extras.total,
470
+ totalStatus: extras.totalStatus,
471
+ truncated: false,
472
+ records: projected.map((row) => ({
338
473
  recordId: row.recordId,
339
- contents: projectContents(row.contents, fieldMap, representation, shape, resolved),
474
+ objectType: id,
475
+ dataCategory: objectType.dataCategory,
476
+ contentTypes: Object.keys(row.contents),
477
+ instances: Object.entries(row.contents).flatMap(([contentType, list]) => list.map((instance) => ({ contentType, ...instance }))),
340
478
  })),
479
+ items: projected,
341
480
  pageInfo: {
342
- nextCursor: typeof pageInfo?.nextCursor === "string" ? pageInfo.nextCursor : null,
481
+ complete: extras.complete,
482
+ nextCursor,
343
483
  limit: typeof pageInfo?.limit === "number" ? pageInfo.limit : limit,
344
484
  },
345
485
  };
346
486
  });
347
487
  app.get("/api/v1/records/*", async (req) => {
348
488
  const scope = await scopeFor(req);
349
- const raw = wildcard(req);
350
- const catalogOnly = raw.endsWith("/content-types");
351
- const recordId = (catalogOnly ? raw.slice(0, -"/content-types".length) : raw).replace(/\/$/, "");
489
+ const rawPath = rawPathAfter(req, "/api/v1/records/") ?? wildcard(req);
490
+ const catalogOnly = rawPath.endsWith("/content-types");
491
+ const recordId = decodeSegments((catalogOnly ? rawPath.slice(0, -"/content-types".length) : rawPath).replace(/\/$/, ""));
352
492
  if (!recordId)
353
493
  throw new ApiError(400, "VALIDATION", "recordId required");
354
494
  const subject = await loadSubject(upstream, scope, recordId);
@@ -358,20 +498,31 @@ export async function createClientApi(options) {
358
498
  }
359
499
  const catalog = await loadCatalog(upstream, scope);
360
500
  const objectType = findObjectType(catalog, subject.objectType);
501
+ const storedTypes = subject.contentTypes.filter((ct) => ct !== "relationships");
502
+ const status = storedTypes.length === 0 ? "directory-only" : "stored";
361
503
  if (catalogOnly) {
504
+ const stored = new Set(subject.contentTypes);
362
505
  return {
363
506
  scope: scopeBody(scope),
364
507
  recordId: subject.recordId,
365
508
  objectType: subject.objectType,
366
509
  dataCategory: subject.dataCategory,
510
+ status,
367
511
  leadingContentType: objectType?.leadingContentType ?? null,
368
512
  leadingSource: objectType?.leadingSource ?? "default",
369
- contentTypes: contentTypeViews(objectType, subject.contentTypes),
513
+ contentTypes: contentTypeViews(objectType, subject.contentTypes).map((view) => ({
514
+ ...view,
515
+ presence: "stored",
516
+ })),
517
+ declaredAbsent: (objectType?.contentTypes ?? [])
518
+ .filter((view) => !stored.has(view.id) && view.id !== "relationships")
519
+ .map((view) => ({ ...view, presence: "declared-absent" })),
370
520
  };
371
521
  }
372
522
  const requested = csv(query.contentTypes);
373
523
  const contentTypes = (requested ?? subject.contentTypes).filter((ct) => ct !== "relationships");
374
524
  const representation = representationOf(query.representation);
525
+ const refs = refsModeOf(query.refs, "expand");
375
526
  let contents = {};
376
527
  let dataCategory = subject.dataCategory;
377
528
  if (contentTypes.length > 0) {
@@ -386,15 +537,51 @@ export async function createClientApi(options) {
386
537
  const composition = asRecord(composed?.composition);
387
538
  contents = asRecord(composition?.contents);
388
539
  }
540
+ const fieldMap = fieldsByType(objectType, contentTypes);
541
+ let projected;
542
+ if (refs === "status") {
543
+ const attempted = new Set();
544
+ const resolved = await loadResolvedReferences(upstream, scope, catalog, collectReferenceTargets(contents, fieldMap, "record"), attempted);
545
+ projected = withReferenceStatus(projectContents(contents, fieldMap, representation, "record"), fieldMap, resolved, attempted);
546
+ }
547
+ else {
548
+ projected = await projectRead(upstream, scope, catalog, contents, fieldMap, representation, "record");
549
+ }
389
550
  return {
390
551
  scope: scopeBody(scope),
391
552
  recordId: subject.recordId,
392
553
  objectType: subject.objectType,
554
+ ...(objectType && isConflicted(objectType) ? { objectTypeResolution: "conflict" } : {}),
393
555
  dataCategory,
556
+ status,
394
557
  contentTypes,
395
- contents: await projectRead(upstream, scope, catalog, contents, fieldsByType(objectType, contentTypes), representation, "record"),
558
+ contents: projected,
559
+ ...(status === "directory-only" ? { instances: [] } : {}),
396
560
  };
397
561
  });
562
+ const memorixBase = options.memorixUrl.replace(/\/+$/, "");
563
+ const probeFetch = options.fetchImpl ?? memorixFetch;
564
+ registerV1Routes(app, {
565
+ upstream,
566
+ directory,
567
+ census,
568
+ planning,
569
+ authMode: auth.mode,
570
+ scopeFor,
571
+ authenticate,
572
+ loadCatalog,
573
+ loadObjectTypeKinds,
574
+ assignees: (orgId) => options.planning?.assignees?.[orgId] ?? null,
575
+ probeMemorix: async () => {
576
+ try {
577
+ const res = await probeFetch(`${memorixBase}/health`, { signal: AbortSignal.timeout(3000) });
578
+ return res.ok;
579
+ }
580
+ catch {
581
+ return false;
582
+ }
583
+ },
584
+ });
398
585
  registerProcessedRoutes(app, { upstream, scopeFor, loadCatalog });
399
586
  registerRefRoutes(app, { upstream, scopeFor, loadCatalog });
400
587
  registerFetchRoutes(app, {