@milaboratories/pl-middle-layer 1.64.41 → 1.64.42

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 (55) hide show
  1. package/dist/index.cjs +16 -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 +443 -16
  10. package/dist/middle_layer/middle_layer.cjs.map +1 -1
  11. package/dist/middle_layer/middle_layer.d.ts +153 -12
  12. package/dist/middle_layer/middle_layer.d.ts.map +1 -1
  13. package/dist/middle_layer/middle_layer.js +445 -18
  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/sharing_list.cjs +203 -0
  24. package/dist/middle_layer/sharing_list.cjs.map +1 -0
  25. package/dist/middle_layer/sharing_list.d.ts +41 -0
  26. package/dist/middle_layer/sharing_list.d.ts.map +1 -0
  27. package/dist/middle_layer/sharing_list.js +198 -0
  28. package/dist/middle_layer/sharing_list.js.map +1 -0
  29. package/dist/model/index.cjs +29 -0
  30. package/dist/model/index.d.ts +2 -1
  31. package/dist/model/index.js +4 -0
  32. package/dist/model/project_model.d.ts +3 -3
  33. package/dist/model/project_model.d.ts.map +1 -1
  34. package/dist/model/sharing_model.cjs +81 -0
  35. package/dist/model/sharing_model.cjs.map +1 -0
  36. package/dist/model/sharing_model.d.ts +112 -0
  37. package/dist/model/sharing_model.d.ts.map +1 -0
  38. package/dist/model/sharing_model.js +68 -0
  39. package/dist/model/sharing_model.js.map +1 -0
  40. package/dist/mutator/block-pack/frontend.cjs +1 -0
  41. package/dist/mutator/block-pack/frontend.cjs.map +1 -1
  42. package/dist/mutator/block-pack/frontend.js +1 -0
  43. package/dist/mutator/block-pack/frontend.js.map +1 -1
  44. package/dist/mutator/sharing.cjs +138 -0
  45. package/dist/mutator/sharing.cjs.map +1 -0
  46. package/dist/mutator/sharing.js +130 -0
  47. package/dist/mutator/sharing.js.map +1 -0
  48. package/package.json +14 -14
  49. package/src/middle_layer/index.ts +1 -0
  50. package/src/middle_layer/middle_layer.ts +644 -22
  51. package/src/middle_layer/ops.ts +7 -0
  52. package/src/middle_layer/sharing_list.ts +343 -0
  53. package/src/model/index.ts +2 -0
  54. package/src/model/sharing_model.ts +150 -0
  55. package/src/mutator/sharing.ts +216 -0
