@peers-app/peers-sdk 0.18.8 → 0.19.6

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 (60) hide show
  1. package/README.md +74 -1
  2. package/dist/data/files/file-read-stream.js +7 -0
  3. package/dist/data/files/file.types.d.ts +6 -0
  4. package/dist/data/files/file.types.js +18 -0
  5. package/dist/data/files/files.test.js +50 -7
  6. package/dist/data/package-version-resolver.d.ts +13 -5
  7. package/dist/data/package-version-resolver.js +64 -6
  8. package/dist/data/package-version-resolver.test.d.ts +0 -4
  9. package/dist/data/package-version-resolver.test.js +127 -5
  10. package/dist/data/package-versions.d.ts +3 -0
  11. package/dist/data/package-versions.js +5 -0
  12. package/dist/data/packages.d.ts +6 -29
  13. package/dist/data/packages.js +8 -6
  14. package/dist/index.d.ts +1 -0
  15. package/dist/index.js +1 -0
  16. package/dist/package-installer/index.d.ts +10 -0
  17. package/dist/package-installer/index.js +26 -0
  18. package/dist/package-installer/package-author-signing.d.ts +54 -0
  19. package/dist/package-installer/package-author-signing.js +82 -0
  20. package/dist/package-installer/package-author-signing.test.d.ts +1 -0
  21. package/dist/package-installer/package-author-signing.test.js +189 -0
  22. package/dist/package-installer/package-cloner.d.ts +16 -0
  23. package/dist/package-installer/package-cloner.js +115 -0
  24. package/dist/package-installer/package-cloner.test.d.ts +1 -0
  25. package/dist/package-installer/package-cloner.test.js +276 -0
  26. package/dist/package-installer/package-creator.d.ts +22 -0
  27. package/dist/package-installer/package-creator.js +154 -0
  28. package/dist/package-installer/package-creator.test.d.ts +1 -0
  29. package/dist/package-installer/package-creator.test.js +354 -0
  30. package/dist/package-installer/package-installer.d.ts +32 -0
  31. package/dist/package-installer/package-installer.js +247 -0
  32. package/dist/package-installer/package-installer.test.d.ts +1 -0
  33. package/dist/package-installer/package-installer.test.js +666 -0
  34. package/dist/package-installer/package-propagation.d.ts +29 -0
  35. package/dist/package-installer/package-propagation.js +364 -0
  36. package/dist/package-installer/package-propagation.test.d.ts +1 -0
  37. package/dist/package-installer/package-propagation.test.js +1145 -0
  38. package/dist/package-installer/package-publisher.d.ts +55 -0
  39. package/dist/package-installer/package-publisher.js +71 -0
  40. package/dist/package-installer/package-publisher.test.d.ts +1 -0
  41. package/dist/package-installer/package-publisher.test.js +142 -0
  42. package/dist/package-installer/package-remote-checker.d.ts +54 -0
  43. package/dist/package-installer/package-remote-checker.js +194 -0
  44. package/dist/package-installer/package-remote-checker.test.d.ts +1 -0
  45. package/dist/package-installer/package-remote-checker.test.js +269 -0
  46. package/dist/package-installer/package-seed-installer.d.ts +45 -0
  47. package/dist/package-installer/package-seed-installer.js +108 -0
  48. package/dist/package-installer/package-seed-installer.test.d.ts +1 -0
  49. package/dist/package-installer/package-seed-installer.test.js +123 -0
  50. package/dist/package-installer/package-tarball.d.ts +35 -0
  51. package/dist/package-installer/package-tarball.js +57 -0
  52. package/dist/package-installer/package-tarball.test.d.ts +1 -0
  53. package/dist/package-installer/package-tarball.test.js +75 -0
  54. package/dist/package-installer/types.d.ts +110 -0
  55. package/dist/package-installer/types.js +2 -0
  56. package/dist/rpc-types.d.ts +14 -0
  57. package/dist/rpc-types.js +6 -0
  58. package/dist/system-ids.d.ts +1 -0
  59. package/dist/system-ids.js +2 -1
  60. package/package.json +3 -2
