Skip to content

Milab 2668/project sharing#1719

Open
AStaroverov wants to merge 15 commits into
mainfrom
MILAB-2668/project-sharing
Open

Milab 2668/project sharing#1719
AStaroverov wants to merge 15 commits into
mainfrom
MILAB-2668/project-sharing

Conversation

@AStaroverov

@AStaroverov AStaroverov commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

Greptile Summary

This PR implements the "Copy & Share" project-sharing feature: donors can package one or more project snapshots into a SharedEnvelope resource, grant it to named recipients or to everyone, and recipients can accept (copying the projects into their own list) or reject. The implementation spans the proto/gRPC layer, pl-client, pl-tree, and pl-middle-layer.

  • Proto / pl-client: Adds ListUsers (recipient picker), ListGrants (donor's "who did I share with"), GrantAccess.MAKE_RESOURCE_PUBLIC, and three new capability tokens (crossTreeRefs:v1, userListing:v1, publicGrants:v1). AccessFlags defaults flip from deny-list to allow-list, and a new set_error control flag is added.
  • pl-tree: PlTreeState and SynchronizedTreeState are generalized from a single root to a Set<SignedResourceId>, with a new SharedTypeSeed that discovers shared envelopes via periodic ListUserResources polling (paced at 1 in 15 iterations). PlTreeRootsEntry / PlTreeRootsAccessor expose the reactive root set to Computables.
  • pl-middle-layer: Three new singleton resources per user (SharingOutbox, SharingState, plus the shared-resource discovery tree), four public share-flow methods on MiddleLayer, a 6-hour envelope cleanup timer, and reactive outgoingShares / pendingShares computables.

Confidence Score: 3/5

The sharing flow has two concrete defects in the share-creation and share-discovery paths that can produce orphaned envelopes and allow post-expiry acceptance of shares.

Two behavioral defects were found: (1) shareProjects creates a SharedEnvelope with no grants when called with an empty recipients array — the envelope lands in the donor's outbox and persists for 14 days, visible but unreachable by anyone; (2) createPendingSharesComputable has no expiry check, so recipients see (and can accept) envelopes that have already passed their TTL, for up to 6 hours until the donor's cleanup timer fires. Both affect the primary sharing path and produce incorrect state visible to users.

middle_layer.ts (shareProjects recipients guard) and sharing_list.ts (createPendingSharesComputable expiry filter) need fixes before the feature is production-ready.

Important Files Changed

Filename Overview
lib/node/pl-middle-layer/src/middle_layer/middle_layer.ts Core orchestration of project sharing — adds shareProjects/acceptShare/rejectShare/revokeShare, envelope cleanup, and three new synchronized tree roots; empty-recipients guard is missing in the targeted-share path
lib/node/pl-middle-layer/src/middle_layer/sharing_list.ts New file implementing reactive pending/outgoing share views; createPendingSharesComputable omits an expiry check, allowing expired envelopes to surface to recipients until donor cleanup fires
lib/node/pl-middle-layer/src/model/sharing_model.ts New data model file: ShareId branding, EnvelopeData, SharingDecision, EnvelopeAcceptance, resource type constants, and the canGrantToEveryone role mirror — well-structured, no issues found
lib/node/pl-middle-layer/src/mutator/sharing.ts New file with donor/acceptor mutators: buildShareEnvelope, writeEnvelopeAcceptance, writeSharingDecision, copyEnvelopeProjectsIntoList — logic is sound, cross-color attach clearly gated on crossTreeRefs:v1
lib/node/pl-tree/src/synchronized_tree.ts Generalized to multi-root and SharedTypeSeed discovery; discovery is correctly paced at 1/15 iterations and single-root callers are guarded with a THROWS accessor
lib/node/pl-tree/src/state.ts PlTreeState extended from single-root to Set-of-roots with setRoots() GC cascade; collectGarbage factored out cleanly; rootsChanged ChangeSource wires reactive recomputation correctly
lib/node/pl-client/proto/plapi/plapiproto/api_types.proto AccessFlags semantics flipped from deny-list to allow-list with new set_error field; create_resource inline comment still says default: true contradicting the allow-list header
lib/node/pl-client/src/core/capabilities.ts Three new capability tokens added (crossTreeRefs:v1, userListing:v1, publicGrants:v1); comments accurately describe each token's semantics
lib/node/pl-client/src/core/client.ts Adds currentUserRole and authUser getters; GetSessionInfo is called once at init with a silent catch for pre-capability backends — safe degradation
lib/node/pl-client/src/core/ll_client.ts New getSessionInfo, listGrants, and listUsers methods; gRPC-only paths correctly throw on REST connections; base64 decode for sessionId in the REST path is correct

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Donor as Donor (MiddleLayer)
    participant Backend as PL Backend
    participant Acceptor as Acceptor (MiddleLayer)

    Donor->>Backend: "withWriteTx(MLShareProjects)<br/>createEphemeral(SharedEnvelope, EnvelopeData)<br/>createField(outbox/{shareId}, envelope)<br/>grantAccess(envelope, recipient, writable:true)"
    Backend-->>Donor: tx committed
    Donor->>Backend: sharingOutboxTree.refreshState()

    Note over Acceptor: every ~3s (15 x 200ms)
    Acceptor->>Backend: "listSharedResourcesByType(SharedEnvelope)<br/>via ListUserResources"
    Backend-->>Acceptor: [envelope signed id]
    Acceptor->>Acceptor: "setRoots(discoveredEnvelopes)<br/>pendingShares recomputed"

    Acceptor->>Backend: "withWriteTx(MLAcceptShare)<br/>copyEnvelopeProjectsIntoList(envelope -> projectList)<br/>writeSharingDecision(SharingState, shareId, accepted)<br/>writeEnvelopeAcceptance(envelope, login, accepted)"
    Backend-->>Acceptor: tx committed
    Acceptor->>Backend: refreshState(projectList, sharingState)

    Note over Donor: every 6h
    Donor->>Backend: "withReadTx(MLEnvelopeCleanupScan)<br/>check expiresAt on each outbox envelope"
    Donor->>Backend: "withWriteTx(MLEnvelopeCleanup)<br/>removeField(outbox/{expiredShareId})<br/>backend auto-revokes all grants"
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Donor as Donor (MiddleLayer)
    participant Backend as PL Backend
    participant Acceptor as Acceptor (MiddleLayer)

    Donor->>Backend: "withWriteTx(MLShareProjects)<br/>createEphemeral(SharedEnvelope, EnvelopeData)<br/>createField(outbox/{shareId}, envelope)<br/>grantAccess(envelope, recipient, writable:true)"
    Backend-->>Donor: tx committed
    Donor->>Backend: sharingOutboxTree.refreshState()

    Note over Acceptor: every ~3s (15 x 200ms)
    Acceptor->>Backend: "listSharedResourcesByType(SharedEnvelope)<br/>via ListUserResources"
    Backend-->>Acceptor: [envelope signed id]
    Acceptor->>Acceptor: "setRoots(discoveredEnvelopes)<br/>pendingShares recomputed"

    Acceptor->>Backend: "withWriteTx(MLAcceptShare)<br/>copyEnvelopeProjectsIntoList(envelope -> projectList)<br/>writeSharingDecision(SharingState, shareId, accepted)<br/>writeEnvelopeAcceptance(envelope, login, accepted)"
    Backend-->>Acceptor: tx committed
    Acceptor->>Backend: refreshState(projectList, sharingState)

    Note over Donor: every 6h
    Donor->>Backend: "withReadTx(MLEnvelopeCleanupScan)<br/>check expiresAt on each outbox envelope"
    Donor->>Backend: "withWriteTx(MLEnvelopeCleanup)<br/>removeField(outbox/{expiredShareId})<br/>backend auto-revokes all grants"
Loading

Fix All in Claude Code

Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
lib/node/pl-middle-layer/src/middle_layer/middle_layer.ts:1281
**Empty recipients list creates a stranded envelope**

`shareProjects` validates that `projectIds` is non-empty but has no guard for an empty `recipients` array in the targeted-share path. When `options.recipients` is `[]`, the `for (const recipient of options.recipients)` loop at the grant step is skipped entirely — the envelope is created and attached to the outbox, but no grants are issued. The orphaned envelope then shows in the donor's `outgoingShares` with `recipients: []`, and it persists until the 14-day cleanup TTL expires (no one can revoke it because there are no grants to list). Add a guard alongside the `projectIds.length === 0` check: `if (!everyone && options.recipients?.length === 0) throw new Error(...)`.

### Issue 2 of 3
lib/node/pl-middle-layer/src/middle_layer/sharing_list.ts:271-280
**Expired envelopes are not filtered from the pending-shares view**

`createPendingSharesComputable` filters handled shares and the user's own sender, but does not check `data.expiresAt`. Between the time an envelope expires and the donor's cleanup timer fires (up to 6 h), recipients still see the share as pending and can accept it. The accepted projects would be copied successfully — the backend access is still active during this window — contradicting the intended TTL semantics. Add `if (data.expiresAt !== null && data.expiresAt <= Date.now()) continue;` after the `handled` check to suppress expired entries client-side while cleanup catches up.

### Issue 3 of 3
lib/node/pl-client/proto/plapi/plapiproto/api_types.proto:75-78
**`create_resource` default contradicts the updated allow-list header comment**

The section header was changed to read "Allow-list approach: default = forbidden (false)", but `create_resource` still carries the inline comment `// default: true (creation allowed)`. Implementors reading these together will get conflicting signals about what an unset field means. Either `create_resource` should have its default changed to match the allow-list invariant, or the header comment should be narrowed to say "allow-list *for reads/writes/KV*" and explicitly carve out `create_resource` as a separately-defaulted exception.

Reviews (1): Last reviewed commit: "feat: Add revokeAccess method and enhanc..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

Context used:

  • Context used - Terms is a types in codebase. Provide the list of ... (source)

- Introduced new types and models for sharing, including `OutgoingShare`, `PendingShare`, and `EnvelopeData`.
- Implemented functions for creating and managing shares, including `buildShareEnvelope`, `writeEnvelopeAcceptance`, and `writeSharingDecision`.
- Added support for managing shared resources with `SynchronizedTreeState` for both donors and acceptors.
- Enhanced the middle layer's ops settings to include `envelopeTtlMs` for envelope expiration management.
- Created computables for reactive views of outgoing and pending shares, allowing for dynamic updates based on user interactions.
- Updated the tree state management to support multi-root trees, enabling better handling of shared resources.
- Added new utility functions for handling resource IDs and project adoption from shared envelopes.
- Added `getSessionInfo` method to retrieve the authenticated caller's session ID and role, supporting both gRPC and REST.
- Introduced `listGrants` method to enumerate grants for a specific resource, ensuring only resource owners can access it via gRPC.
- Updated `UserResources` to include `listGrants` and `listUsers` methods, with the latter currently returning a stubbed response.
- Enhanced `MiddleLayer` to expose user roles and sharing capabilities, including the ability to share resources with everyone.
- Refactored sharing logic to support both targeted and public sharing, with appropriate handling of grants and envelope management.
- Introduced `ShareId` type for better identification of shares across the system.
- Updated sharing model to include role-based permissions for public grants.
- Improved performance of tree polling for user resources to optimize discovery of shared resources.
@notion-workspace

Copy link
Copy Markdown

@changeset-bot

changeset-bot Bot commented Jun 26, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f01c92d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 21 packages
Name Type
@platforma-sdk/block-tools Patch
@milaboratories/pl-middle-layer Patch
@milaboratories/pl-client Patch
@milaboratories/pl-tree Patch
@milaboratories/pl-model-common Patch
@platforma-sdk/pl-cli Patch
@platforma-sdk/test Patch
@milaboratories/pl-model-backend Patch
@milaboratories/pl-errors Patch
@milaboratories/pl-drivers Patch
@milaboratories/pl-model-middle-layer Patch
@milaboratories/pf-driver Patch
@milaboratories/pf-spec-driver Patch
@milaboratories/pl-deployments Patch
@platforma-open/milaboratories.software-ptabler.schema Patch
@platforma-sdk/model Patch
@platforma-sdk/ui-vue Patch
@platforma-sdk/tengo-builder Patch
@platforma-sdk/bootstrap Patch
@milaboratories/ptabler-expression-js Patch
@milaboratories/uikit Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@AStaroverov AStaroverov requested a review from dbolotin June 26, 2026 10:44

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces project-sharing capabilities (Copy & Share) across the platform, updating the API definitions, pl-client, pl-middle-layer, and pl-tree to support multi-root synchronized trees and dynamic root discovery. The review feedback focuses on performance and robustness improvements: avoiding a redundant gRPC call at startup by initializing the loop iteration counter to 1, parallelizing sequential resource reads using Promise.all during envelope cleanup and supersede searches, and enhancing the outgoing shares computable to gracefully handle REST clients and filter out stale recipient responses.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread lib/node/pl-tree/src/synchronized_tree.ts
Comment thread lib/node/pl-middle-layer/src/middle_layer/middle_layer.ts
Comment thread lib/node/pl-middle-layer/src/middle_layer/middle_layer.ts
Comment thread lib/node/pl-middle-layer/src/middle_layer/sharing_list.ts
Comment thread lib/node/pl-middle-layer/src/middle_layer/sharing_list.ts
@codecov

codecov Bot commented Jun 26, 2026

Copy link
Copy Markdown

❌ 1 Tests Failed:

Tests completed Failed Passed Skipped
417 1 416 6
View the top 1 failed test(s) by shortest run time
src/core/ll_client.test.ts > unauthenticated status change
Stack Traces | 0.0361s run time
AssertionError: expected 'OK' to deeply equal 'Unauthenticated'

Expected: "Unauthenticated"
Received: "OK"

 ❯ src/core/ll_client.test.ts:78:25

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

Comment thread lib/node/pl-client/src/core/client.ts Outdated
try {
this._currentUserRole = (await this._ll.getSessionInfo()).role;
} catch {
this._currentUserRole = null;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's not rely on exception here, and clearly write the conditions when it should be null, I guess it is not only anonymous, but also for older backend versions?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

const cl = this.clientProvider.get();

if (!(cl instanceof GrpcPlApiClient)) {
throw new Error("ListUsers requires gRPC wire protocol; REST is not supported");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure this is a runtime check? In other words if you instantiated new client (packed with this version), but back-end does not support it, then cl instanceof GrpcPlApiClient will be false? Please confirm, looks suspicious, most probably you need to use feature flags from the backend here.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only caller gates on the capability

* ({@link GrantType.MAKE_RESOURCE_PUBLIC}) grant is recorded against this login,
* and recipients of an everyone-grant surface in `ListGrants` with this value —
* callers map it to "*". Mirror of `EveryoneUser` in
* `pl/platform/model/user.go`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please don't cite back-end code in here.

* callers map it to "*". Mirror of `EveryoneUser` in
* `pl/platform/model/user.go`.
*/
export const EveryoneUser =

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed with @DenKoren, not a part of public contract, we don't need it here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unfortunately right know it's only one way

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

incorrect, see comment from Denis below


async function loadTreeStateViaBfs(
tx: PlTransaction,
loadingRequest: TreeLoadingRequest,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To merge the grand listing logic the idea is basically to move it here, so, just adding a list of those grants in the request and they will be polled within the same transaction using transactional version of list grants.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addressed in a different way

Comment on lines +197 to +199
export function isEveryoneUserLogin(login: string): boolean {
return login.length === EveryoneLoginLength && login.startsWith(EveryonePrefix);
}

@DenKoren DenKoren Jun 29, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately, we have to hardcode the detection logic, you're right :(. Better to stick to a constant you tried earlier :( (everyone-Po9ahwahxai7Aejingaiyiequuecu3ei4moaNge4xahTh0Co7XeeLeiph6ahy3As). It will never change in backend and I will bring the right way of detection via grant type. We'll just wire new capability detection and 'everyone-*' user will never be exposed to API in modern backends.

We already have a version of backend that released with a bug exposing 'everyone' to the API, so we can't avoid checking this completely, but we know this constant will not change in API.

It's OK to write this const on a fence. There are guards for this const in code that prevent its usage of it in API in critical places.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reverted

DenKoren and others added 6 commits June 29, 2026 19:52
MILAB-2668: refresh API, bring constants renaming
…t reads

Revoking an everyone-share tore down the client session: a revoked resource
signature comes back as UNAUTHENTICATED, which the transport escalated to a
session reset, and discovery trees + sharing-list reads then failed.

- pl-client: distinguish a per-resource signature failure from a dead session
  by probing getSessionInfo before escalating; recognise the PlError family in
  isUnauthenticated; register sharing resource types (silence UNKNOWN RESOURCE
  TYPE); add the txListGrants:v1 capability; gate init role-resolution on
  publicGrants:v1; everyone-sentinel exact-token match.
- pl-tree: discovery trees self-heal (re-discover + retry) when a discovered
  grant is revoked/expired instead of failing the whole ResourceTree poll.
- pl-middle-layer: per-envelope grant reads with failure isolation; idempotent
  revokeShare.
- pl-model-common: SharingOutbox / SharingState / SharedEnvelope type names.
do-pack produces package.tgz, so a second run saw two .tgz files and
`mv *.tgz package.tgz` failed ("dest is not a directory"). Remove the prior
package.tgz before packing. Fixed in the structurer template and propagated to
all blocks via refresh-blocks (only the do-pack line changed).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants