@peers-app/peers-sdk 0.26.0 → 0.26.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.
@@ -7,6 +7,7 @@ const field_type_1 = require("../../types/field-type");
7
7
  const zod_types_1 = require("../../types/zod-types");
8
8
  const utils_1 = require("../../utils");
9
9
  const registry_1 = require("../registry");
10
+ const tool_catalog_contract_1 = require("../system/tool-catalog-contract");
10
11
  const systemContracts = require("../system-contracts");
11
12
  const tool_catalog_1 = require("../tool-catalog");
12
13
  const tool_catalog_identity_1 = require("../tool-catalog-identity");
@@ -570,6 +571,280 @@ test("untargeted deterministic IDs and quarantined rows cannot be claimed by a p
570
571
  expect(() => (0, tool_catalog_1.validateToolCatalogWrite)({ ...row, catalogState: "quarantined" }, { ...row, catalogState: "resolved" })).toThrow(/Quarantined/);
571
572
  await f.catalog.dispose();
572
573
  });
574
+ describe("bare deterministic row repair", () => {
575
+ const target = { contractId, version: 1, toolName: "read" };
576
+ const toolId = (0, tool_catalog_identity_1.deriveToolCatalogId)(target);
577
+ const bare = {
578
+ toolId,
579
+ name: "read",
580
+ usageDescription: "stale",
581
+ code: "",
582
+ inputSchema: { type: tools_1.IOSchemaType.none, fields: [] },
583
+ outputSchema: { type: tools_1.IOSchemaType.none, fields: [] },
584
+ catalogState: "quarantined",
585
+ catalogReason: `Error: Missing tool catalog target: ${toolId}`,
586
+ };
587
+ const restored = {
588
+ ...bare,
589
+ contractId,
590
+ contractVersion: 1,
591
+ toolName: "read",
592
+ catalogIdentity: "deterministic",
593
+ catalogState: "resolved",
594
+ catalogReason: undefined,
595
+ };
596
+ test.each([undefined, "deterministic"].flatMap((catalogIdentity) => [undefined, "unresolved", "quarantined"].map((catalogState) => ({
597
+ catalogIdentity,
598
+ catalogState,
599
+ }))))("publication restores identity=$catalogIdentity state=$catalogState and preserves current policy", async (state) => {
600
+ const old = {
601
+ ...bare,
602
+ ...state,
603
+ packageId: (0, utils_1.newid)(),
604
+ codeRepository: "historical repository",
605
+ accessLevel: 70,
606
+ remoteAccessLevel: 60,
607
+ authorityPropagation: "cap",
608
+ isAssistantRunner: true,
609
+ detailedDescription: "saved details",
610
+ configSchema: { type: tools_1.IOSchemaType.none, fields: [] },
611
+ };
612
+ expect(() => (0, tool_catalog_1.validateToolCatalogWrite)(old, restored, toolId, true)).not.toThrow();
613
+ const f = fixture(new Map([[toolId, old]]));
614
+ const update = required(f.table.update.getMockImplementation());
615
+ f.table.update.mockImplementation(async (patch) => {
616
+ if (patch.toolId === toolId && patch.catalogState === "resolved") {
617
+ expect((0, tools_1.isToolCatalogPatch)(patch)).toBe(true);
618
+ expect(patch).toEqual({
619
+ toolId,
620
+ contractId,
621
+ contractVersion: 1,
622
+ toolName: "read",
623
+ catalogIdentity: "deterministic",
624
+ catalogState: "resolved",
625
+ catalogReason: undefined,
626
+ });
627
+ f.rows.set(toolId, {
628
+ ...required(f.rows.get(toolId)),
629
+ accessLevel: 90,
630
+ remoteAccessLevel: 80,
631
+ authorityPropagation: "preserve",
632
+ });
633
+ }
634
+ return update(patch);
635
+ });
636
+ try {
637
+ await f.publish();
638
+ expect(f.rows.get(toolId)).toEqual({
639
+ ...old,
640
+ ...restored,
641
+ accessLevel: 90,
642
+ remoteAccessLevel: 80,
643
+ authorityPropagation: "preserve",
644
+ });
645
+ expect(f.registry.resolve(contractId, 1)?.name).toBe("Test");
646
+ }
647
+ finally {
648
+ await f.catalog.dispose();
649
+ }
650
+ });
651
+ test.each([
652
+ ["executable source", { code: "existing" }],
653
+ ["whitespace source", { code: " " }],
654
+ ["missing source", { code: undefined }],
655
+ ["another identity", { catalogIdentity: "legacy-alias" }],
656
+ ["invalid empty identity", { catalogIdentity: "" }],
657
+ ["different name", { name: "write" }],
658
+ ["different name case", { name: "Read" }],
659
+ ["matching stored target", { contractId, contractVersion: 1, toolName: "read" }],
660
+ ["conflicting stored target", { contractId, contractVersion: 1, toolName: "write" }],
661
+ ["partial contract ID", { contractId }],
662
+ ["partial version", { contractVersion: 1 }],
663
+ ["partial member name", { toolName: "read" }],
664
+ ])("rejects %s even for an explicit catalog patch", (_name, fields) => {
665
+ expect(() => (0, tool_catalog_1.validateToolCatalogWrite)({ ...bare, ...fields }, restored, toolId, true)).toThrow(/Quarantined/);
666
+ // Exercise the cannot-be-claimed branch without a quarantine early-out too.
667
+ // A matching established target is already a valid mapping in this case.
668
+ if (_name !== "matching stored target")
669
+ expect(() => (0, tool_catalog_1.validateToolCatalogWrite)({ ...bare, ...fields, catalogState: undefined }, restored, toolId, true)).toThrow();
670
+ });
671
+ test.each([
672
+ ["missing target", { contractId: undefined, contractVersion: undefined, toolName: undefined }],
673
+ ["partial target", { contractVersion: undefined }],
674
+ ["invalid target", { contractVersion: 0 }],
675
+ ["nonmatching deterministic ID", { contractVersion: 2 }],
676
+ ["different member", { toolName: "write" }],
677
+ // A repair must resolve in the same write; it cannot retarget quarantine evidence.
678
+ ["quarantined state", { catalogState: "quarantined" }],
679
+ ["unset state", { catalogState: undefined }],
680
+ ["unset identity kind", { catalogIdentity: undefined }],
681
+ ])("rejects a candidate with %s", (_name, fields) => {
682
+ expect(() => (0, tool_catalog_1.validateToolCatalogWrite)(bare, { ...restored, ...fields }, toolId, true)).toThrow();
683
+ });
684
+ test("ordinary saves and replay cannot clear quarantine, even with the canonical target", () => {
685
+ for (const candidate of [
686
+ restored,
687
+ { ...bare, catalogState: "resolved" },
688
+ { ...bare, catalogState: undefined, usageDescription: "metadata" },
689
+ ])
690
+ expect(() => (0, tool_catalog_1.validateToolCatalogWrite)(bare, candidate)).toThrow(/Quarantined/);
691
+ const metadata = (0, tool_catalog_1.retainToolCatalogFields)(bare, {
692
+ ...legacy(toolId),
693
+ code: "",
694
+ name: "read",
695
+ usageDescription: "older peer metadata",
696
+ });
697
+ expect(() => (0, tool_catalog_1.validateToolCatalogWrite)(bare, metadata)).not.toThrow();
698
+ expect(metadata.catalogState).toBe("quarantined");
699
+ });
700
+ test("repair requires exactly one selected ID even when the first ID matches", async () => {
701
+ const ids = jest
702
+ .spyOn(await Promise.resolve().then(() => require("../tool-catalog-migrations")), "toolCatalogIds")
703
+ .mockReturnValue([toolId, (0, utils_1.newid)()]);
704
+ try {
705
+ expect(() => (0, tool_catalog_1.validateToolCatalogWrite)(bare, restored, toolId, true)).toThrow(/Quarantined/);
706
+ }
707
+ finally {
708
+ ids.mockRestore();
709
+ }
710
+ });
711
+ test.each([
712
+ { contractId, version: 1, toolName: "new-task" },
713
+ { contractId: "00muxgugn6xvs8f2r80ru205t", version: 1, toolName: "invoke" },
714
+ ])("aliases suppress deterministic repair for $toolName", async (aliasedTarget) => {
715
+ const deterministicId = (0, tool_catalog_identity_1.deriveToolCatalogId)(aliasedTarget);
716
+ const aliases = (0, tool_catalog_migrations_1.toolCatalogIds)(aliasedTarget);
717
+ const old = { ...bare, toolId: deterministicId, name: aliasedTarget.toolName };
718
+ const candidate = {
719
+ ...restored,
720
+ toolId: deterministicId,
721
+ contractId: aliasedTarget.contractId,
722
+ toolName: aliasedTarget.toolName,
723
+ };
724
+ expect(() => (0, tool_catalog_1.validateToolCatalogWrite)(old, candidate, deterministicId, true)).toThrow();
725
+ for (const alias of aliases)
726
+ expect(() => (0, tool_catalog_1.validateToolCatalogWrite)({ ...old, toolId: alias }, { ...candidate, toolId: alias, catalogIdentity: "legacy-alias" }, alias, true)).toThrow(/Quarantined/);
727
+ const f = fixture(new Map([[deterministicId, old]]));
728
+ try {
729
+ await f.publish({
730
+ ...definition([aliasedTarget.toolName]),
731
+ contractId: aliasedTarget.contractId,
732
+ });
733
+ expect(f.rows.get(deterministicId)).toEqual(old);
734
+ for (const alias of aliases)
735
+ expect(f.rows.get(alias)?.catalogIdentity).toBe("legacy-alias");
736
+ expect(f.table.save.mock.calls.some(([row]) => row.toolId === deterministicId)).toBe(false);
737
+ }
738
+ finally {
739
+ await f.catalog.dispose();
740
+ }
741
+ });
742
+ test.each([
743
+ "collision",
744
+ "quarantine",
745
+ ])("a later %s prevents all repair writes in the batch", async (failure) => {
746
+ const f = fixture();
747
+ await f.catalog.ready();
748
+ const hash = failure === "collision"
749
+ ? jest.spyOn(nacl, "hash").mockReturnValue(new Uint8Array(64))
750
+ : undefined;
751
+ const firstId = (0, tool_catalog_identity_1.deriveToolCatalogId)(target);
752
+ const old = { ...bare, toolId: firstId };
753
+ f.rows.set(firstId, old);
754
+ if (failure === "quarantine") {
755
+ const secondId = (0, tool_catalog_identity_1.deriveToolCatalogId)({ ...target, toolName: "write" });
756
+ f.rows.set(secondId, {
757
+ ...restored,
758
+ toolId: secondId,
759
+ toolName: "write",
760
+ catalogState: "quarantined",
761
+ });
762
+ }
763
+ f.table.update.mockClear();
764
+ f.table.save.mockClear();
765
+ try {
766
+ await expect(f.publish(definition(["read", "write"]))).rejects.toThrow(/collision|Quarantined/);
767
+ expect(f.rows.get(firstId)).toEqual(old);
768
+ expect(f.table.update).not.toHaveBeenCalled();
769
+ expect(f.table.save).not.toHaveBeenCalled();
770
+ expect(f.registry.resolve(contractId, 1)).toBeUndefined();
771
+ }
772
+ finally {
773
+ hash?.mockRestore();
774
+ await f.catalog.dispose();
775
+ }
776
+ });
777
+ });
778
+ test.each([
779
+ false,
780
+ true,
781
+ ])("system readiness restores the startup listTools row (quarantined=%s)", async (quarantined) => {
782
+ const definition = (0, tool_catalog_contract_1.getToolCatalogContractDefinition)();
783
+ const toolId = (0, tool_catalog_identity_1.deriveToolCatalogId)({
784
+ contractId: definition.contractId,
785
+ version: definition.version,
786
+ toolName: "listTools",
787
+ });
788
+ expect(toolId).toBe("000tool0ggg5rilyp6kbxevip");
789
+ const bare = {
790
+ ...legacy(toolId),
791
+ name: "listTools",
792
+ code: "",
793
+ catalogState: quarantined ? "quarantined" : undefined,
794
+ catalogReason: quarantined ? `Error: Missing tool catalog target: ${toolId}` : undefined,
795
+ };
796
+ const f = fixture(new Map([[toolId, bare]]));
797
+ try {
798
+ await f.catalog.ready();
799
+ expect(f.rows.get(toolId)).toMatchObject({
800
+ contractId: definition.contractId,
801
+ contractVersion: definition.version,
802
+ toolName: "listTools",
803
+ catalogIdentity: "deterministic",
804
+ catalogState: "resolved",
805
+ code: "",
806
+ accessLevel: 70,
807
+ });
808
+ expect(f.rows.get(toolId)?.catalogReason).toBeUndefined();
809
+ expect(f.registry.getProviderPackageId(definition.contractId, definition.version)).toBeDefined();
810
+ const writes = f.table.update.mock.calls.length;
811
+ await f.catalog.ready();
812
+ expect(f.table.update).toHaveBeenCalledTimes(writes);
813
+ }
814
+ finally {
815
+ await f.catalog.dispose();
816
+ }
817
+ });
818
+ test.each([
819
+ ["executable source", { code: "existing" }],
820
+ ["a different name", { name: "list-tools" }],
821
+ ["another identity", { catalogIdentity: "legacy-alias" }],
822
+ ])("system readiness still aborts on a real listTools quarantine with %s", async (_name, fields) => {
823
+ const definition = (0, tool_catalog_contract_1.getToolCatalogContractDefinition)();
824
+ const toolId = (0, tool_catalog_identity_1.deriveToolCatalogId)({
825
+ contractId: definition.contractId,
826
+ version: definition.version,
827
+ toolName: "listTools",
828
+ });
829
+ const quarantined = {
830
+ ...legacy(toolId),
831
+ name: "listTools",
832
+ code: "",
833
+ ...fields,
834
+ catalogState: "quarantined",
835
+ catalogReason: `Error: Missing tool catalog target: ${toolId}`,
836
+ };
837
+ const f = fixture(new Map([[toolId, quarantined]]));
838
+ const publish = jest.spyOn(f.registry, "replaceBatch");
839
+ try {
840
+ await expect(f.catalog.ready()).rejects.toThrow(`Quarantined tool identity: ${toolId}`);
841
+ expect(f.rows.get(toolId)).toEqual(quarantined);
842
+ expect(publish).not.toHaveBeenCalled();
843
+ }
844
+ finally {
845
+ await f.catalog.dispose();
846
+ }
847
+ });
573
848
  test("host removal waits for publication and disposal cancels queued activation", async () => {
574
849
  const f = fixture();
575
850
  const provider = (0, utils_1.newid)();
@@ -26,9 +26,14 @@ export declare function retainToolCatalogFields<T extends Partial<ITool>>(existi
26
26
  *
27
27
  * Catalog identities are permanent. Unresolved legacy rows are a holdover until their
28
28
  * tools move to contracts: they stay ordinary rows that local writers and older
29
- * runtimes may delete.
29
+ * runtimes may delete. A bare deterministic row with an empty executable and the
30
+ * member's own name may receive that member's canonical target; every other
31
+ * untargeted or quarantined identity stays rejected.
32
+ *
33
+ * @param catalogPatch - Only explicit local catalog patches may restore a bare
34
+ * deterministic row. Ordinary replacements and sync replay must leave it quarantined.
30
35
  */
31
- export declare function validateToolCatalogWrite(existing: Partial<ITool> | undefined, candidate: Partial<ITool> | undefined, recordId?: string | undefined): void;
36
+ export declare function validateToolCatalogWrite(existing: Partial<ITool> | undefined, candidate: Partial<ITool> | undefined, recordId?: string | undefined, catalogPatch?: boolean): void;
32
37
  /**
33
38
  * Per-context runtime owner of permanent Tools identities. Construction is inert;
34
39
  * ready() explicitly initializes storage after UserContext construction completes.
@@ -62,6 +62,35 @@ function hasToolCatalogEvidence(row) {
62
62
  row.contractVersion !== undefined ||
63
63
  row.toolName !== undefined);
64
64
  }
65
+ /**
66
+ * A catalog insert that lost its target columns, or was stored before those columns
67
+ * existed, still carries the member's deterministic ID, the member name and an empty
68
+ * executable. Publication may write the canonical target back, resolving the row in
69
+ * the same write; a repair cannot leave the target on a still-quarantined row. Any
70
+ * other bare `000tool0` row, including one that still holds executable source or a
71
+ * different name, stays unclaimable.
72
+ */
73
+ function canRestoreBareDeterministicRow(existing, candidate) {
74
+ if (candidate.catalogState !== "resolved" || candidate.catalogIdentity !== "deterministic")
75
+ return false;
76
+ if (existing.code !== "")
77
+ return false;
78
+ if (existing.catalogIdentity !== undefined && existing.catalogIdentity !== "deterministic")
79
+ return false;
80
+ let target;
81
+ try {
82
+ if (toolCatalogTarget(existing))
83
+ return false;
84
+ target = toolCatalogTarget(candidate);
85
+ }
86
+ catch {
87
+ return false;
88
+ }
89
+ if (!target || !candidate.toolId || existing.name !== target.toolName)
90
+ return false;
91
+ const ids = (0, tool_catalog_migrations_1.toolCatalogIds)(target);
92
+ return ids.length === 1 && ids[0] === candidate.toolId && candidate.toolId.startsWith("000tool0");
93
+ }
65
94
  /**
66
95
  * Shared pre-persistence identity guard for local writes and reconstructed sync rows.
67
96
  * Existing mappings cannot be retargeted, stripped or deleted. Rejected sync evidence
@@ -70,9 +99,14 @@ function hasToolCatalogEvidence(row) {
70
99
  *
71
100
  * Catalog identities are permanent. Unresolved legacy rows are a holdover until their
72
101
  * tools move to contracts: they stay ordinary rows that local writers and older
73
- * runtimes may delete.
102
+ * runtimes may delete. A bare deterministic row with an empty executable and the
103
+ * member's own name may receive that member's canonical target; every other
104
+ * untargeted or quarantined identity stays rejected.
105
+ *
106
+ * @param catalogPatch - Only explicit local catalog patches may restore a bare
107
+ * deterministic row. Ordinary replacements and sync replay must leave it quarantined.
74
108
  */
75
- function validateToolCatalogWrite(existing, candidate, recordId = candidate?.toolId ?? existing?.toolId) {
109
+ function validateToolCatalogWrite(existing, candidate, recordId = candidate?.toolId ?? existing?.toolId, catalogPatch = false) {
76
110
  if (!candidate) {
77
111
  if ((recordId !== undefined && isReservedToolCatalogId(recordId)) ||
78
112
  (existing && hasToolCatalogEvidence(existing)))
@@ -83,7 +117,9 @@ function validateToolCatalogWrite(existing, candidate, recordId = candidate?.too
83
117
  !(0, tool_catalog_identity_1.isCatalogToolId)(candidate.toolId) ||
84
118
  (existing && existing.toolId !== candidate.toolId))
85
119
  throw new Error("Tool catalog primary key mismatch");
86
- if (existing?.catalogState === "quarantined" && candidate.catalogState !== "quarantined")
120
+ if (existing?.catalogState === "quarantined" &&
121
+ candidate.catalogState !== "quarantined" &&
122
+ !(catalogPatch && canRestoreBareDeterministicRow(existing, candidate)))
87
123
  throw new Error(`Quarantined tool identity: ${candidate.toolId}`);
88
124
  // Quarantine preserves the original evidence byte-for-byte; it cannot retarget it.
89
125
  if (existing &&
@@ -99,7 +135,10 @@ function validateToolCatalogWrite(existing, candidate, recordId = candidate?.too
99
135
  if (previous && (!target || (0, tool_catalog_identity_1.toolCatalogTargetKey)(previous) !== (0, tool_catalog_identity_1.toolCatalogTargetKey)(target)))
100
136
  throw new Error(`Tool catalog identity cannot change: ${candidate.toolId}`);
101
137
  if (target) {
102
- if (existing && !previous && !legacy)
138
+ if (existing &&
139
+ !previous &&
140
+ !legacy &&
141
+ !(catalogPatch && canRestoreBareDeterministicRow(existing, candidate)))
103
142
  throw new Error(`Unresolved tool identity cannot be claimed: ${candidate.toolId}`);
104
143
  if (!(0, tool_catalog_migrations_1.toolCatalogIds)(target).includes(candidate.toolId))
105
144
  throw new Error(`Unknown tool catalog mapping: ${candidate.toolId}`);
@@ -364,9 +403,10 @@ class ToolCatalog {
364
403
  ...resolvedFields(target, toolId),
365
404
  };
366
405
  const stored = existing.get(toolId);
367
- if (stored?.catalogState === "quarantined")
406
+ if (stored?.catalogState === "quarantined" &&
407
+ !canRestoreBareDeterministicRow(stored, row))
368
408
  throw new Error(`Quarantined tool identity: ${toolId}`);
369
- validateToolCatalogWrite(stored, row);
409
+ validateToolCatalogWrite(stored, row, toolId, true);
370
410
  candidates.set(toolId, row);
371
411
  }
372
412
  }
@@ -374,6 +414,18 @@ class ToolCatalog {
374
414
  // Validate the entire batch before the first write. Catalog metadata never
375
415
  // overwrites provider-specific policy or another provider's descriptions.
376
416
  for (const row of candidates.values()) {
417
+ const previous = existing.get(row.toolId);
418
+ // Restore missing identity columns only after validating the entire batch.
419
+ // This marked patch leaves concurrent policy and historical fields intact.
420
+ if (previous && canRestoreBareDeterministicRow(previous, row)) {
421
+ const restored = await table.update((0, tools_1.toolCatalogPatch)({
422
+ toolId: row.toolId,
423
+ ...resolvedFields(toolCatalogTarget(row), row.toolId),
424
+ }));
425
+ validateToolCatalogWrite(row, restored);
426
+ if (restored.catalogState === "quarantined")
427
+ throw new Error(`Quarantined tool identity: ${row.toolId}`);
428
+ }
377
429
  // Older runtimes allowed provider removal to leave tombstones. Only a
378
430
  // validated public target may restore one; insert-only still preserves any
379
431
  // row that arrived concurrently, and the returned mapping is checked below.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peers-app/peers-sdk",
3
- "version": "0.26.0",
3
+ "version": "0.26.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/peers-app/peers-sdk.git"