@peers-app/peers-sdk 0.18.4 → 0.18.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.
@@ -0,0 +1,335 @@
1
+ "use strict";
2
+ /**
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
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ const SQLiteDB = require("better-sqlite3");
11
+ const user_context_1 = require("../context/user-context");
12
+ const user_context_singleton_1 = require("../context/user-context-singleton");
13
+ const utils_1 = require("../utils");
14
+ const assistants_1 = require("./assistants");
15
+ const sql_data_source_1 = require("./orm/sql.data-source");
16
+ const package_version_resolver_1 = require("./package-version-resolver");
17
+ const package_versions_1 = require("./package-versions");
18
+ const packages_1 = require("./packages");
19
+ const persistent_vars_1 = require("./persistent-vars");
20
+ const workflows_1 = require("./workflows");
21
+ class DBHarness {
22
+ _db = null;
23
+ get db() {
24
+ if (!this._db) {
25
+ this._db = new SQLiteDB(":memory:");
26
+ this._db.pragma("journal_mode = WAL");
27
+ }
28
+ return this._db;
29
+ }
30
+ async get(sql, params = []) {
31
+ return this.db.prepare(sql).get(params);
32
+ }
33
+ async all(sql, params = []) {
34
+ return this.db.prepare(sql).all(params);
35
+ }
36
+ async exec(sql, params = []) {
37
+ const result = this.db.prepare(sql).run(params);
38
+ return { changes: result.changes };
39
+ }
40
+ async close() {
41
+ this._db?.close();
42
+ this._db = null;
43
+ }
44
+ }
45
+ const testUserId = (0, utils_1.newid)();
46
+ const testGroupId = (0, utils_1.newid)();
47
+ let userContext;
48
+ let db;
49
+ function groupDc() {
50
+ const dc = userContext.getDataContext(testGroupId);
51
+ dc.setAsDefault();
52
+ return dc;
53
+ }
54
+ function makePV(packageId, overrides = {}) {
55
+ return {
56
+ packageVersionId: (0, utils_1.newid)(),
57
+ packageId,
58
+ version: "1.0.0",
59
+ versionTag: "stable",
60
+ packageVersionHash: "hash",
61
+ packageBundleFileId: (0, utils_1.newid)(),
62
+ packageBundleFileHash: "bundlehash",
63
+ signature: "",
64
+ createdBy: testUserId,
65
+ createdAt: new Date().toISOString(),
66
+ ...overrides,
67
+ };
68
+ }
69
+ function makePkg(overrides = {}) {
70
+ return {
71
+ packageId: (0, utils_1.newid)(),
72
+ name: "test-package",
73
+ description: "test",
74
+ createdBy: testUserId,
75
+ signature: "",
76
+ ...overrides,
77
+ };
78
+ }
79
+ beforeAll(async () => {
80
+ db = new DBHarness();
81
+ const dataSourceFactory = (metaData, schema) => new sql_data_source_1.SQLDataSource(db, metaData, schema);
82
+ userContext = new user_context_1.UserContext(testUserId, dataSourceFactory, true);
83
+ await userContext.loadingPromise;
84
+ userContext.deviceId((0, utils_1.newid)());
85
+ (0, user_context_singleton_1.setUserContext)(userContext);
86
+ packages_1.PackagesTable.isPassthrough = true;
87
+ package_versions_1.PackageVersionsTable.isPassthrough = true;
88
+ });
89
+ afterAll(async () => {
90
+ packages_1.PackagesTable.isPassthrough = false;
91
+ package_versions_1.PackageVersionsTable.isPassthrough = false;
92
+ userContext?.dispose();
93
+ await db?.close();
94
+ });
95
+ afterEach(() => {
96
+ jest.restoreAllMocks();
97
+ });
98
+ describe("resolveDevicePackageVersion — known gaps (expected to fail until fixed)", () => {
99
+ it("does not auto-upgrade when IPackage.versionFollowRange is pinned (no device prefs)", async () => {
100
+ const dc = groupDc();
101
+ const packageId = (0, utils_1.newid)();
102
+ const stableV1 = makePV(packageId, { version: "1.0.0", versionTag: "stable" });
103
+ const stableV2 = makePV(packageId, { version: "2.0.0", versionTag: "stable" });
104
+ await (0, package_versions_1.PackageVersions)(dc).save(stableV1);
105
+ await (0, package_versions_1.PackageVersions)(dc).save(stableV2);
106
+ const pkg = makePkg({
107
+ packageId,
108
+ activePackageVersionId: stableV1.packageVersionId,
109
+ versionFollowRange: "pinned",
110
+ });
111
+ await (0, packages_1.Packages)(dc).save(pkg);
112
+ const loadedPvIds = [];
113
+ jest.spyOn(dc.packageLoader, "loadPackage").mockImplementation(async (_pkg, opts) => {
114
+ if (opts?.packageVersionId)
115
+ loadedPvIds.push(opts.packageVersionId);
116
+ return { packageId };
117
+ });
118
+ const result = await (0, package_version_resolver_1.resolveDevicePackageVersion)(pkg, dc, { force: true });
119
+ expect(result.upgraded).toBe(false);
120
+ expect(loadedPvIds).toContain(stableV1.packageVersionId);
121
+ expect(loadedPvIds).not.toContain(stableV2.packageVersionId);
122
+ });
123
+ it("pinned device keeps stable PV after activePackageVersionId is overwritten to dev (updatePackageBundle sequence)", async () => {
124
+ const dc = groupDc();
125
+ const packageId = (0, utils_1.newid)();
126
+ const stablePv = makePV(packageId, { version: "1.0.0", versionTag: "stable" });
127
+ const devPv = makePV(packageId, { version: "1.0.1", versionTag: "dev" });
128
+ await (0, package_versions_1.PackageVersions)(dc).save(stablePv);
129
+ await (0, package_versions_1.PackageVersions)(dc).save(devPv);
130
+ const pkg = makePkg({
131
+ packageId,
132
+ activePackageVersionId: stablePv.packageVersionId,
133
+ });
134
+ await (0, packages_1.Packages)(dc).save(pkg);
135
+ await (0, package_version_resolver_1.updatePackagePrefs)(packageId, { pinned: true, activePackageVersionId: stablePv.packageVersionId }, dc);
136
+ // Mirrors updatePackageBundle: unconditionally merges dev active id before resolve
137
+ await (0, package_version_resolver_1.updatePackagePrefs)(packageId, { activePackageVersionId: devPv.packageVersionId }, dc);
138
+ const loadedPvIds = [];
139
+ jest.spyOn(dc.packageLoader, "loadPackage").mockImplementation(async (_pkg, opts) => {
140
+ if (opts?.packageVersionId)
141
+ loadedPvIds.push(opts.packageVersionId);
142
+ return { packageId };
143
+ });
144
+ await (0, package_version_resolver_1.resolveDevicePackageVersion)(pkg, dc, { force: true });
145
+ expect(loadedPvIds[loadedPvIds.length - 1]).toBe(stablePv.packageVersionId);
146
+ });
147
+ it("honors legacy deviceVersionTag pvar when choosing beta auto-upgrade", async () => {
148
+ const dc = groupDc();
149
+ const packageId = (0, utils_1.newid)();
150
+ const stablePv = makePV(packageId, { version: "1.0.0", versionTag: "stable" });
151
+ const betaPv = makePV(packageId, { version: "2.0.0", versionTag: "beta" });
152
+ await (0, package_versions_1.PackageVersions)(dc).save(stablePv);
153
+ await (0, package_versions_1.PackageVersions)(dc).save(betaPv);
154
+ const legacyVarName = `deviceVersionTag_${dc.dataContextId}`;
155
+ await (0, persistent_vars_1.PersistentVars)(userContext.userDataContext).save({
156
+ persistentVarId: (0, utils_1.deterministicPvarId)(legacyVarName),
157
+ name: legacyVarName,
158
+ scope: "groupDevice",
159
+ value: { value: "beta" },
160
+ });
161
+ const pkg = makePkg({
162
+ packageId,
163
+ activePackageVersionId: stablePv.packageVersionId,
164
+ versionFollowRange: "latest",
165
+ });
166
+ await (0, packages_1.Packages)(dc).save(pkg);
167
+ const loadedPvIds = [];
168
+ jest.spyOn(dc.packageLoader, "loadPackage").mockImplementation(async (_pkg, opts) => {
169
+ if (opts?.packageVersionId)
170
+ loadedPvIds.push(opts.packageVersionId);
171
+ return { packageId };
172
+ });
173
+ const result = await (0, package_version_resolver_1.resolveDevicePackageVersion)(pkg, dc, { force: true });
174
+ expect(result.upgraded).toBe(true);
175
+ expect(loadedPvIds).toContain(betaPv.packageVersionId);
176
+ });
177
+ });
178
+ describe("resolveDevicePackageVersion — expected behavior (control)", () => {
179
+ it("does not auto-activate dev when device has no prefs and group default is stable", async () => {
180
+ const dc = groupDc();
181
+ const packageId = (0, utils_1.newid)();
182
+ const stablePv = makePV(packageId, { version: "1.0.0", versionTag: "stable" });
183
+ const devPv = makePV(packageId, { version: "9.9.9", versionTag: "dev" });
184
+ await (0, package_versions_1.PackageVersions)(dc).save(stablePv);
185
+ await (0, package_versions_1.PackageVersions)(dc).save(devPv);
186
+ const pkg = makePkg({
187
+ packageId,
188
+ activePackageVersionId: stablePv.packageVersionId,
189
+ versionFollowRange: "latest",
190
+ });
191
+ await (0, packages_1.Packages)(dc).save(pkg);
192
+ const pvar = (0, package_version_resolver_1.packagePrefsVar)(packageId, dc);
193
+ await pvar.loadingPromise;
194
+ expect(pvar()).toEqual({});
195
+ const loadedPvIds = [];
196
+ jest.spyOn(dc.packageLoader, "loadPackage").mockImplementation(async (_pkg, opts) => {
197
+ if (opts?.packageVersionId)
198
+ loadedPvIds.push(opts.packageVersionId);
199
+ return { packageId };
200
+ });
201
+ const result = await (0, package_version_resolver_1.resolveDevicePackageVersion)(pkg, dc, { force: true });
202
+ expect(result.upgraded).toBe(false);
203
+ expect(loadedPvIds).toContain(stablePv.packageVersionId);
204
+ expect(loadedPvIds).not.toContain(devPv.packageVersionId);
205
+ });
206
+ it("installs assistants and workflows into the data context on auto-upgrade", async () => {
207
+ const dc = groupDc();
208
+ const packageId = (0, utils_1.newid)();
209
+ const stablePv = makePV(packageId, { version: "1.0.0", versionTag: "stable" });
210
+ const stableV2 = makePV(packageId, { version: "2.0.0", versionTag: "stable" });
211
+ await (0, package_versions_1.PackageVersions)(dc).save(stablePv);
212
+ await (0, package_versions_1.PackageVersions)(dc).save(stableV2);
213
+ const pkg = makePkg({
214
+ packageId,
215
+ activePackageVersionId: stablePv.packageVersionId,
216
+ versionFollowRange: "latest",
217
+ });
218
+ await (0, packages_1.Packages)(dc).save(pkg);
219
+ // Explicitly set followTags so the legacy-pvar migration from earlier tests
220
+ // doesn't interfere (it would write "beta" followTags otherwise).
221
+ await (0, package_version_resolver_1.updatePackagePrefs)(packageId, { followTags: "stable" }, dc);
222
+ const testAssistantId = (0, utils_1.newid)();
223
+ const testWorkflowId = (0, utils_1.newid)();
224
+ const testAssistant = {
225
+ assistantId: testAssistantId,
226
+ name: "Upgrade Test Assistant",
227
+ packageId,
228
+ createdAt: new Date(),
229
+ assistantRunnerToolId: "000peers0tool00000runner1",
230
+ assistantRunnerConfig: {},
231
+ toolsToInclude: "",
232
+ toolInclusionStrategy: "Linked",
233
+ };
234
+ const testWorkflow = {
235
+ workflowId: testWorkflowId,
236
+ name: "Upgrade Test Workflow",
237
+ description: "test",
238
+ defaultAssistantId: testAssistantId,
239
+ createdBy: testUserId,
240
+ createdAt: new Date(),
241
+ updatedAt: new Date(),
242
+ instructions: [],
243
+ packageId,
244
+ };
245
+ jest.spyOn(dc.packageLoader, "loadPackage").mockResolvedValue({
246
+ packageId,
247
+ assistants: [testAssistant],
248
+ workflows: [testWorkflow],
249
+ });
250
+ const result = await (0, package_version_resolver_1.resolveDevicePackageVersion)(pkg, dc, { force: true });
251
+ expect(result.upgraded).toBe(true);
252
+ expect(result.pv?.packageVersionId).toBe(stableV2.packageVersionId);
253
+ const savedAssistant = await (0, assistants_1.Assistants)(dc).get(testAssistantId);
254
+ expect(savedAssistant).toBeDefined();
255
+ expect(savedAssistant?.name).toBe("Upgrade Test Assistant");
256
+ const savedWorkflow = await (0, workflows_1.Workflows)(dc).get(testWorkflowId);
257
+ expect(savedWorkflow).toBeDefined();
258
+ expect(savedWorkflow?.name).toBe("Upgrade Test Workflow");
259
+ });
260
+ });
261
+ describe("getEffectivePackagePrefs", () => {
262
+ it("uses group defaults when device prefs are absent", () => {
263
+ const pkg = makePkg({
264
+ activePackageVersionId: "pv-group",
265
+ followVersionTags: "stable,beta",
266
+ versionFollowRange: "minor",
267
+ });
268
+ expect((0, package_version_resolver_1.getEffectivePackagePrefs)(pkg, undefined)).toEqual({
269
+ activePackageVersionId: "pv-group",
270
+ isPinned: false,
271
+ followRange: "minor",
272
+ followTags: "stable,beta",
273
+ });
274
+ });
275
+ it("honors group-level pinned when there are no device prefs", () => {
276
+ const pkg = makePkg({
277
+ activePackageVersionId: "pv-pin",
278
+ versionFollowRange: "pinned",
279
+ });
280
+ const eff = (0, package_version_resolver_1.getEffectivePackagePrefs)(pkg, undefined);
281
+ expect(eff.isPinned).toBe(true);
282
+ expect(eff.activePackageVersionId).toBe("pv-pin");
283
+ });
284
+ it("prefers device active id over group default", () => {
285
+ const pkg = makePkg({ activePackageVersionId: "pv-group" });
286
+ expect((0, package_version_resolver_1.getEffectivePackagePrefs)(pkg, {
287
+ activePackageVersionId: "pv-device",
288
+ })).toMatchObject({
289
+ activePackageVersionId: "pv-device",
290
+ isPinned: false,
291
+ });
292
+ });
293
+ it("device pinned overrides group not pinned", () => {
294
+ const pkg = makePkg({
295
+ activePackageVersionId: "pv1",
296
+ versionFollowRange: "latest",
297
+ });
298
+ expect((0, package_version_resolver_1.getEffectivePackagePrefs)(pkg, { pinned: true }).isPinned).toBe(true);
299
+ });
300
+ it("empty device prefs falls back to group pinned (matches groupDeviceVar default)", () => {
301
+ const pkg = makePkg({
302
+ activePackageVersionId: "pv1",
303
+ versionFollowRange: "pinned",
304
+ });
305
+ expect((0, package_version_resolver_1.getEffectivePackagePrefs)(pkg, {}).isPinned).toBe(true);
306
+ });
307
+ it("device explicitly unpinned overrides group pinned", () => {
308
+ const pkg = makePkg({
309
+ activePackageVersionId: "pv1",
310
+ versionFollowRange: "pinned",
311
+ });
312
+ expect((0, package_version_resolver_1.getEffectivePackagePrefs)(pkg, { pinned: false }).isPinned).toBe(false);
313
+ });
314
+ it("device followRange overrides group default", () => {
315
+ const pkg = makePkg({ versionFollowRange: "latest" });
316
+ expect((0, package_version_resolver_1.getEffectivePackagePrefs)(pkg, { followRange: "patch" }).followRange).toBe("patch");
317
+ });
318
+ it("device followTags overrides group default", () => {
319
+ const pkg = makePkg({ followVersionTags: "stable" });
320
+ expect((0, package_version_resolver_1.getEffectivePackagePrefs)(pkg, { followTags: "*" }).followTags).toBe("*");
321
+ });
322
+ it("falls back to 'latest' followRange when group has no setting", () => {
323
+ const pkg = makePkg({});
324
+ expect((0, package_version_resolver_1.getEffectivePackagePrefs)(pkg, undefined).followRange).toBe("latest");
325
+ });
326
+ });
327
+ describe("packagePrefsVar", () => {
328
+ it("returns the same observable instance for repeated calls with the same args", () => {
329
+ const dc = groupDc();
330
+ const id = (0, utils_1.newid)();
331
+ const a = (0, package_version_resolver_1.packagePrefsVar)(id, dc);
332
+ const b = (0, package_version_resolver_1.packagePrefsVar)(id, dc);
333
+ expect(a).toBe(b);
334
+ });
335
+ });
@@ -53,12 +53,12 @@ declare const schema: z.ZodObject<{
53
53
  version: string;
54
54
  signature: string;
55
55
  packageId: string;
56
+ createdBy: string;
57
+ createdAt: string;
56
58
  packageVersionId: string;
57
59
  packageVersionHash: string;
58
60
  packageBundleFileId: string;
59
61
  packageBundleFileHash: string;
60
- createdBy: string;
61
- createdAt: string;
62
62
  history?: {
63
63
  at: string;
64
64
  action: string;
@@ -80,12 +80,12 @@ declare const schema: z.ZodObject<{
80
80
  version: string;
81
81
  signature: string;
82
82
  packageId: string;
83
+ createdBy: string;
84
+ createdAt: string;
83
85
  packageVersionId: string;
84
86
  packageVersionHash: string;
85
87
  packageBundleFileId: string;
86
88
  packageBundleFileHash: string;
87
- createdBy: string;
88
- createdAt: string;
89
89
  history?: {
90
90
  at: string;
91
91
  action: string;
@@ -120,10 +120,17 @@ export declare function PackageVersions(dataContext?: DataContext): PackageVersi
120
120
  * content only — `versionTag` is intentionally excluded so the same code
121
121
  * produces the same hash regardless of its promotion level (dev/beta/stable).
122
122
  *
123
- * @deprecated The `versionTag` parameter is accepted for backward compatibility
124
- * but ignored. It will be removed in a future release.
123
+ * @param version - Semver version string.
124
+ * @param _versionTag - **Deprecated.** Accepted for backward compatibility
125
+ * but ignored. Will be removed in a future release.
126
+ * @param packageBundleFileHash - Hash of the main package bundle.
127
+ * @param routesBundleFileHash - Hash of the routes bundle, if any.
128
+ * @param uiBundleFileHash - Hash of the UI bundle, if any.
129
+ * @returns A content-addressable hash string.
125
130
  */
