@milaboratories/pl-middle-layer 1.64.41 → 1.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/index.cjs +17 -0
  2. package/dist/index.d.ts +3 -1
  3. package/dist/index.js +3 -1
  4. package/dist/middle_layer/frontend_path.cjs +1 -0
  5. package/dist/middle_layer/frontend_path.cjs.map +1 -1
  6. package/dist/middle_layer/frontend_path.js +1 -0
  7. package/dist/middle_layer/frontend_path.js.map +1 -1
  8. package/dist/middle_layer/index.d.ts +2 -1
  9. package/dist/middle_layer/middle_layer.cjs +482 -16
  10. package/dist/middle_layer/middle_layer.cjs.map +1 -1
  11. package/dist/middle_layer/middle_layer.d.ts +172 -12
  12. package/dist/middle_layer/middle_layer.d.ts.map +1 -1
  13. package/dist/middle_layer/middle_layer.js +485 -19
  14. package/dist/middle_layer/middle_layer.js.map +1 -1
  15. package/dist/middle_layer/ops.cjs +2 -1
  16. package/dist/middle_layer/ops.cjs.map +1 -1
  17. package/dist/middle_layer/ops.d.ts +5 -1
  18. package/dist/middle_layer/ops.d.ts.map +1 -1
  19. package/dist/middle_layer/ops.js +2 -1
  20. package/dist/middle_layer/ops.js.map +1 -1
  21. package/dist/middle_layer/project.d.ts +4 -4
  22. package/dist/middle_layer/project.d.ts.map +1 -1
  23. package/dist/middle_layer/project_list.cjs +19 -0
  24. package/dist/middle_layer/project_list.cjs.map +1 -1
  25. package/dist/middle_layer/project_list.d.ts +1 -1
  26. package/dist/middle_layer/project_list.d.ts.map +1 -1
  27. package/dist/middle_layer/project_list.js +20 -2
  28. package/dist/middle_layer/project_list.js.map +1 -1
  29. package/dist/middle_layer/sharing_list.cjs +203 -0
  30. package/dist/middle_layer/sharing_list.cjs.map +1 -0
  31. package/dist/middle_layer/sharing_list.d.ts +41 -0
  32. package/dist/middle_layer/sharing_list.d.ts.map +1 -0
  33. package/dist/middle_layer/sharing_list.js +198 -0
  34. package/dist/middle_layer/sharing_list.js.map +1 -0
  35. package/dist/model/index.cjs +30 -0
  36. package/dist/model/index.d.ts +2 -1
  37. package/dist/model/index.js +4 -0
  38. package/dist/model/project_model.d.ts +3 -3
  39. package/dist/model/project_model.d.ts.map +1 -1
  40. package/dist/model/sharing_model.cjs +98 -0
  41. package/dist/model/sharing_model.cjs.map +1 -0
  42. package/dist/model/sharing_model.d.ts +122 -0
  43. package/dist/model/sharing_model.d.ts.map +1 -0
  44. package/dist/model/sharing_model.js +84 -0
  45. package/dist/model/sharing_model.js.map +1 -0
  46. package/dist/mutator/block-pack/frontend.cjs +1 -0
  47. package/dist/mutator/block-pack/frontend.cjs.map +1 -1
  48. package/dist/mutator/block-pack/frontend.js +1 -0
  49. package/dist/mutator/block-pack/frontend.js.map +1 -1
  50. package/dist/mutator/sharing.cjs +138 -0
  51. package/dist/mutator/sharing.cjs.map +1 -0
  52. package/dist/mutator/sharing.js +130 -0
  53. package/dist/mutator/sharing.js.map +1 -0
  54. package/package.json +15 -15
  55. package/src/middle_layer/index.ts +1 -0
  56. package/src/middle_layer/middle_layer.ts +715 -23
  57. package/src/middle_layer/ops.ts +7 -0
  58. package/src/middle_layer/project_list.ts +33 -2
  59. package/src/middle_layer/sharing_list.ts +343 -0
  60. package/src/model/index.ts +2 -0
  61. package/src/model/sharing_model.test.ts +22 -0
  62. package/src/model/sharing_model.ts +169 -0
  63. package/src/mutator/sharing.ts +216 -0
@@ -1,10 +1,13 @@
1
1
  import { V2RegistryProvider } from "../block_registry/registry-v2-provider.js";
2
2
  import "../block_registry/index.js";
3
3
  import { ProjectMetaKey } from "../model/project_model.js";
