@x12i/memorix-service 3.2.1 → 3.3.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 (104) hide show
  1. package/README.md +43 -2
  2. package/dist/app.d.ts.map +1 -1
  3. package/dist/app.js +4 -0
  4. package/dist/app.js.map +1 -1
  5. package/dist/catalog/catalog-service.d.ts +48 -0
  6. package/dist/catalog/catalog-service.d.ts.map +1 -0
  7. package/dist/catalog/catalog-service.js +397 -0
  8. package/dist/catalog/catalog-service.js.map +1 -0
  9. package/dist/catalog/package-list-source.d.ts +35 -0
  10. package/dist/catalog/package-list-source.d.ts.map +1 -0
  11. package/dist/catalog/package-list-source.js +47 -0
  12. package/dist/catalog/package-list-source.js.map +1 -0
  13. package/dist/catalog/types.d.ts +65 -0
  14. package/dist/catalog/types.d.ts.map +1 -0
  15. package/dist/catalog/types.js +16 -0
  16. package/dist/catalog/types.js.map +1 -0
  17. package/dist/catalog-store-cli.d.ts +20 -0
  18. package/dist/catalog-store-cli.d.ts.map +1 -0
  19. package/dist/catalog-store-cli.js +206 -0
  20. package/dist/catalog-store-cli.js.map +1 -0
  21. package/dist/cli.d.ts +3 -1
  22. package/dist/cli.d.ts.map +1 -1
  23. package/dist/cli.js +48 -18
  24. package/dist/cli.js.map +1 -1
  25. package/dist/connector-ops/attempt-store.d.ts +5 -0
  26. package/dist/connector-ops/attempt-store.d.ts.map +1 -0
  27. package/dist/connector-ops/attempt-store.js +186 -0
  28. package/dist/connector-ops/attempt-store.js.map +1 -0
  29. package/dist/connector-ops/index.d.ts +5 -0
  30. package/dist/connector-ops/index.d.ts.map +1 -0
  31. package/dist/connector-ops/index.js +5 -0
  32. package/dist/connector-ops/index.js.map +1 -0
  33. package/dist/connector-ops/override-store.d.ts +7 -0
  34. package/dist/connector-ops/override-store.d.ts.map +1 -0
  35. package/dist/connector-ops/override-store.js +123 -0
  36. package/dist/connector-ops/override-store.js.map +1 -0
  37. package/dist/connector-ops/sanitize.d.ts +16 -0
  38. package/dist/connector-ops/sanitize.d.ts.map +1 -0
  39. package/dist/connector-ops/sanitize.js +48 -0
  40. package/dist/connector-ops/sanitize.js.map +1 -0
  41. package/dist/connector-ops/types.d.ts +76 -0
  42. package/dist/connector-ops/types.d.ts.map +1 -0
  43. package/dist/connector-ops/types.js +12 -0
  44. package/dist/connector-ops/types.js.map +1 -0
  45. package/dist/effective-listing.d.ts +11 -0
  46. package/dist/effective-listing.d.ts.map +1 -0
  47. package/dist/effective-listing.js +41 -0
  48. package/dist/effective-listing.js.map +1 -0
  49. package/dist/errors.d.ts +5 -1
  50. package/dist/errors.d.ts.map +1 -1
  51. package/dist/errors.js +10 -3
  52. package/dist/errors.js.map +1 -1
  53. package/dist/execution-target.d.ts +16 -0
  54. package/dist/execution-target.d.ts.map +1 -1
  55. package/dist/execution-target.js +54 -0
  56. package/dist/execution-target.js.map +1 -1
  57. package/dist/index.d.ts +3 -1
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +3 -1
  60. package/dist/index.js.map +1 -1
  61. package/dist/magit/magit-pack-service.d.ts +252 -70
  62. package/dist/magit/magit-pack-service.d.ts.map +1 -1
  63. package/dist/magit/magit-pack-service.js +556 -202
  64. package/dist/magit/magit-pack-service.js.map +1 -1
  65. package/dist/magit/pack-to-patchset.d.ts +21 -0
  66. package/dist/magit/pack-to-patchset.d.ts.map +1 -1
  67. package/dist/magit/pack-to-patchset.js +29 -0
  68. package/dist/magit/pack-to-patchset.js.map +1 -1
  69. package/dist/magit/package-catalog-bridge.d.ts +4 -14
  70. package/dist/magit/package-catalog-bridge.d.ts.map +1 -1
  71. package/dist/magit/package-catalog-bridge.js +2 -21
  72. package/dist/magit/package-catalog-bridge.js.map +1 -1
  73. package/dist/mgc-packages.d.ts +4 -3
  74. package/dist/mgc-packages.d.ts.map +1 -1
  75. package/dist/mgc-packages.js +4 -3
  76. package/dist/mgc-packages.js.map +1 -1
  77. package/dist/openapi/openapi.json +1716 -137
  78. package/dist/optional-companion-url.d.ts +72 -0
  79. package/dist/optional-companion-url.d.ts.map +1 -0
  80. package/dist/optional-companion-url.js +148 -0
  81. package/dist/optional-companion-url.js.map +1 -0
  82. package/dist/platform.d.ts +11 -0
  83. package/dist/platform.d.ts.map +1 -1
  84. package/dist/platform.js +77 -2
  85. package/dist/platform.js.map +1 -1
  86. package/dist/routes/catalog.d.ts +4 -0
  87. package/dist/routes/catalog.d.ts.map +1 -0
  88. package/dist/routes/catalog.js +103 -0
  89. package/dist/routes/catalog.js.map +1 -0
  90. package/dist/routes/connector-ops.d.ts +12 -0
  91. package/dist/routes/connector-ops.d.ts.map +1 -0
  92. package/dist/routes/connector-ops.js +160 -0
  93. package/dist/routes/connector-ops.js.map +1 -0
  94. package/dist/routes/memory.d.ts.map +1 -1
  95. package/dist/routes/memory.js +9 -2
  96. package/dist/routes/memory.js.map +1 -1
  97. package/dist/routes/metadata.d.ts.map +1 -1
  98. package/dist/routes/metadata.js +135 -186
  99. package/dist/routes/metadata.js.map +1 -1
  100. package/dist/routes/pipelines.d.ts.map +1 -1
  101. package/dist/routes/pipelines.js +3 -2
  102. package/dist/routes/pipelines.js.map +1 -1
  103. package/openapi/openapi.json +1716 -137
  104. package/package.json +30 -7
@@ -1,32 +1,95 @@
1
1
  /**
2
- * MagitPackService — server-only façade over @x12i/magit for pack versioning
3
- * and Magit 2.9+ FR package catalog (packPackageFR / list / fetch).
2
+ * MagitPackService — server-only façade over @x12i/magit for pack versioning.
3
+ * Phase 0–5: ensureRepo, saveVersion, history, diff, packAtCommit, publish, proposals.
4
+ *
5
+ * Package catalog (Magit 2.10+): flat FR-CPS when store env is unset; store-
6
+ * scoped when both MAGIT_PACKAGE_STORE_SELECTOR + MAGIT_PACKAGE_TYPE_ID are
7
+ * set. Selector is capability-like — never log it. Half-config fails closed.
8
+ * Store packages: pack/list/fetch + pullStore only (no Magit restore).
4
9
  */
5
10
  import { createHash } from "node:crypto";
6
11
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
12
  import { join } from "node:path";
8
13
  import { MongoClient } from "mongodb";
9
- import { magit, listProposalLedgerEntries, isPatchsetError, isPackageCollisionError, createProgressReporter, labelDiff, } from "@x12i/magit";
14
+ import { magit, listProposalLedgerEntries, isPatchsetError, createProgressReporter, labelDiff, redactStoreSelector, } from "@x12i/magit";
10
15
  import { checksumMetadataPack } from "@x12i/memorix-metadata-runtime";
11
- import { ServiceError } from "../errors.js";
16
+ import { ServiceError, redactSecrets } from "../errors.js";
12
17
  import { isOrgRuntimeFolderSource, stripOrgRuntimeSourcesFromPack, } from "../org-sources.js";
13
- import { PACKAGE_CATALOG_ORG, } from "./package-catalog-bridge.js";
14
- import { MAGIT_AGENTS_COLLECTION, MAGIT_SEED_AGENT_ID, metadataPackToPackagePayload, packagePayloadToMetadataPack, packToPatchset, parseRecordId, parseSaveVersionIdempotencyKey, recordIdForDefinition, recordsToPack, } from "./pack-to-patchset.js";
18
+ import { MAGIT_AGENTS_COLLECTION, MAGIT_SEED_AGENT_ID, packToPackagePayload, packToPatchset, packToDefinitionRecords, parseRecordId, parseSaveVersionIdempotencyKey, recordIdForDefinition, recordsToPack, packagePayloadToPack, } from "./pack-to-patchset.js";
15
19
  import { mergeProposalIntoPack, } from "./proposal-to-pack.js";
20
+ // ---------------------------------------------------------------------------
21
+ // Central package catalog (FR-CPS). Org-free — kind/id/version only. Distinct
22
+ // from the org-scoped saveVersion/history/diff/packAtCommit API above, which
23
+ // remains this environment's own live-metadata history.
24
+ // ---------------------------------------------------------------------------
25
+ /** Fixed local working directory for the shared package-catalog repo. Not an
26
+ * org — purely a local disk key; the remote binding is the catalog tenant
27
+ * (resolveMagitCatalogTenant), applied uniformly by ensureRepo. */
28
+ const PACKAGE_CATALOG_LOCAL_DIR = "_package-catalog";
16
29
  /**
17
- * Magit storage auth is the Authix service token.
18
- * Prefers MAGIT_STORAGE_TOKEN_ENV, else MAGIT_TOKEN / AUTHIX_TOKEN when set.
30
+ * Any error surfaced from a catalog-flagged operation that isn't already a
31
+ * ServiceError or PatchsetError (both handled earlier in mapError, and
32
+ * neither indicates a reachability problem) is treated as central-storage
33
+ * unreachable — network/DNS failure, connection refused, and auth failure
34
+ * (missing/expired MAGIT_TOKEN) all leave the catalog equally unusable from
35
+ * an operator's "is Magit healthy" perspective, and the FR (§8's retirement)
36
+ * requires surfacing every one of them rather than silently falling back to
37
+ * local-only behaviour. The first line of the error message is kept as the
38
+ * classified reason for diagnostics.
19
39
  */
