@milaboratories/pl-middle-layer 1.64.42 → 1.65.1

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.
@@ -40,6 +40,22 @@ function canGrantToEveryone(role) {
40
40
  default: return false;
41
41
  }
42
42
  }
43
+ /**
44
+ * Whether a role may impersonate another user: open/create another user's root and list
45
+ * the resources that user can access. Mirrors the backend's authorization rule
46
+ * `util/misecurity/role.go` `CanImpersonate` — true for controller and admin only. This is
47
+ * the admin gate for the "open another user's root" feature and is intentionally stricter
48
+ * than {@link canGrantToEveryone}, which also returns true for a regular user (a normal user
49
+ * may share their own projects, but must never be offered impersonation). `null` (no-auth
50
+ * mode) returns false.
51
+ */
52
+ function canImpersonate(role) {
53
+ switch (role) {
54
+ case Role.CONTROLLER:
55
+ case Role.ADMIN: return true;
56
+ default: return false;
57
+ }
58
+ }
43
59
  /** Dynamic field on SharingState, one per handled share, keyed by shareId. */
44
60
  const decisionField = (shareId) => `decision/${shareId}`;
45
61
  /** Dynamic field on SharedEnvelope, one per recipient who accepted or rejected, keyed
@@ -63,6 +79,6 @@ function decodeEnvelopeData(data) {
63
79
  return JSON.parse(Buffer.from(data).toString("utf-8"));
64
80
  }
65
81
  //#endregion
66
- export { AcceptanceFieldPrefix, SharedEnvelopeResourceType, SharingOutboxField, SharingOutboxResourceType, SharingStateField, SharingStateResourceType, acceptanceField, acceptanceFieldLogin, asShareId, canGrantToEveryone, decisionField, decodeEnvelopeData, isAcceptanceField, newShareId };
82
+ export { AcceptanceFieldPrefix, SharedEnvelopeResourceType, SharingOutboxField, SharingOutboxResourceType, SharingStateField, SharingStateResourceType, acceptanceField, acceptanceFieldLogin, asShareId, canGrantToEveryone, canImpersonate, decisionField, decodeEnvelopeData, isAcceptanceField, newShareId };
67
83
 
68
84
  //# sourceMappingURL=sharing_model.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"sharing_model.js","names":["RoleEnum"],"sources":["../../src/model/sharing_model.ts"],"sourcesContent":["import type { ResourceType, Role } from \"@milaboratories/pl-client\";\nimport { Role as RoleEnum } from \"@milaboratories/pl-client\";\nimport type { Branded, ProjectId } from \"@milaboratories/pl-model-common\";\nimport { randomUUID } from \"node:crypto\";\n\n/**\n * Logical identity of a share, stable across replaces. A donor-generated UUID string,\n * branded so it cannot be silently confused with a project id, a login, or a raw field\n * name. Minted once with {@link newShareId}; every other site receives it (from decoded\n * {@link EnvelopeData} or by parsing a `decision/{shareId}` field name) and threads it\n * through unchanged.\n */\nexport type ShareId = Branded<string, \"ShareId\">;\n\n/** Mints a fresh {@link ShareId}. The single place a share's logical identity is created. */\nexport function newShareId(): ShareId {\n return randomUUID() as ShareId;\n}\n\n/** Brands a string already known to be a share id (e.g. parsed from a `decision/{shareId}`\n * field name) as a {@link ShareId}, without minting a new one. */\nexport function asShareId(id: string): ShareId {\n return id as ShareId;\n}\n\n//\n// Pl Model — Project Sharing\n//\n// All sharing structures are defined and managed by the middle layer; the\n// backend knows nothing about envelopes.\n//\n\n/** Field on the donor's clientRoot holding the {@link SharingOutboxResourceType} resource. */\nexport const SharingOutboxField = \"sharingOutbox\";\n/** Field on the acceptor's clientRoot holding the {@link SharingStateResourceType} resource. */\nexport const SharingStateField = \"sharingState\";\n\nexport const SharingOutboxResourceType: ResourceType = { name: \"SharingOutbox\", version: \"1\" };\nexport const SharedEnvelopeResourceType: ResourceType = { name: \"SharedEnvelope\", version: \"1\" };\nexport const SharingStateResourceType: ResourceType = { name: \"SharingState\", version: \"1\" };\n\nexport type EnvelopeMode = \"copy\" | \"read-only\" | \"collaboration\";\n\n/** Per-project decision on change, matching the UI labels: re-snapshot the live source (\"update\"),\n * carry the existing snapshot (\"keep\"), or drop the project from the pack (\"remove\"). */\nexport type ProjectChangeAction = \"keep\" | \"update\" | \"remove\";\n\n/** Key of the per-project envelope maps: a uuid minted per snapshot to name the `project/{uuid}`\n * field. Distinct from {@link ProjectId} — re-snapshotting one source yields a new uuid each time. */\nexport type ProjectFieldUuid = Branded<string, \"ProjectFieldUuid\">;\n\n/**\n * Whether a role may make a resource public (grant to everyone): true for controller,\n * admin, and user; false for workflow and unspecified. The middle layer carries no policy\n * of its own here — a crafted call still hits the backend's role + permission-ceiling gate.\n * `null` (no-auth mode) returns false.\n */\nexport function canGrantToEveryone(role: Role | null): boolean {\n switch (role) {\n case RoleEnum.CONTROLLER:\n case RoleEnum.ADMIN:\n case RoleEnum.USER:\n return true;\n default:\n return false;\n }\n}\n\n/** One project's snapshot inside an envelope, keyed by {@link ProjectFieldUuid} in {@link EnvelopeData.projects}. */\nexport interface EnvelopeProject {\n label: string; // carried so the pending-share UI renders without traversing into the project\n source: ProjectId; // donor's source projectId; supersedes a prior share and matches the snapshot to its live source on change\n updatedAt: number; // ms epoch of the last (re)snapshot\n}\n\n/** Immutable `data` on a SharedEnvelope, set at createEphemeral, never mutated. */\nexport interface EnvelopeData {\n schemaVersion: 1;\n shareId: ShareId; // donor-generated UUID; logical share identity, stable across changes\n sharedAt: number; // ms epoch; this instance's creation time — distinguishes instances of one shareId\n expiresAt: number | null; // ms epoch; sharedAt + ttl (default 14 days) for a targeted share; null for share-with-everybody (never expires)\n mode: EnvelopeMode; // what the acceptor's app should do with the contents\n sender: string; // donor login (informational; backend granted_by is authoritative)\n title: string; // display name shown to recipients; defaults to the first project's name\n projects: Record<ProjectFieldUuid, EnvelopeProject>; // contained projects, keyed by project field uuid\n}\n\n/** Dynamic field on SharingState, one per handled share, keyed by shareId. */\nexport const decisionField = (shareId: ShareId) => `decision/${shareId}`;\n\nexport interface SharingDecision {\n decision: \"accepted\" | \"rejected\";\n timestamp: number; // ms epoch — when the acceptor acted\n envelopeSharedAt: number; // the acted-on envelope instance's sharedAt — pins which instance was handled (paired with the shareId key; the resource id is never stored)\n acceptedProjects: string[]; // ids of the projects created in the acceptor's list ([] for a rejected share)\n}\n\n/** Dynamic field on SharedEnvelope, one per recipient who accepted or rejected, keyed\n * by recipient login. Written by the acceptor in read-write shares only (Copy & Share,\n * Live collaboration) — the acceptor's writable envelope grant is what permits the\n * write; read-only shares omit it. The donor reads these from its own outbox to see\n * who responded and when. Informational, not authoritative (a writable grant holder\n * could write under another login — same trust assumption as the sender field).\n * Copied forward when a share is changed. */\nexport const AcceptanceFieldPrefix = \"acceptance/\";\nexport const acceptanceField = (login: string) => `${AcceptanceFieldPrefix}${login}`;\nexport const isAcceptanceField = (name: string) => name.startsWith(AcceptanceFieldPrefix);\nexport const acceptanceFieldLogin = (name: string) => name.slice(AcceptanceFieldPrefix.length);\n\nexport interface EnvelopeAcceptance {\n action: \"accepted\" | \"rejected\";\n timestamp: number; // ms since epoch\n}\n\n/**\n * Single owner of the raw-data → {@link EnvelopeData} decode. The envelope's immutable `data`\n * blob is UTF-8 JSON set once at createEphemeral; every site that reads it from a raw resource\n * `data` byte buffer (the basic-resource read path) goes through here. The reactive tree-node\n * path uses `node.getDataAsJson<EnvelopeData>()`, which decodes the same JSON.\n */\nexport function decodeEnvelopeData(data: Uint8Array): EnvelopeData {\n return JSON.parse(Buffer.from(data).toString(\"utf-8\")) as EnvelopeData;\n}\n\n/**\n * Options for {@link MiddleLayer.shareProjects}.\n *\n * Recipients XOR everyone — two clean variants, not one struct with mutually exclusive\n * optional fields. The everyone variant issues a single make-public grant (the envelope's\n * `expiresAt` is set to `null`, so it never expires); the recipients variant grants each\n * named recipient and the envelope expires after the default TTL.\n */\nexport type ShareProjectsOptions =\n | {\n recipients: string[]; // recipient logins\n title: string; // display name shown to recipients; defaults to the first project's name\n mode: EnvelopeMode; // v1 UI always sends \"copy\"\n }\n | {\n everyone: true; // share with all users on the server\n /**\n * When true and an everyone-share of the same project already exists, refresh it under its\n * stable shareId (recipients who already accepted or rejected are not re-prompted) instead of\n * minting a new share. No-op when no prior everyone-share of the project exists. Callers that\n * don't care pass `false`.\n */\n replace: boolean;\n title: string;\n mode: EnvelopeMode;\n };\n"],"mappings":";;;;AAeA,SAAgB,aAAsB;CACpC,OAAO,WAAW;AACpB;;;AAIA,SAAgB,UAAU,IAAqB;CAC7C,OAAO;AACT;;AAUA,MAAa,qBAAqB;;AAElC,MAAa,oBAAoB;AAEjC,MAAa,4BAA0C;CAAE,MAAM;CAAiB,SAAS;AAAI;AAC7F,MAAa,6BAA2C;CAAE,MAAM;CAAkB,SAAS;AAAI;AAC/F,MAAa,2BAAyC;CAAE,MAAM;CAAgB,SAAS;AAAI;;;;;;;AAkB3F,SAAgB,mBAAmB,MAA4B;CAC7D,QAAQ,MAAR;EACE,KAAKA,KAAS;EACd,KAAKA,KAAS;EACd,KAAKA,KAAS,MACZ,OAAO;EACT,SACE,OAAO;CACX;AACF;;AAsBA,MAAa,iBAAiB,YAAqB,YAAY;;;;;;;;AAgB/D,MAAa,wBAAwB;AACrC,MAAa,mBAAmB,UAAkB,GAAG,wBAAwB;AAC7E,MAAa,qBAAqB,SAAiB,KAAK,WAAW,qBAAqB;AACxF,MAAa,wBAAwB,SAAiB,KAAK,MAAM,EAA4B;;;;;;;AAa7F,SAAgB,mBAAmB,MAAgC;CACjE,OAAO,KAAK,MAAM,OAAO,KAAK,IAAI,CAAC,CAAC,SAAS,OAAO,CAAC;AACvD"}