4
- import { ProjectsField, ProjectsResourceType, createProjectList } from "./project_list.js";
4
+ import { ProjectsField, ProjectsResourceType, createProjectList, ensureProjectListRid } from "./project_list.js";
5
+ import { SharingOutboxField, SharingOutboxResourceType, SharingStateField, SharingStateResourceType, acceptanceFieldLogin, canGrantToEveryone, canImpersonate, decodeEnvelopeData, isAcceptanceField } from "../model/sharing_model.js";
5
6
  import { BlockPackPreparer } from "../mutator/block-pack/block_pack.js";
6
7
  import { getDebugFlags } from "../debug/index.js";
7
8
  import { createProject, duplicateProject, withProjectAuthored } from "../mutator/project.js";
9
+ import { buildShareEnvelope, copyEnvelopeProjectsIntoList, envelopeProjectFieldUuid, isEnvelopeProjectField, resourceIdsToStrings, writeEnvelopeAcceptance, writeSharingDecision } from "../mutator/sharing.js";
10
+ import { createLiveEnvelopesComputable, createOutgoingShares, createPendingSharesComputable, createPendingSharesTree, createSharingStateTree } from "./sharing_list.js";
8
11
  import { Project } from "./project.js";
9
12
  import { DefaultMiddleLayerOpsPaths, DefaultMiddleLayerOpsSettings } from "./ops.js";
10
13
  import { BlockUpdateWatcher } from "../block_registry/watcher.js";
@@ -13,8 +16,8 @@ import { createModelServiceRegistry } from "../service_factories.js";
13
16
  import { ProjectHelper } from "../model/project_helper.js";
14
17
  import { RuntimeCapabilities } from "@platforma-sdk/model";
15
18
  import { RetryAgent } from "undici";
16
- import { BlockEventDispatcher, HmacSha256Signer } from "@milaboratories/ts-helpers";
17
- import { field, isNotNullSignedResourceId, isNullSignedResourceId, resourceIdToString } from "@milaboratories/pl-client";
19
+ import { BlockEventDispatcher, HmacSha256Signer, cachedDeserialize } from "@milaboratories/ts-helpers";
20
+ import { GrantType, field, isEveryoneUserLogin, isNotNullSignedResourceId, isNullSignedResourceId, resourceIdToString } from "@milaboratories/pl-client";
18
21
  import { LRUCache } from "lru-cache";
19
22
  import { WatchableValue } from "@milaboratories/computable";
20
23
  import { randomUUID } from "node:crypto";