126
- export declare function computePackageVersionHash(version: string, _versionTag: string, packageBundleFileHash: string, routesBundleFileHash?: string, uiBundleFileHash?: string): string;
131
+ export declare function computePackageVersionHash(version: string,
132
+ /** @deprecated Ignored — will be removed in a future release. */
133
+ _versionTag: string, packageBundleFileHash: string, routesBundleFileHash?: string, uiBundleFileHash?: string): string;
127
134
  export declare function isVersionInRange(activeVersion: string, incomingVersion: string, range: "pinned" | "patch" | "minor" | "latest"): boolean;
128
135
  /**
129
136
  * Returns true if incomingVersion is strictly newer than activeVersion (semver comparison).
@@ -113,14 +113,7 @@ let PackageVersionsTable = (() => {
113
113
  // users can do whatever they want to package versions in their personal space
114
114
  return super.save(packageVersion, opts);
115
115
  }
116
- try {
117
- await (0, package_version_permissions_1.verifyPackageVersionSignature)(packageVersion, this.groupId);
118
- }
119
- catch (err) {
120
- throw new Error("Package version verification failed. Did you mean to call `signAndSave`?", {
121
- cause: err,
122
- });
123
- }
116
+ await (0, package_version_permissions_1.verifyPackageVersionSignature)(packageVersion, this.groupId);
124
117
  return super.save(packageVersion, opts);
125
118
  }
126
119
  async signAndSave(packageVersion, opts) {
@@ -128,7 +121,7 @@ let PackageVersionsTable = (() => {
128
121
  throw new Error("Package version signing is not enabled. Call PackageVersionsTable.enablePackageVersionSigning(fn) to enable it.");
129
122
  }
130
123
  packageVersion = await PackageVersionsTable.addSignatureToPackageVersion(packageVersion);
131
- return super.save(packageVersion, opts);
124
+ return this.save(packageVersion, opts);
132
125
  }
133
126
  static addSignatureToPackageVersion = undefined;
134
127
  static enablePackageVersionSigning(fn) {
@@ -150,10 +143,17 @@ function PackageVersions(dataContext) {
150
143
  * content only — `versionTag` is intentionally excluded so the same code
151
144
  * produces the same hash regardless of its promotion level (dev/beta/stable).
152
145
  *
153
- * @deprecated The `versionTag` parameter is accepted for backward compatibility
154
- * but ignored. It will be removed in a future release.
146
+ * @param version - Semver version string.
147
+ * @param _versionTag - **Deprecated.** Accepted for backward compatibility
148
+ * but ignored. Will be removed in a future release.
149
+ * @param packageBundleFileHash - Hash of the main package bundle.
150
+ * @param routesBundleFileHash - Hash of the routes bundle, if any.
151
+ * @param uiBundleFileHash - Hash of the UI bundle, if any.
152
+ * @returns A content-addressable hash string.
155
153
  */