20
- export function resolveMagitStorageTokenEnv() {
21
- const explicit = process.env.MAGIT_STORAGE_TOKEN_ENV?.trim();
22
- if (explicit)
23
- return explicit;
24
- if (process.env.MAGIT_TOKEN?.trim())
25
- return "MAGIT_TOKEN";
26
- if (process.env.AUTHIX_TOKEN?.trim())
27
- return "AUTHIX_TOKEN";
28
- // Magit SDK default name put the Authix service token here.
29
- return "MAGIT_TOKEN";
40
+ /** First line of an error, with Magit store-selector material redacted. */
41
+ export function classifyCatalogUnreachable(err) {
42
+ const message = err instanceof Error ? err.message : String(err);
43
+ const first = message.split("\n")[0] ?? message;
44
+ return String(redactSecrets(redactStoreSelector(first)));
45
+ }
46
+ /**
47
+ * Pure record-level pack compare used for Mode A (immutable store payloads)
48
+ * and unit tests. Keys definitions by kind/id with stable JSON equality.
49
+ */
50
+ export function diffMetadataPacks(agentId, from, to, fromPack, toPack) {
51
+ const before = new Map();
52
+ const after = new Map();
53
+ for (const r of packToDefinitionRecords(fromPack)) {
54
+ before.set(`${r.kind}/${r.id}`, {
55
+ kind: r.kind,
56
+ id: r.id,
57
+ body: r.definition,
58
+ });
59
+ }
60
+ for (const r of packToDefinitionRecords(toPack)) {
61
+ after.set(`${r.kind}/${r.id}`, {
62
+ kind: r.kind,
63
+ id: r.id,
64
+ body: r.definition,
65
+ });
66
+ }
67
+ const keys = new Set([...before.keys(), ...after.keys()]);
68
+ const changes = [];
69
+ for (const key of [...keys].sort()) {
70
+ const b = before.get(key);
71
+ const a = after.get(key);
72
+ if (!b && a) {
73
+ changes.push({ kind: a.kind, id: a.id, change: "added" });
74
+ continue;
75
+ }
76
+ if (b && !a) {
77
+ changes.push({ kind: b.kind, id: b.id, change: "removed" });
78
+ continue;
79
+ }
80
+ if (b && a) {
81
+ const fields = fieldDiffs(b.body, a.body);
82
+ if (fields?.length) {
83
+ changes.push({
84
+ kind: a.kind,
85
+ id: a.id,
86
+ change: "changed",
87
+ fields,
88
+ });
89
+ }
90
+ }
91
+ }
92
+ return { agentId, from, to, changes };
30
93
  }
31
94
  const MEMORIX_LENS = {
32
95
  [MAGIT_AGENTS_COLLECTION]: {
@@ -56,12 +119,93 @@ export function isMetadataMagitEnabled() {
56
119
  // Default-on when unset or any other truthy-ish value (including "1"/"true"/"on").
57
120
  return true;
58
121
  }
122
+ /**
123
+ * `MAGIT_REPO_ROOT` is the parent of each repo's LOCAL working checkout / cache
124
+ * (per-org live-history repos and the shared package catalog repo alike). It is
125
+ * demoted, not removed, by the central-catalog cutover (X12-65): it no longer
126
+ * doubles as the catalog — that is central storage, bound via `storageTenant`
127
+ * (see `resolveMagitCatalogTenant`). This directory can be wiped and
128
+ * re-bootstrapped from central storage without data loss.
129
+ */
59
130
  export function resolveMagitRepoRootParent() {
60
131
  const fromEnv = process.env.MAGIT_REPO_ROOT?.trim();
61
132
  if (fromEnv)
62
133
  return fromEnv;
63
134
  return join(process.cwd(), ".tmp", "magit-repos");
64
135
  }
136
+ /**
137
+ * Central Magit package-catalog tenant (FR-CPS-1). This is a fixed
138
+ * developer/solution scope for Memorix's own package storage — it must NEVER
139
+ * be a runtime `orgId`. Org only appears as a Restore install target, never as
140
+ * the catalog key. Default `"memorix"`.
141
+ */
142
+ export function resolveMagitCatalogTenant() {
143
+ return process.env.MAGIT_CATALOG_TENANT?.trim() || "memorix";
144
+ }
145
+ /**
146
+ * Magit storage repo id for the central package catalog (FR-CPS). Defaults to
147
+ * `memorix-metadata-_package-catalog`. Override with MAGIT_CATALOG_REPO when the
148
+ * Authix token only grants a specific repo (e.g. `demo`).
149
+ */
150
+ export function resolveMagitCatalogRepoName() {
151
+ return (process.env.MAGIT_CATALOG_REPO?.trim() ||
152
+ `memorix-metadata-${PACKAGE_CATALOG_LOCAL_DIR}`);
153
+ }
154
+ /**
155
+ * Env var *name* Magit uses to load the central-storage bearer token.
156
+ * Priority: MAGIT_STORAGE_TOKEN_ENV (explicit name) → AUTHIX_TOKEN if set → MAGIT_TOKEN.
157
+ */
158
+ export function resolveMagitStorageTokenEnv() {
159
+ const explicit = process.env.MAGIT_STORAGE_TOKEN_ENV?.trim();
160
+ if (explicit)
161
+ return explicit;
162
+ if (process.env.AUTHIX_TOKEN?.trim())
163
+ return "AUTHIX_TOKEN";
164
+ return "MAGIT_TOKEN";
165
+ }
166
+ /**
167
+ * Magit 2.10 typed global store binding.
168
+ * - both MAGIT_PACKAGE_STORE_SELECTOR + MAGIT_PACKAGE_TYPE_ID → store path
169
+ * - neither → flat catalog (2.9 compatible)
170
+ * - only one set → fail closed (never half-use store APIs)
171
+ */
172
+ export function resolveMagitTypedStoreConfig() {
173
+ const storeSelector = process.env.MAGIT_PACKAGE_STORE_SELECTOR?.trim() || "";
174
+ const packageTypeId = process.env.MAGIT_PACKAGE_TYPE_ID?.trim() || "";
175
+ if (!storeSelector && !packageTypeId)
176
+ return null;
177
+ if (!storeSelector || !packageTypeId) {
178
+ throw new ServiceError("VALIDATION", "MAGIT_PACKAGE_STORE_SELECTOR and MAGIT_PACKAGE_TYPE_ID must both be set for store-scoped packs, or both unset for the flat 2.9 catalog", 400);
179
+ }
180
+ const publisherId = process.env.MAGIT_PACKAGE_PUBLISHER_ID?.trim() || "memorix";
181
+ const publisherAppId = process.env.MAGIT_PACKAGE_PUBLISHER_APP_ID?.trim() || "memorix-service";
182
+ return {
183
+ storeSelector,
184
+ packageTypeId,
185
+ publisher: { id: publisherId, appId: publisherAppId || undefined },
186
+ };
187
+ }
188
+ /** Whether store env is configured (yes/no only — never selector values). */
189
+ export function magitTypedStoreConfigured() {
190
+ try {
191
+ return resolveMagitTypedStoreConfig() != null;
192
+ }
193
+ catch {
194
+ return false;
195
+ }
196
+ }
197
+ /** Whether MAGIT_PACKAGE_TYPE_ID is set (safe to report; not a capability secret). */
198
+ export function magitPackageTypeIdConfigured() {
199
+ return Boolean(process.env.MAGIT_PACKAGE_TYPE_ID?.trim());
200
+ }
201
+ /** Whether MAGIT_PACKAGE_STORE_SELECTOR is set (yes/no only — never the value). */
202
+ export function magitStoreSelectorConfigured() {
203
+ return Boolean(process.env.MAGIT_PACKAGE_STORE_SELECTOR?.trim());
204
+ }
205
+ /** Structure language id for diagnostics when set. Never the store selector. */
206
+ export function magitPackageTypeIdForDiagnostics() {
207
+ return process.env.MAGIT_PACKAGE_TYPE_ID?.trim() || null;
208
+ }
65
209
  function isLiveRef(ref) {
66
210
  return ref === "live" || ref === "LIVE";
67
211
  }
@@ -108,9 +252,14 @@ function unwrapDefinitionBody(body) {
108
252
  export class MagitPackService {
109
253
  cfg;
110
254
  ensured = new Set();
255
+ catalogHealth = { reachable: null, reason: null };
111
256
  constructor(cfg) {
112
257
  this.cfg = cfg;
113
258
  }
259
+ /** Last-observed central package-catalog reachability (task 5 diagnostics). */
260
+ catalogHealthStatus() {
261
+ return this.catalogHealth;
262
+ }
114
263
  progress() {
115
264
  const log = this.cfg.log;
116
265
  createProgressReporter({ mode: "json" });
@@ -120,33 +269,10 @@ export class MagitPackService {
120
269
  },
121
270
  };
122
271
  }
123
- storageTokenEnvName() {
124
- return this.cfg.storageTokenEnv?.trim() || resolveMagitStorageTokenEnv();
125
- }
126
- storageUrl() {
127
- return (this.cfg.storageUrl ||
128
- process.env.MAGIT_STORAGE_URL?.trim() ||
129
- "https://magit-storage.x12i.com");
130
- }
131
- /** True when Authix/Magit token is present for remote catalog push/pull. */
132
- hasRemoteCatalog() {
133
- const env = this.storageTokenEnvName();
134
- return Boolean(process.env[env]?.trim());
135
- }
136
272
  repoRootFor(orgId) {
137
273
  return join(this.cfg.repoRootParent, orgId);
138
274
  }
139
- catalogRepoRoot() {
140
- return this.repoRootFor(PACKAGE_CATALOG_ORG);
141
- }
142
275
  async ensureRepo(orgId) {
143
- await this.ensureRepoInternal(orgId, { catalog: false });
144
- }
145
- /** Dedicated Magit root for org-free package catalog (`packages/`). */
146
- async ensureCatalogRepo() {
147
- await this.ensureRepoInternal(PACKAGE_CATALOG_ORG, { catalog: true });
148
- }
149
- async ensureRepoInternal(orgId, opts) {
150
276
  if (this.ensured.has(orgId))
151
277
  return;
152
278
  const { tick } = this.progress();
@@ -158,14 +284,13 @@ export class MagitPackService {
158
284
  const repo = await magit.open({ repoRoot });
159
285
  const configPath = join(repoRoot, ".magit", "config.json");
160
286
  if (!existsSync(configPath)) {
161
- tick("ensureRepo.init", { orgId, catalog: opts.catalog });
162
- // Catalog: enable remote bootstrap when Authix/Magit token is set.
163
- // Org versioning repos stay local-only (`skipRemoteBootstrap: true`).
164
- const remote = opts.catalog && this.hasRemoteCatalog();
287
+ tick("ensureRepo.init", { orgId });
288
+ const isPackageCatalog = orgId === PACKAGE_CATALOG_LOCAL_DIR;
289
+ const repoName = isPackageCatalog
290
+ ? resolveMagitCatalogRepoName()
291
+ : `memorix-metadata-${orgId}`;
165
292
  await repo.init({
166
- name: opts.catalog
167
- ? "memorix-package-catalog"
168
- : `memorix-metadata-${orgId}`,
293
+ name: repoName,
169
294
  driver: "transform",
170
295
  urlEnv: this.cfg.mongoUrlEnv,
171
296
  db: this.cfg.metadataDb,
@@ -180,11 +305,19 @@ export class MagitPackService {
180
305
  encodingProfile: "current",
181
306
  },
182
307
  storageProvider: "x12i-http",
183
- storageUrl: this.storageUrl(),
184
- storageTokenEnv: this.storageTokenEnvName(),
185
- storageTenant: orgId,
308
+ storageUrl: this.cfg.storageUrl ||
309
+ process.env.MAGIT_STORAGE_URL?.trim() ||
310
+ "https://magit-storage.x12i.com",
311
+ storageTokenEnv: this.cfg.storageTokenEnv || "MAGIT_TOKEN",
312
+ // FR-CPS-1: catalog tenant is a fixed developer/solution scope, never
313
+ // the runtime org — `orgId` here only names this repo's LOCAL working
314
+ // directory (see repoRootFor), not its central storage binding.
315
+ storageTenant: resolveMagitCatalogTenant(),
186
316
  environmentName: "draft",
187
- skipRemoteBootstrap: !remote,
317
+ // FR-CPS-3: bootstrap/sync with central storage instead of staying
318
+ // local-only. The interim §8 bridge (skipRemoteBootstrap: true) is
319
+ // retired — no silent local-history fallback.
320
+ skipRemoteBootstrap: false,
188
321
  });
189
322
  writeFileSync(join(repoRoot, ".magitignore"), MAGITIGNORE, "utf8");
190
323
  await this.seedAgentsCollectionCommit(repo);
@@ -196,180 +329,249 @@ export class MagitPackService {
196
329
  }
197
330
  }
198
331
  /**
199
- * Pull remote package catalog when Authix token is configured.
200
- * `full: true` for fetch/restore so record objects for package contents exist.
332
+ * Open the shared central package-catalog repo (org-free). Opening the
333
+ * LOCAL repo directory can succeed even when central storage itself is
334
+ * unreachable (`repo.init` only warns on a failed remote bootstrap by
335
+ * default — see `strictRemote` — and `magit.open` is a local-only call), so
336
+ * this deliberately does NOT mark catalogHealth reachable; only an actual
337
+ * round-trip (pull/pack/list/fetch) that completes should do that.
201
338
  */
202
- async syncCatalogPull(opts) {
203
- await this.ensureCatalogRepo();
204
- if (!this.hasRemoteCatalog())
205
- return { remote: false };
206
- const { tick } = this.progress();
207
- try {
208
- this.ensureMongoEnv();
209
- const repo = await magit.open({ repoRoot: this.catalogRepoRoot() });
210
- tick("catalog.pull", { full: Boolean(opts?.full) });
211
- const result = await repo.pull({ full: Boolean(opts?.full) });
212
- return { remote: true, downloaded: result.downloaded };
213
- }
214
- catch (err) {
215
- // Local catalog still usable; surface via log only for soft pull failures.
216
- this.cfg.log?.("[magit] catalog.pull failed", {
217
- error: err instanceof Error ? err.message : String(err),
218
- });
219
- return { remote: true };
220
- }
339
+ async ensureCatalogRepo() {
340
+ await this.ensureRepo(PACKAGE_CATALOG_LOCAL_DIR);
341
+ return magit.open({ repoRoot: this.repoRootFor(PACKAGE_CATALOG_LOCAL_DIR) });
221
342
  }
222
- async syncCatalogPush() {
223
- if (!this.hasRemoteCatalog())
224
- return { remote: false };
225
- await this.ensureCatalogRepo();
226
- const { tick } = this.progress();
227
- try {
228
- this.ensureMongoEnv();
229
- const repo = await magit.open({ repoRoot: this.catalogRepoRoot() });
230
- tick("catalog.push", {});
231
- const result = await repo.push();
232
- return { remote: true, uploaded: result.uploaded };
233
- }
234
- catch (err) {
235
- throw this.mapError(err, "failed to push package catalog");
236
- }
237
- }
238
- /** Pack a MetadataPack into Magit packages/ (does not change live metadata). */
239
- async packCatalogPackage(input) {
240
- const kind = input.kind;
241
- const id = input.id?.trim();
242
- const message = input.message?.trim();
243
- if (!id)
244
- throw new ServiceError("VALIDATION", "id required", 400);
245
- if (!message)
246
- throw new ServiceError("VALIDATION", "message required", 400);
247
- await this.ensureCatalogRepo();
343
+ /**
344
+ * Pack a payload into the central catalog (FR-CPS-4), or into the typed
345
+ * global store when MAGIT_PACKAGE_STORE_SELECTOR + MAGIT_PACKAGE_TYPE_ID are set.
346
+ *
347
+ * Flat: pull then packPackageFR without selector.
348
+ * Store: packPackageFR with selector/type/kind/publisher; do not full-tree pull.
349
+ */
350
+ async packPackage(opts) {
248
351
  const { tick } = this.progress();
249
352
  try {
250
- this.ensureMongoEnv();
251
- const clean = stripOrgRuntimeSourcesFromPack(input.pack);
252
- const payload = metadataPackToPackagePayload(clean);
253
- const repo = await magit.open({ repoRoot: this.catalogRepoRoot() });
254
- tick("catalog.packPackageFR", { kind, id, mode: input.mode });
255
- const mode = input.mode ??
256
- (input.pins?.length ? "full" : "narrow");
257
- const result = await repo.packPackageFR({
258
- kind,
259
- id,
260
- message,
261
- payload,
262
- mode,
263
- associations: input.associations
353
+ const store = resolveMagitTypedStoreConfig();
354
+ const repo = await this.ensureCatalogRepo();
355
+ const { payload } = packToPackagePayload(opts.pack);
356
+ tick("packPackage.start", {
357
+ kind: opts.kind,
358
+ id: opts.id,
359
+ storeScoped: Boolean(store),
360
+ });
361
+ if (store) {
362
+ const publisher = opts.actor?.id
264
363
  ? {
265
- connectorIds: input.associations.connectorIds,
266
- serviceIds: input.associations.serviceIds,
364
+ id: opts.actor.id,
365
+ appId: store.publisher.appId,
267
366
  }
268
- : undefined,
269
- pins: input.pins,
270
- actor: input.actor
271
- ? { id: input.actor.subject, email: undefined }
272
- : undefined,
273
- });
274
- // Pin catalog HEAD so subsequent pin packs / full agent packs share lineage.
275
- if (!result.reused) {
276
- this.pinPackTip(repo, result.version);
367
+ : store.publisher;
368
+ const result = await repo.packPackageFR({
369
+ storeSelector: store.storeSelector,
370
+ packageTypeId: store.packageTypeId,
371
+ kind: opts.kind,
372
+ id: opts.id,
373
+ message: opts.message,
374
+ payload,
375
+ associations: opts.associations,
376
+ mode: opts.mode,
377
+ publisher,
378
+ actor: opts.actor,
379
+ });
380
+ // Magit 2.11+ throws when default publish fails; still bind health to
381
+ // published so clients and diagnostics stay honest if publish:false ever used.
382
+ const published = result.published === true;
383
+ if (published) {
384
+ this.catalogHealth = { reachable: true, reason: null };
385
+ }
386
+ else {
387
+ this.catalogHealth = {
388
+ reachable: false,
389
+ reason: "pack completed without remote publish (published=false)",
390
+ };
391
+ }
392
+ tick("packPackage.done", {
393
+ version: result.version,
394
+ reused: result.reused,
395
+ published,
396
+ storeScoped: true,
397
+ });
398
+ return {
399
+ version: result.version,
400
+ checksum: result.checksum,
401
+ reused: result.reused,
402
+ published,
403
+ storeId: result.storeId,
404
+ packageTypeId: result.packageTypeId ?? store.packageTypeId,
405
+ };
277
406
  }
278
- const push = await this.syncCatalogPush();
279
- tick("catalog.packPackageFR.done", {
280
- version: result.version,
281
- reused: result.reused,
282
- remote: push.remote,
407
+ await repo.pull();
408
+ const result = await repo.packPackageFR({
409
+ kind: opts.kind,
410
+ id: opts.id,
411
+ message: opts.message,
412
+ payload,
413
+ associations: opts.associations,
414
+ mode: opts.mode,
415
+ pins: opts.pins,
416
+ actor: opts.actor,
283
417
  });
418
+ this.catalogHealth = { reachable: true, reason: null };
419
+ tick("packPackage.done", { version: result.version, reused: result.reused });
284
420
  return {
285
421
  version: result.version,
286
422
  checksum: result.checksum,
287
423
  reused: result.reused,
288
- remote: push.remote,
289
424
  };
290
425
  }
291
426
  catch (err) {
292
- if (isPackageCollisionError(err)) {
293
- throw new ServiceError("VALIDATION", `package collision: ${err.message}`, 409);
294
- }
295
- throw this.mapError(err, "failed to pack package into Magit catalog");
427
+ if (err instanceof ServiceError)
428
+ throw err;
429
+ throw this.mapError(err, "failed to pack package version", { catalog: true });
296
430
  }
297
431
  }
298
- async listCatalogPackages(filter) {
299
- await this.ensureCatalogRepo();
300
- const pull = await this.syncCatalogPull({ full: false });
432
+ /**
433
+ * List packages — flat catalog or store-scoped list (kind required on store path).
434
+ * Org-free: kind + id only; never a store selector from the client.
435
+ */
436
+ async listPackages(filter) {
301
437
  try {
302
- this.ensureMongoEnv();
303
- const repo = await magit.open({ repoRoot: this.catalogRepoRoot() });
438
+ const store = resolveMagitTypedStoreConfig();
439
+ const repo = await this.ensureCatalogRepo();
440
+ if (store) {
441
+ if (!filter?.kind) {
442
+ throw new ServiceError("VALIDATION", "kind is required when Magit typed store is configured", 400);
443
+ }
444
+ await repo.pullStore({ storeSelector: store.storeSelector });
445
+ const items = await repo.listPackagesFR({
446
+ storeSelector: store.storeSelector,
447
+ packageTypeId: store.packageTypeId,
448
+ kind: filter.kind,
449
+ id: filter.id,
450
+ publisher: filter.publisher,
451
+ maxCount: filter.maxCount,
452
+ });
453
+ this.catalogHealth = { reachable: true, reason: null };
454
+ return items.map((item) => ({
455
+ kind: item.kind,
456
+ id: item.id,
457
+ version: item.version,
458
+ checksum: item.checksum,
459
+ message: item.message,
460
+ createdAt: item.createdAt,
461
+ associations: item.associations,
462
+ mode: item.mode,
463
+ publisher: item.publisher,
464
+ }));
465
+ }
466
+ await repo.pull();
304
467
  const items = await repo.listPackagesFR({
305
468
  kind: filter?.kind,
306
- id: filter?.id?.trim() || undefined,
469
+ id: filter?.id,
307
470
  maxCount: filter?.maxCount,
308
471
  });
309
- const mapped = items
310
- .filter((row) => {
311
- const k = row.kind;
312
- return k === "agent" || k === "connector" || k === "service";
313
- })
314
- .map((row) => ({
315
- kind: row.kind,
316
- id: row.id,
317
- version: row.version,
318
- commit: row.version,
319
- checksum: row.checksum,
320
- message: row.message,
321
- createdAt: row.createdAt,
322
- mode: row.mode,
323
- associations: row.associations
324
- ? {
325
- connectorIds: [...(row.associations.connectorIds ?? [])].sort(),
326
- serviceIds: [...(row.associations.serviceIds ?? [])].sort(),
327
- }
328
- : undefined,
329
- }));
330
- return { items: mapped, remote: pull.remote };
472
+ this.catalogHealth = { reachable: true, reason: null };
473
+ return items;
331
474
  }
332
475
  catch (err) {
333
- throw this.mapError(err, "failed to list Magit package catalog");
334
- }
335
- }
336
- async fetchCatalogPackage(ref, preferredAgentId) {
337
- const version = ref.version?.trim();
338
- const id = ref.id?.trim();
339
- if (!id)
340
- throw new ServiceError("VALIDATION", "id required", 400);
341
- if (!version)
342
- throw new ServiceError("VALIDATION", "version required", 400);
343
- await this.ensureCatalogRepo();
344
- // Full pull so package contents + record bodies resolve after remote sync.
345
- const pull = await this.syncCatalogPull({ full: true });
346
- const { tick } = this.progress();
476
+ if (err instanceof ServiceError)
477
+ throw err;
478
+ throw this.mapError(err, "failed to list packages", { catalog: true });
479
+ }
480
+ }
481
+ /**
482
+ * Fetch a package version WITH its payload.
483
+ * Flat: full pull + fetchPackageFR(ref).
484
+ * Store: pullStore + fetchPackageFR({ storeSelector, packageTypeId, kind, id, version }).
485
+ * Restore remains product-side (install after fetch) — no Magit store restore.
486
+ */
487
+ async fetchPackage(ref) {
347
488
  try {
348
- this.ensureMongoEnv();
349
- const repo = await magit.open({ repoRoot: this.catalogRepoRoot() });
350
- tick("catalog.fetchPackageFR", { kind: ref.kind, id, version });
351
- const fetched = await repo.fetchPackageFR({
352
- kind: ref.kind,
489
+ const store = resolveMagitTypedStoreConfig();
490
+ const repo = await this.ensureCatalogRepo();
491
+ if (store) {
492
+ await repo.pullStore({ storeSelector: store.storeSelector });
493
+ const fetched = await repo.fetchPackageFR({
494
+ storeSelector: store.storeSelector,
495
+ packageTypeId: store.packageTypeId,
496
+ kind: ref.kind,
497
+ id: ref.id,
498
+ version: ref.version,
499
+ });
500
+ this.catalogHealth = { reachable: true, reason: null };
501
+ return {
502
+ ref: {
503
+ kind: fetched.kind,
504
+ id: fetched.id,
505
+ version: fetched.version,
506
+ },
507
+ checksum: fetched.checksum,
508
+ payload: fetched.payload,
509
+ associations: fetched.associations,
510
+ publisher: fetched.publisher,
511
+ };
512
+ }
513
+ await repo.pull({ full: true });
514
+ const fetched = await repo.fetchPackageFR(ref);
515
+ this.catalogHealth = { reachable: true, reason: null };
516
+ return fetched;
517
+ }
518
+ catch (err) {
519
+ if (err instanceof ServiceError)
520
+ throw err;
521
+ throw this.mapError(err, "failed to fetch package version", { catalog: true });
522
+ }
523
+ }
524
+ /**
525
+ * Compare two package versions (or a version vs live/installed).
526
+ *
527
+ * Mode A (typed store): package versions are immutable store payloads, not
528
+ * catalog-repo commits. Fetch both refs (or accept an installed pack for the
529
+ * installed/live sentinel) and produce a record-level PackDiffResult.
530
+ *
531
+ * Mode B (flat FR-CPS): reuses the org-scoped commit diff against the
532
+ * catalog local repo. `sliceAgentId` is the catalog-internal record grouping
533
+ * packPackage embeds, not a runtime org.
534
+ */
535
+ async diffPackages(sliceAgentId, opts) {
536
+ const from = opts.from?.trim();
537
+ const to = opts.to?.trim();
538
+ if (!from || !to) {
539
+ throw new ServiceError("VALIDATION", "from and to required", 400);
540
+ }
541
+ const store = resolveMagitTypedStoreConfig();
542
+ if (!store) {
543
+ return this.diff(PACKAGE_CATALOG_LOCAL_DIR, sliceAgentId, { from, to });
544
+ }
545
+ const kind = opts.kind;
546
+ const id = opts.id?.trim();
547
+ if (!kind || !id) {
548
+ throw new ServiceError("VALIDATION", "kind and id required for typed-store package compare", 400);
549
+ }
550
+ const resolveSide = async (ref) => {
551
+ if (ref === "live" || ref === "LIVE" || ref === "installed") {
552
+ if (!opts.installedPack) {
553
+ throw new ServiceError("VALIDATION", "installed package payload required when comparing against installed/live", 400);
554
+ }
555
+ return opts.installedPack;
556
+ }
557
+ const fetched = await this.fetchPackage({
558
+ kind: kind,
353
559
  id,
354
- version,
560
+ version: ref,
355
561
  });
356
- const pack = stripOrgRuntimeSourcesFromPack(packagePayloadToMetadataPack(fetched.payload, preferredAgentId?.trim() ||
357
- (ref.kind === "agent" ? id : undefined)));
358
- return {
359
- pack,
360
- checksum: fetched.checksum,
361
- version: fetched.ref.version,
362
- associations: fetched.associations
363
- ? {
364
- connectorIds: [...(fetched.associations.connectorIds ?? [])].sort(),
365
- serviceIds: [...(fetched.associations.serviceIds ?? [])].sort(),
366
- }
367
- : undefined,
368
- remote: pull.remote,
369
- };
562
+ return packagePayloadToPack(sliceAgentId, fetched.payload);
563
+ };
564
+ try {
565
+ const fromPack = await resolveSide(from);
566
+ const toPack = await resolveSide(to);
567
+ return diffMetadataPacks(sliceAgentId, from, to, fromPack, toPack);
370
568
  }
371
569
  catch (err) {
372
- throw this.mapError(err, "failed to fetch package from Magit catalog");
570
+ if (err instanceof ServiceError)
571
+ throw err;
572
+ throw this.mapError(err, "failed to compare package versions", {
573
+ catalog: true,
574
+ });
373
575
  }
374
576
  }
375
577
  async saveVersion(orgId, pack, opts) {
@@ -902,18 +1104,170 @@ export class MagitPackService {
902
1104
  process.env[this.cfg.mongoUrlEnv] = this.cfg.mongoUri;
903
1105
  }
904
1106
  }
905
- mapError(err, fallback) {
1107
+ mapError(err, fallback, opts) {
906
1108
  if (err instanceof ServiceError)
907
1109
  return err;
908
1110
  if (isPatchsetError(err)) {
909
- return new ServiceError("VALIDATION", `${fallback}: ${err.message}`, 400);
1111
+ const safe = String(redactSecrets(redactStoreSelector(err.message)));
1112
+ return new ServiceError("VALIDATION", `${fallback}: ${safe}`, 400);
910
1113
  }
911
- if (isPackageCollisionError(err)) {
912
- return new ServiceError("VALIDATION", `${fallback}: ${err.message}`, 409);
1114
+ const raw = err instanceof Error ? err.message : String(err);
1115
+ const message = String(redactSecrets(redactStoreSelector(raw)));
1116
+ if (opts?.catalog) {
1117
+ const reason = classifyCatalogUnreachable(err);
1118
+ this.catalogHealth = { reachable: false, reason };
1119
+ return new ServiceError("MAGIT_CATALOG_UNREACHABLE", `${fallback}: central package catalog is not reachable (${reason})`, 503);
913
1120
  }
914
- const message = err instanceof Error ? err.message : String(err);
915
1121
  return new ServiceError("MAGIT_UNAVAILABLE", `${fallback}: ${message}`, 503);
916
1122
  }
1123
+ // -------------------------------------------------------------------------
1124
+ // Catalog store administration (Magit 2.12.1 owner operations)
1125
+ // -------------------------------------------------------------------------
1126
+ /**
1127
+ * Provision a typed global package store (create + pushStore + verify).
1128
+ * Returns the selector EXACTLY ONCE — caller must write it to a secret store.
1129
+ * Duplicate packageTypeId stores abort unless force=true.
1130
+ */
1131
+ async provisionTypedPackageStore(opts) {
1132
+ const packageTypeId = opts?.packageTypeId?.trim() ||
1133
+ process.env.MAGIT_PACKAGE_TYPE_ID?.trim() ||
1134
+ "memorix.packages.v1";
1135
+ const allowedKinds = opts?.allowedKinds && opts.allowedKinds.length > 0
1136
+ ? opts.allowedKinds
1137
+ : ["agent", "connector", "service", "agent-bundle"];
1138
+ try {
1139
+ const repo = await this.ensureCatalogRepo();
1140
+ const existing = await repo.listStores();
1141
+ const match = existing.find((s) => s.typeDescriptor?.packageTypeId === packageTypeId);
1142
+ if (match && !opts?.force) {
1143
+ throw new ServiceError("VALIDATION", `A store for packageTypeId=${packageTypeId} already exists (createdAt=${match.createdAt}). Pass force to create another, or use recover with an explicit storeId.`, 409);
1144
+ }
1145
+ const created = await repo.createGlobalStore({
1146
+ packageTypeId,
1147
+ allowedKinds,
1148
+ });
1149
+ const pushed = await repo.pushStore({
1150
+ storeSelector: created.storeSelector,
1151
+ packageTypeId: created.packageTypeId,
1152
+ });
1153
+ // Metadata-only verify (listStores never returns selectors).
1154
+ await repo.listStores();
1155
+ this.catalogHealth = { reachable: true, reason: null };
1156
+ return {
1157
+ storeId: created.storeId,
1158
+ storeSelector: created.storeSelector,
1159
+ packageTypeId: created.packageTypeId,
1160
+ pushed: {
1161
+ uploaded: pushed.uploaded ?? 0,
1162
+ skipped: pushed.skipped ?? 0,
1163
+ },
1164
+ existingStoreCount: existing.length,
1165
+ };
1166
+ }
1167
+ catch (err) {
1168
+ if (err instanceof ServiceError)
1169
+ throw err;
1170
+ throw this.mapError(err, "failed to provision typed package store", {
1171
+ catalog: true,
1172
+ });
1173
+ }
1174
+ }
1175
+ /**
1176
+ * Verify configured catalog reachability (typed store via pullStore, or flat pull).
1177
+ * Never prints selector/token values.
1178
+ */
1179
+ async verifyTypedPackageStore() {
1180
+ try {
1181
+ const store = resolveMagitTypedStoreConfig();
1182
+ const repo = await this.ensureCatalogRepo();
1183
+ if (store) {
1184
+ await repo.pullStore({
1185
+ storeSelector: store.storeSelector,
1186
+ packageTypeId: store.packageTypeId,
1187
+ });
1188
+ this.catalogHealth = { reachable: true, reason: null };
1189
+ return {
1190
+ ok: true,
1191
+ mode: "typed-store",
1192
+ packageTypeId: store.packageTypeId,
1193
+ reachable: true,
1194
+ };
1195
+ }
1196
+ await repo.pull();
1197
+ this.catalogHealth = { reachable: true, reason: null };
1198
+ return {
1199
+ ok: true,
1200
+ mode: "flat",
1201
+ packageTypeId: null,
1202
+ reachable: true,
1203
+ };
1204
+ }
1205
+ catch (err) {
1206
+ if (err instanceof ServiceError)
1207
+ throw err;
1208
+ throw this.mapError(err, "failed to verify package catalog", {
1209
+ catalog: true,
1210
+ });
1211
+ }
1212
+ }
1213
+ /**
1214
+ * Owner recovery: mint a new selector for an existing storeId, then pushStore.
1215
+ * The new selector is returned ONCE. storeId is sensitive — operators only.
1216
+ */
1217
+ async recoverStoreSelector(opts) {
1218
+ const storeId = opts.storeId?.trim();
1219
+ if (!storeId) {
1220
+ throw new ServiceError("VALIDATION", "storeId required for recovery", 400);
1221
+ }
1222
+ try {
1223
+ const repo = await this.ensureCatalogRepo();
1224
+ const rotated = await repo.rotateStoreSelector({ storeId });
1225
+ const pushed = await repo.pushStore({
1226
+ storeSelector: rotated.storeSelector,
1227
+ packageTypeId: rotated.packageTypeId,
1228
+ });
1229
+ this.catalogHealth = { reachable: true, reason: null };
1230
+ return {
1231
+ storeId: rotated.storeId,
1232
+ storeSelector: rotated.storeSelector,
1233
+ packageTypeId: rotated.packageTypeId,
1234
+ pushed: {
1235
+ uploaded: pushed.uploaded ?? 0,
1236
+ skipped: pushed.skipped ?? 0,
1237
+ },
1238
+ };
1239
+ }
1240
+ catch (err) {
1241
+ if (err instanceof ServiceError)
1242
+ throw err;
1243
+ throw this.mapError(err, "failed to recover store selector", {
1244
+ catalog: true,
1245
+ });
1246
+ }
1247
+ }
1248
+ /**
1249
+ * Owner inventory of stores (metadata only — never selectors).
1250
+ */
1251
+ async listPackageStores() {
1252
+ try {
1253
+ const repo = await this.ensureCatalogRepo();
1254
+ const stores = await repo.listStores();
1255
+ this.catalogHealth = { reachable: true, reason: null };
1256
+ return stores.map((s) => ({
1257
+ storeId: s.storeId,
1258
+ packageTypeId: s.typeDescriptor.packageTypeId,
1259
+ allowedKinds: [...(s.typeDescriptor.allowedKinds ?? [])],
1260
+ createdAt: s.createdAt,
1261
+ }));
1262
+ }
1263
+ catch (err) {
1264
+ if (err instanceof ServiceError)
1265
+ throw err;
1266
+ throw this.mapError(err, "failed to list package stores", {
1267
+ catalog: true,
1268
+ });
1269
+ }
1270
+ }
917
1271
  }
918
1272
  export function createMagitPackService(cfg) {
919
1273
  return new MagitPackService(cfg);