@peers-app/peers-sdk 0.25.19 → 0.25.21

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.
@@ -27,30 +27,39 @@ export declare function ensureTemplateCache(deps: IPackageInstallerDeps, templat
27
27
  /**
28
28
  * Resolve (or create) the Ed25519 publish public key for a package.
29
29
  *
30
- * Idempotent: never rotates an established key. Reuses `existingPublishPublicKey`
31
- * when present, derives the public key from an existing secret pvar when possible,
32
- * and only generates a new keypair when both are missing.
30
+ * Never rotates an established identity. Checks the Packages key, the public key
31
+ * cached in the personal signing pvar's description, and any key recoverable from
32
+ * its secret for agreement before writing. Hosts may decrypt legacy secrets via
33
+ * `resolveSigningPublicKey`; plaintext secrets never need to leave the host.
34
+ * Lazily caches recovered public keys without changing the stored secret value.
35
+ * Generates keys only when both the secret and every public-key source are missing.
33
36
  *
34
- * @throws When a readable secret key does not match an established `publishPublicKey`.
37
+ * @throws When recovered public keys disagree, or a secret has no recoverable public key.
35
38
  */
36
39
  export declare function ensurePackageSigningKey(opts: {
37
40
  personalContext?: DataContext;
38
41
  packageId: string;
39
42
  packageName: string;
40
43
  existingPublishPublicKey?: string;
44
+ /** Host-only recovery of a public key from an encrypted personal signing secret. */
45
+ resolveSigningPublicKey?: ICreatePackageOpts["resolveSigningPublicKey"];
41
46
  }): Promise<string>;
42
47
  /**
43
48
  * Import an existing on-disk Peers package directory into the current data context.
44
49
  *
45
50
  * Validates the manifest packageId, creates/updates the Packages record, persists
46
51
  * `packageLocalPath_*`, provisions a signing key when missing, then installs the
47
- * local dev bundles.
52
+ * local dev bundles. When the target Packages row is absent, reuses the first
53
+ * nonblank public key for the same packageId from the user's personal context
54
+ * or groups. Other contexts' package metadata is not copied.
48
55
  */
49
56
  export declare function importExistingPackage(dataContext: DataContext, deps: IPackageInstallerDeps, opts: {
50
57
  location: string;
51
58
  personalContext?: DataContext;
52
59
  /** Display name fallback when the manifest name is a slug. */
53
60
  displayName?: string;
61
+ /** Host-only recovery of a public key from an encrypted personal signing secret. */
62
+ resolveSigningPublicKey?: ICreatePackageOpts["resolveSigningPublicKey"];
54
63
  }): Promise<ICreatePackageResult>;
55
64
  /**
56
65
  * Create a new Peers package from the package template.
@@ -106,63 +106,96 @@ async function ensureTemplateCache(deps, templateRepo) {
106
106
  await deps.shell.exec(`git clone ${templateRepo} ${cachePath}`);
107
107
  return { cachePath };
108
108
  }
109
+ const SIGNING_KEY_MARKER_PREFIX = "peers-package-signing-key:";
110
+ function validSigningPublicKey(value) {
111
+ return typeof value === "string" && (0, keys_1.derivePublicBoxKey)(value) ? value : undefined;
112
+ }
113
+ function cachedSigningPublicKey(description, packageId) {
114
+ const markers = (description || "")
115
+ .split(/\r?\n/)
116
+ .filter((line) => line.startsWith(SIGNING_KEY_MARKER_PREFIX));
117
+ if (markers.length !== 1)
118
+ return undefined;
119
+ const match = /^peers-package-signing-key:v1:([^:]+):([^:]+)$/.exec(markers[0]);
120
+ return match?.[1] === packageId ? validSigningPublicKey(match[2]) : undefined;
121
+ }
122
+ function signingKeyDescription(description, packageId, publicKey) {
123
+ const text = description
124
+ .split(/\r?\n/)
125
+ .filter((line) => !line.startsWith(SIGNING_KEY_MARKER_PREFIX))
126
+ .join("\n");
127
+ return `${text}${text ? "\n" : ""}${SIGNING_KEY_MARKER_PREFIX}v1:${packageId}:${publicKey}`;
128
+ }
109
129
  /**
110
130
  * Resolve (or create) the Ed25519 publish public key for a package.
111
131
  *
112
- * Idempotent: never rotates an established key. Reuses `existingPublishPublicKey`
113
- * when present, derives the public key from an existing secret pvar when possible,
114
- * and only generates a new keypair when both are missing.
132
+ * Never rotates an established identity. Checks the Packages key, the public key
133
+ * cached in the personal signing pvar's description, and any key recoverable from
134
+ * its secret for agreement before writing. Hosts may decrypt legacy secrets via
135
+ * `resolveSigningPublicKey`; plaintext secrets never need to leave the host.
136
+ * Lazily caches recovered public keys without changing the stored secret value.
137
+ * Generates keys only when both the secret and every public-key source are missing.
115
138
  *
116
- * @throws When a readable secret key does not match an established `publishPublicKey`.
139
+ * @throws When recovered public keys disagree, or a secret has no recoverable public key.
117
140
  */
118
141
  async function ensurePackageSigningKey(opts) {
119
142
  const { personalContext, packageId, packageName, existingPublishPublicKey } = opts;
120
143
  const establishedKey = existingPublishPublicKey?.trim() || "";
121
- if (personalContext) {
122
- const keyName = `${package_author_signing_1.PACKAGE_SIGNING_KEY_PREFIX}${packageId}`;
123
- const existingKey = await (0, persistent_vars_1.PersistentVars)(personalContext).findOne({ name: keyName });
124
- const rawSecret = existingKey?.value?.value;
125
- if (typeof rawSecret === "string" && rawSecret.length > 0) {
144
+ if (!personalContext)
145
+ return establishedKey;
146
+ const keyName = `${package_author_signing_1.PACKAGE_SIGNING_KEY_PREFIX}${packageId}`;
147
+ const table = (0, persistent_vars_1.PersistentVars)(personalContext);
148
+ const existingKey = await table.findOne({ name: keyName });
149
+ const rawSecret = existingKey?.value?.value;
150
+ const cachedKey = cachedSigningPublicKey(existingKey?.description, packageId);
151
+ let derivedKey;
152
+ if (typeof rawSecret === "string" && rawSecret.length > 0) {
153
+ try {
154
+ derivedKey = validSigningPublicKey((0, keys_1.hydrateKeys)(rawSecret).publicKey);
155
+ }
156
+ catch {
157
+ // Encrypted-at-rest secrets cannot be hydrated directly by the SDK.
158
+ }
159
+ if (!derivedKey && opts.resolveSigningPublicKey) {
126
160
  try {
127
- const derivedPublicKey = (0, keys_1.hydrateKeys)(rawSecret).publicKey;
128
- if (establishedKey && derivedPublicKey !== establishedKey) {
129
- throw new Error(`Package signing key mismatch for "${packageName}" (${packageId}): ` +
130
- "stored secret does not match Packages.publishPublicKey. " +
131
- "Refusing to rotate the established key.");
132
- }
133
- return establishedKey || derivedPublicKey;
161
+ derivedKey = validSigningPublicKey(await opts.resolveSigningPublicKey(rawSecret));
134
162
  }
135
- catch (err) {
136
- if (err instanceof Error && err.message.includes("signing key mismatch")) {
137
- throw err;
138
- }
139
- // Secret is likely encrypted at rest — cannot derive here without the user key.
140
- if (establishedKey) {
141
- return establishedKey;
142
- }
143
- throw new Error(`Package "${packageName}" (${packageId}) has a signing secret pvar but no ` +
144
- "publishPublicKey, and the secret could not be hydrated. " +
145
- "Restore publishPublicKey or replace the unreadable key before importing.");
163
+ catch {
164
+ // Decryption is best-effort. Never expose ciphertext, plaintext, or host errors.
146
165
  }
147
166
  }
148
167
  }
149
- if (establishedKey) {
150
- return establishedKey;
168
+ const publicKey = establishedKey || cachedKey || derivedKey;
169
+ if ([establishedKey, cachedKey, derivedKey].some((key) => key && key !== publicKey)) {
170
+ throw new Error(`Package signing key mismatch for "${packageName}" (${packageId}): ` +
171
+ "Packages.publishPublicKey, cached public key, and stored secret must agree. " +
172
+ "Refusing to rotate the established key.");
151
173
  }
152
- if (!personalContext) {
153
- return "";
174
+ if (publicKey) {
175
+ if (existingKey && validSigningPublicKey(publicKey)) {
176
+ const description = signingKeyDescription(existingKey.description || "", packageId, publicKey);
177
+ if (description !== existingKey.description) {
178
+ // Keep the ciphertext identical so PersistentVarsTable.save does not encrypt it again.
179
+ await table.save({ ...existingKey, description });
180
+ }
181
+ }
182
+ return publicKey;
183
+ }
184
+ if (rawSecret !== undefined && rawSecret !== null && rawSecret !== "") {
185
+ throw new Error(`Package "${packageName}" (${packageId}) has a signing secret pvar but no ` +
186
+ "recoverable publishPublicKey, and the secret could not be hydrated. " +
187
+ "Restore publishPublicKey or unlock the signing secret before importing.");
154
188
  }
155
189
  const keys = (0, keys_1.newKeys)();
156
- const keyName = `${package_author_signing_1.PACKAGE_SIGNING_KEY_PREFIX}${packageId}`;
157
- await (0, persistent_vars_1.PersistentVars)(personalContext).save({
190
+ await table.save({
158
191
  persistentVarId: (0, utils_1.deterministicPvarId)(keyName),
159
192
  name: keyName,
160
193
  scope: "user",
161
194
  value: { value: keys.secretKey },
162
195
  isSecret: true,
163
- description: `Signing key for package "${packageName}"`,
196
+ description: signingKeyDescription(`Signing key for package "${packageName}"`, packageId, keys.publicKey),
164
197
  modifiedAt: Date.now(),
165
- });
198
+ }, { restoreIfDeleted: true });
166
199
  return keys.publicKey;
167
200
  }
168
201
  async function registerPackageRecord(dataContext, opts) {
@@ -195,7 +228,9 @@ async function persistPackageLocalPath(dataContext, deps, packageId, location) {
195
228
  *
196
229
  * Validates the manifest packageId, creates/updates the Packages record, persists
197
230
  * `packageLocalPath_*`, provisions a signing key when missing, then installs the
198
- * local dev bundles.
231
+ * local dev bundles. When the target Packages row is absent, reuses the first
232
+ * nonblank public key for the same packageId from the user's personal context
233
+ * or groups. Other contexts' package metadata is not copied.
199
234
  */
200
235
  async function importExistingPackage(dataContext, deps, opts) {
201
236
  const info = await (0, package_installer_1.getPackageInfo)(deps, opts.location);
@@ -204,12 +239,30 @@ async function importExistingPackage(dataContext, deps, opts) {
204
239
  }
205
240
  const packageId = info.packageId;
206
241
  const existing = await (0, packages_1.Packages)(dataContext).get(packageId);
242
+ let existingPublishPublicKey = existing?.publishPublicKey;
243
+ const userContext = dataContext.userContext;
244
+ if (!existing && userContext) {
245
+ for (const sourceContext of [
246
+ userContext.userDataContext,
247
+ ...userContext.groupDataContexts.values(),
248
+ ]) {
249
+ if (sourceContext.dataContextId === dataContext.dataContextId)
250
+ continue;
251
+ const sourcePackage = await (0, packages_1.Packages)(sourceContext).get(packageId);
252
+ const sourcePublicKey = sourcePackage?.publishPublicKey?.trim();
253
+ if (sourcePublicKey) {
254
+ existingPublishPublicKey = sourcePublicKey;
255
+ break;
256
+ }
257
+ }
258
+ }
207
259
  const packageName = opts.displayName || info.name || packageId;
208
260
  const publishPublicKey = await ensurePackageSigningKey({
209
261
  personalContext: opts.personalContext,
210
262
  packageId,
211
263
  packageName,
212
- existingPublishPublicKey: existing?.publishPublicKey,
264
+ existingPublishPublicKey,
265
+ resolveSigningPublicKey: opts.resolveSigningPublicKey,
213
266
  });
214
267
  await registerPackageRecord(dataContext, {
215
268
  packageId,
@@ -248,6 +301,7 @@ async function createPackage(dataContext, deps, opts) {
248
301
  location,
249
302
  personalContext: opts.personalContext,
250
303
  displayName: opts.name,
304
+ resolveSigningPublicKey: opts.resolveSigningPublicKey,
251
305
  });
252
306
  }
253
307
  await (0, package_dev_tools_1.assertPackageDevTools)(deps);
@@ -313,6 +367,7 @@ async function createPackage(dataContext, deps, opts) {
313
367
  personalContext: opts.personalContext,
314
368
  packageId,
315
369
  packageName: opts.name,
370
+ resolveSigningPublicKey: opts.resolveSigningPublicKey,
316
371
  });
317
372
  await registerPackageRecord(dataContext, {
318
373
  packageId,
@@ -1,14 +1,16 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ const system_ids_1 = require("../system-ids");
4
+ const utils_1 = require("../utils");
3
5
  const package_creator_1 = require("./package-creator");
4
6
  const package_dev_tools_1 = require("./package-dev-tools");
5
7
  // --- Mocks for SDK internals ---
6
8
  const mockSignAndSavePkg = jest.fn();
7
9
  const mockGetPkg = jest.fn();
8
10
  jest.mock("../data/packages", () => ({
9
- Packages: () => ({
10
- signAndSave: mockSignAndSavePkg,
11
- get: mockGetPkg,
11
+ Packages: (dataContext) => ({
12
+ signAndSave: (...args) => mockSignAndSavePkg(dataContext, ...args),
13
+ get: (packageId) => mockGetPkg(dataContext, packageId),
12
14
  }),
13
15
  }));
14
16
  const mockGroupDeviceVar = jest.fn();
@@ -36,6 +38,10 @@ jest.mock("./package-installer", () => ({
36
38
  installPackageFromBundles: (...args) => mockInstallPackageFromBundles(...args),
37
39
  getPackageInfo: (...args) => mockGetPackageInfo(...args),
38
40
  }));
41
+ const { newKeys: realNewKeys } = jest.requireActual("../keys");
42
+ const generatedKeys = realNewKeys();
43
+ const sourceKeys = realNewKeys();
44
+ const targetKeys = realNewKeys();
39
45
  // --- Test utilities ---
40
46
  function makeMockDeps(files, shellExec) {
41
47
  return {
@@ -67,6 +73,7 @@ function makeMockDeps(files, shellExec) {
67
73
  }
68
74
  function mockDataContext() {
69
75
  return {
76
+ dataContextId: (0, utils_1.newid)(),
70
77
  packageLoader: {
71
78
  loadPackage: jest.fn().mockResolvedValue(undefined),
72
79
  },
@@ -75,13 +82,10 @@ function mockDataContext() {
75
82
  // --- Tests ---
76
83
  beforeEach(() => {
77
84
  jest.clearAllMocks();
78
- mockGetPkg.mockResolvedValue(undefined);
79
- mockPersistentVarsFindOne.mockResolvedValue(undefined);
80
- mockNewKeys.mockReturnValue({
81
- publicKey: "test-public-key-abc",
82
- secretKey: "test-secret-key-xyz",
83
- publicBoxKey: "test-box-key",
84
- });
85
+ mockGetPkg.mockReset().mockResolvedValue(undefined);
86
+ mockPersistentVarsFindOne.mockReset().mockResolvedValue(undefined);
87
+ mockNewKeys.mockReturnValue(generatedKeys);
88
+ mockPersistentVarsSave.mockReset();
85
89
  });
86
90
  describe("unwrapAddOrUpdatePackageResult", () => {
87
91
  it("accepts a legacy packageId string", () => {
@@ -204,7 +208,7 @@ describe("createPackage", () => {
204
208
  cwd: "/packages/my-widget",
205
209
  });
206
210
  // Verify package record was created
207
- expect(mockSignAndSavePkg).toHaveBeenCalledWith(expect.objectContaining({
211
+ expect(mockSignAndSavePkg).toHaveBeenCalledWith(dc, expect.objectContaining({
208
212
  name: "My Widget",
209
213
  disabled: false,
210
214
  }), { restoreIfDeleted: true, saveAsSnapshot: true });
@@ -273,18 +277,18 @@ describe("createPackage", () => {
273
277
  personalContext: personalCtx,
274
278
  });
275
279
  expect(deps.shell.exec).not.toHaveBeenCalled();
276
- expect(mockGetPkg).toHaveBeenCalledWith("00mnkoyo7mizav6tseubjzr3a");
280
+ expect(mockGetPkg).toHaveBeenCalledWith(dc, "00mnkoyo7mizav6tseubjzr3a");
277
281
  expect(mockNewKeys).toHaveBeenCalled();
278
282
  expect(mockPersistentVarsSave).toHaveBeenCalledWith(expect.objectContaining({
279
283
  name: "packageSigningKey_00mnkoyo7mizav6tseubjzr3a",
280
284
  isSecret: true,
281
- value: { value: "test-secret-key-xyz" },
282
- }));
283
- expect(mockSignAndSavePkg).toHaveBeenCalledWith(expect.objectContaining({
285
+ value: { value: generatedKeys.secretKey },
286
+ }), { restoreIfDeleted: true });
287
+ expect(mockSignAndSavePkg).toHaveBeenCalledWith(dc, expect.objectContaining({
284
288
  packageId: "00mnkoyo7mizav6tseubjzr3a",
285
289
  name: "Existing Pkg",
286
290
  description: "Imported package",
287
- publishPublicKey: "test-public-key-abc",
291
+ publishPublicKey: generatedKeys.publicKey,
288
292
  }), { restoreIfDeleted: true, saveAsSnapshot: true });
289
293
  expect(mockGroupDeviceVar).toHaveBeenCalledWith("packageLocalPath_00mnkoyo7mizav6tseubjzr3a", expect.any(Object));
290
294
  expect(pvarSetter).toHaveBeenCalledWith("/custom/path");
@@ -324,11 +328,145 @@ describe("createPackage", () => {
324
328
  });
325
329
  expect(mockNewKeys).not.toHaveBeenCalled();
326
330
  expect(mockPersistentVarsSave).not.toHaveBeenCalled();
327
- expect(mockSignAndSavePkg).toHaveBeenCalledWith(expect.objectContaining({
331
+ expect(mockSignAndSavePkg).toHaveBeenCalledWith(dc, expect.objectContaining({
328
332
  publishPublicKey: "already-established-public-key",
329
333
  createdBy: "00m87fbwu96jzf8qnwllly0fd",
330
334
  }), expect.any(Object));
331
335
  });
336
+ describe("importing by name across contexts", () => {
337
+ const packageId = "00mnkoyo7mizav6tseubjzr3a";
338
+ let contexts;
339
+ let deps;
340
+ beforeEach(() => {
341
+ contexts = {
342
+ personal: mockDataContext(),
343
+ blankGroup: mockDataContext(),
344
+ group: mockDataContext(),
345
+ otherGroup: mockDataContext(),
346
+ };
347
+ const userContext = {
348
+ userId: contexts.personal.dataContextId,
349
+ userDataContext: contexts.personal,
350
+ groupDataContexts: new Map(Object.values(contexts)
351
+ .slice(1)
352
+ .map((dc) => [dc.dataContextId, dc])),
353
+ };
354
+ for (const dc of Object.values(contexts))
355
+ dc.userContext = userContext;
356
+ deps = makeMockDeps({ "/packages/existing-pkg/package.json": "{}" });
357
+ mockGetPackageInfo.mockResolvedValue({ packageId, description: "Imported package" });
358
+ mockGroupDeviceVar.mockReturnValue(jest.fn());
359
+ mockInstallPackageFromBundles.mockResolvedValue({ packageVersion: { packageId } });
360
+ mockPersistentVarsFindOne.mockResolvedValue({ value: { value: "unreadable-secret" } });
361
+ });
362
+ it.each([
363
+ ["group", "personal", ["personal", "blankGroup", "group"]],
364
+ ["group", "otherGroup", ["otherGroup", "personal", "blankGroup", "group"]],
365
+ ["personal", "group", ["group", "personal"]],
366
+ ])("reuses the key from %s when importing into %s", async (source, target, lookups) => {
367
+ mockGetPkg.mockImplementation(async (dc, id) => {
368
+ if (id !== packageId || dc === contexts[target])
369
+ return undefined;
370
+ if (dc === contexts[source]) {
371
+ return {
372
+ publishPublicKey: sourceKeys.publicKey,
373
+ name: "Source name",
374
+ description: "Source description",
375
+ createdBy: (0, utils_1.newid)(),
376
+ disabled: true,
377
+ activePackageVersionId: (0, utils_1.newid)(),
378
+ versionFollowRange: "^1.0.0",
379
+ followVersionTags: ["stable"],
380
+ updateUrl: "source-url",
381
+ remoteRepo: "source-repo",
382
+ signature: "source-signature",
383
+ };
384
+ }
385
+ if (dc === contexts.personal || dc === contexts.blankGroup) {
386
+ return { publishPublicKey: " \t " };
387
+ }
388
+ return undefined;
389
+ });
390
+ const result = await (0, package_creator_1.createPackage)(contexts[target], deps, {
391
+ name: "Existing Pkg",
392
+ personalContext: contexts.personal,
393
+ });
394
+ expect(mockGetPkg.mock.calls).toEqual(lookups.map((name) => [contexts[name], packageId]));
395
+ expect(mockSignAndSavePkg).toHaveBeenCalledWith(contexts[target], expect.objectContaining({
396
+ packageId,
397
+ name: "Existing Pkg",
398
+ description: "Imported package",
399
+ publishPublicKey: sourceKeys.publicKey,
400
+ createdBy: system_ids_1.peersRootUserId,
401
+ disabled: false,
402
+ activePackageVersionId: undefined,
403
+ versionFollowRange: undefined,
404
+ followVersionTags: undefined,
405
+ updateUrl: undefined,
406
+ remoteRepo: undefined,
407
+ signature: "",
408
+ }), { restoreIfDeleted: true, saveAsSnapshot: true });
409
+ expect(mockInstallPackageFromBundles).toHaveBeenCalledWith(contexts[target], deps, packageId, {
410
+ localPath: "/packages/existing-pkg",
411
+ });
412
+ expect(result.packageVersion).toEqual({ packageId });
413
+ expect(mockNewKeys).not.toHaveBeenCalled();
414
+ expect(mockPersistentVarsSave).toHaveBeenCalledWith(expect.objectContaining({
415
+ value: { value: "unreadable-secret" },
416
+ description: expect.stringContaining(`v1:${packageId}:${sourceKeys.publicKey}`),
417
+ }));
418
+ });
419
+ it("keeps the target key without looking up a differing source key", async () => {
420
+ mockGetPkg.mockImplementation(async (dc) => ({
421
+ publishPublicKey: dc === contexts.otherGroup ? targetKeys.publicKey : sourceKeys.publicKey,
422
+ }));
423
+ await (0, package_creator_1.createPackage)(contexts.otherGroup, deps, {
424
+ name: "Existing Pkg",
425
+ personalContext: contexts.personal,
426
+ });
427
+ expect(mockGetPkg.mock.calls).toEqual([[contexts.otherGroup, packageId]]);
428
+ expect(mockSignAndSavePkg).toHaveBeenCalledWith(contexts.otherGroup, expect.objectContaining({ publishPublicKey: targetKeys.publicKey }), expect.any(Object));
429
+ expect(mockNewKeys).not.toHaveBeenCalled();
430
+ expect(mockPersistentVarsSave).toHaveBeenCalledWith(expect.objectContaining({
431
+ value: { value: "unreadable-secret" },
432
+ description: expect.stringContaining(`v1:${packageId}:${targetKeys.publicKey}`),
433
+ }));
434
+ });
435
+ it.each([
436
+ "missing",
437
+ "mismatch",
438
+ "lookup-error",
439
+ "empty-target",
440
+ ])("fails without installing or changing keys for %s", async (failure) => {
441
+ mockGetPkg.mockImplementation(async (dc) => {
442
+ if (dc === contexts.otherGroup) {
443
+ return failure === "empty-target" ? { publishPublicKey: "" } : undefined;
444
+ }
445
+ if (failure === "lookup-error")
446
+ throw new Error("source lookup failed");
447
+ return failure === "missing" ? undefined : { publishPublicKey: sourceKeys.publicKey };
448
+ });
449
+ if (failure === "mismatch") {
450
+ const { newKeys: realNewKeys } = jest.requireActual("../keys");
451
+ mockPersistentVarsFindOne.mockResolvedValue({ value: { value: realNewKeys().secretKey } });
452
+ }
453
+ await expect((0, package_creator_1.createPackage)(contexts.otherGroup, deps, {
454
+ name: "Existing Pkg",
455
+ personalContext: contexts.personal,
456
+ })).rejects.toThrow(failure === "mismatch"
457
+ ? "signing key mismatch"
458
+ : failure === "lookup-error"
459
+ ? "source lookup failed"
460
+ : "secret could not be hydrated");
461
+ if (failure === "empty-target") {
462
+ expect(mockGetPkg.mock.calls).toEqual([[contexts.otherGroup, packageId]]);
463
+ }
464
+ expect(mockSignAndSavePkg).not.toHaveBeenCalled();
465
+ expect(mockInstallPackageFromBundles).not.toHaveBeenCalled();
466
+ expect(mockNewKeys).not.toHaveBeenCalled();
467
+ expect(mockPersistentVarsSave).not.toHaveBeenCalled();
468
+ });
469
+ });
332
470
  it("throws when directory exists but has no valid package", async () => {
333
471
  const deps = makeMockDeps({});
334
472
  const dc = mockDataContext();
@@ -410,13 +548,13 @@ describe("createPackage", () => {
410
548
  expect(mockPersistentVarsSave).toHaveBeenCalledWith(expect.objectContaining({
411
549
  name: expect.stringMatching(/^packageSigningKey_[a-z0-9]{25}$/),
412
550
  scope: "user",
413
- value: { value: "test-secret-key-xyz" },
551
+ value: { value: generatedKeys.secretKey },
414
552
  isSecret: true,
415
553
  description: expect.stringContaining("Signed Pkg"),
416
- }));
554
+ }), { restoreIfDeleted: true });
417
555
  // Verify publishPublicKey was set on the package record
418
- expect(mockSignAndSavePkg).toHaveBeenCalledWith(expect.objectContaining({
419
- publishPublicKey: "test-public-key-abc",
556
+ expect(mockSignAndSavePkg).toHaveBeenCalledWith(dc, expect.objectContaining({
557
+ publishPublicKey: generatedKeys.publicKey,
420
558
  }), expect.any(Object));
421
559
  });
422
560
  it("skips key generation when personalContext is not provided", async () => {
@@ -441,7 +579,7 @@ describe("createPackage", () => {
441
579
  // Should NOT save any pvar
442
580
  expect(mockPersistentVarsSave).not.toHaveBeenCalled();
443
581
  // publishPublicKey should be empty string
444
- expect(mockSignAndSavePkg).toHaveBeenCalledWith(expect.objectContaining({
582
+ expect(mockSignAndSavePkg).toHaveBeenCalledWith(dc, expect.objectContaining({
445
583
  publishPublicKey: "",
446
584
  }), expect.any(Object));
447
585
  });
@@ -454,9 +592,14 @@ describe("ensurePackageSigningKey", () => {
454
592
  packageId: "00mnkoyo7mizav6tseubjzr3a",
455
593
  packageName: "Test Pkg",
456
594
  });
457
- expect(publicKey).toBe("test-public-key-abc");
595
+ expect(publicKey).toBe(generatedKeys.publicKey);
458
596
  expect(mockNewKeys).toHaveBeenCalledTimes(1);
459
597
  expect(mockPersistentVarsSave).toHaveBeenCalledTimes(1);
598
+ expect(mockPersistentVarsSave).toHaveBeenCalledWith(expect.objectContaining({
599
+ persistentVarId: (0, utils_1.deterministicPvarId)("packageSigningKey_00mnkoyo7mizav6tseubjzr3a"),
600
+ value: { value: generatedKeys.secretKey },
601
+ description: `Signing key for package "Test Pkg"\npeers-package-signing-key:v1:00mnkoyo7mizav6tseubjzr3a:${generatedKeys.publicKey}`,
602
+ }), { restoreIfDeleted: true });
460
603
  });
461
604
  it("reuses an established publishPublicKey without generating a new key", async () => {
462
605
  const personalCtx = mockDataContext();
@@ -489,6 +632,148 @@ describe("ensurePackageSigningKey", () => {
489
632
  expect(mockNewKeys).not.toHaveBeenCalled();
490
633
  });
491
634
  });
635
+ describe("signing key recovery after uninstall", () => {
636
+ const packageId = "00mnkoyo7mizav6tseubjzr3a";
637
+ const marker = `peers-package-signing-key:v1:${packageId}:${generatedKeys.publicKey}`;
638
+ let record;
639
+ let deps;
640
+ let personalContext;
641
+ let target;
642
+ beforeEach(() => {
643
+ personalContext = mockDataContext();
644
+ target = mockDataContext();
645
+ record = {
646
+ persistentVarId: (0, utils_1.deterministicPvarId)(`packageSigningKey_${packageId}`),
647
+ name: `packageSigningKey_${packageId}`,
648
+ scope: "user",
649
+ isSecret: true,
650
+ value: { value: "encrypted-secret~nonce" },
651
+ description: 'Signing key for package "Test Pkg"',
652
+ modifiedAt: 123,
653
+ };
654
+ mockPersistentVarsFindOne.mockImplementation(async () => record);
655
+ mockPersistentVarsSave.mockImplementation(async (saved) => {
656
+ record = saved;
657
+ });
658
+ deps = makeMockDeps({ "/packages/test-pkg/package.json": "{}" });
659
+ mockGetPackageInfo.mockResolvedValue({ packageId, name: "test-pkg" });
660
+ mockGroupDeviceVar.mockReturnValue(jest.fn());
661
+ mockInstallPackageFromBundles.mockResolvedValue({ packageVersion: { packageId } });
662
+ });
663
+ async function importPackage(resolveSigningPublicKey) {
664
+ return (0, package_creator_1.createPackage)(target, deps, {
665
+ name: "Test Pkg",
666
+ personalContext,
667
+ resolveSigningPublicKey,
668
+ });
669
+ }
670
+ function expectNoWrites() {
671
+ expect(mockPersistentVarsSave).not.toHaveBeenCalled();
672
+ expect(mockNewKeys).not.toHaveBeenCalled();
673
+ expect(mockSignAndSavePkg).not.toHaveBeenCalled();
674
+ expect(mockGroupDeviceVar).not.toHaveBeenCalled();
675
+ expect(mockInstallPackageFromBundles).not.toHaveBeenCalled();
676
+ }
677
+ it("recovers a cached public key with no Packages row or decryption callback", async () => {
678
+ record.description += `\n${marker}`;
679
+ await importPackage();
680
+ expect(mockSignAndSavePkg).toHaveBeenCalledWith(target, expect.objectContaining({ publishPublicKey: generatedKeys.publicKey }), expect.any(Object));
681
+ expect(mockPersistentVarsSave).not.toHaveBeenCalled();
682
+ expect(mockNewKeys).not.toHaveBeenCalled();
683
+ });
684
+ it("recovers a legacy encrypted secret through the host and backfills only description once", async () => {
685
+ const original = { ...record };
686
+ const resolve = jest.fn(async () => generatedKeys.publicKey);
687
+ await importPackage(resolve);
688
+ expect(resolve).toHaveBeenCalledWith(original.value.value);
689
+ expect(mockPersistentVarsSave).toHaveBeenCalledWith({
690
+ ...original,
691
+ description: `${original.description}\n${marker}`,
692
+ });
693
+ await importPackage(resolve);
694
+ expect(mockPersistentVarsSave).toHaveBeenCalledTimes(1);
695
+ expect(record.value).toBe(original.value);
696
+ expect(mockNewKeys).not.toHaveBeenCalled();
697
+ });
698
+ it("retains raw-secret hydration and backfills its public key without invoking the host", async () => {
699
+ record.value = { value: generatedKeys.secretKey };
700
+ const resolve = jest.fn();
701
+ await importPackage(resolve);
702
+ expect(resolve).not.toHaveBeenCalled();
703
+ expect(record.description).toContain(marker);
704
+ expect(record.value.value).toBe(generatedKeys.secretKey);
705
+ });
706
+ it.each([
707
+ "absent",
708
+ "throws",
709
+ "undefined",
710
+ "invalid",
711
+ ])("fails safely when host recovery is %s", async (mode) => {
712
+ const resolve = mode === "absent"
713
+ ? undefined
714
+ : jest.fn(async () => {
715
+ if (mode === "throws")
716
+ throw new Error(`sensitive ${record.value.value} ${generatedKeys.secretKey}`);
717
+ return mode === "invalid" ? "not-a-public-key" : undefined;
718
+ });
719
+ await expect(importPackage(resolve)).rejects.toThrow(`Package "Test Pkg" (${packageId}) has a signing secret pvar but no recoverable publishPublicKey, ` +
720
+ "and the secret could not be hydrated. Restore publishPublicKey or unlock the signing secret before importing.");
721
+ expectNoWrites();
722
+ });
723
+ it.each([
724
+ "established",
725
+ "cached",
726
+ ])("uses the %s key when host decryption fails", async (source) => {
727
+ if (source === "cached")
728
+ record.description += `\n${marker}`;
729
+ else
730
+ mockGetPkg.mockResolvedValue({ publishPublicKey: generatedKeys.publicKey });
731
+ await importPackage(async () => {
732
+ throw new Error("decryption unavailable");
733
+ });
734
+ expect(record.description).toContain(marker);
735
+ expect(record.value.value).toBe("encrypted-secret~nonce");
736
+ expect(mockNewKeys).not.toHaveBeenCalled();
737
+ });
738
+ it.each([
739
+ "",
740
+ `peers-package-signing-key:v2:${packageId}:${generatedKeys.publicKey}`,
741
+ `peers-package-signing-key:v1:${(0, utils_1.newid)()}:${generatedKeys.publicKey}`,
742
+ `peers-package-signing-key:v1:${packageId}:invalid-key`,
743
+ `${marker}:extra`,
744
+ `prefix ${marker}`,
745
+ `${marker}\n${marker}`,
746
+ ])("does not trust malformed or unrelated metadata: %s", async (description) => {
747
+ record.description = description;
748
+ await expect(importPackage()).rejects.toThrow("secret could not be hydrated");
749
+ expectNoWrites();
750
+ });
751
+ it("repairs malformed metadata after recovering a valid key", async () => {
752
+ record.description += `\npeers-package-signing-key:v1:${packageId}:invalid`;
753
+ await importPackage(async () => generatedKeys.publicKey);
754
+ expect(record.description).toBe(`Signing key for package "Test Pkg"\n${marker}`);
755
+ expect(record.value.value).toBe("encrypted-secret~nonce");
756
+ });
757
+ it.each([
758
+ "established/cache",
759
+ "raw/cache",
760
+ "host/cache",
761
+ "host/established",
762
+ ])("rejects a %s mismatch before writes", async (source) => {
763
+ if (source.endsWith("cache"))
764
+ record.description += `\n${marker}`;
765
+ if (source.startsWith("established") || source.endsWith("established")) {
766
+ mockGetPkg.mockResolvedValue({ publishPublicKey: sourceKeys.publicKey });
767
+ }
768
+ if (source === "raw/cache")
769
+ record.value = { value: sourceKeys.secretKey };
770
+ const resolve = source.startsWith("host")
771
+ ? async () => (source.endsWith("cache") ? sourceKeys.publicKey : generatedKeys.publicKey)
772
+ : undefined;
773
+ await expect(importPackage(resolve)).rejects.toThrow("signing key mismatch");
774
+ expectNoWrites();
775
+ });
776
+ });
492
777
  describe("ensureTemplateCache", () => {
493
778
  it("clones template when cache directory does not exist", async () => {
494
779
  const deps = makeMockDeps({});
@@ -83,6 +83,14 @@ export interface ICreatePackageOpts {
83
83
  * personal storage isn't available). The migration script backfills these.
84
84
  */
85
85
  personalContext?: import("../context/data-context").DataContext;
86
+ /**
87
+ * Optional host-only recovery for an encrypted personal signing secret. Return
88
+ * only its Ed25519 public key, or `undefined` when recovery is unavailable.
89
+ * Called after raw hydration fails, including when a public key is already known
90
+ * so mismatches can be rejected. Errors are suppressed without logging secret
91
+ * material; existing or cached public keys remain usable when decryption fails.
92
+ */
93
+ resolveSigningPublicKey?: (encryptedSecret: string) => Promise<string | undefined>;
86
94
  }
87
95
  /** Why a `git pull` of the cached package template did not succeed. */
88
96
  export type TemplateCacheRefreshFailure = "offline" | "could-not-refresh";
@@ -72,6 +72,8 @@ export interface IIsolatedPackageNetworkCredential {
72
72
  *
73
73
  * Honored at install the same way `suggestedAccessLevel` is: there is no
74
74
  * separate approval step yet. The Packages screen displays the declaration.
75
+ * This constrains the System HTTP bridge in both execution modes. Privileged
76
+ * providers can also use Node's network APIs directly, outside this policy.
75
77
  */
76
78
  export interface IIsolatedPackageNetwork {
77
79
  allowedHosts: string[];
@@ -152,11 +154,21 @@ export interface IIsolatedActionAlias {
152
154
  export interface IIsolatedPackageManifest {
153
155
  packageId: string;
154
156
  version: string;
157
+ /**
158
+ * Provider execution backend. Omission selects the SES sandbox. Privileged
159
+ * providers run as trusted code in a separate Node process with the host's
160
+ * OS account, environment, working directory, and unrestricted OS access;
161
+ * this does not elevate to root/admin and does not apply to UI code.
162
+ */
163
+ executionMode?: "sandbox" | "privileged";
155
164
  provides: IIsolatedProvidedContract[];
156
165
  consumes: IIsolatedConsumedContract[];
157
166
  /** Package-owned table schemas. Contract references decide public exposure. */
158
167
  tables?: IIsolatedPackageTable[];
159
- /** Host allowlist and credential refs for System HTTP. Omit to deny outbound HTTP. */
168
+ /**
169
+ * System HTTP allowlist and credentials. Omit to deny requests through that bridge.
170
+ * Privileged providers retain direct OS/network access.
171
+ */
160
172
  network?: IIsolatedPackageNetwork;
161
173
  appNavs?: IAppNav[];
162
174
  /** Private checked targets, independent of the frozen Voice Skill catalog. */
@@ -164,7 +176,7 @@ export interface IIsolatedPackageManifest {
164
176
  }
165
177
  /**
166
178
  * Installable isolated package: declarative manifest plus provider source
167
- * that may run only inside a supervised worker.
179
+ * that runs only inside a supervised sandbox worker or privileged Node process.
168
180
  */
169
181
  export interface IIsolatedPackageArtifact {
170
182
  manifest: IIsolatedPackageManifest;
@@ -587,10 +587,16 @@ function validateIsolatedPackageArtifact(artifact) {
587
587
  "network",
588
588
  "appNavs",
589
589
  "actionAliases",
590
+ "executionMode",
590
591
  ], "manifest");
591
592
  if (typeof providerSource !== "string" || providerSource.trim() === "") {
592
593
  throw new Error("Package artifact.providerSource must be a non-empty string");
593
594
  }
595
+ if (manifest.executionMode !== undefined &&
596
+ manifest.executionMode !== "sandbox" &&
597
+ manifest.executionMode !== "privileged") {
598
+ throw new Error('manifest.executionMode must be "sandbox" or "privileged"');
599
+ }
594
600
  assertPeerId(manifest.packageId, "packageId");
595
601
  if (typeof manifest.version !== "string" || manifest.version.trim() === "") {
596
602
  throw new Error("package version is required");
@@ -40,6 +40,40 @@ function makeArtifact(overrides = {}) {
40
40
  };
41
41
  }
42
42
  describe("isolated package artifact", () => {
43
+ test.each([
44
+ undefined,
45
+ "sandbox",
46
+ "privileged",
47
+ ])("round-trips executionMode=%s without changing the legacy envelope", (executionMode) => {
48
+ const { artifact } = makeArtifact();
49
+ if (executionMode !== undefined)
50
+ artifact.manifest.executionMode = executionMode;
51
+ const bundle = (0, isolated_artifact_1.serializeIsolatedPackageArtifact)(artifact);
52
+ const parsed = (0, isolated_artifact_1.parseIsolatedPackageArtifact)(bundle);
53
+ expect(parsed.manifest.executionMode).toBe(executionMode);
54
+ expect((0, isolated_artifact_1.serializeIsolatedPackageArtifact)(parsed)).toBe(bundle);
55
+ if (executionMode === undefined)
56
+ expect(bundle).not.toContain("executionMode");
57
+ });
58
+ test.each([
59
+ null,
60
+ true,
61
+ false,
62
+ 1,
63
+ "",
64
+ "node",
65
+ "Privileged",
66
+ {},
67
+ [],
68
+ ])("rejects malformed executionMode=%p at validation and parse time", (executionMode) => {
69
+ const { artifact } = makeArtifact();
70
+ Object.assign(artifact.manifest, { executionMode });
71
+ const bundle = `${isolated_artifact_1.ISOLATED_PACKAGE_MAGIC}\n${JSON.stringify(artifact)}\n`;
72
+ expect((0, isolated_artifact_1.looksLikeIsolatedPackageArtifact)(bundle)).toBe(true);
73
+ expect(() => (0, isolated_artifact_1.validateIsolatedPackageArtifact)(artifact)).toThrow(/executionMode/);
74
+ expect(() => (0, isolated_artifact_1.parseIsolatedPackageArtifact)(bundle)).toThrow(/executionMode/);
75
+ expect(() => (0, isolated_artifact_1.serializeIsolatedPackageArtifact)(artifact)).toThrow(/executionMode/);
76
+ });
43
77
  test("round-trips a generic authority request and rejects unknown modes", () => {
44
78
  const { artifact } = makeArtifact();
45
79
  const tool = artifact.manifest.provides[0].tools[0];
@@ -252,7 +252,12 @@ describe("PackageLoader isolated artifacts", () => {
252
252
  (0, isolated_runtime_1.setIsolatedPackageRuntimeFactory)(() => runtime);
253
253
  return runtime;
254
254
  }
255
- it("installs a valid isolated artifact without evaluating provider source", async () => {
255
+ it.each([
256
+ undefined,
257
+ "sandbox",
258
+ "privileged",
259
+ ])("installs executionMode=%s without host evaluation", async (executionMode) => {
260
+ artifact.manifest.executionMode = executionMode;
256
261
  const runtime = makeRuntime();
257
262
  mockGet.mockResolvedValue(selectedVersion());
258
263
  mockGetFileContents.mockResolvedValue(Buffer.from((0, isolated_artifact_1.serializeIsolatedPackageArtifact)(artifact)));
@@ -405,6 +410,21 @@ describe("PackageLoader isolated artifacts", () => {
405
410
  await expect(loader.loadPackage(pkg)).rejects.toThrow(/JSON is invalid/);
406
411
  expect(globalThis.__shouldNotEvalOnHost).toBeUndefined();
407
412
  });
413
+ it.each([
414
+ null,
415
+ true,
416
+ "unknown",
417
+ ])("fails closed on malformed executionMode=%p", async (executionMode) => {
418
+ const runtime = makeRuntime();
419
+ mockGet.mockResolvedValue(selectedVersion());
420
+ const malformed = structuredClone(artifact);
421
+ Object.assign(malformed.manifest, { executionMode });
422
+ mockGetFileContents.mockResolvedValue(Buffer.from(`PEERS_ISOLATED_PACKAGE_V1\n${JSON.stringify(malformed)}`));
423
+ const loader = new package_loader_1.PackageLoader({ dataContextId: "ctx-bad-mode" });
424
+ await expect(loader.loadPackage(pkg)).rejects.toThrow(/executionMode/);
425
+ expect(runtime.install).not.toHaveBeenCalled();
426
+ expect(globalThis.__shouldNotEvalOnHost).toBeUndefined();
427
+ });
408
428
  it("passes gateways and a trusted local identity resolver from its data context", () => {
409
429
  const factory = jest.fn((_options) => ({
410
430
  install: jest.fn(async () => ({ packageId, provided: [], consumes: [] })),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peers-app/peers-sdk",
3
- "version": "0.25.19",
3
+ "version": "0.25.21",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/peers-app/peers-sdk.git"