1
+ {"version":3,"file":"sharing_model.js","names":["RoleEnum"],"sources":["../../src/model/sharing_model.ts"],"sourcesContent":["import type { ResourceType, Role } from \"@milaboratories/pl-client\";\nimport { Role as RoleEnum } from \"@milaboratories/pl-client\";\nimport type { Branded, ProjectId } from \"@milaboratories/pl-model-common\";\nimport { randomUUID } from \"node:crypto\";\n\n/**\n * Logical identity of a share, stable across replaces. A donor-generated UUID string,\n * branded so it cannot be silently confused with a project id, a login, or a raw field\n * name. Minted once with {@link newShareId}; every other site receives it (from decoded\n * {@link EnvelopeData} or by parsing a `decision/{shareId}` field name) and threads it\n * through unchanged.\n */\nexport type ShareId = Branded<string, \"ShareId\">;\n\n/** Mints a fresh {@link ShareId}. The single place a share's logical identity is created. */\nexport function newShareId(): ShareId {\n return randomUUID() as ShareId;\n}\n\n/** Brands a string already known to be a share id (e.g. parsed from a `decision/{shareId}`\n * field name) as a {@link ShareId}, without minting a new one. */\nexport function asShareId(id: string): ShareId {\n return id as ShareId;\n}\n\n//\n// Pl Model — Project Sharing\n//\n// All sharing structures are defined and managed by the middle layer; the\n// backend knows nothing about envelopes.\n//\n\n/** Field on the donor's clientRoot holding the {@link SharingOutboxResourceType} resource. */\nexport const SharingOutboxField = \"sharingOutbox\";\n/** Field on the acceptor's clientRoot holding the {@link SharingStateResourceType} resource. */\nexport const SharingStateField = \"sharingState\";\n\nexport const SharingOutboxResourceType: ResourceType = { name: \"SharingOutbox\", version: \"1\" };\nexport const SharedEnvelopeResourceType: ResourceType = { name: \"SharedEnvelope\", version: \"1\" };\nexport const SharingStateResourceType: ResourceType = { name: \"SharingState\", version: \"1\" };\n\nexport type EnvelopeMode = \"copy\" | \"read-only\" | \"collaboration\";\n\n/** Per-project decision on change, matching the UI labels: re-snapshot the live source (\"update\"),\n * carry the existing snapshot (\"keep\"), or drop the project from the pack (\"remove\"). */\nexport type ProjectChangeAction = \"keep\" | \"update\" | \"remove\";\n\n/** Key of the per-project envelope maps: a uuid minted per snapshot to name the `project/{uuid}`\n * field. Distinct from {@link ProjectId} — re-snapshotting one source yields a new uuid each time. */\nexport type ProjectFieldUuid = Branded<string, \"ProjectFieldUuid\">;\n\n/**\n * Whether a role may make a resource public (grant to everyone): true for controller,\n * admin, and user; false for workflow and unspecified. The middle layer carries no policy\n * of its own here — a crafted call still hits the backend's role + permission-ceiling gate.\n * `null` (no-auth mode) returns false.\n */\nexport function canGrantToEveryone(role: Role | null): boolean {\n switch (role) {\n case RoleEnum.CONTROLLER:\n case RoleEnum.ADMIN:\n case RoleEnum.USER:\n return true;\n default:\n return false;\n }\n}\n\n/**\n * Whether a role may impersonate another user: open/create another user's root and list\n * the resources that user can access. Mirrors the backend's authorization rule\n * `util/misecurity/role.go` `CanImpersonate` — true for controller and admin only. This is\n * the admin gate for the \"open another user's root\" feature and is intentionally stricter\n * than {@link canGrantToEveryone}, which also returns true for a regular user (a normal user\n * may share their own projects, but must never be offered impersonation). `null` (no-auth\n * mode) returns false.\n */\nexport function canImpersonate(role: Role | null): boolean {\n switch (role) {\n case RoleEnum.CONTROLLER:\n case RoleEnum.ADMIN:\n return true;\n default:\n return false;\n }\n}\n\n/** One project's snapshot inside an envelope, keyed by {@link ProjectFieldUuid} in {@link EnvelopeData.projects}. */\nexport interface EnvelopeProject {\n label: string; // carried so the pending-share UI renders without traversing into the project\n source: ProjectId; // donor's source projectId; supersedes a prior share and matches the snapshot to its live source on change\n updatedAt: number; // ms epoch of the last (re)snapshot\n}\n\n/** Immutable `data` on a SharedEnvelope, set at createEphemeral, never mutated. */\nexport interface EnvelopeData {\n schemaVersion: 1;\n shareId: ShareId; // donor-generated UUID; logical share identity, stable across changes\n sharedAt: number; // ms epoch; this instance's creation time — distinguishes instances of one shareId\n expiresAt: number | null; // ms epoch; sharedAt + ttl (default 14 days) for a targeted share; null for share-with-everybody (never expires)\n mode: EnvelopeMode; // what the acceptor's app should do with the contents\n sender: string; // donor login (informational; backend granted_by is authoritative)\n title: string; // display name shown to recipients; defaults to the first project's name\n projects: Record<ProjectFieldUuid, EnvelopeProject>; // contained projects, keyed by project field uuid\n}\n\n/** Dynamic field on SharingState, one per handled share, keyed by shareId. */\nexport const decisionField = (shareId: ShareId) => `decision/${shareId}`;\n\nexport interface SharingDecision {\n decision: \"accepted\" | \"rejected\";\n timestamp: number; // ms epoch — when the acceptor acted\n envelopeSharedAt: number; // the acted-on envelope instance's sharedAt — pins which instance was handled (paired with the shareId key; the resource id is never stored)\n acceptedProjects: string[]; // ids of the projects created in the acceptor's list ([] for a rejected share)\n}\n\n/** Dynamic field on SharedEnvelope, one per recipient who accepted or rejected, keyed\n * by recipient login. Written by the acceptor in read-write shares only (Copy & Share,\n * Live collaboration) — the acceptor's writable envelope grant is what permits the\n * write; read-only shares omit it. The donor reads these from its own outbox to see\n * who responded and when. Informational, not authoritative (a writable grant holder\n * could write under another login — same trust assumption as the sender field).\n * Copied forward when a share is changed. */\nexport const AcceptanceFieldPrefix = \"acceptance/\";\nexport const acceptanceField = (login: string) => `${AcceptanceFieldPrefix}${login}`;\nexport const isAcceptanceField = (name: string) => name.startsWith(AcceptanceFieldPrefix);\nexport const acceptanceFieldLogin = (name: string) => name.slice(AcceptanceFieldPrefix.length);\n\nexport interface EnvelopeAcceptance {\n action: \"accepted\" | \"rejected\";\n timestamp: number; // ms since epoch\n}\n\n/**\n * Single owner of the raw-data → {@link EnvelopeData} decode. The envelope's immutable `data`\n * blob is UTF-8 JSON set once at createEphemeral; every site that reads it from a raw resource\n * `data` byte buffer (the basic-resource read path) goes through here. The reactive tree-node\n * path uses `node.getDataAsJson<EnvelopeData>()`, which decodes the same JSON.\n */\nexport function decodeEnvelopeData(data: Uint8Array): EnvelopeData {\n return JSON.parse(Buffer.from(data).toString(\"utf-8\")) as EnvelopeData;\n}\n\n/**\n * Options for {@link MiddleLayer.shareProjects}.\n *\n * Recipients XOR everyone — two clean variants, not one struct with mutually exclusive\n * optional fields. The everyone variant issues a single make-public grant (the envelope's\n * `expiresAt` is set to `null`, so it never expires); the recipients variant grants each\n * named recipient and the envelope expires after the default TTL.\n */\nexport type ShareProjectsOptions =\n | {\n recipients: string[]; // recipient logins\n title: string; // display name shown to recipients; defaults to the first project's name\n mode: EnvelopeMode; // v1 UI always sends \"copy\"\n }\n | {\n everyone: true; // share with all users on the server\n /**\n * When true and an everyone-share of the same project already exists, refresh it under its\n * stable shareId (recipients who already accepted or rejected are not re-prompted) instead of\n * minting a new share. No-op when no prior everyone-share of the project exists. Callers that\n * don't care pass `false`.\n */\n replace: boolean;\n title: string;\n mode: EnvelopeMode;\n };\n"],"mappings":";;;;AAeA,SAAgB,aAAsB;CACpC,OAAO,WAAW;AACpB;;;AAIA,SAAgB,UAAU,IAAqB;CAC7C,OAAO;AACT;;AAUA,MAAa,qBAAqB;;AAElC,MAAa,oBAAoB;AAEjC,MAAa,4BAA0C;CAAE,MAAM;CAAiB,SAAS;AAAI;AAC7F,MAAa,6BAA2C;CAAE,MAAM;CAAkB,SAAS;AAAI;AAC/F,MAAa,2BAAyC;CAAE,MAAM;CAAgB,SAAS;AAAI;;;;;;;AAkB3F,SAAgB,mBAAmB,MAA4B;CAC7D,QAAQ,MAAR;EACE,KAAKA,KAAS;EACd,KAAKA,KAAS;EACd,KAAKA,KAAS,MACZ,OAAO;EACT,SACE,OAAO;CACX;AACF;;;;;;;;;;AAWA,SAAgB,eAAe,MAA4B;CACzD,QAAQ,MAAR;EACE,KAAKA,KAAS;EACd,KAAKA,KAAS,OACZ,OAAO;EACT,SACE,OAAO;CACX;AACF;;AAsBA,MAAa,iBAAiB,YAAqB,YAAY;;;;;;;;AAgB/D,MAAa,wBAAwB;AACrC,MAAa,mBAAmB,UAAkB,GAAG,wBAAwB;AAC7E,MAAa,qBAAqB,SAAiB,KAAK,WAAW,qBAAqB;AACxF,MAAa,wBAAwB,SAAiB,KAAK,MAAM,EAA4B;;;;;;;AAa7F,SAAgB,mBAAmB,MAAgC;CACjE,OAAO,KAAK,MAAM,OAAO,KAAK,IAAI,CAAC,CAAC,SAAS,OAAO,CAAC;AACvD"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@milaboratories/pl-middle-layer",
3
- "version": "1.64.42",
3
+ "version": "1.65.1",
4
4
  "description": "Pl Middle Layer",
5
5
  "keywords": [],
6
6
  "license": "UNLICENSED",
@@ -32,22 +32,22 @@
32
32
  "yaml": "^2.8.0",
33
33
  "zod": "~3.25.76",
34
34
  "@milaboratories/computable": "2.9.5",
35
+ "@milaboratories/helpers": "1.14.2",
35
36
  "@milaboratories/pf-driver": "1.7.15",
36
- "@milaboratories/pl-client": "3.12.1",
37
+ "@milaboratories/pl-client": "3.13.1",
37
38
  "@milaboratories/pl-deployments": "3.0.9",
38
- "@milaboratories/helpers": "1.14.2",
39
39
  "@milaboratories/pf-spec-driver": "1.4.17",
40
- "@milaboratories/pl-errors": "1.4.25",
41
- "@milaboratories/pl-drivers": "1.16.4",
40
+ "@milaboratories/pl-drivers": "1.16.6",
41
+ "@milaboratories/pl-errors": "1.4.27",
42
42
  "@milaboratories/pl-http": "1.2.4",
43
- "@milaboratories/pl-model-middle-layer": "1.30.10",
44
- "@milaboratories/pl-model-backend": "1.4.10",
43
+ "@milaboratories/pl-model-backend": "1.4.12",
45
44
  "@milaboratories/pl-model-common": "1.46.3",
46
- "@milaboratories/pl-tree": "1.12.15",
47
- "@platforma-sdk/block-tools": "2.11.7",
45
+ "@milaboratories/pl-model-middle-layer": "1.30.10",
48
46
  "@milaboratories/resolve-helper": "1.1.3",
49
- "@platforma-sdk/model": "1.79.24",
47
+ "@milaboratories/pl-tree": "1.12.17",
50
48
  "@milaboratories/ts-helpers": "1.8.3",
49
+ "@platforma-sdk/block-tools": "2.11.9",
50
+ "@platforma-sdk/model": "1.79.24",
51
51
  "@platforma-sdk/workflow-tengo": "6.6.5"
52
52
  },
