@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.
- package/README.md +74 -1
- package/dist/data/files/file-read-stream.js +7 -0
- package/dist/data/files/file.types.d.ts +6 -0
- package/dist/data/files/file.types.js +18 -0
- package/dist/data/files/files.test.js +50 -7
- package/dist/data/package-version-resolver.d.ts +13 -5
- package/dist/data/package-version-resolver.js +64 -6
- package/dist/data/package-version-resolver.test.d.ts +0 -4
- package/dist/data/package-version-resolver.test.js +127 -5
- package/dist/data/package-versions.d.ts +3 -0
- package/dist/data/package-versions.js +5 -0
- package/dist/data/packages.d.ts +6 -29
- package/dist/data/packages.js +8 -6
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/package-installer/index.d.ts +10 -0
- package/dist/package-installer/index.js +26 -0
- package/dist/package-installer/package-author-signing.d.ts +54 -0
- package/dist/package-installer/package-author-signing.js +82 -0
- package/dist/package-installer/package-author-signing.test.d.ts +1 -0
- package/dist/package-installer/package-author-signing.test.js +189 -0
- package/dist/package-installer/package-cloner.d.ts +16 -0
- package/dist/package-installer/package-cloner.js +115 -0
- package/dist/package-installer/package-cloner.test.d.ts +1 -0
- package/dist/package-installer/package-cloner.test.js +276 -0
- package/dist/package-installer/package-creator.d.ts +22 -0
- package/dist/package-installer/package-creator.js +154 -0
- package/dist/package-installer/package-creator.test.d.ts +1 -0
- package/dist/package-installer/package-creator.test.js +354 -0
- package/dist/package-installer/package-installer.d.ts +32 -0
- package/dist/package-installer/package-installer.js +247 -0
- package/dist/package-installer/package-installer.test.d.ts +1 -0
- package/dist/package-installer/package-installer.test.js +666 -0
- package/dist/package-installer/package-propagation.d.ts +29 -0
- package/dist/package-installer/package-propagation.js +364 -0
- package/dist/package-installer/package-propagation.test.d.ts +1 -0
- package/dist/package-installer/package-propagation.test.js +1145 -0
- package/dist/package-installer/package-publisher.d.ts +55 -0
- package/dist/package-installer/package-publisher.js +71 -0
- package/dist/package-installer/package-publisher.test.d.ts +1 -0
- package/dist/package-installer/package-publisher.test.js +142 -0
- package/dist/package-installer/package-remote-checker.d.ts +54 -0
- package/dist/package-installer/package-remote-checker.js +194 -0
- package/dist/package-installer/package-remote-checker.test.d.ts +1 -0
- package/dist/package-installer/package-remote-checker.test.js +269 -0
- package/dist/package-installer/package-seed-installer.d.ts +45 -0
- package/dist/package-installer/package-seed-installer.js +108 -0
- package/dist/package-installer/package-seed-installer.test.d.ts +1 -0
- package/dist/package-installer/package-seed-installer.test.js +123 -0
- package/dist/package-installer/package-tarball.d.ts +35 -0
- package/dist/package-installer/package-tarball.js +57 -0
- package/dist/package-installer/package-tarball.test.d.ts +1 -0
- package/dist/package-installer/package-tarball.test.js +75 -0
- package/dist/package-installer/types.d.ts +110 -0
- package/dist/package-installer/types.js +2 -0
- package/dist/rpc-types.d.ts +14 -0
- package/dist/rpc-types.js +6 -0
- package/dist/system-ids.d.ts +1 -0
- package/dist/system-ids.js +2 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1 +1,74 @@
|
|
|
1
|
-
|
|
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
|
|
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
|
|
214
|
+
// Insert metadata directly with mismatched fileHash
|
|
214
215
|
await fileTable.dataSource.insert(metadata);
|
|
215
|
-
|
|
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
|
|
682
|
-
chunkHashes
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* the group
|
|
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
|
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* the group
|
|
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
|
|
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
|
-
|
|
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 —
|
|
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"),
|