@@ -1,6 +1,14 @@
1
- import type { PlClient, SignedResourceId, ResourceRef } from "@milaboratories/pl-client";
1
+ import type {
2
+ PlClient,
3
+ PlTransaction,
4
+ SignedResourceId,
5
+ ResourceRef,
6
+ Role,
7
+ } from "@milaboratories/pl-client";
2
8
  import {
9
+ isEveryoneUserLogin,
3
10
  field,
11
+ GrantType,
4
12
  isNotNullSignedResourceId,
5
13
  isNullSignedResourceId,
6
14
  resourceIdToString,
@@ -11,11 +19,45 @@ import { createProject, duplicateProject, withProjectAuthored } from "../mutator
11
19
  import { ProjectMetaKey } from "../model/project_model";
12
20
  import type { ProjectId } from "../model/project_model";
13
21
  import type { SynchronizedTreeState } from "@milaboratories/pl-tree";
22
+ import {
23
+ acceptanceFieldLogin,
24
+ canGrantToEveryone,
25
+ decodeEnvelopeData,
26
+ isAcceptanceField,
27
+ SharingOutboxField,
28
+ SharingOutboxResourceType,
29
+ SharingStateField,
30
+ SharingStateResourceType,
31
+ type EnvelopeAcceptance,
32
+ type EnvelopeData,
33
+ type ProjectChangeAction,
34
+ type ProjectFieldUuid,
35
+ type ShareId,
36
+ type ShareProjectsOptions,
37
+ } from "../model/sharing_model";
38
+ import {
39
+ buildShareEnvelope,
40
+ copyEnvelopeProjectsIntoList,
41
+ envelopeProjectFieldUuid,
42
+ isEnvelopeProjectField,
43
+ resourceIdsToStrings,
44
+ writeEnvelopeAcceptance,
45
+ writeSharingDecision,
46
+ type EnvelopeProjectSource,
47
+ } from "../mutator/sharing";
48
+ import type { LiveEnvelope, OutgoingShare, PendingShare } from "./sharing_list";
49
+ import {
50
+ createLiveEnvelopesComputable,
51
+ createOutgoingShares,
52
+ createPendingSharesComputable,
53
+ createPendingSharesTree,
54
+ createSharingStateTree,
55
+ } from "./sharing_list";
14
56
  import { BlockPackPreparer } from "../mutator/block-pack/block_pack";
15
57
  import type { MiLogger, Signer } from "@milaboratories/ts-helpers";
16
- import { BlockEventDispatcher } from "@milaboratories/ts-helpers";
58
+ import { BlockEventDispatcher, cachedDeserialize } from "@milaboratories/ts-helpers";
17
59
  import { HmacSha256Signer } from "@milaboratories/ts-helpers";
18
- import type { ComputableStableDefined } from "@milaboratories/computable";
60
+ import type { Computable, ComputableStableDefined } from "@milaboratories/computable";
19
61
  import { WatchableValue } from "@milaboratories/computable";
20
62
  import { Project } from "./project";
21
63
  import type { MiddleLayerOps, MiddleLayerOpsConstructor } from "./ops";
@@ -81,21 +123,34 @@ export interface MiddleLayerEnvironment {
81
123
  export class MiddleLayer {
82
124
  public readonly pl: PlClient;
83
125
 
84
- /** Contains a reactive list of projects along with their meta information. */
85
- public readonly projectList: ComputableStableDefined<ProjectListEntry[]>;
86
-
87
126
  private constructor(
88
127
  private readonly env: MiddleLayerEnvironment,
89
128
  public readonly driverKit: DriverKit,
90
129
  public readonly signer: Signer,
91
130
  private readonly projectListResourceId: SignedResourceId,
131
+ private readonly sharingOutboxResourceId: SignedResourceId,
132
+ private readonly sharingStateResourceId: SignedResourceId,
92
133
  private readonly openedProjectsList: WatchableValue<ProjectId[]>,
93
134
  private readonly projectListTree: SynchronizedTreeState,
135
+ private readonly sharingOutboxTree: SynchronizedTreeState,
136
+ private readonly sharingStateTree: SynchronizedTreeState,
137
+ private readonly pendingSharesTree: SynchronizedTreeState,
94
138
  public readonly blockRegistryProvider: V2RegistryProvider,
95
- projectList: ComputableStableDefined<ProjectListEntry[]>,
139
+ /** Contains a reactive list of projects along with their meta information. */
140
+ public readonly projectList: ComputableStableDefined<ProjectListEntry[]>,
141
+ /** Reactive view of the donor's outbox — the shares this user has created.
142
+ * v1: API only, no UI. */
143
+ public outgoingShares: Computable<OutgoingShare[] | undefined>,
144
+ /** Envelopes granted to this user, not yet accepted or rejected. Fed by the
145
+ * shared-resource discovery tree. */
146
+ public pendingShares: Computable<PendingShare[] | undefined>,
147
+ /** Internal: the acceptor's currently-live envelopes, read from the same shared-resource
148
+ * discovery tree as {@link pendingShares}. The single source the accept/reject flow resolves
149
+ * live envelopes from — no second discovery path. */
150
+ private readonly liveEnvelopes: Computable<LiveEnvelope[] | undefined>,
96
151
  ) {
97
- this.projectList = projectList;
98
152
  this.pl = this.env.pl;
153
+ this.startEnvelopeCleanup();
99
154
  }
100
155
 
101
156
  /**
@@ -116,6 +171,45 @@ export class MiddleLayer {
116
171
  return this.pl.serverInfo.capabilities ?? [];
117
172
  }
118
173
 
174
+ /**
175
+ * Login of the authenticated user, for the "Signed in as" UI. `null` when the
176
+ * backend has no auth (local/dev mode) — the UI hides the element.
177
+ */
178
+ public get currentUserLogin(): string | null {
179
+ return this.pl.userResources.authUser;
180
+ }
181
+
182
+ /**
183
+ * Whether the connected backend supports project sharing. Synthetic — computed
184
+ * in the middle layer from the backend capabilities the share flow needs (the
185
+ * cross-color field-reference relaxation the accept flow rests on). It can absorb
186
+ * additional required capabilities later without a UI change.
187
+ */
188
+ public get sharingSupported(): boolean {
189
+ return this.serverCapabilities.includes("crossTreeRefs:v1");
190
+ }
191
+
192
+ /**
193
+ * Role of the authenticated user, from the `GetSessionInfo` RPC surfaced through the
194
+ * pl-client. `null` in no-auth mode (when {@link currentUserLogin} is null).
195
+ */
196
+ public get currentUserRole(): Role | null {
197
+ return this.pl.currentUserRole;
198
+ }
199
+
200
+ /**
201
+ * Whether the UI offers share-with-everybody — no role policy lives in the UI:
202
+ * serverCapabilities.has("publicGrants:v1") && canGrantToEveryone(currentUserRole)
203
+ * Not a security boundary: a crafted call still hits the backend's role +
204
+ * permission-ceiling gate.
205
+ */
206
+ public get canShareWithEveryone(): boolean {
207
+ return (
208
+ this.serverCapabilities.includes("publicGrants:v1") &&
209
+ canGrantToEveryone(this.currentUserRole)
210
+ );
211
+ }
212
+
119
213
  /** Adds a runtime capability to the middle layer. */
120
214
  public addRuntimeCapability(
121
215
  requirement: SupportedRequirement,
@@ -275,6 +369,492 @@ export class MiddleLayer {
275
369
  return newProjectId;
276
370
  }
277
371
 
372
+ //
373
+ // Project Sharing (Copy & Share)
374
+ //
375
+
376
+ /**
377
+ * Shares the given projects (Copy & Share). Snapshots the projects, creates one envelope, and
378
+ * grants it — all in one atomic write transaction, so a failed grant rolls the whole thing back
379
+ * and the outbox is left as it was.
380
+ *
381
+ * Two variants (see {@link ShareProjectsOptions}):
382
+ * - `{ recipients }` — one writable grant per named recipient; the envelope expires after the
383
+ * default TTL (`sharedAt + envelopeTtlMs`).
384
+ * - `{ everyone: true }` — one make-public grant (backend rewrites the target to the
385
+ * everyone-user); the envelope's `expiresAt` is `null`, so it never expires.
386
+ *
387
+ * v1 always passes `mode: "copy"`.
388
+ */
389
+ public async shareProjects(
390
+ projectIds: ProjectId[],
391
+ options: ShareProjectsOptions,
392
+ ): Promise<void> {
393
+ if (projectIds.length === 0) throw new Error("shareProjects: no projects given");
394
+
395
+ // Everyone + replace: refresh the existing everyone-share of this project under its stable
396
+ // shareId (so recipients who already decided aren't re-prompted), if one exists. Found
397
+ // automatically by project overlap; falls through to a fresh share when none exists.
398
+ if ("everyone" in options && options.replace) {
399
+ const priorEveryone = (await this.findSupersedableEnvelopes(projectIds)).find(
400
+ (p) => p.everyone,
401
+ );
402
+ if (priorEveryone !== undefined) {
403
+ await this.changeShare(priorEveryone.shareId, { title: options.title });
404
+ return;
405
+ }
406
+ }
407
+
408
+ await this.createNewShare(projectIds, options);
409
+ }
410
+
411
+ /**
412
+ * Mints a fresh share: snapshots the projects into one new envelope (a fresh shareId),
413
+ * supersedes prior shares of the same project, and grants it — all in one atomic write
414
+ * transaction, so a failed grant rolls the whole thing back and the outbox is left as it was.
415
+ * The everyone-refresh path is the {@link changeShare} branch of {@link shareProjects}; this is
416
+ * the mint-a-new-envelope branch.
417
+ */
418
+ private async createNewShare(
419
+ projectIds: ProjectId[],
420
+ options: ShareProjectsOptions,
421
+ ): Promise<void> {
422
+ const everyone = "everyone" in options;
423
+ const sources: EnvelopeProjectSource[] = await Promise.all(
424
+ projectIds.map(
425
+ async (id): Promise<EnvelopeProjectSource> => ({
426
+ kind: "fresh",
427
+ projectId: id,
428
+ sourceRid: await this.resolveProjectId(id),
429
+ }),
430
+ ),
431
+ );
432
+ const sender = this.currentUserLogin ?? "";
433
+ // Targeted share: sharedAt + ttl. Share-with-everybody: never expires (null).
434
+ const expiresAt = everyone ? null : Date.now() + this.env.ops.envelopeTtlMs;
435
+
436
+ // Supersede prior shares of the same project(s) so they never pile up. Resolved before
437
+ // the write tx (ListGrants is a separate RPC). Everyone-share supersedes a prior
438
+ // everyone-share of the same project; a targeted share pulls each named recipient out of
439
+ // any prior share of that project, deleting that share if it ends up with no recipients.
440
+ const priors = await this.findSupersedableEnvelopes(projectIds);
441
+
442
+ await this.pl.withWriteTx("MLShareProjects", async (tx) => {
443
+ if (everyone) {
444
+ for (const prior of priors) {
445
+ if (prior.everyone) tx.removeField(field(this.sharingOutboxResourceId, prior.fieldName));
446
+ }
447
+ } else {
448
+ const newRecipients = new Set(options.recipients);
449
+ for (const prior of priors) {
450
+ if (prior.everyone) continue; // a single user can't be pulled from an everyone-grant
451
+ const toRemove = prior.recipients.filter((u) => newRecipients.has(u));
452
+ if (toRemove.length === 0) continue;
453
+ const remaining = prior.recipients.filter((u) => !newRecipients.has(u));
454
+ if (remaining.length === 0) {
455
+ // Nobody left on the old share — drop the whole envelope.
456
+ tx.removeField(field(this.sharingOutboxResourceId, prior.fieldName));
457
+ } else {
458
+ for (const u of toRemove) tx.revokeAccess(prior.rid, u);
459
+ }
460
+ }
461
+ }
462
+
463
+ const { envelope } = await buildShareEnvelope(tx, this.sharingOutboxResourceId, sources, {
464
+ mode: options.mode,
465
+ sender,
466
+ title: options.title,
467
+ expiresAt,
468
+ });
469
+
470
+ // Grant in the same transaction (writable: the cross-color accept rule demands a writable
471
+ // grant on the envelope). Atomic with the create.
472
+ const envelopeGid = await envelope.globalId;
473
+ if (everyone) {
474
+ // One everyone-grant: empty/ignored target, ANY_AUTHORISED. The backend rewrites
475
+ // the target to the everyone-user; gated by role + permission ceiling.
476
+ tx.grantAccess(envelopeGid, "", { writable: true }, GrantType.ANY_AUTHORISED);
477
+ } else {
478
+ for (const recipient of options.recipients) {
479
+ tx.grantAccess(envelopeGid, recipient, { writable: true });
480
+ }
481
+ }
482
+
483
+ await tx.commit();
484
+ });
485
+
486
+ await this.sharingOutboxTree.refreshState();
487
+ }
488
+
489
+ /**
490
+ * Changes a share in place (same {@link ShareId}), in one write transaction: re-snapshots live
491
+ * source projects and carries deleted ones' snapshots forward; applies edited recipients/title;
492
+ * transfers already-decided recipients' accept/reject records (they keep their copy and aren't
493
+ * re-prompted); re-grants; drops the old envelope.
494
+ *
495
+ * `opts.recipients` is the full targeted set (decided users are always kept). `opts.title`
496
+ * replaces the title — omit keeps the current one. `opts.everyone` upgrades targeted ->
497
+ * everyone; the reverse is impossible and ignored.
498
+ *
499
+ * `opts.projectActions` is a per-source-project decision, keyed by projectId: `update`
500
+ * re-snapshots the live source (falls back to carry if the source is gone), `keep` carries the
501
+ * existing snapshot (and its timestamp), `remove` drops the project from the pack. A project not
502
+ * in the map defaults to `keep`. Omit the whole map for the legacy auto behavior (live sources
503
+ * updated, gone ones kept) — the everyone-refresh path relies on that.
504
+ */
505
+ public async changeShare(
506
+ shareId: ShareId,
507
+ opts: {
508
+ recipients?: string[];
509
+ everyone?: boolean;
510
+ title?: string;
511
+ projectActions?: Record<ProjectId, ProjectChangeAction>;
512
+ } = {},
513
+ ): Promise<void> {
514
+ await this.pl.withWriteTx("MLChangeShare", async (tx) => {
515
+ const old = await this.resolveOutboxEnvelope(tx, shareId);
516
+ if (old === undefined)
517
+ throw new Error(`changeShare: no live share with id ${shareId} in the outbox.`);
518
+
519
+ const self = this.currentUserLogin ?? "";
520
+ const grants = await tx.listGrants(old.rid);
521
+ // A targeted share may be upgraded to everyone; an everyone-share can't be narrowed back.
522
+ const everyone = grants.some((g) => isEveryoneUserLogin(g.user)) || opts.everyone === true;
523
+
524
+ // Read the old envelope's project snapshots (uuid -> rid) and accept/reject records.
525
+ const oldRd = await tx.getResourceData(old.rid, true);
526
+ const snapshotByUuid = new Map<string, SignedResourceId>();
527
+ const acceptances: { login: string; acc: EnvelopeAcceptance }[] = [];
528
+ for (const f of oldRd.fields) {
529
+ if (isNullSignedResourceId(f.value)) continue;
530
+ if (isEnvelopeProjectField(f.name)) {
531
+ snapshotByUuid.set(envelopeProjectFieldUuid(f.name), f.value);
532
+ } else if (isAcceptanceField(f.name)) {
533
+ const raw = (await tx.getResourceData(f.value, false)).data;
534
+ if (raw === undefined) continue;
535
+ acceptances.push({
536
+ login: acceptanceFieldLogin(f.name),
537
+ acc: cachedDeserialize(raw) as EnvelopeAcceptance,
538
+ });
539
+ }
540
+ }
541
+ const decidedLogins = acceptances.map((a) => a.login);
542
+
543
+ // Everyone-shares ignore recipients; targeted shares keep decided users plus the edited set.
544
+ const priorRecipients = grants
545
+ .filter((g) => !isEveryoneUserLogin(g.user) && g.user !== self)
546
+ .map((g) => g.user);
547
+ const recipients = everyone
548
+ ? []
549
+ : Array.from(new Set([...(opts.recipients ?? priorRecipients), ...decidedLogins]));
550
+
551
+ // Live source projects by persistable id — these get a fresh snapshot.
552
+ const liveProjects = new Map<string, SignedResourceId>();
553
+ const projList = await tx.getResourceData(this.projectListResourceId, true);
554
+ for (const f of projList.fields) {
555
+ if (isNullSignedResourceId(f.value)) continue;
556
+ liveProjects.set(resourceIdToString(f.value), f.value);
557
+ }
558
+
559
+ // Per project (keyed by field uuid), apply the caller's decision (default `keep`); with no
560
+ // projectActions map, fall back to the legacy auto behavior: update a live source, keep a gone one.
561
+ const actions = opts.projectActions;
562
+ const sources: EnvelopeProjectSource[] = [];
563
+ for (const uuid of Object.keys(old.data.projects) as ProjectFieldUuid[]) {
564
+ const { label, source, updatedAt } = old.data.projects[uuid];
565
+ const liveRid = liveProjects.get(source);
566
+
567
+ const action = actions
568
+ ? (actions[source] ?? "keep")
569
+ : liveRid !== undefined
570
+ ? "update"
571
+ : "keep";
572
+ if (action === "remove") continue;
573
+
574
+ if (action === "update" && liveRid !== undefined) {
575
+ sources.push({ kind: "fresh", projectId: source, sourceRid: liveRid });
576
+ } else {
577
+ // keep, or an "update" whose source vanished before commit (deleted meanwhile, e.g. from
578
+ // another client): carry the prior snapshot. Liveness is read inside this write tx — race-safe.
579
+ const snapshotRid = snapshotByUuid.get(uuid);
580
+ if (snapshotRid !== undefined)
581
+ sources.push({ kind: "carry", projectId: source, label, snapshotRid, updatedAt });
582
+ }
583
+ }
584
+
585
+ // Omit (undefined) keeps the current title; a provided value replaces it.
586
+ const title = opts.title === undefined ? old.data.title : opts.title.trim();
587
+ const expiresAt = everyone ? null : Date.now() + this.env.ops.envelopeTtlMs;
588
+
589
+ // Same shareId, same outbox field name — detach the old field before rebuilding, or they collide.
590
+ tx.removeField(field(this.sharingOutboxResourceId, old.fieldName));
591
+
592
+ const { envelope } = await buildShareEnvelope(tx, this.sharingOutboxResourceId, sources, {
593
+ mode: old.data.mode,
594
+ sender: self,
595
+ title,
596
+ expiresAt,
597
+ shareId, // SAME shareId — the essence of change
598
+ });
599
+
600
+ // Transfer the decided users' records onto the new envelope (donor-written copies).
601
+ for (const { login, acc } of acceptances) {
602
+ if (!everyone && !recipients.includes(login)) continue;
603
+ writeEnvelopeAcceptance(tx, envelope, login, acc.action, acc.timestamp);
604
+ }
605
+
606
+ const gid = await envelope.globalId;
607
+ if (everyone) tx.grantAccess(gid, "", { writable: true }, GrantType.ANY_AUTHORISED);
608
+ else for (const r of recipients) tx.grantAccess(gid, r, { writable: true });
609
+
610
+ await tx.commit();
611
+ });
612
+
613
+ await this.sharingOutboxTree.refreshState();
614
+ }
615
+
616
+ /**
617
+ * Finds the donor's own outgoing envelopes built from any of the given source projects —
618
+ * the supersede candidates for a fresh share of the same project(s). Reads each envelope's
619
+ * recipient set via `ListGrants` so the caller can pull individual recipients or detect an
620
+ * everyone-share.
621
+ */
622
+ private async findSupersedableEnvelopes(projectIds: ProjectId[]): Promise<
623
+ {
624
+ fieldName: string;
625
+ rid: SignedResourceId;
626
+ shareId: ShareId;
627
+ everyone: boolean;
628
+ recipients: string[];
629
+ }[]
630
+ > {
631
+ const wanted = new Set(projectIds);
632
+
633
+ const matched = await this.pl.withReadTx("MLFindSupersede", async (tx) => {
634
+ const outbox = await tx.getResourceData(this.sharingOutboxResourceId, true);
635
+ const out: { fieldName: string; rid: SignedResourceId; shareId: ShareId }[] = [];
636
+ for (const f of outbox.fields) {
637
+ if (isNullSignedResourceId(f.value)) continue;
638
+ const rd = await tx.getResourceData(f.value, false);
639
+ if (rd.data === undefined) continue;
640
+ const data = decodeEnvelopeData(rd.data);
641
+ if (Object.values(data.projects).some((p) => wanted.has(p.source)))
642
+ out.push({ fieldName: f.name, rid: f.value, shareId: data.shareId });
643
+ }
644
+ return out;
645
+ });
646
+
647
+ return await Promise.all(
648
+ matched.map(async ({ fieldName, rid, shareId }) => {
649
+ const grants = await this.pl.userResources.listGrants(rid);
650
+ return {
651
+ fieldName,
652
+ rid,
653
+ shareId,
654
+ everyone: grants.some((g) => isEveryoneUserLogin(g.user)),
655
+ recipients: grants.filter((g) => !isEveryoneUserLogin(g.user)).map((g) => g.user),
656
+ };
657
+ }),
658
+ );
659
+ }
660
+
661
+ /**
662
+ * Revokes and deletes an outgoing share for all recipients: detaches and deletes the envelope, and
663
+ * its grants are revoked along with it. Already-accepted copies are unaffected (ref-counting keeps
664
+ * the adopted resources alive). Idempotent — revoking a share that is already gone is a no-op.
665
+ */
666
+ public async revokeShare(shareId: ShareId): Promise<void> {
667
+ await this.pl.withWriteTx("MLRevokeShare", async (tx) => {
668
+ const target = await this.resolveOutboxEnvelope(tx, shareId);
669
+ if (target === undefined) return;
670
+ tx.removeField(field(this.sharingOutboxResourceId, target.fieldName));
671
+ await tx.commit();
672
+ });
673
+
674
+ await this.sharingOutboxTree.refreshState();
675
+ }
676
+
677
+ /**
678
+ * Resolves a live envelope from the donor's own outbox by its logical `shareId`, returning the
679
+ * outbox field name (for detach), the signed envelope id, and its decoded {@link EnvelopeData}.
680
+ * The outbox is keyed by `{shareId}` directly, but a replaced/legacy share may have drifted, so
681
+ * we match on the decoded `shareId` rather than the field name alone.
682
+ */
683
+ private async resolveOutboxEnvelope(
684
+ tx: PlTransaction,
685
+ shareId: ShareId,
686
+ ): Promise<{ fieldName: string; rid: SignedResourceId; data: EnvelopeData } | undefined> {
687
+ const outboxData = await tx.getResourceData(this.sharingOutboxResourceId, true);
688
+ for (const f of outboxData.fields) {
689
+ if (isNullSignedResourceId(f.value)) continue;
690
+ const rd = await tx.getResourceData(f.value, false);
691
+ if (rd.data === undefined) continue;
692
+ const data = decodeEnvelopeData(rd.data);
693
+ if (data.shareId === shareId) return { fieldName: f.name, rid: f.value, data };
694
+ }
695
+ return undefined;
696
+ }
697
+
698
+ /**
699
+ * Resolves currently-shared envelopes (granted to this user) to their resource ids, keyed by
700
+ * the envelope's logical `shareId`.
701
+ *
702
+ * Reads the {@link liveEnvelopes} Computable — the same shared-resource discovery tree that
703
+ * feeds {@link pendingShares}. This is the single discovery mechanism: there is no separate
704
+ * `ListUserResources` re-stream on every accept/reject. `refreshState()` is awaited first so a
705
+ * just-granted envelope is observed (the tree's discovery poll may otherwise lag a freshly
706
+ * landed grant). The tree is gRPC-only, so this is empty on a REST-connected client.
707
+ */
708
+ private async resolveLiveEnvelopes(): Promise<Map<ShareId, LiveEnvelope>> {
709
+ await this.pendingSharesTree.refreshState();
710
+ const live = (await this.liveEnvelopes.getValue()) ?? [];
711
+ // Dedup by logical shareId (last writer wins — at most one live envelope per shareId).
712
+ const map = new Map<ShareId, LiveEnvelope>();
713
+ for (const e of live) map.set(e.data.shareId, e);
714
+ return map;
715
+ }
716
+
717
+ /**
718
+ * Accepts one or more pending shares: duplicates each share's projects into this user's
719
+ * project list, records the decision per share, and (read-write share) writes the donor-visible
720
+ * acceptance onto the envelope. Per-share failures (e.g. an expiry race) are collected, not
721
+ * short-circuited — the rest still get accepted. Accept-all = pass every current pending shareId.
722
+ *
723
+ * `rename` resolves label collisions (same callback contract as {@link duplicateProject}), but
724
+ * the source lives in the envelope tree, so accept calls the low-level mutator directly.
725
+ */
726
+ public async acceptShare(
727
+ shareIds: ShareId[],
728
+ rename?: (previousLabel: string, existingLabels: string[]) => string,
729
+ ): Promise<{ accepted: ProjectId[]; failed: { shareId: ShareId; error: string }[] }> {
730
+ const live = await this.resolveLiveEnvelopes();
731
+ const login = this.currentUserLogin;
732
+
733
+ const accepted: ProjectId[] = [];
734
+ const failed: { shareId: ShareId; error: string }[] = [];
735
+
736
+ for (const shareId of shareIds) {
737
+ const envelope = live.get(shareId);
738
+ if (envelope === undefined) {
739
+ failed.push({ shareId, error: "Share is no longer available." });
740
+ continue;
741
+ }
742
+ try {
743
+ const now = Date.now();
744
+ const createdRids = await this.pl.withWriteTx("MLAcceptShare", async (tx) => {
745
+ const created = await copyEnvelopeProjectsIntoList(
746
+ tx,
747
+ envelope.rid,
748
+ this.projectListResourceId,
749
+ rename,
750
+ );
751
+
752
+ // Record the decision on the acceptor's own SharingState, keyed on shareId.
753
+ writeSharingDecision(tx, this.sharingStateResourceId, shareId, {
754
+ decision: "accepted",
755
+ timestamp: now,
756
+ envelopeSharedAt: envelope.data.sharedAt,
757
+ acceptedProjects: resourceIdsToStrings(created),
758
+ });
759
+
760
+ // Read-write share: write the donor-visible acceptance onto the envelope.
761
+ if (login !== null && envelope.data.mode !== "read-only")
762
+ writeEnvelopeAcceptance(tx, envelope.rid, login, "accepted", now);
763
+
764
+ await tx.commit();
765
+ return created;
766
+ });
767
+ for (const rid of createdRids) {
768
+ const projectId = resourceIdToString(rid) as ProjectId;
769
+ this.projectIdCache.set(projectId, rid);
770
+ accepted.push(projectId);
771
+ }
772
+ } catch (e) {
773
+ failed.push({ shareId, error: e instanceof Error ? e.message : String(e) });
774
+ }
775
+ }
776
+
777
+ await Promise.all([this.projectListTree.refreshState(), this.sharingStateTree.refreshState()]);
778
+ return { accepted, failed };
779
+ }
780
+
781
+ /** Records rejection of a pending share; it never surfaces again. */
782
+ public async rejectShare(shareId: ShareId): Promise<void> {
783
+ const live = await this.resolveLiveEnvelopes();
784
+ const envelope = live.get(shareId);
785
+ const login = this.currentUserLogin;
786
+ const now = Date.now();
787
+
788
+ await this.pl.withWriteTx("MLRejectShare", async (tx) => {
789
+ writeSharingDecision(tx, this.sharingStateResourceId, shareId, {
790
+ decision: "rejected",
791
+ timestamp: now,
792
+ envelopeSharedAt: envelope?.data.sharedAt ?? now,
793
+ acceptedProjects: [],
794
+ });
795
+
796
+ // Read-write share: write the donor-visible rejection onto the envelope (if still live).
797
+ if (envelope !== undefined && login !== null && envelope.data.mode !== "read-only")
798
+ writeEnvelopeAcceptance(tx, envelope.rid, login, "rejected", now);
799
+
800
+ await tx.commit();
801
+ });
802
+
803
+ await this.sharingStateTree.refreshState();
804
+ }
805
+
806
+ //
807
+ // Outbox cleanup (donor side)
808
+ //
809
+
810
+ private static readonly EnvelopeCleanupIntervalMs = 6 * 3600 * 1000; // every 6h
811
+ private envelopeCleanupTimer: ReturnType<typeof setInterval> | undefined;
812
+
813
+ /** On ML start and every 6h, delete envelopes whose immutable `expiresAt` has passed. */
814
+ private startEnvelopeCleanup(): void {
815
+ void this.runEnvelopeCleanup();
816
+ this.envelopeCleanupTimer = setInterval(() => {
817
+ void this.runEnvelopeCleanup();
818
+ }, MiddleLayer.EnvelopeCleanupIntervalMs);
819
+ // Don't keep the process alive solely for cleanup.
820
+ this.envelopeCleanupTimer.unref?.();
821
+ }
822
+
823
+ /** Scans the donor's outbox and deletes expired envelopes (backend auto-revokes their grants).
824
+ * Envelopes with `expiresAt: null` (share-with-everybody) are skipped. */
825
+ private async runEnvelopeCleanup(): Promise<void> {
826
+ try {
827
+ const now = Date.now();
828
+ const expired = await this.pl.withReadTx("MLEnvelopeCleanupScan", async (tx) => {
829
+ const data = await tx.getResourceData(this.sharingOutboxResourceId, true);
830
+ const toDelete: { fieldName: string }[] = [];
831
+ for (const f of data.fields) {
832
+ if (isNullSignedResourceId(f.value)) continue;
833
+ const rd = await tx.getResourceData(f.value, false);
834
+ if (rd.data === undefined) continue;
835
+ const envData = decodeEnvelopeData(rd.data);
836
+ if (envData.expiresAt === null) continue; // never expires
837
+ if (envData.expiresAt <= now) toDelete.push({ fieldName: f.name });
838
+ }
839
+ return toDelete;
840
+ });
841
+
842
+ if (expired.length === 0) return;
843
+
844
+ await this.pl.withWriteTx("MLEnvelopeCleanup", async (tx) => {
845
+ for (const { fieldName } of expired)
846
+ tx.removeField(field(this.sharingOutboxResourceId, fieldName));
847
+ await tx.commit();
848
+ });
849
+
850
+ await this.sharingOutboxTree.refreshState();
851
+ } catch (e) {
852
+ this.env.logger.warn(
853
+ `envelope cleanup failed: ${e instanceof Error ? e.message : String(e)}`,
854
+ );
855
+ }
856
+ }
857
+
278
858
  //
279
859
  // Projects
280
860
  //
@@ -316,9 +896,15 @@ export class MiddleLayer {
316
896
  * them.
317
897
  */
318
898
  public async close() {
899
+ if (this.envelopeCleanupTimer !== undefined) clearInterval(this.envelopeCleanupTimer);
319
900
  await Promise.all([...this.openedProjects.values()].map((prj) => prj.destroy()));
320
901
  // this.env.quickJs;
321
- await this.projectListTree.terminate();
902
+ await Promise.all([
903
+ this.projectListTree.terminate(),
904
+ this.sharingOutboxTree.terminate(),
905
+ this.sharingStateTree.terminate(),
906
+ this.pendingSharesTree.terminate(),
907
+ ]);
322
908
  await this.env.dispose();
323
909
  await this.pl.close();
324
910
  }
@@ -361,23 +947,40 @@ export class MiddleLayer {
361
947
  )
362
948
  ops.defaultTreeOptions.traversalMode = getDebugFlags().treeTraversalMode;
363
949
 
364
- const projects = await pl.withWriteTx("MLInitialization", async (tx) => {
365
- const projectsField = field(tx.clientRoot, ProjectsField);
366
- tx.createField(projectsField, "Dynamic");
367
- const projectsFieldData = await tx.getField(projectsField);
368
- if (isNullSignedResourceId(projectsFieldData.value)) {
369
- const projects = tx.createEphemeral(ProjectsResourceType);
370
- tx.lock(projects);
950
+ const { projects, sharingOutbox, sharingState } = await pl.withWriteTx(
951
+ "MLInitialization",
952
+ async (tx) => {
953
+ // Lazily create each clientRoot-attached singleton resource. Returns the existing
954
+ // resource id if the field is already populated, otherwise creates + locks + sets it.
955
+ const lazyInit = async (
956
+ fieldName: string,
957
+ type: { name: string; version: string },
958
+ ): Promise<{ ref?: ResourceRef; existing?: SignedResourceId }> => {
959
+ const f = field(tx.clientRoot, fieldName);
960
+ tx.createField(f, "Dynamic");
961
+ const fData = await tx.getField(f);
962
+ if (isNullSignedResourceId(fData.value)) {
963
+ const ref = tx.createEphemeral(type);
964
+ tx.lock(ref);
965
+ tx.setField(f, ref);
966
+ return { ref };
967
+ }
968
+ return { existing: fData.value };
969
+ };
371
970
 
372
- tx.setField(projectsField, projects);
971
+ const projectsR = await lazyInit(ProjectsField, ProjectsResourceType);
972
+ const outboxR = await lazyInit(SharingOutboxField, SharingOutboxResourceType);
973
+ const stateR = await lazyInit(SharingStateField, SharingStateResourceType);
373
974
 
374
975
  await tx.commit();
375
976
 
376
- return await projects.globalId;
377
- } else {
378
- return projectsFieldData.value;
379
- }
380
- });
977
+ return {
978
+ projects: projectsR.existing ?? (await projectsR.ref!.globalId),
979
+ sharingState: stateR.existing ?? (await stateR.ref!.globalId),
980
+ sharingOutbox: outboxR.existing ?? (await outboxR.ref!.globalId),
981
+ };
982
+ },
983
+ );
381
984
 
382
985
  const logger = ops.logger;
383
986
 
@@ -439,15 +1042,34 @@ export class MiddleLayer {
439
1042
  const openedProjects = new WatchableValue<ProjectId[]>([]);
440
1043
  const projectListTC = await createProjectList(pl, projects, openedProjects, env);
441
1044
 
1045
+ // Project sharing trees and reactive views.
1046
+ const outgoingTC = await createOutgoingShares(pl, sharingOutbox, env);
1047
+ const sharingStateTree = await createSharingStateTree(pl, sharingState, env);
1048
+ const pendingSharesTree = await createPendingSharesTree(pl, env);
1049
+ const pendingShares = createPendingSharesComputable(
1050
+ pendingSharesTree,
1051
+ sharingStateTree,
1052
+ pl.userResources.authUser,
1053
+ );
1054
+ const liveEnvelopes = createLiveEnvelopesComputable(pendingSharesTree);
1055
+
442
1056
  return new MiddleLayer(
443
1057
  env,
444
1058
  driverKit,
445
1059
  driverKit.signer,
446
1060
  projects,
1061
+ sharingOutbox,
1062
+ sharingState,
447
1063
  openedProjects,
448
1064
  projectListTC.tree,
1065
+ outgoingTC.tree,
1066
+ sharingStateTree,
1067
+ pendingSharesTree,
449
1068
  v2RegistryProvider,
450
1069
  projectListTC.computable,
1070
+ outgoingTC.computable,
1071
+ pendingShares,
1072
+ liveEnvelopes,
451
1073
  );
452
1074
  }
453
1075
  }