53
53
  "devDependencies": {
@@ -14,7 +14,12 @@ import {
14
14
  resourceIdToString,
15
15
  } from "@milaboratories/pl-client";
16
16
  import { LRUCache } from "lru-cache";
17
- import { createProjectList, ProjectsField, ProjectsResourceType } from "./project_list";
17
+ import {
18
+ createProjectList,
19
+ ensureProjectListRid,
20
+ ProjectsField,
21
+ ProjectsResourceType,
22
+ } from "./project_list";
18
23
  import { createProject, duplicateProject, withProjectAuthored } from "../mutator/project";
19
24
  import { ProjectMetaKey } from "../model/project_model";
20
25
  import type { ProjectId } from "../model/project_model";
@@ -22,6 +27,7 @@ import type { SynchronizedTreeState } from "@milaboratories/pl-tree";
22
27
  import {
23
28
  acceptanceFieldLogin,
24
29
  canGrantToEveryone,
30
+ canImpersonate,
25
31
  decodeEnvelopeData,
26
32
  isAcceptanceField,
27
33
  SharingOutboxField,
@@ -186,7 +192,15 @@ export class MiddleLayer {
186
192
  * additional required capabilities later without a UI change.
187
193
  */
188
194
  public get sharingSupported(): boolean {
189
- return this.serverCapabilities.includes("crossTreeRefs:v1");
195
+ // Sharing does not compose with impersonation (discovery is scoped to the admin's session,
196
+ // decisions attribute to the admin), so it is disabled while impersonating. Admins move
197
+ // projects across roots with duplicateProjectToUser instead.
198
+ return this.serverCapabilities.includes("crossTreeRefs:v1") && !this.impersonating;
199
+ }
200
+
201
+ /** True when this session is impersonating another user (an admin opened another user's root). */
202
+ public get impersonating(): boolean {
203
+ return this.pl.conf.asUser !== undefined;
190
204
  }
191
205
 
192
206
  /**
@@ -205,11 +219,23 @@ export class MiddleLayer {
205
219
  */
206
220
  public get canShareWithEveryone(): boolean {
207
221
  return (
222
+ !this.impersonating &&
208
223
  this.serverCapabilities.includes("publicGrants:v1") &&
209
224
  canGrantToEveryone(this.currentUserRole)
210
225
  );
211
226
  }
212
227
 
228
+ /**
229
+ * Whether the authenticated user may impersonate others (open another user's root). Mirrors
230
+ * the backend's `CanImpersonate` role gate — admin/controller only, never a regular user.
231
+ * Derived from the session role, which stays the authenticated admin's even while
232
+ * impersonating, so this stays true across a switch: the "return to my root" affordance must
233
+ * not vanish mid-impersonation. Not a security boundary; the backend re-checks on every call.
234
+ */
235
+ public get currentUserCanImpersonate(): boolean {
236
+ return canImpersonate(this.currentUserRole);
237
+ }
238
+
213
239
  /** Adds a runtime capability to the middle layer. */
214
240
  public addRuntimeCapability(
215
241
  requirement: SupportedRequirement,
@@ -369,6 +395,50 @@ export class MiddleLayer {
369
395
  return newProjectId;
370
396
  }
371
397
 
398
+ /**
399
+ * Duplicates a project into another user's root, minted in the TARGET user's color so the target
400
+ * owns it. Sibling of {@link duplicateProject}, but writes into a different root. The source
401
+ * project (on the current client root) is referenced cross-color for its block data, kept alive
402
+ * by refcounting, exactly like accepting a shared project. Works both ways: pull (while
403
+ * impersonating a user, copy their project to yourself) and push (from your own root, copy a
404
+ * project to a user). Admin cross-root op; requires the crossTreeRefs:v1 backend capability.
405
+ */
406
+ public async duplicateProjectToUser(
407
+ srcProjectId: ProjectId,
408
+ targetLogin: string,
409
+ rename?: (previousLabel: string, existingLabels: string[]) => string,
410
+ ): Promise<void> {
411
+ if (!this.serverCapabilities.includes("crossTreeRefs:v1"))
412
+ throw new Error("duplicateProjectToUser requires the crossTreeRefs:v1 backend capability.");
413
+
414
+ const sourceRid = await this.resolveProjectId(srcProjectId);
415
+ const targetRoot = await this.pl.getUserRoot({ login: targetLogin, createIfNotExists: true });
416
+
417
+ // Run on the target root: the tx default color is the target's, so the new project (and the
418
+ // target's project list, if created here) are minted in the target's color.
419
+ await this.pl.withWriteTxOnRoot(targetRoot, "MLDuplicateProjectToUser", async (tx) => {
420
+ // Resolve or lazily create the target root's project list (tx.clientRoot === targetRoot).
421
+ const targetProjectListRid = await ensureProjectListRid(tx);
422
+
423
+ // Source label + the target's existing labels, for collision-aware renaming.
424
+ const sourceMeta = await tx.getKValueJson<ProjectMeta>(sourceRid, ProjectMetaKey);
425
+ const targetListData = await tx.getResourceData(targetProjectListRid, true);
426
+ const existingLabels = (
427
+ await Promise.all(
428
+ targetListData.fields
429
+ .map((f) => f.value)
430
+ .filter(isNotNullSignedResourceId)
431
+ .map((rid) => tx.getKValueJson<ProjectMeta>(rid, ProjectMetaKey)),
432
+ )
433
+ ).map((m) => m.label);
434
+ const newLabel = rename ? rename(sourceMeta.label, existingLabels) : sourceMeta.label;
435
+
436
+ const newPrj = await duplicateProject(tx, sourceRid, { label: newLabel });
437
+ tx.createField(field(targetProjectListRid, randomUUID()), "Dynamic", newPrj);
438
+ await tx.commit();
439
+ });
440
+ }
441
+
372
442
  //
373
443
  // Project Sharing (Copy & Share)
374
444
  //
@@ -1,7 +1,19 @@
1
1
  import type { PruningFunction } from "@milaboratories/pl-tree";
2
2
  import { SynchronizedTreeState } from "@milaboratories/pl-tree";
3
- import type { Filter, PlClient, ResourceType, SignedResourceId } from "@milaboratories/pl-client";
4
- import { resourceIdToString, resourceTypesEqual, treeFilter } from "@milaboratories/pl-client";
3
+ import type {
4
+ Filter,
5
+ PlClient,
6
+ PlTransaction,
7
+ ResourceType,
8
+ SignedResourceId,
9
+ } from "@milaboratories/pl-client";
10
+ import {
11
+ field,
12
+ isNullSignedResourceId,
13
+ resourceIdToString,
14
+ resourceTypesEqual,
15
+ treeFilter,
16
+ } from "@milaboratories/pl-client";
5
17
  import type { TreeAndComputableU } from "./types";
6
18
  import type { WatchableValue } from "@milaboratories/computable";
7
19
  import { Computable } from "@milaboratories/computable";
@@ -18,6 +30,25 @@ import type { ProjectMeta } from "@milaboratories/pl-model-middle-layer";
18
30
  export const ProjectsField = "projects";
19
31
  export const ProjectsResourceType: ResourceType = { name: "Projects", version: "1" };
20
32
 
33
+ /**
34
+ * Resolves the projects-list resource on the transaction's client root, lazily creating (and
35
+ * locking) an empty one when the {@link ProjectsField} is not yet populated. Returns its signed
36
+ * id. Used when writing into a root that may have no projects list yet, e.g. copying a project
37
+ * into another user's root during admin impersonation.
38
+ */
39
+ export async function ensureProjectListRid(tx: PlTransaction): Promise<SignedResourceId> {
40
+ const projectsField = field(tx.clientRoot, ProjectsField);
41
+ tx.createField(projectsField, "Dynamic");
42
+ const fData = await tx.getField(projectsField);
43
+ if (isNullSignedResourceId(fData.value)) {
44
+ const ref = tx.createEphemeral(ProjectsResourceType);
45
+ tx.lock(ref);
46
+ tx.setField(projectsField, ref);
47
+ return await ref.globalId;
48
+ }
49
+ return fData.value;
50
+ }
51
+
21
52
  export const ProjectsListTreePruningFunction: PruningFunction = (resource) => {
22
53
  if (!resourceTypesEqual(resource.type, ProjectsResourceType)) return [];
23
54
  return resource.fields;
@@ -0,0 +1,22 @@
1
+ import { test, expect } from "vitest";
2
+ import { Role } from "@milaboratories/pl-client";
3
+ import { canGrantToEveryone, canImpersonate } from "./sharing_model";
4
+
5
+ // canImpersonate is the admin gate for "open another user's root". It must be strictly
6
+ // stricter than canGrantToEveryone: a regular USER may share their own projects but must
7
+ // never be offered impersonation. Reusing the sharing predicate here let a regular user
8
+ // see the admin menu item (MILAB-6484 regression), so this asymmetry is pinned.
9
+
10
+ test("canImpersonate: admin and controller only", () => {
11
+ expect(canImpersonate(Role.ADMIN)).toBe(true);
12
+ expect(canImpersonate(Role.CONTROLLER)).toBe(true);
13
+ expect(canImpersonate(Role.USER)).toBe(false);
14
+ expect(canImpersonate(Role.WORKFLOW)).toBe(false);
15
+ expect(canImpersonate(Role.UNSPECIFIED)).toBe(false);
16
+ expect(canImpersonate(null)).toBe(false);
17
+ });
18
+
19
+ test("canGrantToEveryone includes USER but canImpersonate does not", () => {
20
+ expect(canGrantToEveryone(Role.USER)).toBe(true);
21
+ expect(canImpersonate(Role.USER)).toBe(false);
22
+ });
@@ -66,6 +66,25 @@ export function canGrantToEveryone(role: Role | null): boolean {
66
66
  }
67
67
  }
68
68
 
69
+ /**
70
+ * Whether a role may impersonate another user: open/create another user's root and list
71
+ * the resources that user can access. Mirrors the backend's authorization rule
72
+ * `util/misecurity/role.go` `CanImpersonate` — true for controller and admin only. This is
73
+ * the admin gate for the "open another user's root" feature and is intentionally stricter
74
+ * than {@link canGrantToEveryone}, which also returns true for a regular user (a normal user
75
+ * may share their own projects, but must never be offered impersonation). `null` (no-auth
76
+ * mode) returns false.
77
+ */
78
+ export function canImpersonate(role: Role | null): boolean {
79
+ switch (role) {
80
+ case RoleEnum.CONTROLLER:
81
+ case RoleEnum.ADMIN:
82
+ return true;
83
+ default:
84
+ return false;
85
+ }
86
+ }
87
+
69
88
  /** One project's snapshot inside an envelope, keyed by {@link ProjectFieldUuid} in {@link EnvelopeData.projects}. */
70
89
  export interface EnvelopeProject {
71
90
  label: string; // carried so the pending-share UI renders without traversing into the project