package/README.md CHANGED
@@ -1 +1,74 @@
1
- Common types, zod schemas, functions, and classes that are used internally in Peers and for building packages.
1
+ # @peers-app/peers-sdk
2
+
3
+ The core SDK for building [Peers](https://peers.app) packages.
4
+
5
+ ## What is Peers?
6
+
7
+ Peers is a local-first personal computing platform. Your data lives on your devices, syncs peer-to-peer, and is end-to-end encrypted — no servers in the middle. You own your data and your identity. Build apps with the SDK or use AI coding tools to create them; either way, the platform handles sync, encryption, and persistence automatically.
8
+
9
+ ## What this package provides
10
+
11
+ ### Package System
12
+
13
+ `definePackage()` and the contract builder API let you declare tables, tools, assistants, screens, and navigation entries. The runtime installs, loads, and hot-reloads your package across all devices.
14
+
15
+ ### ORM
16
+
17
+ Table definitions with Zod schemas, data queries with a Mongo-style filter syntax, and async cursors. Tables sync across devices and encrypt automatically — you just define the schema.
18
+
19
+ ### Observables and Persistent Variables
20
+
21
+ Lightweight reactive primitives (`observable()`, `computed()`) with `.subscribe()`. Persistent variables (`deviceVar`, `userVar`, `groupVar`) are observables backed by the database — write once, subscribe everywhere, synced across devices.
22
+
23
+ ### Types and Schemas
24
+
25
+ Zod schemas and TypeScript types for the entire Peers data model: users, groups, devices, packages, tools, assistants, workflows, messages, channels, and more.
26
+
27
+ ### Encryption
28
+
29
+ NaCl-based key management, message signing and verification, box encryption, and deterministic hashing. Identity is a cryptographic key pair — no passwords, no email.
30
+
31
+ ### Tools Framework
32
+
33
+ Define AI-callable tools with `ITool` and `IToolInstance`. Tools are registered at runtime and available to built-in and user-defined AI assistants and workflows.
34
+
35
+ ### Identity and Groups
36
+
37
+ User, group, and permission management types. Group-scoped data contexts for multi-tenant data isolation. Invite flows and trust levels.
38
+
39
+ ## Quick Start
40
+
41
+ ```bash
42
+ npm install @peers-app/peers-sdk
43
+ ```
44
+
45
+ The fastest way to build a Peers package is to start from the template:
46
+
47
+ **[peers-package-template](https://github.com/peers-app/peers-package-template)** — scaffold, build, and install a custom package into the Peers runtime. This will be setup automatically when you create a new package in the desktop app.
48
+
49
+ ## Key Interfaces
50
+
51
+
52
+ | Export | Purpose |
53
+ | ------------------------------------ | ----------------------------------------------------------------------- |
54
+ | `definePackage()` | Entry point for declaring a package's contracts, tools, tables, and nav |
55
+ | `Table` | ORM table with CRUD, Zod validation, and `dataChanged` events |
56
+ | `DataQuery` / `DataFilter` | Mongo-style query filters translated to SQL |
57
+ | `observable()` / `computed()` | Reactive state primitives |
58
+ | `deviceVar` / `userVar` / `groupVar` | Persistent variables — observables backed by the database |
59
+ | `ITool` / `IToolInstance` | AI-callable tool definitions |
60
+ | `IPeersUI` / `IPeersUIRoute` | Screen and route declarations for package UIs |
61
+ | `newid()` | Generate 25-character time-sortable peer IDs |
62
+ | `newKeys()` / `hydrateKeys()` | Cryptographic key pair generation and hydration |
63
+ | `UserContext` / `DataContext` | Multi-group data scoping and package loading |
64
+
65
+
66
+ ## Links
67
+
68
+ - [peers.app](https://peers.app) — try Peers in your browser or download the desktop app
69
+ - [Documentation](https://peers-app.github.io) — architecture, package development, and API reference
70
+ - [GitHub](https://github.com/peers-app) — source repositories and package template
71
+
72
+ ## License
73
+
74
+ MIT
@@ -36,6 +36,13 @@ class FileReadStream {
36
36
  else {
37
37
  throw new Error(`File ${this.fileRecord.fileId} has neither chunkHashes nor indexFileId`);
38
38
  }
39
+ if (!this.skipVerification) {
40
+ const computedFileHash = (0, keys_1.hashBytes)(new TextEncoder().encode(JSON.stringify(this.chunkHashes)));
41
+ if (computedFileHash !== this.fileRecord.fileHash) {
42
+ throw new Error(`File integrity check failed for ${this.fileRecord.fileId}: ` +
43
+ `chunk hashes produce ${computedFileHash}, expected ${this.fileRecord.fileHash}`);
44
+ }
45
+ }
39
46
  }
40
47
  async loadChunkByIndex(chunkIndex) {
41
48
  await this.loadChunkHashes();
@@ -35,6 +35,12 @@ export declare const FILE_CHUNK_SIZE: number;
35
35
  export declare const CHUNKS_DIR = "file_chunks";
36
36
  export declare let CHUNK_INDEX_THRESHOLD: number;
37
37
  export declare function setChunkIndexThreshold(threshold: number): void;
38
+ /**
39
+ * Compute the file-system hash for content without saving it.
40
+ * Mirrors the chunking + hashing logic of FileWriteStream so the result
41
+ * matches `IFile.fileHash` for the same bytes.
42
+ */
43
+ export declare function computeFileHash(content: string | Uint8Array): string;
38
44
  export interface FileOps {
39
45
  downloadFileChunk(chunkHash: string): Promise<Uint8Array | null>;
40
46
  fileExists(path: string): Promise<boolean>;
@@ -2,10 +2,12 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.CHUNK_INDEX_THRESHOLD = exports.CHUNKS_DIR = exports.FILE_CHUNK_SIZE = exports.filesMetaData = exports.fileSchema = void 0;
4
4
  exports.setChunkIndexThreshold = setChunkIndexThreshold;
5
+ exports.computeFileHash = computeFileHash;
5
6
  exports.setFileOps = setFileOps;
6
7
  exports.getFileOps = getFileOps;
7
8
  exports.resetFileOps = resetFileOps;
8
9
  const zod_1 = require("zod");
10
+ const keys_1 = require("../../keys");
9
11
  const zod_types_1 = require("../../types/zod-types");
10
12
  const types_1 = require("../orm/types");
11
13
  exports.fileSchema = zod_1.z.object({
@@ -38,6 +40,22 @@ exports.CHUNK_INDEX_THRESHOLD = 1000; // Use chunk index file for files with >10
38
40
  function setChunkIndexThreshold(threshold) {
39
41
  exports.CHUNK_INDEX_THRESHOLD = threshold;
40
42
  }
43
+ /**
44
+ * Compute the file-system hash for content without saving it.
45
+ * Mirrors the chunking + hashing logic of FileWriteStream so the result
46
+ * matches `IFile.fileHash` for the same bytes.
47
+ */
48
+ function computeFileHash(content) {
49
+ const data = typeof content === "string" ? new Uint8Array(Buffer.from(content, "utf8")) : content;
50
+ const chunkHashes = [];
51
+ let offset = 0;
52
+ while (offset < data.length) {
53
+ const end = Math.min(offset + exports.FILE_CHUNK_SIZE, data.length);
54
+ chunkHashes.push((0, keys_1.hashBytes)(data.subarray(offset, end)));
55
+ offset = end;
56
+ }
57
+ return (0, keys_1.hashBytes)(new Uint8Array(Buffer.from(JSON.stringify(chunkHashes), "utf8")));
58
+ }
41
59
  let fileOps = null;
42
60
  let fileOpsReady = null;
43
61
  function setFileOps(ops) {
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ const keys_1 = require("../../keys");
3
4
  const field_type_1 = require("../../types/field-type");
4
5
  const utils_1 = require("../../utils");
5
6
  const file_types_1 = require("./file.types");
@@ -201,7 +202,7 @@ describe("FileTable", () => {
201
202
  const result = await fileTable.getFileContents("non-existent");
202
203
  expect(result).toBeNull();
203
204
  });
204
- it("should return null when chunk is missing", async () => {
205
+ it("should throw integrity error when fileHash doesn't match chunk hashes", async () => {
205
206
  const fileId = (0, utils_1.newid)();
206
207
  const metadata = {
207
208
  fileId,
@@ -210,10 +211,9 @@ describe("FileTable", () => {
210
211
  fileHash: "hash123",
211
212
  chunkHashes: ["hash1", "hash2"],
212
213
  };
213
- // Insert metadata directly without saving chunks
214
+ // Insert metadata directly with mismatched fileHash
214
215
  await fileTable.dataSource.insert(metadata);
215
- const result = await fileTable.getFileContents(fileId);
216
- expect(result).toBeNull();
216
+ await expect(fileTable.getFileContents(fileId)).rejects.toThrow("File integrity check failed");
217
217
  });
218
218
  it("should return null when chunk is missing during read", async () => {
219
219
  const result = await fileTable.getFileContents("non-existent");
@@ -674,20 +674,63 @@ describe("FileTable", () => {
674
674
  });
675
675
  it("should return null when chunk is unavailable", async () => {
676
676
  const fileId = (0, utils_1.newid)();
677
+ const chunkHashes = ["missing_chunk_hash"];
678
+ const fileHash = (0, keys_1.hashBytes)(new TextEncoder().encode(JSON.stringify(chunkHashes)));
677
679
  const metadata = {
678
680
  fileId,
679
681
  name: "missing-chunk.txt",
680
682
  fileSize: 50,
681
- fileHash: "hash123",
682
- chunkHashes: ["missing_chunk_hash"],
683
+ fileHash,
684
+ chunkHashes,
683
685
  };
684
686
  // Insert file metadata without storing the actual chunk
685
687
  await fileTable.dataSource.insert(metadata);
686
688
  // This should return null when the chunk can't be found
687
- // instead of throwing an error or hanging
688
689
  const result = await fileTable.getFileContents(fileId);
689
690
  expect(result).toBeNull();
690
691
  });
692
+ it("should throw when fileHash does not match chunk hashes", async () => {
693
+ const fileId = (0, utils_1.newid)();
694
+ const data = new Uint8Array(Buffer.from("Valid content", "utf8"));
695
+ const metadata = {
696
+ fileId,
697
+ name: "tampered.txt",
698
+ fileSize: data.length,
699
+ mimeType: "text/plain",
700
+ };
701
+ // Save file normally to get valid chunks on disk
702
+ const saved = await fileTable.saveFile(metadata, data);
703
+ // Tamper with the fileHash in the database record
704
+ const tamperedRecord = {
705
+ ...saved,
706
+ fileHash: "tampered-hash-value",
707
+ };
708
+ await fileTable.dataSource.save(tamperedRecord);
709
+ // Reading should throw a file integrity error
710
+ await expect(fileTable.getFileContents(fileId)).rejects.toThrow("File integrity check failed");
711
+ });
712
+ it("should skip fileHash verification when skipVerification is true", async () => {
713
+ const fileId = (0, utils_1.newid)();
714
+ const data = new Uint8Array(Buffer.from("Valid content", "utf8"));
715
+ const metadata = {
716
+ fileId,
717
+ name: "skip-verify.txt",
718
+ fileSize: data.length,
719
+ mimeType: "text/plain",
720
+ };
721
+ // Save file normally to get valid chunks on disk
722
+ const saved = await fileTable.saveFile(metadata, data);
723
+ // Tamper with the fileHash in the database record
724
+ const tamperedRecord = {
725
+ ...saved,
726
+ fileHash: "tampered-hash-value",
727
+ };
728
+ await fileTable.dataSource.save(tamperedRecord);
729
+ // Reading with skipVerification should succeed
730
+ const result = await fileTable.getFileContents(fileId, { skipVerification: true });
731
+ expect(result).not.toBeNull();
732
+ expect(new Uint8Array(result)).toEqual(data);
733
+ });
691
734
  });
692
735
  describe("Round-trip streaming", () => {
693
736
  it("should write and read back identical data using streams", async () => {
@@ -1,14 +1,14 @@
1
1
  /**
2
2
  * Device-local package version resolver.
3
3
  *
4
- * Each device independently decides which package version to run based on a
5
- * per-package `groupDeviceVar` containing the active PV ID and follow
6
- * preferences. The shared `IPackage.activePackageVersionId` serves only as
7
- * the group-level default for new devices.
4
+ * Each device resolves which package version to run from the shared group
5
+ * package settings plus optional device-local prefs. When an admin device
6
+ * upgrades a non-dev version while following group settings, the shared
7
+ * `IPackage.activePackageVersionId` advances so the group stays aligned.
8
8
  */
9
9
  import type { DataContext } from "../context/data-context";
10
10
  import { type IPackageVersion } from "./package-versions";
11
- import type { IPackage } from "./packages";
11
+ import { type IPackage } from "./packages";
12
12
  import { type PersistentVar } from "./persistent-vars";
13
13
  /**
14
14
  * Device-local preferences for a single package. Stored in a `groupDeviceVar`
@@ -68,6 +68,7 @@ export interface IEffectivePackagePrefs {
68
68
  * resolver and by UI components that need to reflect device behavior.
69
69
  */
70
70
  export declare function getEffectivePackagePrefs(pkg: IPackage, devicePrefs: IDevicePackagePrefs | undefined): IEffectivePackagePrefs;
71
+ /** Result of a device-level package version resolution attempt. */
71
72
  export interface IResolveResult {
72
73
  /** Whether a package was successfully loaded */
73
74
  loaded: boolean;
@@ -90,3 +91,10 @@ export declare function resolveDevicePackageVersion(pkg: IPackage, dataContext:
90
91
  force?: boolean;
91
92
  localPath?: string;
92
93
  }): Promise<IResolveResult>;
94
+ /**
95
+ * Returns true when the device has an explicit follow-policy override
96
+ * (`followRange`, `followTags`, or `pinned`). Used by the UI to drive the
97
+ * "Override on this device" toggle and by the resolver to decide whether
98
+ * to advance the group-level active version on upgrade.
99
+ */
100
+ export declare function hasDeviceFollowOverride(prefs: IDevicePackagePrefs | undefined): boolean;
@@ -2,19 +2,23 @@
2
2
  /**
3
3
  * Device-local package version resolver.
4
4
  *
5
- * Each device independently decides which package version to run based on a
6
- * per-package `groupDeviceVar` containing the active PV ID and follow
7
- * preferences. The shared `IPackage.activePackageVersionId` serves only as
8
- * the group-level default for new devices.
5
+ * Each device resolves which package version to run from the shared group
6
+ * package settings plus optional device-local prefs. When an admin device
7
+ * upgrades a non-dev version while following group settings, the shared
8
+ * `IPackage.activePackageVersionId` advances so the group stays aligned.
9
9
  */
10
10
  Object.defineProperty(exports, "__esModule", { value: true });
11
11
  exports.packagePrefsVar = packagePrefsVar;
12
12
  exports.updatePackagePrefs = updatePackagePrefs;
13
13
  exports.getEffectivePackagePrefs = getEffectivePackagePrefs;
14
14
  exports.resolveDevicePackageVersion = resolveDevicePackageVersion;
15
+ exports.hasDeviceFollowOverride = hasDeviceFollowOverride;
15
16
  const user_context_singleton_1 = require("../context/user-context-singleton");
16
17
  const assistants_1 = require("./assistants");
18
+ const group_member_roles_1 = require("./group-member-roles");
19
+ const group_permissions_1 = require("./group-permissions");
17
20
  const package_versions_1 = require("./package-versions");
21
+ const packages_1 = require("./packages");
18
22
  const persistent_vars_1 = require("./persistent-vars");
19
23
  const workflows_1 = require("./workflows");
20
24
  /**
@@ -129,6 +133,17 @@ async function resolveDevicePackageVersion(pkg, dataContext, opts) {
129
133
  .get(effectiveActivePvId)
130
134
  .catch(() => undefined);
131
135
  }
136
+ // Dev versions are intentionally local working versions. If a device is
137
+ // currently running dev, keep loading that exact PV until the user manually
138
+ // activates a non-dev version.
139
+ if (activePv?.versionTag === "dev") {
140
+ const instance = await dataContext.packageLoader.loadPackage(pkg, {
141
+ force: opts?.force,
142
+ localPath: opts?.localPath,
143
+ packageVersionId: effectiveActivePvId,
144
+ });
145
+ return { loaded: !!instance, pv: activePv, upgraded: false };
146
+ }
132
147
  // Evaluate all available PVs to find the best one
133
148
  const allVersions = await (0, package_versions_1.PackageVersions)(dataContext).list({ packageId: pkg.packageId });
134
149
  let bestPv = activePv;
@@ -178,9 +193,41 @@ async function resolveDevicePackageVersion(pkg, dataContext, opts) {
178
193
  }
179
194
  return { loaded: false, upgraded: false };
180
195
  }
181
- // Load succeeded - install assistants/workflows, then update device prefs
196
+ // Load succeeded - install assistants/workflows, then persist the activation
197
+ // either as a group-level active version or as device-local state.
182
198
  await installPackageContents(dataContext, pkg, instance);
183
- await updatePackagePrefs(pkg.packageId, { activePackageVersionId: bestPv.packageVersionId }, dataContext);
199
+ // When the device is following group settings (no device-level follow
200
+ // override) and the upgraded version is non-dev, also advance the group's
201
+ // activePackageVersionId so other devices stay in sync. Only admin+ users
202
+ // are allowed to write to the Packages record.
203
+ let advancedGroupActiveVersion = false;
204
+ if (bestPv.versionTag !== "dev" && !hasDeviceFollowOverride(rawPrefs)) {
205
+ try {
206
+ const groupId = dataContext.groupId;
207
+ if (groupId) {
208
+ const userCtx = await (0, user_context_singleton_1.getUserContext)();
209
+ const role = await (0, group_permissions_1.getUserRole)(groupId, userCtx.userId);
210
+ if (role >= group_member_roles_1.GroupMemberRole.Admin) {
211
+ const freshPkg = await (0, packages_1.Packages)(dataContext).get(pkg.packageId);
212
+ if (freshPkg) {
213
+ if (freshPkg.activePackageVersionId !== bestPv.packageVersionId) {
214
+ freshPkg.activePackageVersionId = bestPv.packageVersionId;
215
+ await (0, packages_1.Packages)(dataContext).signAndSave(freshPkg);
216
+ }
217
+ advancedGroupActiveVersion = true;
218
+ }
219
+ }
220
+ }
221
+ }
222
+ catch {
223
+ // Non-critical: personal contexts have no group, signature checks may
224
+ // fail for non-admins. Swallow and continue — the device upgrade
225
+ // already succeeded.
226
+ }
227
+ }
228
+ await updatePackagePrefs(pkg.packageId, {
229
+ activePackageVersionId: advancedGroupActiveVersion ? undefined : bestPv.packageVersionId,
230
+ }, dataContext);
184
231
  return { loaded: true, pv: bestPv, upgraded: true };
185
232
  }
186
233
  /**
@@ -197,6 +244,17 @@ async function installPackageContents(dataContext, pkg, instance) {
197
244
  ];
198
245
  await Promise.all(saves);
199
246
  }
247
+ /**
248
+ * Returns true when the device has an explicit follow-policy override
249
+ * (`followRange`, `followTags`, or `pinned`). Used by the UI to drive the
250
+ * "Override on this device" toggle and by the resolver to decide whether
251
+ * to advance the group-level active version on upgrade.
252
+ */
253
+ function hasDeviceFollowOverride(prefs) {
254
+ if (!prefs)
255
+ return false;
256
+ return prefs.followRange != null || prefs.followTags != null || prefs.pinned != null;
257
+ }
200
258
  /** Convert `IPackage.versionFollowRange` to the resolver's range type. */
201
259
  function rangeFromPkg(pkg) {
202
260
  const r = pkg.versionFollowRange;
@@ -1,8 +1,4 @@
1
1
  /**
2
2
  * Regression tests for device-local package version resolution.
3
- *
4
- * Several cases document known gaps from the device-local package versions review.
5
- * They are expected to fail until the resolver honors group-level pinned, legacy
6
- * deviceVersionTag, and pinned devices are not overridden before resolve.
7
3
  */
8
4
  export {};
@@ -1,10 +1,6 @@
1
1
  "use strict";
2
2
  /**
3
3
  * Regression tests for device-local package version resolution.
4
- *
5
- * Several cases document known gaps from the device-local package versions review.
6
- * They are expected to fail until the resolver honors group-level pinned, legacy
7
- * deviceVersionTag, and pinned devices are not overridden before resolve.
8
4
  */
9
5
  Object.defineProperty(exports, "__esModule", { value: true });
10
6
  const SQLiteDB = require("better-sqlite3");
@@ -12,6 +8,8 @@ const user_context_1 = require("../context/user-context");
12
8
  const user_context_singleton_1 = require("../context/user-context-singleton");
13
9
  const utils_1 = require("../utils");
14
10
  const assistants_1 = require("./assistants");
11
+ const group_member_roles_1 = require("./group-member-roles");
12
+ const groups_1 = require("./groups");
15
13
  const sql_data_source_1 = require("./orm/sql.data-source");
16
14
  const package_version_resolver_1 = require("./package-version-resolver");
17
15
  const package_versions_1 = require("./package-versions");
@@ -51,6 +49,11 @@ function groupDc() {
51
49
  dc.setAsDefault();
52
50
  return dc;
53
51
  }
52
+ function freshGroupDc() {
53
+ const dc = userContext.getDataContext((0, utils_1.newid)());
54
+ dc.setAsDefault();
55
+ return dc;
56
+ }
54
57
  function makePV(packageId, overrides = {}) {
55
58
  return {
56
59
  packageVersionId: (0, utils_1.newid)(),
@@ -72,6 +75,7 @@ function makePkg(overrides = {}) {
72
75
  name: "test-package",
73
76
  description: "test",
74
77
  createdBy: testUserId,
78
+ publishPublicKey: "",
75
79
  signature: "",
76
80
  ...overrides,
77
81
  };
@@ -84,6 +88,7 @@ beforeAll(async () => {
84
88
  userContext.deviceId((0, utils_1.newid)());
85
89
  (0, user_context_singleton_1.setUserContext)(userContext);
86
90
  packages_1.PackagesTable.isPassthrough = true;
91
+ packages_1.PackagesTable.enablePackageSigning((pkg) => pkg);
87
92
  package_versions_1.PackageVersionsTable.isPassthrough = true;
88
93
  });
89
94
  afterAll(async () => {
@@ -95,7 +100,7 @@ afterAll(async () => {
95
100
  afterEach(() => {
96
101
  jest.restoreAllMocks();
97
102
  });
98
- describe("resolveDevicePackageVersion — known gaps (expected to fail until fixed)", () => {
103
+ describe("resolveDevicePackageVersion — regression coverage", () => {
99
104
  it("does not auto-upgrade when IPackage.versionFollowRange is pinned (no device prefs)", async () => {
100
105
  const dc = groupDc();
101
106
  const packageId = (0, utils_1.newid)();
@@ -176,6 +181,123 @@ describe("resolveDevicePackageVersion — known gaps (expected to fail until fix
176
181
  });
177
182
  });
178
183
  describe("resolveDevicePackageVersion — expected behavior (control)", () => {
184
+ it("advances the group active version when an admin auto-upgrades without device overrides", async () => {
185
+ const dc = freshGroupDc();
186
+ const packageId = (0, utils_1.newid)();
187
+ const stablePv = makePV(packageId, { version: "1.0.0", versionTag: "stable" });
188
+ const stableV2 = makePV(packageId, { version: "2.0.0", versionTag: "stable" });
189
+ await (0, package_versions_1.PackageVersions)(dc).save(stablePv);
190
+ await (0, package_versions_1.PackageVersions)(dc).save(stableV2);
191
+ const pkg = makePkg({
192
+ packageId,
193
+ activePackageVersionId: stablePv.packageVersionId,
194
+ versionFollowRange: "latest",
195
+ followVersionTags: "stable",
196
+ });
197
+ await (0, packages_1.Packages)(dc).save(pkg);
198
+ jest.spyOn(dc.packageLoader, "loadPackage").mockResolvedValue({ packageId });
199
+ const result = await (0, package_version_resolver_1.resolveDevicePackageVersion)(pkg, dc, { force: true });
200
+ expect(result.upgraded).toBe(true);
201
+ expect(result.pv?.packageVersionId).toBe(stableV2.packageVersionId);
202
+ const savedPkg = await (0, packages_1.Packages)(dc).get(packageId);
203
+ expect(savedPkg?.activePackageVersionId).toBe(stableV2.packageVersionId);
204
+ const pvar = (0, package_version_resolver_1.packagePrefsVar)(packageId, dc);
205
+ await pvar.loadingPromise;
206
+ expect(pvar()?.activePackageVersionId).toBeUndefined();
207
+ });
208
+ it("keeps an auto-upgrade device-local when device follow overrides are enabled", async () => {
209
+ const dc = freshGroupDc();
210
+ const packageId = (0, utils_1.newid)();
211
+ const stablePv = makePV(packageId, { version: "1.0.0", versionTag: "stable" });
212
+ const stableV2 = makePV(packageId, { version: "2.0.0", versionTag: "stable" });
213
+ await (0, package_versions_1.PackageVersions)(dc).save(stablePv);
214
+ await (0, package_versions_1.PackageVersions)(dc).save(stableV2);
215
+ const pkg = makePkg({
216
+ packageId,
217
+ activePackageVersionId: stablePv.packageVersionId,
218
+ versionFollowRange: "latest",
219
+ followVersionTags: "stable",
220
+ });
221
+ await (0, packages_1.Packages)(dc).save(pkg);
222
+ await (0, package_version_resolver_1.updatePackagePrefs)(packageId, { followRange: "latest", followTags: "stable" }, dc);
223
+ jest.spyOn(dc.packageLoader, "loadPackage").mockResolvedValue({ packageId });
224
+ const result = await (0, package_version_resolver_1.resolveDevicePackageVersion)(pkg, dc, { force: true });
225
+ expect(result.upgraded).toBe(true);
226
+ expect(result.pv?.packageVersionId).toBe(stableV2.packageVersionId);
227
+ const savedPkg = await (0, packages_1.Packages)(dc).get(packageId);
228
+ expect(savedPkg?.activePackageVersionId).toBe(stablePv.packageVersionId);
229
+ const pvar = (0, package_version_resolver_1.packagePrefsVar)(packageId, dc);
230
+ await pvar.loadingPromise;
231
+ expect(pvar()?.activePackageVersionId).toBe(stableV2.packageVersionId);
232
+ });
233
+ it("keeps an auto-upgrade device-local when the device user is not a group admin", async () => {
234
+ const dc = freshGroupDc();
235
+ const packageId = (0, utils_1.newid)();
236
+ const stablePv = makePV(packageId, { version: "1.0.0", versionTag: "stable" });
237
+ const stableV2 = makePV(packageId, { version: "2.0.0", versionTag: "stable" });
238
+ await (0, package_versions_1.PackageVersions)(dc).save(stablePv);
239
+ await (0, package_versions_1.PackageVersions)(dc).save(stableV2);
240
+ await (0, groups_1.Groups)(userContext.userDataContext).save({
241
+ groupId: dc.dataContextId,
242
+ name: "Non-admin resolver test",
243
+ description: "test",
244
+ founderUserId: (0, utils_1.newid)(),
245
+ publicRole: group_member_roles_1.GroupMemberRole.Writer,
246
+ publicKey: "",
247
+ publicBoxKey: "",
248
+ signature: "",
249
+ });
250
+ const pkg = makePkg({
251
+ packageId,
252
+ activePackageVersionId: stablePv.packageVersionId,
253
+ versionFollowRange: "latest",
254
+ followVersionTags: "stable",
255
+ });
256
+ await (0, packages_1.Packages)(dc).save(pkg);
257
+ jest.spyOn(dc.packageLoader, "loadPackage").mockResolvedValue({ packageId });
258
+ const result = await (0, package_version_resolver_1.resolveDevicePackageVersion)(pkg, dc, { force: true });
259
+ expect(result.upgraded).toBe(true);
260
+ expect(result.pv?.packageVersionId).toBe(stableV2.packageVersionId);
261
+ const savedPkg = await (0, packages_1.Packages)(dc).get(packageId);
262
+ expect(savedPkg?.activePackageVersionId).toBe(stablePv.packageVersionId);
263
+ const pvar = (0, package_version_resolver_1.packagePrefsVar)(packageId, dc);
264
+ await pvar.loadingPromise;
265
+ expect(pvar()?.activePackageVersionId).toBe(stableV2.packageVersionId);
266
+ });
267
+ it("keeps a local dev activation on this device without turning override on", async () => {
268
+ const dc = freshGroupDc();
269
+ const packageId = (0, utils_1.newid)();
270
+ const stablePv = makePV(packageId, { version: "1.0.0", versionTag: "stable" });
271
+ const devPv = makePV(packageId, { version: "1.2.0", versionTag: "dev" });
272
+ const stableV2 = makePV(packageId, { version: "2.0.0", versionTag: "stable" });
273
+ await (0, package_versions_1.PackageVersions)(dc).save(stablePv);
274
+ await (0, package_versions_1.PackageVersions)(dc).save(devPv);
275
+ await (0, package_versions_1.PackageVersions)(dc).save(stableV2);
276
+ const pkg = makePkg({
277
+ packageId,
278
+ activePackageVersionId: stablePv.packageVersionId,
279
+ versionFollowRange: "latest",
280
+ followVersionTags: "stable",
281
+ });
282
+ await (0, packages_1.Packages)(dc).save(pkg);
283
+ await (0, package_version_resolver_1.updatePackagePrefs)(packageId, { activePackageVersionId: devPv.packageVersionId }, dc);
284
+ const pvar = (0, package_version_resolver_1.packagePrefsVar)(packageId, dc);
285
+ await pvar.loadingPromise;
286
+ expect((0, package_version_resolver_1.hasDeviceFollowOverride)(pvar())).toBe(false);
287
+ const loadedPvIds = [];
288
+ jest.spyOn(dc.packageLoader, "loadPackage").mockImplementation(async (_pkg, opts) => {
289
+ if (opts?.packageVersionId)
290
+ loadedPvIds.push(opts.packageVersionId);
291
+ return { packageId };
292
+ });
293
+ const result = await (0, package_version_resolver_1.resolveDevicePackageVersion)(pkg, dc, { force: true });
294
+ expect(result.upgraded).toBe(false);
295
+ expect(result.pv?.packageVersionId).toBe(devPv.packageVersionId);
296
+ expect(loadedPvIds).toEqual([devPv.packageVersionId]);
297
+ const savedPkg = await (0, packages_1.Packages)(dc).get(packageId);
298
+ expect(savedPkg?.activePackageVersionId).toBe(stablePv.packageVersionId);
299
+ expect(pvar()?.activePackageVersionId).toBe(devPv.packageVersionId);
300
+ });
179
301
  it("does not auto-activate dev when device has no prefs and group default is stable", async () => {
180
302
  const dc = groupDc();
181
303
  const packageId = (0, utils_1.newid)();
@@ -30,6 +30,7 @@ declare const schema: z.ZodObject<{
30
30
  navigationPath: string;
31
31
  displayName?: string | undefined;
32
32
  }>, "many">>;
33
+ packageAuthorSignature: z.ZodOptional<z.ZodString>;
33
34
  history: z.ZodOptional<z.ZodArray<z.ZodObject<{
34
35
  action: z.ZodString;
35
36
  by: z.ZodString;
@@ -76,6 +77,7 @@ declare const schema: z.ZodObject<{
76
77
  navigationPath: string;
77
78
  displayName?: string | undefined;
78
79
  }[] | undefined;
80
+ packageAuthorSignature?: string | undefined;
79
81
  }, {
80
82
  version: string;
81
83
  signature: string;
@@ -103,6 +105,7 @@ declare const schema: z.ZodObject<{
103
105
  navigationPath: string;
104
106
  displayName?: string | undefined;
105
107
  }[] | undefined;
108
+ packageAuthorSignature?: string | undefined;
106
109
  }>;
107
110
  export type IPackageVersion = z.infer<typeof schema>;
108
111
  export declare class PackageVersionsTable extends Table<IPackageVersion> {
@@ -66,6 +66,11 @@ const schema = zod_1.z.object({
66
66
  .array()
67
67
  .optional()
68
68
  .describe("The app navigation items that this version provides"),
69
+ packageAuthorSignature: zod_1.z
70
+ .string()
71
+ .optional()
72
+ .describe("Publisher's Ed25519 detached signature over the author-signed payload. " +
73
+ "Enables cross-group trust verification without needing the original source."),
69
74
  history: zod_1.z
70
75
  .array(zod_1.z.object({
71
76
  action: zod_1.z.string().describe("created | promoted:beta | promoted:stable | activated"),