156
- function computePackageVersionHash(version, _versionTag, packageBundleFileHash, routesBundleFileHash, uiBundleFileHash) {
154
+ function computePackageVersionHash(version,
155
+ /** @deprecated Ignored — will be removed in a future release. */
156
+ _versionTag, packageBundleFileHash, routesBundleFileHash, uiBundleFileHash) {
157
157
  return (0, keys_1.hashValue)([version, packageBundleFileHash, routesBundleFileHash ?? "", uiBundleFileHash ?? ""].join(":"));
158
158
  }
159
159
  function isVersionInRange(activeVersion, incomingVersion, range) {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,227 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const SQLiteDB = require("better-sqlite3");
4
+ const user_context_1 = require("../context/user-context");
5
+ const user_context_singleton_1 = require("../context/user-context-singleton");
6
+ const keys_1 = require("../keys");
7
+ const utils_1 = require("../utils");
8
+ const group_member_roles_1 = require("./group-member-roles");
9
+ const group_members_1 = require("./group-members");
10
+ const groups_1 = require("./groups");
11
+ const sql_data_source_1 = require("./orm/sql.data-source");
12
+ const package_versions_1 = require("./package-versions");
13
+ const users_1 = require("./users");
14
+ // ---------------------------------------------------------------------------
15
+ // In-memory SQLite harness (same pattern as sql.data-source.test.ts)
16
+ // ---------------------------------------------------------------------------
17
+ class DBHarness {
18
+ _db = null;
19
+ get db() {
20
+ if (!this._db) {
21
+ this._db = new SQLiteDB(":memory:");
22
+ this._db.pragma("journal_mode = WAL");
23
+ }
24
+ return this._db;
25
+ }
26
+ async get(sql, params = []) {
27
+ return this.db.prepare(sql).get(params);
28
+ }
29
+ async all(sql, params = []) {
30
+ return this.db.prepare(sql).all(params);
31
+ }
32
+ async exec(sql, params = []) {
33
+ const result = this.db.prepare(sql).run(params);
34
+ return { changes: result.changes };
35
+ }
36
+ async close() {
37
+ this._db?.close();
38
+ this._db = null;
39
+ }
40
+ }
41
+ // ---------------------------------------------------------------------------
42
+ // Test fixtures
43
+ // ---------------------------------------------------------------------------
44
+ const writerKeys = (0, keys_1.newKeys)();
45
+ const adminKeys = (0, keys_1.newKeys)();
46
+ const testGroupId = (0, utils_1.newid)();
47
+ const writerUserId = (0, utils_1.newid)();
48
+ const adminUserId = (0, utils_1.newid)();
49
+ function makePV(overrides = {}) {
50
+ return {
51
+ packageVersionId: (0, utils_1.newid)(),
52
+ packageId: (0, utils_1.newid)(),
53
+ version: "1.0.0",
54
+ versionTag: "dev",
55
+ packageVersionHash: "testhash",
56
+ packageBundleFileId: (0, utils_1.newid)(),
57
+ packageBundleFileHash: "bundlehash",
58
+ signature: "",
59
+ createdBy: writerUserId,
60
+ createdAt: new Date().toISOString(),
61
+ ...overrides,
62
+ };
63
+ }
64
+ // ---------------------------------------------------------------------------
65
+ // Setup: create a real ephemeral UserContext with Users + GroupMembers seeded
66
+ // so that getUserRoleFromPublicKey resolves Writer vs Admin from real data.
67
+ // ---------------------------------------------------------------------------
68
+ let userContext;
69
+ beforeAll(async () => {
70
+ const db = new DBHarness();
71
+ const dataSourceFactory = (metaData, schema) => {
72
+ return new sql_data_source_1.SQLDataSource(db, metaData, schema);
73
+ };
74
+ userContext = new user_context_1.UserContext(writerUserId, dataSourceFactory, true);
75
+ await userContext.loadingPromise;
76
+ userContext.deviceId((0, utils_1.newid)());
77
+ (0, user_context_singleton_1.setUserContext)(userContext);
78
+ const dc = userContext.userDataContext;
79
+ // Enable passthrough so seeding skips signature checks on Groups/GroupMembers/Users
80
+ groups_1.GroupsTable.isPassthrough = true;
81
+ group_members_1.GroupMembersTable.isPassthrough = true;
82
+ users_1.UsersTable.isPassthrough = true;
83
+ // Seed the writer user
84
+ const { Users } = await Promise.resolve().then(() => require("./users"));
85
+ const writerUser = {
86
+ userId: writerUserId,
87
+ name: "Writer User",
88
+ publicKey: writerKeys.publicKey,
89
+ publicBoxKey: "",
90
+ signature: "",
91
+ };
92
+ await Users(dc).save(writerUser);
93
+ // Seed the admin user
94
+ const adminUser = {
95
+ userId: adminUserId,
96
+ name: "Admin User",
97
+ publicKey: adminKeys.publicKey,
98
+ publicBoxKey: "",
99
+ signature: "",
100
+ };
101
+ await Users(dc).save(adminUser);
102
+ // Seed a group so the group lookup finds real data (not "group not found → Founder")
103
+ const { Groups } = await Promise.resolve().then(() => require("./groups"));
104
+ await Groups(dc).save({
105
+ groupId: testGroupId,
106
+ name: "Test Group",
107
+ description: "",
108
+ founderUserId: (0, utils_1.newid)(), // different from writer/admin so nobody is auto-Founder
109
+ signature: "",
110
+ publicKey: "",
111
+ publicBoxKey: "",
112
+ });
113
+ // Seed group memberships with real roles
114
+ const { GroupMembers } = await Promise.resolve().then(() => require("./group-members"));
115
+ const writerMembership = {
116
+ groupMemberId: (0, utils_1.newid)(),
117
+ groupId: testGroupId,
118
+ userId: writerUserId,
119
+ role: group_member_roles_1.GroupMemberRole.Writer,
120
+ signature: "",
121
+ };
122
+ await GroupMembers(dc).save(writerMembership);
123
+ const adminMembership = {
124
+ groupMemberId: (0, utils_1.newid)(),
125
+ groupId: testGroupId,
126
+ userId: adminUserId,
127
+ role: group_member_roles_1.GroupMemberRole.Admin,
128
+ signature: "",
129
+ };
130
+ await GroupMembers(dc).save(adminMembership);
131
+ // Disable passthrough so the real permission checks apply during tests
132
+ groups_1.GroupsTable.isPassthrough = false;
133
+ group_members_1.GroupMembersTable.isPassthrough = false;
134
+ users_1.UsersTable.isPassthrough = false;
135
+ // Enable real signing
136
+ package_versions_1.PackageVersionsTable.enablePackageVersionSigning((pv) => (0, keys_1.addSignatureToObject)(pv, activeSecretKey));
137
+ });
138
+ let activeSecretKey = writerKeys.secretKey;
139
+ // ---------------------------------------------------------------------------
140
+ // Tests
141
+ // ---------------------------------------------------------------------------
142
+ describe("PackageVersionsTable.signAndSave — promotion permission enforcement", () => {
143
+ // These tests exercise the real signAndSave → save → verifyPackageVersionSignature
144
+ // chain against a real in-memory database with real group membership data.
145
+ //
146
+ // THE BUG: signAndSave previously called super.save() which skipped the
147
+ // overridden save() and its verifyPackageVersionSignature check. A Writer
148
+ // could promote dev → beta/stable unchallenged.
149
+ //
150
+ // THE FIX: signAndSave now calls this.save(), so the permission check fires.
151
+ // Helper: get the PackageVersions table in the test group context
152
+ function pvTable() {
153
+ const dc = userContext.getDataContext(testGroupId);
154
+ return (0, package_versions_1.PackageVersions)(dc);
155
+ }
156
+ describe("Writer role", () => {
157
+ beforeEach(() => {
158
+ activeSecretKey = writerKeys.secretKey;
159
+ });
160
+ it("can signAndSave a dev version", async () => {
161
+ const pv = makePV({ versionTag: "dev" });
162
+ const saved = await pvTable().signAndSave(pv);
163
+ expect(saved.versionTag).toBe("dev");
164
+ expect(saved.signature).toBeTruthy();
165
+ });
166
+ it("is blocked from signAndSave with versionTag=beta", async () => {
167
+ const pv = makePV({ versionTag: "beta" });
168
+ // Before the fix: this would PASS (super.save skipped the check)
169
+ // After the fix: this correctly REJECTS
170
+ await expect(pvTable().signAndSave(pv)).rejects.toThrow("Only group admins can create or update beta/stable package versions");
171
+ });
172
+ it("is blocked from signAndSave with versionTag=stable", async () => {
173
+ const pv = makePV({ versionTag: "stable" });
174
+ await expect(pvTable().signAndSave(pv)).rejects.toThrow("Only group admins can create or update beta/stable package versions");
175
+ });
176
+ it("is blocked from promoting an existing dev version to beta", async () => {
177
+ const pv = makePV({ versionTag: "dev" });
178
+ const saved = await pvTable().signAndSave(pv);
179
+ const promoted = { ...saved, versionTag: "beta" };
180
+ await expect(pvTable().signAndSave(promoted)).rejects.toThrow("Only group admins can create or update beta/stable package versions");
181
+ });
182
+ });
183
+ describe("Admin role", () => {
184
+ beforeEach(() => {
185
+ activeSecretKey = adminKeys.secretKey;
186
+ });
187
+ it("can signAndSave a dev version", async () => {
188
+ const pv = makePV({ versionTag: "dev" });
189
+ const saved = await pvTable().signAndSave(pv);
190
+ expect(saved.versionTag).toBe("dev");
191
+ });
192
+ it("can signAndSave a beta version", async () => {
193
+ const pv = makePV({ versionTag: "beta" });
194
+ const saved = await pvTable().signAndSave(pv);
195
+ expect(saved.versionTag).toBe("beta");
196
+ });
197
+ it("can signAndSave a stable version", async () => {
198
+ const pv = makePV({ versionTag: "stable" });
199
+ const saved = await pvTable().signAndSave(pv);
200
+ expect(saved.versionTag).toBe("stable");
201
+ });
202
+ it("can promote an existing dev version to beta", async () => {
203
+ const pv = makePV({ versionTag: "dev" });
204
+ const saved = await pvTable().signAndSave(pv);
205
+ const promoted = { ...saved, versionTag: "beta" };
206
+ const result = await pvTable().signAndSave(promoted);
207
+ expect(result.versionTag).toBe("beta");
208
+ });
209
+ it("can promote an existing beta version to stable", async () => {
210
+ const pv = makePV({ versionTag: "beta" });
211
+ const saved = await pvTable().signAndSave(pv);
212
+ const promoted = { ...saved, versionTag: "stable" };
213
+ const result = await pvTable().signAndSave(promoted);
214
+ expect(result.versionTag).toBe("stable");
215
+ });
216
+ });
217
+ describe("personal space (no group)", () => {
218
+ it("allows Writer to signAndSave any tag without restriction", async () => {
219
+ activeSecretKey = writerKeys.secretKey;
220
+ const dc = userContext.userDataContext;
221
+ const table = (0, package_versions_1.PackageVersions)(dc);
222
+ const pv = makePV({ versionTag: "stable" });
223
+ const saved = await table.signAndSave(pv);
224
+ expect(saved.versionTag).toBe("stable");
225
+ });
226
+ });
227
+ });
@@ -9,6 +9,7 @@ declare const schema: z.ZodObject<{
9
9
  createdBy: z.ZodEffects<z.ZodString, string, string>;
10
10
  disabled: z.ZodOptional<z.ZodBoolean>;
11
11
  remoteRepo: z.ZodOptional<z.ZodString>;
12
+ /** @deprecated Read appNavs from the active IPackageVersion record instead. */
12
13
  appNavs: z.ZodOptional<z.ZodArray<z.ZodObject<{
13
14
  name: z.ZodString;
14
15
  displayName: z.ZodOptional<z.ZodString>;