@@ -38,22 +41,38 @@ var MiddleLayer = class MiddleLayer {
38
41
  driverKit;
39
42
  signer;
40
43
  projectListResourceId;
44
+ sharingOutboxResourceId;
45
+ sharingStateResourceId;
41
46
  openedProjectsList;
42
47
  projectListTree;
48
+ sharingOutboxTree;
49
+ sharingStateTree;
50
+ pendingSharesTree;
43
51
  blockRegistryProvider;
44
- pl;
45
- /** Contains a reactive list of projects along with their meta information. */
46
52
  projectList;
47
- constructor(env, driverKit, signer, projectListResourceId, openedProjectsList, projectListTree, blockRegistryProvider, projectList) {
53
+ outgoingShares;
54
+ pendingShares;
55
+ liveEnvelopes;
56
+ pl;
57
+ constructor(env, driverKit, signer, projectListResourceId, sharingOutboxResourceId, sharingStateResourceId, openedProjectsList, projectListTree, sharingOutboxTree, sharingStateTree, pendingSharesTree, blockRegistryProvider, projectList, outgoingShares, pendingShares, liveEnvelopes) {
48
58
  this.env = env;
49
59
  this.driverKit = driverKit;
50
60
  this.signer = signer;
51
61
  this.projectListResourceId = projectListResourceId;
62
+ this.sharingOutboxResourceId = sharingOutboxResourceId;
63
+ this.sharingStateResourceId = sharingStateResourceId;
52
64
  this.openedProjectsList = openedProjectsList;
53
65
  this.projectListTree = projectListTree;
66
+ this.sharingOutboxTree = sharingOutboxTree;
67
+ this.sharingStateTree = sharingStateTree;
68
+ this.pendingSharesTree = pendingSharesTree;
54
69
  this.blockRegistryProvider = blockRegistryProvider;
55
70
  this.projectList = projectList;
71
+ this.outgoingShares = outgoingShares;
72
+ this.pendingShares = pendingShares;
73
+ this.liveEnvelopes = liveEnvelopes;
56
74
  this.pl = this.env.pl;
75
+ this.startEnvelopeCleanup();
57
76
  }
58
77
  /**
59
78
  * Get the OS where backend is running.
@@ -71,6 +90,52 @@ var MiddleLayer = class MiddleLayer {
71
90
  get serverCapabilities() {
72
91
  return this.pl.serverInfo.capabilities ?? [];
73
92
  }
93
+ /**
94
+ * Login of the authenticated user, for the "Signed in as" UI. `null` when the
95
+ * backend has no auth (local/dev mode) — the UI hides the element.
96
+ */
97
+ get currentUserLogin() {
98
+ return this.pl.userResources.authUser;
99
+ }
100
+ /**
101
+ * Whether the connected backend supports project sharing. Synthetic — computed
102
+ * in the middle layer from the backend capabilities the share flow needs (the
103
+ * cross-color field-reference relaxation the accept flow rests on). It can absorb
104
+ * additional required capabilities later without a UI change.
105
+ */
106
+ get sharingSupported() {
107
+ return this.serverCapabilities.includes("crossTreeRefs:v1") && !this.impersonating;
108
+ }
109
+ /** True when this session is impersonating another user (an admin opened another user's root). */
110
+ get impersonating() {
111
+ return this.pl.conf.asUser !== void 0;
112
+ }
113
+ /**
114
+ * Role of the authenticated user, from the `GetSessionInfo` RPC surfaced through the
115
+ * pl-client. `null` in no-auth mode (when {@link currentUserLogin} is null).
116
+ */
117
+ get currentUserRole() {
118
+ return this.pl.currentUserRole;
119
+ }
120
+ /**
121
+ * Whether the UI offers share-with-everybody — no role policy lives in the UI:
122
+ * serverCapabilities.has("publicGrants:v1") && canGrantToEveryone(currentUserRole)
123
+ * Not a security boundary: a crafted call still hits the backend's role +
124
+ * permission-ceiling gate.
125
+ */
126
+ get canShareWithEveryone() {
127
+ return !this.impersonating && this.serverCapabilities.includes("publicGrants:v1") && canGrantToEveryone(this.currentUserRole);
128
+ }
129
+ /**
130
+ * Whether the authenticated user may impersonate others (open another user's root). Mirrors
131
+ * the backend's `CanImpersonate` role gate — admin/controller only, never a regular user.
132
+ * Derived from the session role, which stays the authenticated admin's even while
133
+ * impersonating, so this stays true across a switch: the "return to my root" affordance must
134
+ * not vanish mid-impersonation. Not a security boundary; the backend re-checks on every call.
135
+ */
136
+ get currentUserCanImpersonate() {
137
+ return canImpersonate(this.currentUserRole);
138
+ }
74
139
  /** Adds a runtime capability to the middle layer. */
75
140
  addRuntimeCapability(requirement, value = true) {
76
141
  this.env.runtimeCapabilities.addSupportedRequirement(requirement, value);
@@ -170,6 +235,385 @@ var MiddleLayer = class MiddleLayer {
170
235
  this.projectIdCache.set(newProjectId, signedRid);
171
236
  return newProjectId;
172
237
  }
238
+ /**
239
+ * Duplicates a project into another user's root, minted in the TARGET user's color so the target
240
+ * owns it. Sibling of {@link duplicateProject}, but writes into a different root. The source
241
+ * project (on the current client root) is referenced cross-color for its block data, kept alive
242
+ * by refcounting, exactly like accepting a shared project. Works both ways: pull (while
243
+ * impersonating a user, copy their project to yourself) and push (from your own root, copy a
244
+ * project to a user). Admin cross-root op; requires the crossTreeRefs:v1 backend capability.
245
+ */
246
+ async duplicateProjectToUser(srcProjectId, targetLogin, rename) {
247
+ if (!this.serverCapabilities.includes("crossTreeRefs:v1")) throw new Error("duplicateProjectToUser requires the crossTreeRefs:v1 backend capability.");
248
+ const sourceRid = await this.resolveProjectId(srcProjectId);
249
+ const targetRoot = await this.pl.getUserRoot({
250
+ login: targetLogin,
251
+ createIfNotExists: true
252
+ });
253
+ await this.pl.withWriteTxOnRoot(targetRoot, "MLDuplicateProjectToUser", async (tx) => {
254
+ const targetProjectListRid = await ensureProjectListRid(tx);
255
+ const sourceMeta = await tx.getKValueJson(sourceRid, ProjectMetaKey);
256
+ const targetListData = await tx.getResourceData(targetProjectListRid, true);
257
+ const existingLabels = (await Promise.all(targetListData.fields.map((f) => f.value).filter(isNotNullSignedResourceId).map((rid) => tx.getKValueJson(rid, ProjectMetaKey)))).map((m) => m.label);
258
+ const newPrj = await duplicateProject(tx, sourceRid, { label: rename ? rename(sourceMeta.label, existingLabels) : sourceMeta.label });
259
+ tx.createField(field(targetProjectListRid, randomUUID()), "Dynamic", newPrj);
260
+ await tx.commit();
261
+ });
262
+ }
263
+ /**
264
+ * Shares the given projects (Copy & Share). Snapshots the projects, creates one envelope, and
265
+ * grants it — all in one atomic write transaction, so a failed grant rolls the whole thing back
266
+ * and the outbox is left as it was.
267
+ *
268
+ * Two variants (see {@link ShareProjectsOptions}):
269
+ * - `{ recipients }` — one writable grant per named recipient; the envelope expires after the
270
+ * default TTL (`sharedAt + envelopeTtlMs`).
271
+ * - `{ everyone: true }` — one make-public grant (backend rewrites the target to the
272
+ * everyone-user); the envelope's `expiresAt` is `null`, so it never expires.
273
+ *
274
+ * v1 always passes `mode: "copy"`.
275
+ */
276
+ async shareProjects(projectIds, options) {
277
+ if (projectIds.length === 0) throw new Error("shareProjects: no projects given");
278
+ if ("everyone" in options && options.replace) {
279
+ const priorEveryone = (await this.findSupersedableEnvelopes(projectIds)).find((p) => p.everyone);
280
+ if (priorEveryone !== void 0) {
281
+ await this.changeShare(priorEveryone.shareId, { title: options.title });
282
+ return;
283
+ }
284
+ }
285
+ await this.createNewShare(projectIds, options);
286
+ }
287
+ /**
288
+ * Mints a fresh share: snapshots the projects into one new envelope (a fresh shareId),
289
+ * supersedes prior shares of the same project, and grants it — all in one atomic write
290
+ * transaction, so a failed grant rolls the whole thing back and the outbox is left as it was.
291
+ * The everyone-refresh path is the {@link changeShare} branch of {@link shareProjects}; this is
292
+ * the mint-a-new-envelope branch.
293
+ */
294
+ async createNewShare(projectIds, options) {
295
+ const everyone = "everyone" in options;
296
+ const sources = await Promise.all(projectIds.map(async (id) => ({
297
+ kind: "fresh",
298
+ projectId: id,
299
+ sourceRid: await this.resolveProjectId(id)
300
+ })));
301
+ const sender = this.currentUserLogin ?? "";
302
+ const expiresAt = everyone ? null : Date.now() + this.env.ops.envelopeTtlMs;
303
+ const priors = await this.findSupersedableEnvelopes(projectIds);
304
+ await this.pl.withWriteTx("MLShareProjects", async (tx) => {
305
+ if (everyone) {
306
+ for (const prior of priors) if (prior.everyone) tx.removeField(field(this.sharingOutboxResourceId, prior.fieldName));
307
+ } else {
308
+ const newRecipients = new Set(options.recipients);
309
+ for (const prior of priors) {
310
+ if (prior.everyone) continue;
311
+ const toRemove = prior.recipients.filter((u) => newRecipients.has(u));
312
+ if (toRemove.length === 0) continue;
313
+ if (prior.recipients.filter((u) => !newRecipients.has(u)).length === 0) tx.removeField(field(this.sharingOutboxResourceId, prior.fieldName));
314
+ else for (const u of toRemove) tx.revokeAccess(prior.rid, u);
315
+ }
316
+ }
317
+ const { envelope } = await buildShareEnvelope(tx, this.sharingOutboxResourceId, sources, {
318
+ mode: options.mode,
319
+ sender,
320
+ title: options.title,
321
+ expiresAt
322
+ });
323
+ const envelopeGid = await envelope.globalId;
324
+ if (everyone) tx.grantAccess(envelopeGid, "", { writable: true }, GrantType.ANY_AUTHORISED);
325
+ else for (const recipient of options.recipients) tx.grantAccess(envelopeGid, recipient, { writable: true });
326
+ await tx.commit();
327
+ });
328
+ await this.sharingOutboxTree.refreshState();
329
+ }
330
+ /**
331
+ * Changes a share in place (same {@link ShareId}), in one write transaction: re-snapshots live
332
+ * source projects and carries deleted ones' snapshots forward; applies edited recipients/title;
333
+ * transfers already-decided recipients' accept/reject records (they keep their copy and aren't
334
+ * re-prompted); re-grants; drops the old envelope.
335
+ *
336
+ * `opts.recipients` is the full targeted set (decided users are always kept). `opts.title`
337
+ * replaces the title — omit keeps the current one. `opts.everyone` upgrades targeted ->
338
+ * everyone; the reverse is impossible and ignored.
339
+ *
340
+ * `opts.projectActions` is a per-source-project decision, keyed by projectId: `update`
341
+ * re-snapshots the live source (falls back to carry if the source is gone), `keep` carries the
342
+ * existing snapshot (and its timestamp), `remove` drops the project from the pack. A project not
343
+ * in the map defaults to `keep`. Omit the whole map for the legacy auto behavior (live sources
344
+ * updated, gone ones kept) — the everyone-refresh path relies on that.
345
+ */
346
+ async changeShare(shareId, opts = {}) {
347
+ await this.pl.withWriteTx("MLChangeShare", async (tx) => {
348
+ const old = await this.resolveOutboxEnvelope(tx, shareId);
349
+ if (old === void 0) throw new Error(`changeShare: no live share with id ${shareId} in the outbox.`);
350
+ const self = this.currentUserLogin ?? "";
351
+ const grants = await tx.listGrants(old.rid);
352
+ const everyone = grants.some((g) => isEveryoneUserLogin(g.user)) || opts.everyone === true;
353
+ const oldRd = await tx.getResourceData(old.rid, true);
354
+ const snapshotByUuid = /* @__PURE__ */ new Map();
355
+ const acceptances = [];
356
+ for (const f of oldRd.fields) {
357
+ if (isNullSignedResourceId(f.value)) continue;
358
+ if (isEnvelopeProjectField(f.name)) snapshotByUuid.set(envelopeProjectFieldUuid(f.name), f.value);
359
+ else if (isAcceptanceField(f.name)) {
360
+ const raw = (await tx.getResourceData(f.value, false)).data;
361
+ if (raw === void 0) continue;
362
+ acceptances.push({
363
+ login: acceptanceFieldLogin(f.name),
364
+ acc: cachedDeserialize(raw)
365
+ });
366
+ }
367
+ }
368
+ const decidedLogins = acceptances.map((a) => a.login);
369
+ const priorRecipients = grants.filter((g) => !isEveryoneUserLogin(g.user) && g.user !== self).map((g) => g.user);
370
+ const recipients = everyone ? [] : Array.from(/* @__PURE__ */ new Set([...opts.recipients ?? priorRecipients, ...decidedLogins]));
371
+ const liveProjects = /* @__PURE__ */ new Map();
372
+ const projList = await tx.getResourceData(this.projectListResourceId, true);
373
+ for (const f of projList.fields) {
374
+ if (isNullSignedResourceId(f.value)) continue;
375
+ liveProjects.set(resourceIdToString(f.value), f.value);
376
+ }
377
+ const actions = opts.projectActions;
378
+ const sources = [];
379
+ for (const uuid of Object.keys(old.data.projects)) {
380
+ const { label, source, updatedAt } = old.data.projects[uuid];
381
+ const liveRid = liveProjects.get(source);
382
+ const action = actions ? actions[source] ?? "keep" : liveRid !== void 0 ? "update" : "keep";
383
+ if (action === "remove") continue;
384
+ if (action === "update" && liveRid !== void 0) sources.push({
385
+ kind: "fresh",
386
+ projectId: source,
387
+ sourceRid: liveRid
388
+ });
389
+ else {
390
+ const snapshotRid = snapshotByUuid.get(uuid);
391
+ if (snapshotRid !== void 0) sources.push({
392
+ kind: "carry",
393
+ projectId: source,
394
+ label,
395
+ snapshotRid,
396
+ updatedAt
397
+ });
398
+ }
399
+ }
400
+ const title = opts.title === void 0 ? old.data.title : opts.title.trim();
401
+ const expiresAt = everyone ? null : Date.now() + this.env.ops.envelopeTtlMs;
402
+ tx.removeField(field(this.sharingOutboxResourceId, old.fieldName));
403
+ const { envelope } = await buildShareEnvelope(tx, this.sharingOutboxResourceId, sources, {
404
+ mode: old.data.mode,
405
+ sender: self,
406
+ title,
407
+ expiresAt,
408
+ shareId
409
+ });
410
+ for (const { login, acc } of acceptances) {
411
+ if (!everyone && !recipients.includes(login)) continue;
412
+ writeEnvelopeAcceptance(tx, envelope, login, acc.action, acc.timestamp);
413
+ }
414
+ const gid = await envelope.globalId;
415
+ if (everyone) tx.grantAccess(gid, "", { writable: true }, GrantType.ANY_AUTHORISED);
416
+ else for (const r of recipients) tx.grantAccess(gid, r, { writable: true });
417
+ await tx.commit();
418
+ });
419
+ await this.sharingOutboxTree.refreshState();
420
+ }
421
+ /**
422
+ * Finds the donor's own outgoing envelopes built from any of the given source projects —
423
+ * the supersede candidates for a fresh share of the same project(s). Reads each envelope's
424
+ * recipient set via `ListGrants` so the caller can pull individual recipients or detect an
425
+ * everyone-share.
426
+ */
427
+ async findSupersedableEnvelopes(projectIds) {
428
+ const wanted = new Set(projectIds);
429
+ const matched = await this.pl.withReadTx("MLFindSupersede", async (tx) => {
430
+ const outbox = await tx.getResourceData(this.sharingOutboxResourceId, true);
431
+ const out = [];
432
+ for (const f of outbox.fields) {
433
+ if (isNullSignedResourceId(f.value)) continue;
434
+ const rd = await tx.getResourceData(f.value, false);
435
+ if (rd.data === void 0) continue;
436
+ const data = decodeEnvelopeData(rd.data);
437
+ if (Object.values(data.projects).some((p) => wanted.has(p.source))) out.push({
438
+ fieldName: f.name,
439
+ rid: f.value,
440
+ shareId: data.shareId
441
+ });
442
+ }
443
+ return out;
444
+ });
445
+ return await Promise.all(matched.map(async ({ fieldName, rid, shareId }) => {
446
+ const grants = await this.pl.userResources.listGrants(rid);
447
+ return {
448
+ fieldName,
449
+ rid,
450
+ shareId,
451
+ everyone: grants.some((g) => isEveryoneUserLogin(g.user)),
452
+ recipients: grants.filter((g) => !isEveryoneUserLogin(g.user)).map((g) => g.user)
453
+ };
454
+ }));
455
+ }
456
+ /**
457
+ * Revokes and deletes an outgoing share for all recipients: detaches and deletes the envelope, and
458
+ * its grants are revoked along with it. Already-accepted copies are unaffected (ref-counting keeps
459
+ * the adopted resources alive). Idempotent — revoking a share that is already gone is a no-op.
460
+ */
461
+ async revokeShare(shareId) {
462
+ await this.pl.withWriteTx("MLRevokeShare", async (tx) => {
463
+ const target = await this.resolveOutboxEnvelope(tx, shareId);
464
+ if (target === void 0) return;
465
+ tx.removeField(field(this.sharingOutboxResourceId, target.fieldName));
466
+ await tx.commit();
467
+ });
468
+ await this.sharingOutboxTree.refreshState();
469
+ }
470
+ /**
471
+ * Resolves a live envelope from the donor's own outbox by its logical `shareId`, returning the
472
+ * outbox field name (for detach), the signed envelope id, and its decoded {@link EnvelopeData}.
473
+ * The outbox is keyed by `{shareId}` directly, but a replaced/legacy share may have drifted, so
474
+ * we match on the decoded `shareId` rather than the field name alone.
475
+ */
476
+ async resolveOutboxEnvelope(tx, shareId) {
477
+ const outboxData = await tx.getResourceData(this.sharingOutboxResourceId, true);
478
+ for (const f of outboxData.fields) {
479
+ if (isNullSignedResourceId(f.value)) continue;
480
+ const rd = await tx.getResourceData(f.value, false);
481
+ if (rd.data === void 0) continue;
482
+ const data = decodeEnvelopeData(rd.data);
483
+ if (data.shareId === shareId) return {
484
+ fieldName: f.name,
485
+ rid: f.value,
486
+ data
487
+ };
488
+ }
489
+ }
490
+ /**
491
+ * Resolves currently-shared envelopes (granted to this user) to their resource ids, keyed by
492
+ * the envelope's logical `shareId`.
493
+ *
494
+ * Reads the {@link liveEnvelopes} Computable — the same shared-resource discovery tree that
495
+ * feeds {@link pendingShares}. This is the single discovery mechanism: there is no separate
496
+ * `ListUserResources` re-stream on every accept/reject. `refreshState()` is awaited first so a
497
+ * just-granted envelope is observed (the tree's discovery poll may otherwise lag a freshly
498
+ * landed grant). The tree is gRPC-only, so this is empty on a REST-connected client.
499
+ */
500
+ async resolveLiveEnvelopes() {
501
+ await this.pendingSharesTree.refreshState();
502
+ const live = await this.liveEnvelopes.getValue() ?? [];
503
+ const map = /* @__PURE__ */ new Map();
504
+ for (const e of live) map.set(e.data.shareId, e);
505
+ return map;
506
+ }
507
+ /**
508
+ * Accepts one or more pending shares: duplicates each share's projects into this user's
509
+ * project list, records the decision per share, and (read-write share) writes the donor-visible
510
+ * acceptance onto the envelope. Per-share failures (e.g. an expiry race) are collected, not
511
+ * short-circuited — the rest still get accepted. Accept-all = pass every current pending shareId.
512
+ *
513
+ * `rename` resolves label collisions (same callback contract as {@link duplicateProject}), but
514
+ * the source lives in the envelope tree, so accept calls the low-level mutator directly.
515
+ */
516
+ async acceptShare(shareIds, rename) {
517
+ const live = await this.resolveLiveEnvelopes();
518
+ const login = this.currentUserLogin;
519
+ const accepted = [];
520
+ const failed = [];
521
+ for (const shareId of shareIds) {
522
+ const envelope = live.get(shareId);
523
+ if (envelope === void 0) {
524
+ failed.push({
525
+ shareId,
526
+ error: "Share is no longer available."
527
+ });
528
+ continue;
529
+ }
530
+ try {
531
+ const now = Date.now();
532
+ const createdRids = await this.pl.withWriteTx("MLAcceptShare", async (tx) => {
533
+ const created = await copyEnvelopeProjectsIntoList(tx, envelope.rid, this.projectListResourceId, rename);
534
+ writeSharingDecision(tx, this.sharingStateResourceId, shareId, {
535
+ decision: "accepted",
536
+ timestamp: now,
537
+ envelopeSharedAt: envelope.data.sharedAt,
538
+ acceptedProjects: resourceIdsToStrings(created)
539
+ });
540
+ if (login !== null && envelope.data.mode !== "read-only") writeEnvelopeAcceptance(tx, envelope.rid, login, "accepted", now);
541
+ await tx.commit();
542
+ return created;
543
+ });
544
+ for (const rid of createdRids) {
545
+ const projectId = resourceIdToString(rid);
546
+ this.projectIdCache.set(projectId, rid);
547
+ accepted.push(projectId);
548
+ }
549
+ } catch (e) {
550
+ failed.push({
551
+ shareId,
552
+ error: e instanceof Error ? e.message : String(e)
553
+ });
554
+ }
555
+ }
556
+ await Promise.all([this.projectListTree.refreshState(), this.sharingStateTree.refreshState()]);
557
+ return {
558
+ accepted,
559
+ failed
560
+ };
561
+ }
562
+ /** Records rejection of a pending share; it never surfaces again. */
563
+ async rejectShare(shareId) {
564
+ const envelope = (await this.resolveLiveEnvelopes()).get(shareId);
565
+ const login = this.currentUserLogin;
566
+ const now = Date.now();
567
+ await this.pl.withWriteTx("MLRejectShare", async (tx) => {
568
+ writeSharingDecision(tx, this.sharingStateResourceId, shareId, {
569
+ decision: "rejected",
570
+ timestamp: now,
571
+ envelopeSharedAt: envelope?.data.sharedAt ?? now,
572
+ acceptedProjects: []
573
+ });
574
+ if (envelope !== void 0 && login !== null && envelope.data.mode !== "read-only") writeEnvelopeAcceptance(tx, envelope.rid, login, "rejected", now);
575
+ await tx.commit();
576
+ });
577
+ await this.sharingStateTree.refreshState();
578
+ }
579
+ static EnvelopeCleanupIntervalMs = 6 * 3600 * 1e3;
580
+ envelopeCleanupTimer;
581
+ /** On ML start and every 6h, delete envelopes whose immutable `expiresAt` has passed. */
582
+ startEnvelopeCleanup() {
583
+ this.runEnvelopeCleanup();
584
+ this.envelopeCleanupTimer = setInterval(() => {
585
+ this.runEnvelopeCleanup();
586
+ }, MiddleLayer.EnvelopeCleanupIntervalMs);
587
+ this.envelopeCleanupTimer.unref?.();
588
+ }
589
+ /** Scans the donor's outbox and deletes expired envelopes (backend auto-revokes their grants).
590
+ * Envelopes with `expiresAt: null` (share-with-everybody) are skipped. */
591
+ async runEnvelopeCleanup() {
592
+ try {
593
+ const now = Date.now();
594
+ const expired = await this.pl.withReadTx("MLEnvelopeCleanupScan", async (tx) => {
595
+ const data = await tx.getResourceData(this.sharingOutboxResourceId, true);
596
+ const toDelete = [];
597
+ for (const f of data.fields) {
598
+ if (isNullSignedResourceId(f.value)) continue;
599
+ const rd = await tx.getResourceData(f.value, false);
600
+ if (rd.data === void 0) continue;
601
+ const envData = decodeEnvelopeData(rd.data);
602
+ if (envData.expiresAt === null) continue;
603
+ if (envData.expiresAt <= now) toDelete.push({ fieldName: f.name });
604
+ }
605
+ return toDelete;
606
+ });
607
+ if (expired.length === 0) return;
608
+ await this.pl.withWriteTx("MLEnvelopeCleanup", async (tx) => {
609
+ for (const { fieldName } of expired) tx.removeField(field(this.sharingOutboxResourceId, fieldName));
610
+ await tx.commit();
611
+ });
612
+ await this.sharingOutboxTree.refreshState();
613
+ } catch (e) {
614
+ this.env.logger.warn(`envelope cleanup failed: ${e instanceof Error ? e.message : String(e)}`);
615
+ }
616
+ }
173
617
  openedProjects = /* @__PURE__ */ new Map();
174
618
  /** Opens a project, and starts corresponding project maintenance loop. */
175
619
  async openProject(id) {
@@ -202,8 +646,14 @@ var MiddleLayer = class MiddleLayer {
202
646
  * them.
203
647
  */
204
648
  async close() {
649
+ if (this.envelopeCleanupTimer !== void 0) clearInterval(this.envelopeCleanupTimer);
205
650
  await Promise.all([...this.openedProjects.values()].map((prj) => prj.destroy()));
206
- await this.projectListTree.terminate();
651
+ await Promise.all([
652
+ this.projectListTree.terminate(),
653
+ this.sharingOutboxTree.terminate(),
654
+ this.sharingStateTree.terminate(),
655
+ this.pendingSharesTree.terminate()
656
+ ]);
207
657
  await this.env.dispose();
208
658
  await this.pl.close();
209
659
  }
@@ -230,17 +680,28 @@ var MiddleLayer = class MiddleLayer {
230
680
  ops.defaultTreeOptions.logStat = getDebugFlags().logTreeStats;
231
681
  ops.debugOps.dumpInitialTreeState = getDebugFlags().dumpInitialTreeState;
232
682
  if (ops.defaultTreeOptions.traversalMode === void 0 && getDebugFlags().treeTraversalMode !== void 0) ops.defaultTreeOptions.traversalMode = getDebugFlags().treeTraversalMode;
233
- const projects = await pl.withWriteTx("MLInitialization", async (tx) => {
234
- const projectsField = field(tx.clientRoot, ProjectsField);
235
- tx.createField(projectsField, "Dynamic");
236
- const projectsFieldData = await tx.getField(projectsField);
237
- if (isNullSignedResourceId(projectsFieldData.value)) {
238
- const projects = tx.createEphemeral(ProjectsResourceType);
239
- tx.lock(projects);
240
- tx.setField(projectsField, projects);
241
- await tx.commit();
242
- return await projects.globalId;
243
- } else return projectsFieldData.value;
683
+ const { projects, sharingOutbox, sharingState } = await pl.withWriteTx("MLInitialization", async (tx) => {
684
+ const lazyInit = async (fieldName, type) => {
685
+ const f = field(tx.clientRoot, fieldName);
686
+ tx.createField(f, "Dynamic");
687
+ const fData = await tx.getField(f);
688
+ if (isNullSignedResourceId(fData.value)) {
689
+ const ref = tx.createEphemeral(type);
690
+ tx.lock(ref);
691
+ tx.setField(f, ref);
692
+ return { ref };
693
+ }
694
+ return { existing: fData.value };
695
+ };
696
+ const projectsR = await lazyInit(ProjectsField, ProjectsResourceType);
697
+ const outboxR = await lazyInit(SharingOutboxField, SharingOutboxResourceType);
698
+ const stateR = await lazyInit(SharingStateField, SharingStateResourceType);
699
+ await tx.commit();
700
+ return {
701
+ projects: projectsR.existing ?? await projectsR.ref.globalId,
702
+ sharingState: stateR.existing ?? await stateR.ref.globalId,
703
+ sharingOutbox: outboxR.existing ?? await outboxR.ref.globalId
704
+ };
244
705
  });
245
706
  const logger = ops.logger;
246
707
  const driverKit = await initDriverKit(pl, workdir, ops.frontendDownloadPath, ops);
@@ -283,7 +744,12 @@ var MiddleLayer = class MiddleLayer {
283
744
  };
284
745
  const openedProjects = new WatchableValue([]);
285
746
  const projectListTC = await createProjectList(pl, projects, openedProjects, env);
286
- return new MiddleLayer(env, driverKit, driverKit.signer, projects, openedProjects, projectListTC.tree, v2RegistryProvider, projectListTC.computable);
747
+ const outgoingTC = await createOutgoingShares(pl, sharingOutbox, env);
748
+ const sharingStateTree = await createSharingStateTree(pl, sharingState, env);
749
+ const pendingSharesTree = await createPendingSharesTree(pl, env);
750
+ const pendingShares = createPendingSharesComputable(pendingSharesTree, sharingStateTree, pl.userResources.authUser);
751
+ const liveEnvelopes = createLiveEnvelopesComputable(pendingSharesTree);
752
+ return new MiddleLayer(env, driverKit, driverKit.signer, projects, sharingOutbox, sharingState, openedProjects, projectListTC.tree, outgoingTC.tree, sharingStateTree, pendingSharesTree, v2RegistryProvider, projectListTC.computable, outgoingTC.computable, pendingShares, liveEnvelopes);
287
753
  }
288
754
  };
289
755
  //#endregion